| 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: "/", |
| |
| 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 }; |
| } |
|
|
| |
| if (session.oauth?.token && session.oauth.refreshToken) { |
| |
| if (differenceInMinutes(session.oauth.token.expiresAt, new Date()) < 5) { |
| const lockKey = `${Semaphores.OAUTH_TOKEN_REFRESH}:${sessionId}`; |
|
|
| |
| const lockId = await acquireLock(lockKey); |
| if (lockId) { |
| try { |
| |
| const newTokenSet = await refreshOAuthToken( |
| { redirectURI: `${config.PUBLIC_ORIGIN}${base}/login/callback` }, |
| session.oauth.refreshToken, |
| url |
| ); |
|
|
| if (!newTokenSet || !newTokenSet.access_token) { |
| |
| return { user: null, invalidateSession: true }; |
| } |
|
|
| |
| const updatedOAuth = tokenSetToSessionOauth(newTokenSet); |
|
|
| if (!updatedOAuth) { |
| |
| return { user: null, invalidateSession: true }; |
| } |
|
|
| await collections.sessions.updateOne( |
| { sessionId }, |
| { |
| $set: { |
| oauth: updatedOAuth, |
| updatedAt: new Date(), |
| }, |
| } |
| ); |
|
|
| session.oauth = updatedOAuth; |
| } catch (err) { |
| logger.error(err, "Error during token refresh:"); |
| return { user: null, invalidateSession: true }; |
| } finally { |
| await releaseLock(lockKey, lockId); |
| } |
| } else if (new Date() > session.oauth.token.expiresAt) { |
| |
| 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, |
| }; |
| } |
|
|
| |
| |
| |
| 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 }; |
| } |
|
|
| |
| |
| |
| 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<{ |
| |
| redirectUrl: string; |
| |
| 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, "Error validating and parsing CSRF token"); |
| } |
| 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 }> { |
| |
| |
| |
| 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), |
| }; |
| } |
| } |
|
|
| |
| 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> { |
| |
| |
| let redirectURI = `${url.origin}${base}/login/callback`; |
|
|
| |
|
|
| if (url.searchParams.has("callback")) { |
| const callback = url.searchParams.get("callback") || redirectURI; |
| if (config.ALTERNATIVE_REDIRECT_URLS.includes(callback)) { |
| redirectURI = callback; |
| } |
| } |
|
|
| |
| |
| let next: string | undefined = undefined; |
| const nextParam = sanitizeReturnPath(url.searchParams.get("next")); |
| if (nextParam) { |
| |
| next = nextParam; |
| } else if (!url.pathname.startsWith(`${base}/login`)) { |
| |
| 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); |
| } |
|
|