From 3f577a4857304a22c60785a4b6ff856a6d4a6779 Mon Sep 17 00:00:00 2001 From: coyotte508 Date: Fri, 7 Nov 2025 15:48:35 +0000 Subject: [PATCH 1/4] Add CIMD support --- .env | 2 +- src/hooks.server.ts | 3 +- src/lib/server/api/authPlugin.ts | 4 +++ src/lib/server/auth.ts | 34 +++++++++++++------ src/routes/.well-known/.oauth-cimd/+server.ts | 27 +++++++++++++++ src/routes/login/callback/+server.ts | 3 +- 6 files changed, 59 insertions(+), 14 deletions(-) create mode 100644 src/routes/.well-known/.oauth-cimd/+server.ts diff --git a/.env b/.env index 6dbec16f5db..46383b810ec 100644 --- a/.env +++ b/.env @@ -163,7 +163,7 @@ 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_ID="" # You can set to "__CIMD__" for automatic oauth app creation when deployed 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 diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 02ca0d9bb18..6a7ac2f227f 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -133,7 +133,8 @@ 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; diff --git a/src/lib/server/api/authPlugin.ts b/src/lib/server/api/authPlugin.ts index d0a4bc091d9..ccf135bd120 100644 --- a/src/lib/server/api/authPlugin.ts +++ b/src/lib/server/api/authPlugin.ts @@ -1,17 +1,21 @@ 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; }> => { + request.url; const auth = await authenticateRequest( { type: "elysia", value: headers }, { type: "elysia", value: cookie }, + new URL(request.url, config.PUBLIC_ORIGIN), true ); return { diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index e67492723e3..fabf4b998c1 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -92,7 +92,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 +122,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 +238,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 +263,13 @@ async function getOIDCClient(settings: OIDCSettings): Promise { id_token_signed_response_alg: OIDConfig.ID_TOKEN_SIGNED_RESPONSE_ALG || undefined, }; + if (OIDConfig.CLIENT_ID === "__CIMD__") { + OIDConfig.CLIENT_ID = new URL( + "/.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,9 +281,9 @@ async function getOIDCClient(settings: OIDCSettings): Promise { export async function getOIDCAuthorizationUrl( settings: OIDCSettings, - params: { sessionId: string; next?: string } + params: { sessionId: string; next?: string; url: URL } ): Promise { - const client = await getOIDCClient(settings); + const client = await getOIDCClient(settings, params.url); const csrfToken = await generateCsrfToken( params.sessionId, settings.redirectURI, @@ -291,9 +300,10 @@ export async function getOIDCAuthorizationUrl( export async function getOIDCUserData( settings: OIDCSettings, code: string, - iss?: string + iss: string | undefined, + url: URL ): Promise { - const client = await getOIDCClient(settings); + const client = await getOIDCClient(settings, url); const token = await client.callback(settings.redirectURI, { code, iss }); const userData = await client.userinfo(token); @@ -305,9 +315,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 +382,7 @@ export async function getCoupledCookieHash(cookie: CookieRecord): Promise { // once the entire API has been moved to elysia @@ -415,7 +427,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(); @@ -539,7 +551,7 @@ export async function triggerOauthFlow({ const authorizationUrl = await getOIDCAuthorizationUrl( { redirectURI }, - { sessionId: locals.sessionId, next } + { sessionId: locals.sessionId, next, url } ); 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..41c35c79b9b --- /dev/null +++ b/src/routes/.well-known/.oauth-cimd/+server.ts @@ -0,0 +1,27 @@ +import { OIDConfig } from "$lib/server/auth"; +import { config } from "$lib/server/config"; + +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__'", { + status: 404, + }); + } + return new Response( + JSON.stringify({ + client_id: new URL("/.well-known/oauth-cimd", config.PUBLIC_ORIGIN || url.origin).toString(), + client_name: config.PUBLIC_APP_NAME, + 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/callback/+server.ts b/src/routes/login/callback/+server.ts index cef96794160..1ec17500d4e 100644 --- a/src/routes/login/callback/+server.ts +++ b/src/routes/login/callback/+server.ts @@ -55,7 +55,8 @@ export async function GET({ url, locals, cookies, request, getClientAddress }) { const { userData, token } = await getOIDCUserData( { redirectURI: validatedToken.redirectUrl }, code, - iss + iss, + url ); // Filter by allowed user emails or domains From a18c26716ea7abdc1443d57618ee28f6a07a362b Mon Sep 17 00:00:00 2001 From: coyotte508 Date: Fri, 7 Nov 2025 15:59:02 +0000 Subject: [PATCH 2/4] update env file --- .env | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.env b/.env index 46383b810ec..66154f545b8 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 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="" # You can set to "__CIMD__" for automatic oauth app creation when deployed -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= From 4493487619cb92dbba86e2399874558be2e2720c Mon Sep 17 00:00:00 2001 From: coyotte508 Date: Fri, 7 Nov 2025 17:07:34 +0000 Subject: [PATCH 3/4] Add PKCE and fix CIMD --- src/hooks.server.ts | 10 ++--- src/lib/server/api/authPlugin.ts | 3 +- src/lib/server/auth.ts | 44 +++++++++++++------ .../{.oauth-cimd => oauth-cimd}/+server.ts | 13 ++++-- src/routes/login/+server.ts | 4 +- src/routes/login/callback/+server.ts | 6 +++ 6 files changed, 51 insertions(+), 29 deletions(-) rename src/routes/.well-known/{.oauth-cimd => oauth-cimd}/+server.ts (64%) diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 6a7ac2f227f..eb17af688c0 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -139,7 +139,7 @@ export const handle: Handle = async ({ event, resolve }) => { 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 ( @@ -148,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) @@ -168,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 ccf135bd120..a2b0a5c2657 100644 --- a/src/lib/server/api/authPlugin.ts +++ b/src/lib/server/api/authPlugin.ts @@ -11,11 +11,10 @@ export const authPlugin = new Elysia({ name: "auth" }).derive( }): Promise<{ locals: App.Locals; }> => { - request.url; const auth = await authenticateRequest( { type: "elysia", value: headers }, { type: "elysia", value: cookie }, - new URL(request.url, config.PUBLIC_ORIGIN), + 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 fabf4b998c1..d12204046a4 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"]) @@ -264,8 +266,8 @@ async function getOIDCClient(settings: OIDCSettings, url: URL): Promise { const client = await getOIDCClient(settings, params.url); const csrfToken = await generateCsrfToken( @@ -290,7 +292,20 @@ export async function getOIDCAuthorizationUrl( 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, @@ -300,11 +315,19 @@ export async function getOIDCAuthorizationUrl( 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 }); + const token = await client.callback( + settings.redirectURI, + { + code, + iss, + }, + { code_verifier: codeVerifier } + ); const userData = await client.userinfo(token); return { token, userData }; @@ -514,14 +537,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`; @@ -551,7 +567,7 @@ export async function triggerOauthFlow({ const authorizationUrl = await getOIDCAuthorizationUrl( { redirectURI }, - { sessionId: locals.sessionId, next, url } + { 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 similarity index 64% rename from src/routes/.well-known/.oauth-cimd/+server.ts rename to src/routes/.well-known/oauth-cimd/+server.ts index 41c35c79b9b..62b7c912cdc 100644 --- a/src/routes/.well-known/.oauth-cimd/+server.ts +++ b/src/routes/.well-known/oauth-cimd/+server.ts @@ -1,3 +1,4 @@ +import { base } from "$app/paths"; import { OIDConfig } from "$lib/server/auth"; import { config } from "$lib/server/config"; @@ -6,14 +7,18 @@ export const GET = ({ url }) => { 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__'", { - status: 404, - }); + 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("/.well-known/oauth-cimd", config.PUBLIC_ORIGIN || url.origin).toString(), + 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, 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 1ec17500d4e..9e04ae8a39f 100644 --- a/src/routes/login/callback/+server.ts +++ b/src/routes/login/callback/+server.ts @@ -52,9 +52,15 @@ 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, + codeVerifier, iss, url ); From 3273315d4465352140aa3050f9715e4bfd4d5633 Mon Sep 17 00:00:00 2001 From: coyotte508 Date: Fri, 7 Nov 2025 22:00:01 +0000 Subject: [PATCH 4/4] add spec refs --- .env | 2 +- src/lib/server/auth.ts | 1 + src/routes/.well-known/oauth-cimd/+server.ts | 3 +++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.env b/.env index 66154f545b8..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="" # You can set to "__CIMD__" for automatic oauth app creation when deployed +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= diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index d12204046a4..052860055dd 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -266,6 +266,7 @@ async function getOIDCClient(settings: OIDCSettings, url: URL): Promise { if (!OIDConfig.CLIENT_ID) { return new Response("Client ID not found", { status: 404 });