import { Issuer, type BaseClient, type UserinfoResponse, type TokenSet, custom, generators, } from "openid-client"; import type { RequestEvent } from "@sveltejs/kit"; import { addHours, addWeeks, differenceInMinutes, subMinutes } from "date-fns"; import { config } from "$lib/server/config"; import { sha256 } from "$lib/utils/sha256"; import { z } from "zod"; import { dev } from "$app/environment"; import { redirect, type Cookies } from "@sveltejs/kit"; import { collections } from "$lib/server/database"; import JSON5 from "json5"; import { logger } from "$lib/server/logger"; import { ObjectId } from "mongodb"; import type { Cookie } from "elysia"; import { adminTokenManager } from "./adminToken"; import type { User } from "$lib/types/User"; import type { Session } from "$lib/types/Session"; import { base } from "$app/paths"; import { acquireLock, isDBLocked, releaseLock } from "$lib/migrations/lock"; import { Semaphores } from "$lib/types/Semaphore"; export interface OIDCSettings { redirectURI: string; } export interface OIDCUserInfo { token: TokenSet; userData: UserinfoResponse; } const stringWithDefault = (value: string) => z .string() .default(value) .transform((el) => (el ? el : value)); export const OIDConfig = z .object({ CLIENT_ID: stringWithDefault(config.OPENID_CLIENT_ID), CLIENT_SECRET: stringWithDefault(config.OPENID_CLIENT_SECRET), PROVIDER_URL: stringWithDefault(config.OPENID_PROVIDER_URL), SCOPES: stringWithDefault(config.OPENID_SCOPES), NAME_CLAIM: stringWithDefault(config.OPENID_NAME_CLAIM).refine( (el) => !["preferred_username", "email", "picture", "sub"].includes(el), { message: "nameClaim cannot be one of the restricted keys." } ), TOLERANCE: stringWithDefault(config.OPENID_TOLERANCE), RESOURCE: stringWithDefault(config.OPENID_RESOURCE), ID_TOKEN_SIGNED_RESPONSE_ALG: z.string().optional(), }) .parse(JSON5.parse(config.OPENID_CONFIG || "{}")); export const loginEnabled = !!OIDConfig.CLIENT_ID; const sameSite = z .enum(["lax", "none", "strict"]) .default(dev || config.ALLOW_INSECURE_COOKIES === "true" ? "lax" : "none") .parse(config.COOKIE_SAMESITE === "" ? undefined : config.COOKIE_SAMESITE); const secure = z .boolean() .default(!(dev || config.ALLOW_INSECURE_COOKIES === "true")) .parse(config.COOKIE_SECURE === "" ? undefined : config.COOKIE_SECURE === "true"); function sanitizeReturnPath(path: string | undefined | null): string | undefined { if (!path) { return undefined; } if (path.startsWith("//")) { return undefined; } if (!path.startsWith("/")) { return undefined; } return path; } export function refreshSessionCookie(cookies: Cookies, sessionId: string) { cookies.set(config.COOKIE_NAME, sessionId, { path: "/", // So that it works inside the space's iframe sameSite, secure, httpOnly: true, expires: addWeeks(new Date(), 2), }); } export async function findUser( sessionId: string, coupledCookieHash: string | undefined, url: URL ): Promise<{ user: User | null; invalidateSession: boolean; oauth?: Session["oauth"]; }> { const session = await collections.sessions.findOne({ sessionId }); if (!session) { return { user: null, invalidateSession: false }; } if (coupledCookieHash && session.coupledCookieHash !== coupledCookieHash) { return { user: null, invalidateSession: true }; } // Check if OAuth token needs refresh if (session.oauth?.token && session.oauth.refreshToken) { // If token expires in less than 5 minutes, refresh it if (differenceInMinutes(session.oauth.token.expiresAt, new Date()) < 5) { const lockKey = `${Semaphores.OAUTH_TOKEN_REFRESH}:${sessionId}`; // Acquire lock for token refresh const lockId = await acquireLock(lockKey); if (lockId) { try { // Attempt to refresh the token const newTokenSet = await refreshOAuthToken( { redirectURI: `${config.PUBLIC_ORIGIN}${base}/login/callback` }, session.oauth.refreshToken, url ); if (!newTokenSet || !newTokenSet.access_token) { // Token refresh failed, invalidate session return { user: null, invalidateSession: true }; } // Update session with new token information const updatedOAuth = tokenSetToSessionOauth(newTokenSet); if (!updatedOAuth) { // Token refresh failed, invalidate session return { user: null, invalidateSession: true }; } await collections.sessions.updateOne( { sessionId }, { $set: { oauth: updatedOAuth, updatedAt: new Date(), }, } ); session.oauth = updatedOAuth; } catch (err) { logger.error("Error during token refresh:", err); return { user: null, invalidateSession: true }; } finally { await releaseLock(lockKey, lockId); } } else if (new Date() > session.oauth.token.expiresAt) { // If the token has expired, we need to wait for the token refresh to complete let attempts = 0; do { await new Promise((resolve) => setTimeout(resolve, 200)); attempts++; if (attempts > 20) { return { user: null, invalidateSession: true }; } } while (await isDBLocked(lockKey)); const updatedSession = await collections.sessions.findOne({ sessionId }); if (!updatedSession || updatedSession.oauth?.token === session.oauth.token) { return { user: null, invalidateSession: true }; } session.oauth = updatedSession.oauth; } } } return { user: await collections.users.findOne({ _id: session.userId }), invalidateSession: false, oauth: session.oauth, }; } export const authCondition = (locals: App.Locals) => { if (!locals.user && !locals.sessionId) { throw new Error("User or sessionId is required"); } return locals.user ? { userId: locals.user._id } : { sessionId: locals.sessionId, userId: { $exists: false } }; }; export function tokenSetToSessionOauth(tokenSet: TokenSet): Session["oauth"] { if (!tokenSet.access_token) { return undefined; } return { token: { value: tokenSet.access_token, expiresAt: tokenSet.expires_at ? subMinutes(new Date(tokenSet.expires_at * 1000), 1) : addWeeks(new Date(), 2), }, refreshToken: tokenSet.refresh_token || undefined, }; } /** * Generates a CSRF token using the user sessionId. Note that we don't need a secret because sessionId is enough. */ export async function generateCsrfToken( sessionId: string, redirectUrl: string, next?: string ): Promise { const sanitizedNext = sanitizeReturnPath(next); const data = { expiration: addHours(new Date(), 1).getTime(), redirectUrl, ...(sanitizedNext ? { next: sanitizedNext } : {}), } as { expiration: number; redirectUrl: string; next?: string; }; return Buffer.from( JSON.stringify({ data, signature: await sha256(JSON.stringify(data) + "##" + sessionId), }) ).toString("base64"); } let lastIssuer: Issuer | null = null; let lastIssuerFetchedAt: Date | null = null; async function getOIDCClient(settings: OIDCSettings, url: URL): Promise { if ( lastIssuer && lastIssuerFetchedAt && differenceInMinutes(new Date(), lastIssuerFetchedAt) >= 10 ) { lastIssuer = null; lastIssuerFetchedAt = null; } if (!lastIssuer) { lastIssuer = await Issuer.discover(OIDConfig.PROVIDER_URL); lastIssuerFetchedAt = new Date(); } const issuer = lastIssuer; const client_config: ConstructorParameters[0] = { client_id: OIDConfig.CLIENT_ID, client_secret: OIDConfig.CLIENT_SECRET, redirect_uris: [settings.redirectURI], response_types: ["code"], [custom.clock_tolerance]: OIDConfig.TOLERANCE || undefined, id_token_signed_response_alg: OIDConfig.ID_TOKEN_SIGNED_RESPONSE_ALG || undefined, }; if (OIDConfig.CLIENT_ID === "__CIMD__") { client_config.client_id = new URL( `${base}/.well-known/oauth-cimd`, config.PUBLIC_ORIGIN || url.origin ).toString(); } const alg_supported = issuer.metadata["id_token_signing_alg_values_supported"]; if (Array.isArray(alg_supported)) { client_config.id_token_signed_response_alg ??= alg_supported[0]; } return new issuer.Client(client_config); } export async function getOIDCAuthorizationUrl( settings: OIDCSettings, params: { sessionId: string; next?: string; url: URL; cookies: Cookies } ): Promise { const client = await getOIDCClient(settings, params.url); const csrfToken = await generateCsrfToken( params.sessionId, settings.redirectURI, sanitizeReturnPath(params.next) ); const codeVerifier = generators.codeVerifier(); const codeChallenge = generators.codeChallenge(codeVerifier); params.cookies.set("hfChat-codeVerifier", codeVerifier, { path: "/", sameSite, secure, httpOnly: true, expires: addHours(new Date(), 1), }); return client.authorizationUrl({ code_challenge_method: "S256", code_challenge: codeChallenge, scope: OIDConfig.SCOPES, state: csrfToken, resource: OIDConfig.RESOURCE || undefined, }); } export async function getOIDCUserData( settings: OIDCSettings, code: string, codeVerifier: string, iss: string | undefined, url: URL ): Promise { const client = await getOIDCClient(settings, url); const token = await client.callback( settings.redirectURI, { code, iss, }, { code_verifier: codeVerifier } ); const userData = await client.userinfo(token); return { token, userData }; } /** * Refreshes an OAuth token using the refresh token */ export async function refreshOAuthToken( settings: OIDCSettings, refreshToken: string, url: URL ): Promise { const client = await getOIDCClient(settings, url); const tokenSet = await client.refresh(refreshToken); return tokenSet; } export async function validateAndParseCsrfToken( token: string, sessionId: string ): Promise<{ /** This is the redirect url that was passed to the OIDC provider */ redirectUrl: string; /** Relative path (within this app) to return to after login */ next?: string; } | null> { try { const { data, signature } = z .object({ data: z.object({ expiration: z.number().int(), redirectUrl: z.string().url(), next: z.string().optional(), }), signature: z.string().length(64), }) .parse(JSON.parse(token)); const reconstructSign = await sha256(JSON.stringify(data) + "##" + sessionId); if (data.expiration > Date.now() && signature === reconstructSign) { return { redirectUrl: data.redirectUrl, next: sanitizeReturnPath(data.next) }; } } catch (e) { logger.error(e); } return null; } type CookieRecord = | { type: "elysia"; value: Record> } | { type: "svelte"; value: Cookies }; type HeaderRecord = | { type: "elysia"; value: Record } | { type: "svelte"; value: Headers }; export async function getCoupledCookieHash(cookie: CookieRecord): Promise { if (!config.COUPLE_SESSION_WITH_COOKIE_NAME) { return undefined; } const cookieValue = cookie.type === "elysia" ? cookie.value[config.COUPLE_SESSION_WITH_COOKIE_NAME]?.value : cookie.value.get(config.COUPLE_SESSION_WITH_COOKIE_NAME); if (!cookieValue) { return "no-cookie"; } return await sha256(cookieValue); } export async function authenticateRequest( headers: HeaderRecord, cookie: CookieRecord, url: URL, isApi?: boolean ): Promise { // once the entire API has been moved to elysia // we can move this function to authPlugin.ts // and get rid of the isApi && type: "svelte" options const token = cookie.type === "elysia" ? cookie.value[config.COOKIE_NAME].value : cookie.value.get(config.COOKIE_NAME); let email = null; if (config.TRUSTED_EMAIL_HEADER) { if (headers.type === "elysia") { email = headers.value[config.TRUSTED_EMAIL_HEADER]; } else { email = headers.value.get(config.TRUSTED_EMAIL_HEADER); } } let secretSessionId: string | null = null; let sessionId: string | null = null; if (email) { secretSessionId = sessionId = await sha256(email); return { user: { _id: new ObjectId(sessionId.slice(0, 24)), name: email, email, createdAt: new Date(), updatedAt: new Date(), hfUserId: email, avatarUrl: "", }, sessionId, secretSessionId, isAdmin: adminTokenManager.isAdmin(sessionId), }; } if (token) { secretSessionId = token; sessionId = await sha256(token); const result = await findUser(sessionId, await getCoupledCookieHash(cookie), url); if (result.invalidateSession) { secretSessionId = crypto.randomUUID(); sessionId = await sha256(secretSessionId); if (await collections.sessions.findOne({ sessionId })) { throw new Error("Session ID collision"); } } return { user: result.user ?? undefined, token: result.oauth?.token?.value, sessionId, secretSessionId, isAdmin: result.user?.isAdmin || adminTokenManager.isAdmin(sessionId), }; } if (isApi) { const authorization = headers.type === "elysia" ? headers.value["Authorization"] : headers.value.get("Authorization"); if (authorization?.startsWith("Bearer ")) { const token = authorization.slice(7); const hash = await sha256(token); sessionId = secretSessionId = hash; const cacheHit = await collections.tokenCaches.findOne({ tokenHash: hash }); if (cacheHit) { const user = await collections.users.findOne({ hfUserId: cacheHit.userId }); if (!user) { throw new Error("User not found"); } return { user, sessionId, token, secretSessionId, isAdmin: user.isAdmin || adminTokenManager.isAdmin(sessionId), }; } const response = await fetch("https://huggingface.co/api/whoami-v2", { headers: { Authorization: `Bearer ${token}` }, }); if (!response.ok) { throw new Error("Unauthorized"); } const data = await response.json(); const user = await collections.users.findOne({ hfUserId: data.id }); if (!user) { throw new Error("User not found"); } await collections.tokenCaches.insertOne({ tokenHash: hash, userId: data.id, createdAt: new Date(), updatedAt: new Date(), }); return { user, sessionId, secretSessionId, token, isAdmin: user.isAdmin || adminTokenManager.isAdmin(sessionId), }; } } // Generate new session if none exists secretSessionId = crypto.randomUUID(); sessionId = await sha256(secretSessionId); if (await collections.sessions.findOne({ sessionId })) { throw new Error("Session ID collision"); } return { user: undefined, sessionId, secretSessionId, isAdmin: false }; } export async function triggerOauthFlow({ url, locals, cookies }: RequestEvent): Promise { // const referer = request.headers.get("referer"); // let redirectURI = `${(referer ? new URL(referer) : url).origin}${base}/login/callback`; let redirectURI = `${url.origin}${base}/login/callback`; // TODO: Handle errors if provider is not responding if (url.searchParams.has("callback")) { const callback = url.searchParams.get("callback") || redirectURI; if (config.ALTERNATIVE_REDIRECT_URLS.includes(callback)) { redirectURI = callback; } } // Preserve a safe in-app return path after login. // Priority: explicit ?next=... (must be an absolute path), else the current path (when auto-login kicks in). let next: string | undefined = undefined; const nextParam = sanitizeReturnPath(url.searchParams.get("next")); if (nextParam) { // Only accept absolute in-app paths to prevent open redirects next = nextParam; } else if (!url.pathname.startsWith(`${base}/login`)) { // For automatic login on protected pages, return to the page the user was on next = sanitizeReturnPath(`${url.pathname}${url.search}`) ?? `${base}/`; } else { next = sanitizeReturnPath(`${base}/`) ?? "/"; } const authorizationUrl = await getOIDCAuthorizationUrl( { redirectURI }, { sessionId: locals.sessionId, next, url, cookies } ); throw redirect(302, authorizationUrl); }