Spaces:
Running
Running
| 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<string> { | |
| 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<BaseClient> | null = null; | |
| let lastIssuerFetchedAt: Date | null = null; | |
| async function getOIDCClient(settings: OIDCSettings, url: URL): Promise<BaseClient> { | |
| 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<typeof issuer.Client>[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<string> { | |
| 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<OIDCUserInfo> { | |
| 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<TokenSet | null> { | |
| 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<string, Cookie<string | undefined>> } | |
| | { type: "svelte"; value: Cookies }; | |
| type HeaderRecord = | |
| | { type: "elysia"; value: Record<string, string | undefined> } | |
| | { type: "svelte"; value: Headers }; | |
| export async function getCoupledCookieHash(cookie: CookieRecord): Promise<string | undefined> { | |
| 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<App.Locals & { secretSessionId: string }> { | |
| // 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<Response> { | |
| // 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); | |
| } | |