Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
13 changes: 5 additions & 8 deletions src/hooks.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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)
Expand All @@ -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);
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions src/lib/server/api/authPlugin.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
71 changes: 50 additions & 21 deletions src/lib/server/auth.ts
Copy link
Member Author

@coyotte508 coyotte508 Nov 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of forwarding url everywhere, we could use contextStore like here: https://github.com/huggingface/Mongoku/blob/2a6c715c5bf4a7f7351aa3b3ce90ecd857e61d70/src/hooks.server.ts#L17-L48

(not sure for compat with non-node runtimes)

could also be used for structued logs (eg request id, ...)

Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"])
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -236,7 +240,7 @@ export async function generateCsrfToken(

let lastIssuer: Issuer<BaseClient> | null = null;
let lastIssuerFetchedAt: Date | null = null;
async function getOIDCClient(settings: OIDCSettings): Promise<BaseClient> {
async function getOIDCClient(settings: OIDCSettings, url: URL): Promise<BaseClient> {
if (
lastIssuer &&
lastIssuerFetchedAt &&
Expand All @@ -261,6 +265,14 @@ async function getOIDCClient(settings: OIDCSettings): Promise<BaseClient> {
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)) {
Expand All @@ -272,16 +284,29 @@ async function getOIDCClient(settings: OIDCSettings): Promise<BaseClient> {

export async function getOIDCAuthorizationUrl(
settings: OIDCSettings,
params: { sessionId: string; next?: string }
params: { sessionId: string; next?: string; url: URL; cookies: Cookies }
): Promise<string> {
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,
Expand All @@ -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<OIDCUserInfo> {
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 };
Expand All @@ -305,9 +339,10 @@ export async function getOIDCUserData(
*/
export async function refreshOAuthToken(
settings: OIDCSettings,
refreshToken: string
refreshToken: string,
url: URL
): Promise<TokenSet | null> {
const client = await getOIDCClient(settings);
const client = await getOIDCClient(settings, url);
const tokenSet = await client.refresh(refreshToken);
return tokenSet;
}
Expand Down Expand Up @@ -371,6 +406,7 @@ export async function getCoupledCookieHash(cookie: CookieRecord): Promise<string
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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<Response> {
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`;
Expand Down Expand Up @@ -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);
Expand Down
35 changes: 35 additions & 0 deletions src/routes/.well-known/oauth-cimd/+server.ts
Original file line number Diff line number Diff line change
@@ -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",
},
}
);
};
4 changes: 2 additions & 2 deletions src/routes/login/+server.ts
Original file line number Diff line number Diff line change
@@ -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);
}
9 changes: 8 additions & 1 deletion src/routes/login/callback/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading