coyotte508 HF Staff commited on
Commit
c0ef209
·
unverified ·
1 Parent(s): 8212474

Add CIMD support for automatic oauth app creation, and PKCE support (#1978)

Browse files

* Add CIMD support

* update env file

* Add PKCE and fix CIMD

* add spec refs

.env CHANGED
@@ -32,7 +32,7 @@ PUBLIC_APPLE_APP_ID=
32
 
33
  COUPLE_SESSION_WITH_COOKIE_NAME=
34
  # when OPEN_ID is configured, users are required to login after the welcome modal
35
- OPENID_CLIENT_ID=
36
  OPENID_CLIENT_SECRET=
37
  OPENID_SCOPES="openid profile inference-api read-mcp"
38
  USE_USER_TOKEN=
@@ -173,9 +173,6 @@ PUBLIC_COMMIT_SHA=
173
  ALLOW_INSECURE_COOKIES=false # LEGACY! Use COOKIE_SECURE and COOKIE_SAMESITE instead
174
  PARQUET_EXPORT_SECRET=#DEPRECATED, use ADMIN_API_SECRET instead
175
  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
179
  OPENID_NAME_CLAIM="name" # Change to "username" for some providers that do not provide name
180
  OPENID_PROVIDER_URL=https://huggingface.co # for Google, use https://accounts.google.com
181
  OPENID_TOLERANCE=
 
32
 
33
  COUPLE_SESSION_WITH_COOKIE_NAME=
34
  # when OPEN_ID is configured, users are required to login after the welcome modal
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/
36
  OPENID_CLIENT_SECRET=
37
  OPENID_SCOPES="openid profile inference-api read-mcp"
38
  USE_USER_TOKEN=
 
173
  ALLOW_INSECURE_COOKIES=false # LEGACY! Use COOKIE_SECURE and COOKIE_SAMESITE instead
174
  PARQUET_EXPORT_SECRET=#DEPRECATED, use ADMIN_API_SECRET instead
175
  RATE_LIMIT= # /!\ DEPRECATED definition of messages per minute. Use USAGE_LIMITS.messagesPerMinute instead
 
 
 
176
  OPENID_NAME_CLAIM="name" # Change to "username" for some providers that do not provide name
177
  OPENID_PROVIDER_URL=https://huggingface.co # for Google, use https://accounts.google.com
178
  OPENID_TOLERANCE=
src/hooks.server.ts CHANGED
@@ -137,12 +137,13 @@ export const handle: Handle = async ({ event, resolve }) => {
137
 
138
  const auth = await authenticateRequest(
139
  { type: "svelte", value: event.request.headers },
140
- { type: "svelte", value: event.cookies }
 
141
  );
142
 
143
  event.locals.sessionId = auth.sessionId;
144
 
145
- if (loginEnabled && !auth.user) {
146
  if (config.AUTOMATIC_LOGIN === "true") {
147
  // AUTOMATIC_LOGIN: always redirect to OAuth flow (unless already on login or healthcheck pages)
148
  if (
@@ -151,11 +152,7 @@ export const handle: Handle = async ({ event, resolve }) => {
151
  ) {
152
  // To get the same CSRF token after callback
153
  refreshSessionCookie(event.cookies, auth.secretSessionId);
154
- return await triggerOauthFlow({
155
- request: event.request,
156
- url: event.url,
157
- locals: event.locals,
158
- });
159
  }
160
  } else {
161
  // 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 }) => {
171
  !event.url.pathname.startsWith(`${base}/api`)
172
  ) {
173
  refreshSessionCookie(event.cookies, auth.secretSessionId);
174
- return triggerOauthFlow({ request: event.request, url: event.url, locals: event.locals });
175
  }
176
  }
177
  }
 
137
 
138
  const auth = await authenticateRequest(
139
  { type: "svelte", value: event.request.headers },
140
+ { type: "svelte", value: event.cookies },
141
+ event.url
142
  );
143
 
144
  event.locals.sessionId = auth.sessionId;
145
 
146
+ if (loginEnabled && !auth.user && !event.url.pathname.startsWith(`${base}/.well-known/`)) {
147
  if (config.AUTOMATIC_LOGIN === "true") {
148
  // AUTOMATIC_LOGIN: always redirect to OAuth flow (unless already on login or healthcheck pages)
149
  if (
 
152
  ) {
153
  // To get the same CSRF token after callback
154
  refreshSessionCookie(event.cookies, auth.secretSessionId);
155
+ return await triggerOauthFlow(event);
 
 
 
 
156
  }
157
  } else {
158
  // Redirect to OAuth flow unless on the authorized pages (home, shared conversation, login, healthcheck, model thumbnails)
 
168
  !event.url.pathname.startsWith(`${base}/api`)
169
  ) {
170
  refreshSessionCookie(event.cookies, auth.secretSessionId);
171
+ return triggerOauthFlow(event);
172
  }
173
  }
174
  }
src/lib/server/api/authPlugin.ts CHANGED
@@ -1,17 +1,20 @@
1
  import Elysia from "elysia";
2
  import { authenticateRequest } from "../auth";
 
3
 
4
  export const authPlugin = new Elysia({ name: "auth" }).derive(
5
  { as: "scoped" },
6
  async ({
7
  headers,
8
  cookie,
 
9
  }): Promise<{
10
  locals: App.Locals;
11
  }> => {
12
  const auth = await authenticateRequest(
13
  { type: "elysia", value: headers },
14
  { type: "elysia", value: cookie },
 
15
  true
16
  );
17
  return {
 
1
  import Elysia from "elysia";
2
  import { authenticateRequest } from "../auth";
3
+ import { config } from "../config";
4
 
5
  export const authPlugin = new Elysia({ name: "auth" }).derive(
6
  { as: "scoped" },
7
  async ({
8
  headers,
9
  cookie,
10
+ request,
11
  }): Promise<{
12
  locals: App.Locals;
13
  }> => {
14
  const auth = await authenticateRequest(
15
  { type: "elysia", value: headers },
16
  { type: "elysia", value: cookie },
17
+ new URL(request.url, config.PUBLIC_ORIGIN || undefined),
18
  true
19
  );
20
  return {
src/lib/server/auth.ts CHANGED
@@ -4,7 +4,9 @@ import {
4
  type UserinfoResponse,
5
  type TokenSet,
6
  custom,
 
7
  } from "openid-client";
 
8
  import { addHours, addWeeks, differenceInMinutes, subMinutes } from "date-fns";
9
  import { config } from "$lib/server/config";
10
  import { sha256 } from "$lib/utils/sha256";
@@ -54,7 +56,7 @@ export const OIDConfig = z
54
  })
55
  .parse(JSON5.parse(config.OPENID_CONFIG || "{}"));
56
 
57
- export const loginEnabled = !!OIDConfig.CLIENT_ID && !!OIDConfig.CLIENT_SECRET;
58
 
59
  const sameSite = z
60
  .enum(["lax", "none", "strict"])
@@ -92,7 +94,8 @@ export function refreshSessionCookie(cookies: Cookies, sessionId: string) {
92
 
93
  export async function findUser(
94
  sessionId: string,
95
- coupledCookieHash?: string
 
96
  ): Promise<{
97
  user: User | null;
98
  invalidateSession: boolean;
@@ -121,7 +124,8 @@ export async function findUser(
121
  // Attempt to refresh the token
122
  const newTokenSet = await refreshOAuthToken(
123
  { redirectURI: `${config.PUBLIC_ORIGIN}${base}/login/callback` },
124
- session.oauth.refreshToken
 
125
  );
126
 
127
  if (!newTokenSet || !newTokenSet.access_token) {
@@ -236,7 +240,7 @@ export async function generateCsrfToken(
236
 
237
  let lastIssuer: Issuer<BaseClient> | null = null;
238
  let lastIssuerFetchedAt: Date | null = null;
239
- async function getOIDCClient(settings: OIDCSettings): Promise<BaseClient> {
240
  if (
241
  lastIssuer &&
242
  lastIssuerFetchedAt &&
@@ -261,6 +265,14 @@ async function getOIDCClient(settings: OIDCSettings): Promise<BaseClient> {
261
  id_token_signed_response_alg: OIDConfig.ID_TOKEN_SIGNED_RESPONSE_ALG || undefined,
262
  };
263
 
 
 
 
 
 
 
 
 
264
  const alg_supported = issuer.metadata["id_token_signing_alg_values_supported"];
265
 
266
  if (Array.isArray(alg_supported)) {
@@ -272,16 +284,29 @@ async function getOIDCClient(settings: OIDCSettings): Promise<BaseClient> {
272
 
273
  export async function getOIDCAuthorizationUrl(
274
  settings: OIDCSettings,
275
- params: { sessionId: string; next?: string }
276
  ): Promise<string> {
277
- const client = await getOIDCClient(settings);
278
  const csrfToken = await generateCsrfToken(
279
  params.sessionId,
280
  settings.redirectURI,
281
  sanitizeReturnPath(params.next)
282
  );
283
 
 
 
 
 
 
 
 
 
 
 
 
284
  return client.authorizationUrl({
 
 
285
  scope: OIDConfig.SCOPES,
286
  state: csrfToken,
287
  resource: OIDConfig.RESOURCE || undefined,
@@ -291,10 +316,19 @@ export async function getOIDCAuthorizationUrl(
291
  export async function getOIDCUserData(
292
  settings: OIDCSettings,
293
  code: string,
294
- iss?: string
 
 
295
  ): Promise<OIDCUserInfo> {
296
- const client = await getOIDCClient(settings);
297
- const token = await client.callback(settings.redirectURI, { code, iss });
 
 
 
 
 
 
 
298
  const userData = await client.userinfo(token);
299
 
300
  return { token, userData };
@@ -305,9 +339,10 @@ export async function getOIDCUserData(
305
  */
306
  export async function refreshOAuthToken(
307
  settings: OIDCSettings,
308
- refreshToken: string
 
309
  ): Promise<TokenSet | null> {
310
- const client = await getOIDCClient(settings);
311
  const tokenSet = await client.refresh(refreshToken);
312
  return tokenSet;
313
  }
@@ -371,6 +406,7 @@ export async function getCoupledCookieHash(cookie: CookieRecord): Promise<string
371
  export async function authenticateRequest(
372
  headers: HeaderRecord,
373
  cookie: CookieRecord,
 
374
  isApi?: boolean
375
  ): Promise<App.Locals & { secretSessionId: string }> {
376
  // once the entire API has been moved to elysia
@@ -415,7 +451,7 @@ export async function authenticateRequest(
415
  secretSessionId = token;
416
  sessionId = await sha256(token);
417
 
418
- const result = await findUser(sessionId, await getCoupledCookieHash(cookie));
419
 
420
  if (result.invalidateSession) {
421
  secretSessionId = crypto.randomUUID();
@@ -502,14 +538,7 @@ export async function authenticateRequest(
502
  return { user: undefined, sessionId, secretSessionId, isAdmin: false };
503
  }
504
 
505
- export async function triggerOauthFlow({
506
- url,
507
- locals,
508
- }: {
509
- request: Request;
510
- url: URL;
511
- locals: App.Locals;
512
- }): Promise<Response> {
513
  // const referer = request.headers.get("referer");
514
  // let redirectURI = `${(referer ? new URL(referer) : url).origin}${base}/login/callback`;
515
  let redirectURI = `${url.origin}${base}/login/callback`;
@@ -539,7 +568,7 @@ export async function triggerOauthFlow({
539
 
540
  const authorizationUrl = await getOIDCAuthorizationUrl(
541
  { redirectURI },
542
- { sessionId: locals.sessionId, next }
543
  );
544
 
545
  throw redirect(302, authorizationUrl);
 
4
  type UserinfoResponse,
5
  type TokenSet,
6
  custom,
7
+ generators,
8
  } from "openid-client";
9
+ import type { RequestEvent } from "@sveltejs/kit";
10
  import { addHours, addWeeks, differenceInMinutes, subMinutes } from "date-fns";
11
  import { config } from "$lib/server/config";
12
  import { sha256 } from "$lib/utils/sha256";
 
56
  })
57
  .parse(JSON5.parse(config.OPENID_CONFIG || "{}"));
58
 
59
+ export const loginEnabled = !!OIDConfig.CLIENT_ID;
60
 
61
  const sameSite = z
62
  .enum(["lax", "none", "strict"])
 
94
 
95
  export async function findUser(
96
  sessionId: string,
97
+ coupledCookieHash: string | undefined,
98
+ url: URL
99
  ): Promise<{
100
  user: User | null;
101
  invalidateSession: boolean;
 
124
  // Attempt to refresh the token
125
  const newTokenSet = await refreshOAuthToken(
126
  { redirectURI: `${config.PUBLIC_ORIGIN}${base}/login/callback` },
127
+ session.oauth.refreshToken,
128
+ url
129
  );
130
 
131
  if (!newTokenSet || !newTokenSet.access_token) {
 
240
 
241
  let lastIssuer: Issuer<BaseClient> | null = null;
242
  let lastIssuerFetchedAt: Date | null = null;
243
+ async function getOIDCClient(settings: OIDCSettings, url: URL): Promise<BaseClient> {
244
  if (
245
  lastIssuer &&
246
  lastIssuerFetchedAt &&
 
265
  id_token_signed_response_alg: OIDConfig.ID_TOKEN_SIGNED_RESPONSE_ALG || undefined,
266
  };
267
 
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
+
276
  const alg_supported = issuer.metadata["id_token_signing_alg_values_supported"];
277
 
278
  if (Array.isArray(alg_supported)) {
 
284
 
285
  export async function getOIDCAuthorizationUrl(
286
  settings: OIDCSettings,
287
+ params: { sessionId: string; next?: string; url: URL; cookies: Cookies }
288
  ): Promise<string> {
289
+ const client = await getOIDCClient(settings, params.url);
290
  const csrfToken = await generateCsrfToken(
291
  params.sessionId,
292
  settings.redirectURI,
293
  sanitizeReturnPath(params.next)
294
  );
295
 
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
+
307
  return client.authorizationUrl({
308
+ code_challenge_method: "S256",
309
+ code_challenge: codeChallenge,
310
  scope: OIDConfig.SCOPES,
311
  state: csrfToken,
312
  resource: OIDConfig.RESOURCE || undefined,
 
316
  export async function getOIDCUserData(
317
  settings: OIDCSettings,
318
  code: string,
319
+ codeVerifier: string,
320
+ iss: string | undefined,
321
+ url: URL
322
  ): Promise<OIDCUserInfo> {
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
+ );
332
  const userData = await client.userinfo(token);
333
 
334
  return { token, userData };
 
339
  */
340
  export async function refreshOAuthToken(
341
  settings: OIDCSettings,
342
+ refreshToken: string,
343
+ url: URL
344
  ): Promise<TokenSet | null> {
345
+ const client = await getOIDCClient(settings, url);
346
  const tokenSet = await client.refresh(refreshToken);
347
  return tokenSet;
348
  }
 
406
  export async function authenticateRequest(
407
  headers: HeaderRecord,
408
  cookie: CookieRecord,
409
+ url: URL,
410
  isApi?: boolean
411
  ): Promise<App.Locals & { secretSessionId: string }> {
412
  // once the entire API has been moved to elysia
 
451
  secretSessionId = token;
452
  sessionId = await sha256(token);
453
 
454
+ const result = await findUser(sessionId, await getCoupledCookieHash(cookie), url);
455
 
456
  if (result.invalidateSession) {
457
  secretSessionId = crypto.randomUUID();
 
538
  return { user: undefined, sessionId, secretSessionId, isAdmin: false };
539
  }
540
 
541
+ export async function triggerOauthFlow({ url, locals, cookies }: RequestEvent): Promise<Response> {
 
 
 
 
 
 
 
542
  // const referer = request.headers.get("referer");
543
  // let redirectURI = `${(referer ? new URL(referer) : url).origin}${base}/login/callback`;
544
  let redirectURI = `${url.origin}${base}/login/callback`;
 
568
 
569
  const authorizationUrl = await getOIDCAuthorizationUrl(
570
  { redirectURI },
571
+ { sessionId: locals.sessionId, next, url, cookies }
572
  );
573
 
574
  throw redirect(302, authorizationUrl);
src/routes/.well-known/oauth-cimd/+server.ts ADDED
@@ -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 CHANGED
@@ -1,5 +1,5 @@
1
  import { triggerOauthFlow } from "$lib/server/auth";
2
 
3
- export async function GET({ request, url, locals }) {
4
- return await triggerOauthFlow({ request, url, locals });
5
  }
 
1
  import { triggerOauthFlow } from "$lib/server/auth";
2
 
3
+ export async function GET(event) {
4
+ return await triggerOauthFlow(event);
5
  }
src/routes/login/callback/+server.ts CHANGED
@@ -52,10 +52,17 @@ export async function GET({ url, locals, cookies, request, getClientAddress }) {
52
  throw error(403, "Invalid or expired CSRF token");
53
  }
54
 
 
 
 
 
 
55
  const { userData, token } = await getOIDCUserData(
56
  { redirectURI: validatedToken.redirectUrl },
57
  code,
58
- iss
 
 
59
  );
60
 
61
  // Filter by allowed user emails or domains
 
52
  throw error(403, "Invalid or expired CSRF token");
53
  }
54
 
55
+ const codeVerifier = cookies.get("hfChat-codeVerifier");
56
+ if (!codeVerifier) {
57
+ throw error(403, "Code verifier cookie not found");
58
+ }
59
+
60
  const { userData, token } = await getOIDCUserData(
61
  { redirectURI: validatedToken.redirectUrl },
62
  code,
63
+ codeVerifier,
64
+ iss,
65
+ url
66
  );
67
 
68
  // Filter by allowed user emails or domains