diff --git a/.env b/.env index 6dbec16f5db..c8b16e2906f 100644 --- a/.env +++ b/.env @@ -32,7 +32,7 @@ PUBLIC_APPLE_APP_ID= COUPLE_SESSION_WITH_COOKIE_NAME= # when OPEN_ID is configured, users are required to login after the welcome modal -OPENID_CLIENT_ID= +OPENID_CLIENT_ID="" # You can set to "__CIMD__" for automatic oauth app creation when deployed, see https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/ OPENID_CLIENT_SECRET= OPENID_SCOPES="openid profile inference-api" USE_USER_TOKEN= @@ -163,9 +163,6 @@ PUBLIC_COMMIT_SHA= ALLOW_INSECURE_COOKIES=false # LEGACY! Use COOKIE_SECURE and COOKIE_SAMESITE instead PARQUET_EXPORT_SECRET=#DEPRECATED, use ADMIN_API_SECRET instead RATE_LIMIT= # /!\ DEPRECATED definition of messages per minute. Use USAGE_LIMITS.messagesPerMinute instead -OPENID_CLIENT_ID= -OPENID_CLIENT_SECRET= -OPENID_SCOPES="openid profile" # Add "email" for some providers like Google that do not provide preferred_username OPENID_NAME_CLAIM="name" # Change to "username" for some providers that do not provide name OPENID_PROVIDER_URL=https://huggingface.co # for Google, use https://accounts.google.com OPENID_TOLERANCE= diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 02ca0d9bb18..eb17af688c0 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -133,12 +133,13 @@ export const handle: Handle = async ({ event, resolve }) => { const auth = await authenticateRequest( { type: "svelte", value: event.request.headers }, - { type: "svelte", value: event.cookies } + { type: "svelte", value: event.cookies }, + event.url ); event.locals.sessionId = auth.sessionId; - if (loginEnabled && !auth.user) { + if (loginEnabled && !auth.user && !event.url.pathname.startsWith(`${base}/.well-known/`)) { if (config.AUTOMATIC_LOGIN === "true") { // AUTOMATIC_LOGIN: always redirect to OAuth flow (unless already on login or healthcheck pages) if ( @@ -147,11 +148,7 @@ export const handle: Handle = async ({ event, resolve }) => { ) { // To get the same CSRF token after callback refreshSessionCookie(event.cookies, auth.secretSessionId); - return await triggerOauthFlow({ - request: event.request, - url: event.url, - locals: event.locals, - }); + return await triggerOauthFlow(event); } } else { // Redirect to OAuth flow unless on the authorized pages (home, shared conversation, login, healthcheck, model thumbnails) @@ -167,7 +164,7 @@ export const handle: Handle = async ({ event, resolve }) => { !event.url.pathname.startsWith(`${base}/api`) ) { refreshSessionCookie(event.cookies, auth.secretSessionId); - return triggerOauthFlow({ request: event.request, url: event.url, locals: event.locals }); + return triggerOauthFlow(event); } } } diff --git a/src/lib/server/api/authPlugin.ts b/src/lib/server/api/authPlugin.ts index d0a4bc091d9..a2b0a5c2657 100644 --- a/src/lib/server/api/authPlugin.ts +++ b/src/lib/server/api/authPlugin.ts @@ -1,17 +1,20 @@ import Elysia from "elysia"; import { authenticateRequest } from "../auth"; +import { config } from "../config"; export const authPlugin = new Elysia({ name: "auth" }).derive( { as: "scoped" }, async ({ headers, cookie, + request, }): Promise<{ locals: App.Locals; }> => { const auth = await authenticateRequest( { type: "elysia", value: headers }, { type: "elysia", value: cookie }, + new URL(request.url, config.PUBLIC_ORIGIN || undefined), true ); return { diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index e67492723e3..052860055dd 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -4,7 +4,9 @@ import { 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"; @@ -54,7 +56,7 @@ export const OIDConfig = z }) .parse(JSON5.parse(config.OPENID_CONFIG || "{}")); -export const loginEnabled = !!OIDConfig.CLIENT_ID && !!OIDConfig.CLIENT_SECRET; +export const loginEnabled = !!OIDConfig.CLIENT_ID; const sameSite = z .enum(["lax", "none", "strict"]) @@ -92,7 +94,8 @@ export function refreshSessionCookie(cookies: Cookies, sessionId: string) { export async function findUser( sessionId: string, - coupledCookieHash?: string + coupledCookieHash: string | undefined, + url: URL ): Promise<{ user: User | null; invalidateSession: boolean; @@ -121,7 +124,8 @@ export async function findUser( // Attempt to refresh the token const newTokenSet = await refreshOAuthToken( { redirectURI: `${config.PUBLIC_ORIGIN}${base}/login/callback` }, - session.oauth.refreshToken + session.oauth.refreshToken, + url ); if (!newTokenSet || !newTokenSet.access_token) { @@ -236,7 +240,7 @@ export async function generateCsrfToken( let lastIssuer: Issuer | null = null; let lastIssuerFetchedAt: Date | null = null; -async function getOIDCClient(settings: OIDCSettings): Promise { +async function getOIDCClient(settings: OIDCSettings, url: URL): Promise { if ( lastIssuer && lastIssuerFetchedAt && @@ -261,6 +265,14 @@ async function getOIDCClient(settings: OIDCSettings): Promise { id_token_signed_response_alg: OIDConfig.ID_TOKEN_SIGNED_RESPONSE_ALG || undefined, }; + if (OIDConfig.CLIENT_ID === "__CIMD__") { + // See https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/ + 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)) { @@ -272,16 +284,29 @@ async function getOIDCClient(settings: OIDCSettings): Promise { export async function getOIDCAuthorizationUrl( settings: OIDCSettings, - params: { sessionId: string; next?: string } + params: { sessionId: string; next?: string; url: URL; cookies: Cookies } ): Promise { - const client = await getOIDCClient(settings); + 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, @@ -291,10 +316,19 @@ export async function getOIDCAuthorizationUrl( export async function getOIDCUserData( settings: OIDCSettings, code: string, - iss?: string + codeVerifier: string, + iss: string | undefined, + url: URL ): Promise { - const client = await getOIDCClient(settings); - const token = await client.callback(settings.redirectURI, { code, iss }); + 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 }; @@ -305,9 +339,10 @@ export async function getOIDCUserData( */ export async function refreshOAuthToken( settings: OIDCSettings, - refreshToken: string + refreshToken: string, + url: URL ): Promise { - const client = await getOIDCClient(settings); + const client = await getOIDCClient(settings, url); const tokenSet = await client.refresh(refreshToken); return tokenSet; } @@ -371,6 +406,7 @@ export async function getCoupledCookieHash(cookie: CookieRecord): Promise { // once the entire API has been moved to elysia @@ -415,7 +451,7 @@ export async function authenticateRequest( secretSessionId = token; sessionId = await sha256(token); - const result = await findUser(sessionId, await getCoupledCookieHash(cookie)); + const result = await findUser(sessionId, await getCoupledCookieHash(cookie), url); if (result.invalidateSession) { secretSessionId = crypto.randomUUID(); @@ -502,14 +538,7 @@ export async function authenticateRequest( return { user: undefined, sessionId, secretSessionId, isAdmin: false }; } -export async function triggerOauthFlow({ - url, - locals, -}: { - request: Request; - url: URL; - locals: App.Locals; -}): Promise { +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`; @@ -539,7 +568,7 @@ export async function triggerOauthFlow({ const authorizationUrl = await getOIDCAuthorizationUrl( { redirectURI }, - { sessionId: locals.sessionId, next } + { sessionId: locals.sessionId, next, url, cookies } ); throw redirect(302, authorizationUrl); diff --git a/src/routes/.well-known/oauth-cimd/+server.ts b/src/routes/.well-known/oauth-cimd/+server.ts new file mode 100644 index 00000000000..4268fa3459f --- /dev/null +++ b/src/routes/.well-known/oauth-cimd/+server.ts @@ -0,0 +1,35 @@ +import { base } from "$app/paths"; +import { OIDConfig } from "$lib/server/auth"; +import { config } from "$lib/server/config"; + +/** + * See https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/ + */ +export const GET = ({ url }) => { + if (!OIDConfig.CLIENT_ID) { + return new Response("Client ID not found", { status: 404 }); + } + if (OIDConfig.CLIENT_ID !== "__CIMD__") { + return new Response( + `Client ID is manually set to something other than '__CIMD__': ${OIDConfig.CLIENT_ID}`, + { + status: 404, + } + ); + } + return new Response( + JSON.stringify({ + client_id: new URL(url, config.PUBLIC_ORIGIN || url.origin).toString(), + client_name: config.PUBLIC_APP_NAME, + client_uri: `${config.PUBLIC_ORIGIN || url.origin}${base}`, + redirect_uris: [new URL("/login/callback", config.PUBLIC_ORIGIN || url.origin).toString()], + token_endpoint_auth_method: "none", + scopes: OIDConfig.SCOPES, + }), + { + headers: { + "Content-Type": "application/json", + }, + } + ); +}; diff --git a/src/routes/login/+server.ts b/src/routes/login/+server.ts index 5cb5b71861f..561bda413fb 100644 --- a/src/routes/login/+server.ts +++ b/src/routes/login/+server.ts @@ -1,5 +1,5 @@ import { triggerOauthFlow } from "$lib/server/auth"; -export async function GET({ request, url, locals }) { - return await triggerOauthFlow({ request, url, locals }); +export async function GET(event) { + return await triggerOauthFlow(event); } diff --git a/src/routes/login/callback/+server.ts b/src/routes/login/callback/+server.ts index cef96794160..9e04ae8a39f 100644 --- a/src/routes/login/callback/+server.ts +++ b/src/routes/login/callback/+server.ts @@ -52,10 +52,17 @@ export async function GET({ url, locals, cookies, request, getClientAddress }) { throw error(403, "Invalid or expired CSRF token"); } + const codeVerifier = cookies.get("hfChat-codeVerifier"); + if (!codeVerifier) { + throw error(403, "Code verifier cookie not found"); + } + const { userData, token } = await getOIDCUserData( { redirectURI: validatedToken.redirectUrl }, code, - iss + codeVerifier, + iss, + url ); // Filter by allowed user emails or domains