Skip to content

Commit cca007b

Browse files
authored
Add CIMD support for automatic oauth app creation, and PKCE support (#1978)
* Add CIMD support * update env file * Add PKCE and fix CIMD * add spec refs
1 parent 59efcb7 commit cca007b

File tree

7 files changed

+104
-36
lines changed

7 files changed

+104
-36
lines changed

.env

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ PUBLIC_APPLE_APP_ID=
3232

3333
COUPLE_SESSION_WITH_COOKIE_NAME=
3434
# when OPEN_ID is configured, users are required to login after the welcome modal
35-
OPENID_CLIENT_ID=
35+
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/
3636
OPENID_CLIENT_SECRET=
3737
OPENID_SCOPES="openid profile inference-api read-mcp"
3838
USE_USER_TOKEN=
@@ -173,9 +173,6 @@ PUBLIC_COMMIT_SHA=
173173
ALLOW_INSECURE_COOKIES=false # LEGACY! Use COOKIE_SECURE and COOKIE_SAMESITE instead
174174
PARQUET_EXPORT_SECRET=#DEPRECATED, use ADMIN_API_SECRET instead
175175
RATE_LIMIT= # /!\ DEPRECATED definition of messages per minute. Use USAGE_LIMITS.messagesPerMinute instead
176-
OPENID_CLIENT_ID=
177-
OPENID_CLIENT_SECRET=
178-
OPENID_SCOPES="openid profile" # Add "email" for some providers like Google that do not provide preferred_username
179176
OPENID_NAME_CLAIM="name" # Change to "username" for some providers that do not provide name
180177
OPENID_PROVIDER_URL=https://huggingface.co # for Google, use https://accounts.google.com
181178
OPENID_TOLERANCE=

src/hooks.server.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -137,12 +137,13 @@ export const handle: Handle = async ({ event, resolve }) => {
137137

138138
const auth = await authenticateRequest(
139139
{ type: "svelte", value: event.request.headers },
140-
{ type: "svelte", value: event.cookies }
140+
{ type: "svelte", value: event.cookies },
141+
event.url
141142
);
142143

143144
event.locals.sessionId = auth.sessionId;
144145

145-
if (loginEnabled && !auth.user) {
146+
if (loginEnabled && !auth.user && !event.url.pathname.startsWith(`${base}/.well-known/`)) {
146147
if (config.AUTOMATIC_LOGIN === "true") {
147148
// AUTOMATIC_LOGIN: always redirect to OAuth flow (unless already on login or healthcheck pages)
148149
if (
@@ -151,11 +152,7 @@ export const handle: Handle = async ({ event, resolve }) => {
151152
) {
152153
// To get the same CSRF token after callback
153154
refreshSessionCookie(event.cookies, auth.secretSessionId);
154-
return await triggerOauthFlow({
155-
request: event.request,
156-
url: event.url,
157-
locals: event.locals,
158-
});
155+
return await triggerOauthFlow(event);
159156
}
160157
} else {
161158
// Redirect to OAuth flow unless on the authorized pages (home, shared conversation, login, healthcheck, model thumbnails)
@@ -171,7 +168,7 @@ export const handle: Handle = async ({ event, resolve }) => {
171168
!event.url.pathname.startsWith(`${base}/api`)
172169
) {
173170
refreshSessionCookie(event.cookies, auth.secretSessionId);
174-
return triggerOauthFlow({ request: event.request, url: event.url, locals: event.locals });
171+
return triggerOauthFlow(event);
175172
}
176173
}
177174
}

src/lib/server/api/authPlugin.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
import Elysia from "elysia";
22
import { authenticateRequest } from "../auth";
3+
import { config } from "../config";
34

45
export const authPlugin = new Elysia({ name: "auth" }).derive(
56
{ as: "scoped" },
67
async ({
78
headers,
89
cookie,
10+
request,
911
}): Promise<{
1012
locals: App.Locals;
1113
}> => {
1214
const auth = await authenticateRequest(
1315
{ type: "elysia", value: headers },
1416
{ type: "elysia", value: cookie },
17+
new URL(request.url, config.PUBLIC_ORIGIN || undefined),
1518
true
1619
);
1720
return {

src/lib/server/auth.ts

Lines changed: 50 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import {
44
type UserinfoResponse,
55
type TokenSet,
66
custom,
7+
generators,
78
} from "openid-client";
9+
import type { RequestEvent } from "@sveltejs/kit";
810
import { addHours, addWeeks, differenceInMinutes, subMinutes } from "date-fns";
911
import { config } from "$lib/server/config";
1012
import { sha256 } from "$lib/utils/sha256";
@@ -54,7 +56,7 @@ export const OIDConfig = z
5456
})
5557
.parse(JSON5.parse(config.OPENID_CONFIG || "{}"));
5658

57-
export const loginEnabled = !!OIDConfig.CLIENT_ID && !!OIDConfig.CLIENT_SECRET;
59+
export const loginEnabled = !!OIDConfig.CLIENT_ID;
5860

5961
const sameSite = z
6062
.enum(["lax", "none", "strict"])
@@ -92,7 +94,8 @@ export function refreshSessionCookie(cookies: Cookies, sessionId: string) {
9294

9395
export async function findUser(
9496
sessionId: string,
95-
coupledCookieHash?: string
97+
coupledCookieHash: string | undefined,
98+
url: URL
9699
): Promise<{
97100
user: User | null;
98101
invalidateSession: boolean;
@@ -121,7 +124,8 @@ export async function findUser(
121124
// Attempt to refresh the token
122125
const newTokenSet = await refreshOAuthToken(
123126
{ redirectURI: `${config.PUBLIC_ORIGIN}${base}/login/callback` },
124-
session.oauth.refreshToken
127+
session.oauth.refreshToken,
128+
url
125129
);
126130

127131
if (!newTokenSet || !newTokenSet.access_token) {
@@ -236,7 +240,7 @@ export async function generateCsrfToken(
236240

237241
let lastIssuer: Issuer<BaseClient> | null = null;
238242
let lastIssuerFetchedAt: Date | null = null;
239-
async function getOIDCClient(settings: OIDCSettings): Promise<BaseClient> {
243+
async function getOIDCClient(settings: OIDCSettings, url: URL): Promise<BaseClient> {
240244
if (
241245
lastIssuer &&
242246
lastIssuerFetchedAt &&
@@ -261,6 +265,14 @@ async function getOIDCClient(settings: OIDCSettings): Promise<BaseClient> {
261265
id_token_signed_response_alg: OIDConfig.ID_TOKEN_SIGNED_RESPONSE_ALG || undefined,
262266
};
263267

268+
if (OIDConfig.CLIENT_ID === "__CIMD__") {
269+
// See https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/
270+
client_config.client_id = new URL(
271+
`${base}/.well-known/oauth-cimd`,
272+
config.PUBLIC_ORIGIN || url.origin
273+
).toString();
274+
}
275+
264276
const alg_supported = issuer.metadata["id_token_signing_alg_values_supported"];
265277

266278
if (Array.isArray(alg_supported)) {
@@ -272,16 +284,29 @@ async function getOIDCClient(settings: OIDCSettings): Promise<BaseClient> {
272284

273285
export async function getOIDCAuthorizationUrl(
274286
settings: OIDCSettings,
275-
params: { sessionId: string; next?: string }
287+
params: { sessionId: string; next?: string; url: URL; cookies: Cookies }
276288
): Promise<string> {
277-
const client = await getOIDCClient(settings);
289+
const client = await getOIDCClient(settings, params.url);
278290
const csrfToken = await generateCsrfToken(
279291
params.sessionId,
280292
settings.redirectURI,
281293
sanitizeReturnPath(params.next)
282294
);
283295

296+
const codeVerifier = generators.codeVerifier();
297+
const codeChallenge = generators.codeChallenge(codeVerifier);
298+
299+
params.cookies.set("hfChat-codeVerifier", codeVerifier, {
300+
path: "/",
301+
sameSite,
302+
secure,
303+
httpOnly: true,
304+
expires: addHours(new Date(), 1),
305+
});
306+
284307
return client.authorizationUrl({
308+
code_challenge_method: "S256",
309+
code_challenge: codeChallenge,
285310
scope: OIDConfig.SCOPES,
286311
state: csrfToken,
287312
resource: OIDConfig.RESOURCE || undefined,
@@ -291,10 +316,19 @@ export async function getOIDCAuthorizationUrl(
291316
export async function getOIDCUserData(
292317
settings: OIDCSettings,
293318
code: string,
294-
iss?: string
319+
codeVerifier: string,
320+
iss: string | undefined,
321+
url: URL
295322
): Promise<OIDCUserInfo> {
296-
const client = await getOIDCClient(settings);
297-
const token = await client.callback(settings.redirectURI, { code, iss });
323+
const client = await getOIDCClient(settings, url);
324+
const token = await client.callback(
325+
settings.redirectURI,
326+
{
327+
code,
328+
iss,
329+
},
330+
{ code_verifier: codeVerifier }
331+
);
298332
const userData = await client.userinfo(token);
299333

300334
return { token, userData };
@@ -305,9 +339,10 @@ export async function getOIDCUserData(
305339
*/
306340
export async function refreshOAuthToken(
307341
settings: OIDCSettings,
308-
refreshToken: string
342+
refreshToken: string,
343+
url: URL
309344
): Promise<TokenSet | null> {
310-
const client = await getOIDCClient(settings);
345+
const client = await getOIDCClient(settings, url);
311346
const tokenSet = await client.refresh(refreshToken);
312347
return tokenSet;
313348
}
@@ -371,6 +406,7 @@ export async function getCoupledCookieHash(cookie: CookieRecord): Promise<string
371406
export async function authenticateRequest(
372407
headers: HeaderRecord,
373408
cookie: CookieRecord,
409+
url: URL,
374410
isApi?: boolean
375411
): Promise<App.Locals & { secretSessionId: string }> {
376412
// once the entire API has been moved to elysia
@@ -415,7 +451,7 @@ export async function authenticateRequest(
415451
secretSessionId = token;
416452
sessionId = await sha256(token);
417453

418-
const result = await findUser(sessionId, await getCoupledCookieHash(cookie));
454+
const result = await findUser(sessionId, await getCoupledCookieHash(cookie), url);
419455

420456
if (result.invalidateSession) {
421457
secretSessionId = crypto.randomUUID();
@@ -502,14 +538,7 @@ export async function authenticateRequest(
502538
return { user: undefined, sessionId, secretSessionId, isAdmin: false };
503539
}
504540

505-
export async function triggerOauthFlow({
506-
url,
507-
locals,
508-
}: {
509-
request: Request;
510-
url: URL;
511-
locals: App.Locals;
512-
}): Promise<Response> {
541+
export async function triggerOauthFlow({ url, locals, cookies }: RequestEvent): Promise<Response> {
513542
// const referer = request.headers.get("referer");
514543
// let redirectURI = `${(referer ? new URL(referer) : url).origin}${base}/login/callback`;
515544
let redirectURI = `${url.origin}${base}/login/callback`;
@@ -539,7 +568,7 @@ export async function triggerOauthFlow({
539568

540569
const authorizationUrl = await getOIDCAuthorizationUrl(
541570
{ redirectURI },
542-
{ sessionId: locals.sessionId, next }
571+
{ sessionId: locals.sessionId, next, url, cookies }
543572
);
544573

545574
throw redirect(302, authorizationUrl);
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { base } from "$app/paths";
2+
import { OIDConfig } from "$lib/server/auth";
3+
import { config } from "$lib/server/config";
4+
5+
/**
6+
* See https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/
7+
*/
8+
export const GET = ({ url }) => {
9+
if (!OIDConfig.CLIENT_ID) {
10+
return new Response("Client ID not found", { status: 404 });
11+
}
12+
if (OIDConfig.CLIENT_ID !== "__CIMD__") {
13+
return new Response(
14+
`Client ID is manually set to something other than '__CIMD__': ${OIDConfig.CLIENT_ID}`,
15+
{
16+
status: 404,
17+
}
18+
);
19+
}
20+
return new Response(
21+
JSON.stringify({
22+
client_id: new URL(url, config.PUBLIC_ORIGIN || url.origin).toString(),
23+
client_name: config.PUBLIC_APP_NAME,
24+
client_uri: `${config.PUBLIC_ORIGIN || url.origin}${base}`,
25+
redirect_uris: [new URL("/login/callback", config.PUBLIC_ORIGIN || url.origin).toString()],
26+
token_endpoint_auth_method: "none",
27+
scopes: OIDConfig.SCOPES,
28+
}),
29+
{
30+
headers: {
31+
"Content-Type": "application/json",
32+
},
33+
}
34+
);
35+
};

src/routes/login/+server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { triggerOauthFlow } from "$lib/server/auth";
22

3-
export async function GET({ request, url, locals }) {
4-
return await triggerOauthFlow({ request, url, locals });
3+
export async function GET(event) {
4+
return await triggerOauthFlow(event);
55
}

src/routes/login/callback/+server.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,17 @@ export async function GET({ url, locals, cookies, request, getClientAddress }) {
5252
throw error(403, "Invalid or expired CSRF token");
5353
}
5454

55+
const codeVerifier = cookies.get("hfChat-codeVerifier");
56+
if (!codeVerifier) {
57+
throw error(403, "Code verifier cookie not found");
58+
}
59+
5560
const { userData, token } = await getOIDCUserData(
5661
{ redirectURI: validatedToken.redirectUrl },
5762
code,
58-
iss
63+
codeVerifier,
64+
iss,
65+
url
5966
);
6067

6168
// Filter by allowed user emails or domains

0 commit comments

Comments
 (0)