@better-auth/oauth-provider 1.7.9 → 1.8.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-wcPFST8Q.mjs";
2
- import { A as claimsRequestParameterSchema, C as clientJwksSchema, D as STANDARD_CLAIM_NAMES, E as STANDARD_CLAIMS, M as getRequestedUserInfoClaims, N as isValidOidcClaimsRequest, O as getSupportedClaims, S as authorizationQuerySchema, _ as logStartupSeedFailure, a as invalidateRefreshFamily, b as ResourceUriSchema, c as normalizeClientCredentialsScopes, d as assertIdentifierValid, f as extractRepeatedResourceFromForm, g as logEnforcePerClientResourcesResolution, h as isAudienceClaimAllowed, j as filterClaimsRequestUserInfoClaims, k as canSatisfyEssentialAcrRequest, l as validateClientCredentialsScopes, m as invalidateResourceCache, o as tokenEndpoint, p as getResource, r as decodeRefreshToken, s as userInfoEndpoint, t as introspectEndpoint, u as JWS_ALGORITHMS, v as resolveResourcePolicy, w as clientRegistrationRequestSchema, x as SafeUrlSchema, y as seedResourcesOnce } from "./introspect-7WCMPe5Z.mjs";
3
- import { B as validateOAuthProviderExtensions, C as storeToken, E as validateClientCredentials, I as getSupportedAuthMethods, L as getSupportedGrantTypes, O as verifyOAuthQueryParams, P as getClientDiscoveries, S as storeClientSecret, T as toResourceList, _ as removeMaxAgeFromQuery, a as getClient, b as resolveSubjectIdentifier, c as getStoredToken, d as mergeDiscoveryMetadata, g as parsePrompt, h as parseClientMetadata, i as extractClientCredentials, k as applyOAuthProviderMetadataExtensions, l as isPKCERequired, m as parseBearerToken, n as decryptStoredClientSecret, o as getJwtPlugin, r as destructureCredentials, t as clientAllowsGrant, u as isSessionFreshForSignedQuery, v as removePromptFromQuery, x as searchParamsToQuery, z as isExtensionTokenEndpointAuthMethod } from "./utils-DKU5wblX.mjs";
2
+ import { A as getAcrValuesSupported, C as authorizationQuerySchema, D as STANDARD_CLAIMS, F as getRequestedUserInfoClaims, I as isValidOidcClaimsRequest, M as canSatisfyEssentialAcrRequest, N as claimsRequestParameterSchema, O as STANDARD_CLAIM_NAMES, P as filterClaimsRequestUserInfoClaims, S as SafeUrlSchema, T as clientRegistrationRequestSchema, _ as logStartupSeedFailure, a as tokenEndpoint, b as userInfoResource, c as normalizeClientCredentialsScopes, d as JWS_ALGORITHMS, f as assertIdentifierValid, g as logEnforcePerClientResourcesResolution, h as isAudienceClaimAllowed, i as invalidateRefreshFamily, j as resolveAuthenticationContext, k as getSupportedClaims, l as validateClientCredentialsScopes, m as invalidateResourceCache, o as userInfoEndpoint, p as getResource, s as findToken, t as introspectEndpoint, u as getEndpointUrl, v as resolveResourcePolicy, w as clientJwksSchema, x as ResourceUriSchema, y as seedResourcesOnce } from "./introspect-CEo9Q06v.mjs";
3
+ import { $ as validateOAuthProviderExtensions, A as storeClientSecret, E as removePromptFromQuery, F as validateClientCredentials, J as getSupportedGrantTypes, K as getExtensionInteractionRequest, L as verifyOAuthQueryParams, N as toResourceList, O as resolveSubjectIdentifier, Q as validateExtensionClientMetadata, R as applyOAuthProviderMetadataExtensions, S as parsePrompt, T as removeMaxAgeFromQuery, U as getClientDiscoveries, W as getExtensionClientMetadataFields, Y as hasExtensionClientMetadata, Z as isExtensionTokenEndpointAuthMethod, _ as mergeDiscoveryMetadata, a as decryptStoredClientSecret, b as parseBearerToken, c as extractClientCredentials, d as getConsentReferenceId, f as getJwtPlugin, g as isSessionFreshForSignedQuery, h as isPKCERequired, i as consentCovers, j as storeToken, k as searchParamsToQuery, l as findConsent, n as assertNotTrustedClient, o as deriveSectorIdentifier, q as getSupportedAuthMethods, r as clientAllowsGrant, s as destructureCredentials, t as applyFormParams, tt as stripReservedOAuthClientMetadataExtensions, u as getClient, w as readFormParams, x as parseClientMetadata } from "./utils-BMNe8Sg2.mjs";
4
4
  import { a as setSignedOAuthQueryParameterNames, i as postLoginClearedParam, n as canonicalizeOAuthQueryParams, o as signedQueryIssuedAtParam, r as getSignedQueryIssuedAt } from "./signed-query-Df1MNiSH.mjs";
5
- import { t as PACKAGE_VERSION } from "./version-DVUrz-nv.mjs";
5
+ import { t as PACKAGE_VERSION } from "./version-DeABnmP9.mjs";
6
6
  import { isBrowserFetchRequest } from "@better-auth/core/utils/fetch-metadata";
7
7
  import { isLoopbackHost, isLoopbackIP, isPublicRoutableHost } from "@better-auth/core/utils/host";
8
8
  import { appendQueryParams } from "@better-auth/core/utils/url";
@@ -11,16 +11,16 @@ import { generateRandomString, makeSignature } from "better-auth/crypto";
11
11
  import { APIError as APIError$1 } from "better-call";
12
12
  import { logger } from "@better-auth/core/env";
13
13
  import * as z from "zod";
14
- import { defineRequestState, getCurrentAdapter, runWithTransaction } from "@better-auth/core/context";
15
14
  import { BetterAuthError } from "@better-auth/core/error";
15
+ import { CLIENT_ASSERTION_TYPE, DPOP_SIGNING_ALGORITHMS, PRIVATE_KEY_JWT_SIGNING_ALGORITHMS } from "@better-auth/core/oauth2";
16
+ import { defineRequestState, getCurrentAdapter, hasRequestState, runWithTransaction } from "@better-auth/core/context";
16
17
  import { deleteSessionCookie, parseSetCookieHeader } from "better-auth/cookies";
17
18
  import { mergeSchema } from "better-auth/db";
18
- import { CLIENT_ASSERTION_TYPE, DPOP_SIGNING_ALGORITHMS, PRIVATE_KEY_JWT_SIGNING_ALGORITHMS } from "@better-auth/core/oauth2";
19
19
  import { createHash } from "@better-auth/utils/hash";
20
20
  import { getJwks, stripAccessTokenAuthorizationScheme } from "better-auth/oauth2";
21
- import { compactVerify, createLocalJWKSet, decodeJwt, decodeProtectedHeader, jwtVerify } from "jose";
22
21
  import { isNativePrivateUseRedirectUri } from "@better-auth/core/utils/redirect-uri";
23
22
  import { resolveSigningKey, signJWT, toExpJWT } from "better-auth/plugins";
23
+ import { compactVerify, createLocalJWKSet, decodeJwt, decodeProtectedHeader, jwtVerify } from "jose";
24
24
  import { encodeDeterministicId } from "@better-auth/core/db/internal";
25
25
  //#region src/consent.ts
26
26
  async function consentEndpoint(ctx, opts, authorize) {
@@ -31,7 +31,7 @@ async function consentEndpoint(ctx, opts, authorize) {
31
31
  error: "invalid_request"
32
32
  });
33
33
  const query = new URLSearchParams(_query);
34
- const originalRequestedScopes = query.get("scope")?.split(" ") ?? [];
34
+ const originalRequestedScopes = query.get("scope")?.split(" ").filter((scope) => scope) ?? [];
35
35
  const supportedClaims = getSupportedClaims(opts);
36
36
  const originalRequestedUserInfoClaims = getRequestedUserInfoClaims(query.get("claims"), supportedClaims);
37
37
  const clientId = query.get("client_id");
@@ -39,7 +39,7 @@ async function consentEndpoint(ctx, opts, authorize) {
39
39
  error_description: "client_id is required",
40
40
  error: "invalid_client"
41
41
  });
42
- const requestedScopes = ctx.body.scope?.split(" ");
42
+ const requestedScopes = ctx.body.scope?.split(" ").filter((scope) => scope);
43
43
  if (requestedScopes) {
44
44
  if (!requestedScopes.every((sc) => originalRequestedScopes?.includes(sc))) throw new APIError("BAD_REQUEST", {
45
45
  error_description: "Scope not originally requested",
@@ -52,11 +52,25 @@ async function consentEndpoint(ctx, opts, authorize) {
52
52
  error_description: "Claim not originally requested",
53
53
  error: "invalid_request"
54
54
  });
55
- if (!(ctx.body.accept === true)) return {
56
- redirect: true,
57
- url: formatErrorURL(query.get("redirect_uri") ?? "", "access_denied", "User denied access", query.get("state") ?? void 0, getIssuer(ctx, opts))
58
- };
59
55
  const session = await getSessionFromCtx(ctx);
56
+ if (!(ctx.body.accept === true)) {
57
+ const interaction = getExtensionInteractionRequest(opts, searchParamsToQuery(query));
58
+ if (interaction) return {
59
+ redirect: true,
60
+ url: await interaction.handler.deny({
61
+ ctx,
62
+ value: interaction.value,
63
+ userId: session?.user.id
64
+ }).catch((error) => getAPIErrorURL(ctx, error, interaction.handler.errorPage?.({
65
+ ctx,
66
+ value: interaction.value
67
+ })))
68
+ };
69
+ return {
70
+ redirect: true,
71
+ url: formatErrorURL(query.get("redirect_uri") ?? "", "access_denied", "User denied access", query.get("state") ?? void 0, getIssuer(ctx, opts))
72
+ };
73
+ }
60
74
  const hasLoginPrompt = parsePrompt(query.get("prompt") ?? "").has("login");
61
75
  const hasSatisfiedLoginPrompt = hasLoginPrompt && isSessionFreshForSignedQuery(session?.session.createdAt, oauthRequest?.signedQueryIssuedAt);
62
76
  if (hasLoginPrompt && !hasSatisfiedLoginPrompt) {
@@ -64,28 +78,12 @@ async function consentEndpoint(ctx, opts, authorize) {
64
78
  ctx.query = searchParamsToQuery(query);
65
79
  return await authorize(ctx);
66
80
  }
67
- const referenceId = await opts.postLogin?.consentReferenceId?.({
81
+ const referenceId = await getConsentReferenceId(opts, {
68
82
  user: session?.user,
69
83
  session: session?.session,
70
84
  scopes: requestedScopes ?? originalRequestedScopes
71
85
  });
72
- const foundConsent = await ctx.context.adapter.findOne({
73
- model: "oauthConsent",
74
- where: [
75
- {
76
- field: "clientId",
77
- value: clientId
78
- },
79
- {
80
- field: "userId",
81
- value: session?.user.id
82
- },
83
- ...referenceId ? [{
84
- field: "referenceId",
85
- value: referenceId
86
- }] : []
87
- ]
88
- });
86
+ const foundConsent = await findConsent(ctx, clientId, session?.user.id, referenceId);
89
87
  const iat = Math.floor(Date.now() / 1e3);
90
88
  const resource = query.getAll("resource");
91
89
  const consent = {
@@ -130,7 +128,10 @@ async function consentEndpoint(ctx, opts, authorize) {
130
128
  authorizationQuery = removeMaxAgeFromQuery(authorizationQuery);
131
129
  }
132
130
  ctx.query = searchParamsToQuery(authorizationQuery);
133
- return await authorize(ctx, { postLogin: oauthRequest?.postLoginClearedForSession !== void 0 && oauthRequest.postLoginClearedForSession === session?.session.id });
131
+ return await authorize(ctx, {
132
+ postLogin: oauthRequest?.postLoginClearedForSession !== void 0 && oauthRequest.postLoginClearedForSession === session?.session.id,
133
+ consentAccepted: true
134
+ });
134
135
  }
135
136
  //#endregion
136
137
  //#region src/continue.ts
@@ -186,6 +187,7 @@ const BACKCHANNEL_LOGOUT_EVENT_URI = "http://schemas.openid.net/event/backchanne
186
187
  const LOGOUT_TOKEN_JWT_TYP = "logout+jwt";
187
188
  const LOGOUT_TOKEN_LIFETIME_SECONDS = 120;
188
189
  const BACKCHANNEL_DISPATCH_TIMEOUT_MS = 5e3;
190
+ const FRONTCHANNEL_LOGOUT_TIMEOUT_MS = 3e3;
189
191
  /**
190
192
  * Signs a Back-Channel Logout Token per OIDC Back-Channel Logout 1.0 §2.4.
191
193
  *
@@ -227,11 +229,12 @@ async function signLogoutToken(ctx, options, resolvedKey, claims) {
227
229
  * Token revocation runs regardless of the JWT plugin (refresh-token revocation
228
230
  * has no dependency on signing). Only Logout Token delivery needs the JWT
229
231
  * plugin, so a plan may contain token identifiers with no delivery targets.
232
+ * Front-channel URIs sign nothing and are planned in either mode.
230
233
  *
231
234
  * Returns `null` when there is nothing to do, so the caller can skip the
232
235
  * background handoff entirely.
233
236
  */
234
- async function prepareBackchannelLogoutPlan(ctx, opts, input) {
237
+ async function prepareSessionLogoutPlan(ctx, opts, input) {
235
238
  const { sessionId, userId } = input;
236
239
  if (!userId) return null;
237
240
  const logger = ctx.context.logger;
@@ -263,24 +266,38 @@ async function prepareBackchannelLogoutPlan(ctx, opts, input) {
263
266
  const accessToRevokeIds = accessTokens.filter((t) => !t.revoked).map((t) => t.id);
264
267
  const refreshToRevokeIds = refreshTokens.filter((t) => !t.revoked && !t.scopes?.includes("offline_access")).map((t) => t.id);
265
268
  const eligibleClients = opts.disableJwtPlugin ? [] : clients.filter((c) => Boolean(c.backchannelLogoutUri) && !c.disabled);
269
+ const backchannelTargets = (await Promise.all(eligibleClients.map(async (client) => {
270
+ try {
271
+ return {
272
+ client,
273
+ sub: await resolveSubjectIdentifier(userId, client, opts)
274
+ };
275
+ } catch (error) {
276
+ logger.warn(`back-channel logout: unable to resolve subject for client ${client.clientId}`, error);
277
+ return null;
278
+ }
279
+ }))).filter((target) => target !== null);
280
+ const issuer = getIssuer(ctx, opts);
266
281
  return {
267
282
  accessTokenIds: accessToRevokeIds,
268
283
  refreshTokenIds: refreshToRevokeIds,
269
284
  sessionId,
270
- targets: (await Promise.all(eligibleClients.map(async (client) => {
285
+ backchannelTargets,
286
+ frontchannelLogoutUris: clients.flatMap((client) => {
287
+ if (!client.frontchannelLogoutUri || client.disabled) return [];
271
288
  try {
272
- return {
273
- client,
274
- sub: await resolveSubjectIdentifier(userId, client, opts)
275
- };
289
+ const uri = new URL(client.frontchannelLogoutUri);
290
+ uri.searchParams.set("iss", issuer);
291
+ uri.searchParams.set("sid", sessionId);
292
+ return [uri.toString()];
276
293
  } catch (error) {
277
- logger.warn(`back-channel logout: unable to resolve subject for client ${client.clientId}`, error);
278
- return null;
294
+ logger.warn(`front-channel logout: invalid frontchannel_logout_uri for client ${client.clientId}`, error);
295
+ return [];
279
296
  }
280
- }))).filter((target) => target !== null)
297
+ })
281
298
  };
282
299
  } catch (error) {
283
- logger.error("back-channel logout planning failed", error);
300
+ logger.error("logout planning failed", error);
284
301
  return null;
285
302
  }
286
303
  }
@@ -289,7 +306,7 @@ async function prepareBackchannelLogoutPlan(ctx, opts, input) {
289
306
  * `session.delete.after` after the row is consumed and, for transactional
290
307
  * callers, only after the transaction commits.
291
308
  */
292
- async function applyBackchannelLogoutPlan(ctx, plan) {
309
+ async function applySessionLogoutPlan(ctx, plan) {
293
310
  const revokedAt = /* @__PURE__ */ new Date();
294
311
  const adapter = await getCurrentAdapter(ctx.context.adapter);
295
312
  const revocations = await Promise.allSettled([plan.accessTokenIds.length > 0 ? adapter.updateMany({
@@ -310,7 +327,7 @@ async function applyBackchannelLogoutPlan(ctx, plan) {
310
327
  update: { revoked: revokedAt }
311
328
  }) : Promise.resolve()]);
312
329
  for (const result of revocations) if (result.status === "rejected") ctx.context.logger.error("back-channel logout: token revocation update failed", result.reason);
313
- if (plan.targets.length > 0) await deliverBackchannelLogoutTokens(ctx, plan);
330
+ if (plan.backchannelTargets.length > 0) await deliverBackchannelLogoutTokens(ctx, plan);
314
331
  }
315
332
  /**
316
333
  * Asynchronous phase: sign one Logout Token per target client and POST it to
@@ -331,7 +348,7 @@ async function deliverBackchannelLogoutTokens(ctx, plan) {
331
348
  const iat = Math.floor(Date.now() / 1e3);
332
349
  const exp = iat + LOGOUT_TOKEN_LIFETIME_SECONDS;
333
350
  const resolvedKey = jwtPluginOptions?.jwt?.sign ? null : await resolveSigningKey(ctx, jwtPluginOptions);
334
- await Promise.allSettled(plan.targets.map(async ({ client, sub }) => {
351
+ await Promise.allSettled(plan.backchannelTargets.map(async ({ client, sub }) => {
335
352
  try {
336
353
  const jti = generateRandomString(32, "a-z", "A-Z", "0-9");
337
354
  const token = await signLogoutToken(ctx, jwtPluginOptions, resolvedKey, {
@@ -359,6 +376,30 @@ async function deliverBackchannelLogoutTokens(ctx, plan) {
359
376
  }
360
377
  }));
361
378
  }
379
+ /**
380
+ * Front-channel logout URIs collected from sessions deleted during the current
381
+ * request. `session.delete.after` records them only once the deletion has
382
+ * committed, so a vetoed or rolled-back delete never reaches the logout page.
383
+ */
384
+ const frontchannelLogoutUrisState = defineRequestState(() => []);
385
+ /**
386
+ * Records a committed plan's front-channel URIs for the logout page of the
387
+ * current request. Deletions outside a request (no browser to render iframes)
388
+ * have nowhere to deliver them, so they are dropped.
389
+ */
390
+ async function recordFrontchannelLogoutUris(uris) {
391
+ if (uris.length === 0 || !await hasRequestState()) return;
392
+ const recorded = await frontchannelLogoutUrisState.get();
393
+ await frontchannelLogoutUrisState.set([...recorded, ...uris]);
394
+ }
395
+ /**
396
+ * Returns and clears the front-channel URIs recorded during this request.
397
+ */
398
+ async function takeFrontchannelLogoutUris() {
399
+ const recorded = await frontchannelLogoutUrisState.get();
400
+ await frontchannelLogoutUrisState.set([]);
401
+ return recorded;
402
+ }
362
403
  const LOGOUT_CONFIRMATION_TTL_SECONDS = 300;
363
404
  const LOGOUT_CONFIRMATION_COOKIE_SUFFIX = ".oauth_logout_confirmation";
364
405
  function escapeHtml(value) {
@@ -379,14 +420,16 @@ function isBrowserNavigation(ctx) {
379
420
  const accept = headers.get("accept") ?? "";
380
421
  return headers.get("sec-fetch-mode") === "navigate" || accept.includes("text/html") || accept.includes("application/xhtml+xml");
381
422
  }
382
- function logoutPage(title, body, status = 200) {
383
- return new Response(`<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title></head><body>${body}</body></html>`, {
423
+ const LOGOUT_PAGE_CSP = "default-src 'none'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'";
424
+ function logoutPage(title, body, status = 200, page = {}) {
425
+ return new Response(`<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title>${page.head ?? ""}</head><body>${body}</body></html>`, {
384
426
  status,
385
427
  headers: {
386
428
  "cache-control": "no-store",
387
- "content-security-policy": "default-src 'none'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
429
+ "content-security-policy": page.contentSecurityPolicy ?? LOGOUT_PAGE_CSP,
388
430
  "content-type": "text/html; charset=utf-8",
389
431
  pragma: "no-cache",
432
+ "referrer-policy": "no-referrer",
390
433
  "x-content-type-options": "nosniff"
391
434
  }
392
435
  });
@@ -407,6 +450,53 @@ function logoutConfirmationPage(ctx) {
407
450
  function logoutSuccessPage(note) {
408
451
  return logoutPage("Logged out", `<main><p data-oidc-logout-state="logged-out">${note ? `Logged out. ${escapeHtml(note)}` : "Logged out."}</p></main>`);
409
452
  }
453
+ const FRONTCHANNEL_LOGOUT_SCRIPT = `(function () {
454
+ var status = document.querySelector("[data-oidc-logout-state]");
455
+ var redirectUri = status.getAttribute("data-post-logout-redirect-uri");
456
+ var frames = document.querySelectorAll("iframe");
457
+ var pending = frames.length;
458
+ var finished = false;
459
+ function finish() {
460
+ if (finished) return;
461
+ finished = true;
462
+ if (redirectUri) {
463
+ window.location.replace(redirectUri);
464
+ return;
465
+ }
466
+ status.setAttribute("data-oidc-logout-state", "logged-out");
467
+ status.textContent = status.getAttribute("data-logged-out-message");
468
+ }
469
+ function settle() {
470
+ pending -= 1;
471
+ if (pending <= 0) finish();
472
+ }
473
+ for (var i = 0; i < frames.length; i++) {
474
+ frames[i].addEventListener("load", settle);
475
+ frames[i].addEventListener("error", settle);
476
+ }
477
+ setTimeout(finish, ${FRONTCHANNEL_LOGOUT_TIMEOUT_MS});
478
+ })();`;
479
+ let frontchannelLogoutScriptHash;
480
+ /**
481
+ * OIDC Front-Channel Logout 1.0 §3 logout page: one hidden iframe per relying
482
+ * party, then a redirect to the verified `post_logout_redirect_uri` or a
483
+ * logged-out message. The OP session has already ended when this renders.
484
+ * Without JavaScript the iframes still load, and a meta refresh performs the
485
+ * redirect.
486
+ */
487
+ async function frontchannelLogoutPage(uris, redirect) {
488
+ frontchannelLogoutScriptHash ??= createHash("SHA-256", "base64").digest(FRONTCHANNEL_LOGOUT_SCRIPT);
489
+ const scriptHash = await frontchannelLogoutScriptHash;
490
+ const frameOrigins = [...new Set(uris.map((uri) => new URL(uri).origin))];
491
+ const loggedOutMessage = redirect.note ? `Logged out. ${redirect.note}` : "Logged out.";
492
+ const iframes = uris.map((uri) => `<iframe hidden src="${escapeHtml(uri)}"></iframe>`).join("");
493
+ const redirectAttribute = redirect.uri ? ` data-post-logout-redirect-uri="${escapeHtml(redirect.uri)}"` : "";
494
+ const noscript = redirect.uri ? `<noscript><a href="${escapeHtml(redirect.uri)}">Continue</a></noscript>` : `<noscript><p>${escapeHtml(loggedOutMessage)}</p></noscript>`;
495
+ return logoutPage("Signing out", `<main><p data-oidc-logout-state="pending" data-logged-out-message="${escapeHtml(loggedOutMessage)}"${redirectAttribute}>Signing out of connected applications&hellip;</p>${noscript}</main>${iframes}<script>${FRONTCHANNEL_LOGOUT_SCRIPT}<\/script>`, 200, {
496
+ head: redirect.uri ? `<noscript><meta http-equiv="refresh" content="${Math.ceil(FRONTCHANNEL_LOGOUT_TIMEOUT_MS / 1e3)};url=${escapeHtml(redirect.uri)}"></noscript>` : void 0,
497
+ contentSecurityPolicy: `${LOGOUT_PAGE_CSP}; frame-src ${frameOrigins.join(" ")}; script-src 'sha256-${scriptHash}'`
498
+ });
499
+ }
410
500
  function logoutErrorPage(description, status) {
411
501
  return logoutPage("Logout error", `<main><h1>Logout error</h1><p data-oidc-logout-state="error">${escapeHtml(description)}</p></main>`, status);
412
502
  }
@@ -604,6 +694,22 @@ async function confirmationRequired(ctx, currentSession, confirmation = {}) {
604
694
  await setLogoutConfirmationState(ctx, currentSession.session.id, confirmation);
605
695
  return logoutConfirmationPage(ctx);
606
696
  }
697
+ const UNREGISTERED_REDIRECT_NOTE = "The requested post-logout redirect was not registered.";
698
+ /**
699
+ * Response once the session has ended. A browser navigation renders the
700
+ * front-channel logout page when relying parties must be notified; otherwise
701
+ * the caller gets the verified redirect, the success page, or an empty result.
702
+ */
703
+ async function loggedOutResponse(ctx, redirect) {
704
+ const note = redirect.invalid ? UNREGISTERED_REDIRECT_NOTE : void 0;
705
+ const frontchannelLogoutUris = await takeFrontchannelLogoutUris();
706
+ if (frontchannelLogoutUris.length > 0 && isBrowserNavigation(ctx)) return frontchannelLogoutPage(frontchannelLogoutUris, {
707
+ uri: redirect.uri,
708
+ note
709
+ });
710
+ if (redirect.uri) return handleRedirect(ctx, redirect.uri);
711
+ return isBrowserNavigation(ctx) ? logoutSuccessPage(note) : null;
712
+ }
607
713
  async function completeConfirmedLogout(ctx, opts) {
608
714
  const state = await getLogoutConfirmationState(ctx);
609
715
  const currentSession = await getCurrentBrowserSession(ctx);
@@ -612,14 +718,13 @@ async function completeConfirmedLogout(ctx, opts) {
612
718
  if (!currentSession) {
613
719
  clearLogoutConfirmationState(ctx);
614
720
  if (redirect.uri) return handleRedirect(ctx, redirect.uri);
615
- return isBrowserNavigation(ctx) ? logoutSuccessPage(redirect.invalid ? "The requested post-logout redirect was not registered." : void 0) : logoutProtocolError(ctx, "BAD_REQUEST", "invalid_request", "No active session is available for logout");
721
+ return isBrowserNavigation(ctx) ? logoutSuccessPage(redirect.invalid ? UNREGISTERED_REDIRECT_NOTE : void 0) : logoutProtocolError(ctx, "BAD_REQUEST", "invalid_request", "No active session is available for logout");
616
722
  }
617
723
  if (state.sessionId && state.sessionId !== currentSession.session.id) return logoutProtocolError(ctx, "BAD_REQUEST", "invalid_request", "The logout confirmation is invalid or expired");
618
724
  await deleteLogoutSession(ctx, currentSession.session);
619
725
  deleteSessionCookie(ctx);
620
726
  clearLogoutConfirmationState(ctx);
621
- if (redirect.uri) return handleRedirect(ctx, redirect.uri);
622
- return isBrowserNavigation(ctx) ? logoutSuccessPage(redirect.invalid ? "The requested post-logout redirect was not registered." : void 0) : null;
727
+ return loggedOutResponse(ctx, redirect);
623
728
  }
624
729
  /**
625
730
  * RP-Initiated Logout (OIDC RP-Initiated Logout 1.0). The RP presents a signed
@@ -630,7 +735,9 @@ async function completeConfirmedLogout(ctx, opts) {
630
735
  *
631
736
  * Session termination goes through `internalAdapter.deleteSession`, which fires
632
737
  * `session.delete.after` so the hook drives revocation and back-channel
633
- * notifications to every RP with tokens on the session.
738
+ * notifications to every RP with tokens on the session. When the browser
739
+ * navigates here, RPs with a `frontchannel_logout_uri` are then notified
740
+ * through hidden iframes before the redirect.
634
741
  *
635
742
  * @see https://openid.net/specs/openid-connect-rpinitiated-1_0.html
636
743
  */
@@ -674,8 +781,7 @@ async function rpInitiatedLogoutEndpoint(ctx, opts) {
674
781
  if (sessionToDelete) await deleteLogoutSession(ctx, sessionToDelete);
675
782
  if (matchesCurrentSession) deleteSessionCookie(ctx);
676
783
  clearLogoutConfirmationState(ctx);
677
- if (redirect.uri) return handleRedirect(ctx, redirect.uri);
678
- return isBrowserNavigation(ctx) ? logoutSuccessPage(redirect.invalid ? "The requested post-logout redirect was not registered." : void 0) : null;
784
+ return loggedOutResponse(ctx, redirect);
679
785
  }
680
786
  /**
681
787
  * Completes the browser confirmation flow for RP-Initiated Logout. This route
@@ -729,7 +835,9 @@ function authServerMetadata(ctx, opts, overrides) {
729
835
  authorization_response_iss_parameter_supported: true,
730
836
  dpop_signing_alg_values_supported: overrides?.dpop_signing_alg_values_supported ?? [...DPOP_SIGNING_ALGORITHMS],
731
837
  backchannel_logout_supported: backchannelSupported,
732
- backchannel_logout_session_supported: backchannelSupported
838
+ backchannel_logout_session_supported: backchannelSupported,
839
+ frontchannel_logout_supported: true,
840
+ frontchannel_logout_session_supported: true
733
841
  };
734
842
  }
735
843
  /**
@@ -772,7 +880,7 @@ function oidcServerMetadata(ctx, opts) {
772
880
  claims_parameter_supported: true,
773
881
  userinfo_endpoint: `${baseURL}/oauth2/userinfo`,
774
882
  subject_types_supported: opts.pairwiseSecret ? ["public", "pairwise"] : ["public"],
775
- acr_values_supported: ["0"],
883
+ acr_values_supported: getAcrValuesSupported(opts),
776
884
  id_token_signing_alg_values_supported: (() => {
777
885
  if (opts.disableJwtPlugin) return ["HS256"];
778
886
  const primary = jwtPluginOptions?.jwks?.keyPairConfig?.alg ?? "EdDSA";
@@ -846,7 +954,8 @@ const oauthProviderOpenIdConfigMetadata = (auth, opts) => {
846
954
  *
847
955
  * A failing issue is routed by its first path segment via `errorCodesByField`:
848
956
  * - missing required (`invalid_type` + "received undefined") → `.missing`
849
- * - unsupported value (`invalid_value`) → `.invalid`
957
+ * - unsupported value (`invalid_value`) or a value a refinement rejected
958
+ * (`custom`, also inside a union branch) → `.invalid`
850
959
  * - anything else (wrong type, duplicated params, bad format) → `defaultError`
851
960
  *
852
961
  * For enum fields that need to distinguish missing from unsupported, compose
@@ -855,16 +964,19 @@ const oauthProviderOpenIdConfigMetadata = (auth, opts) => {
855
964
  * enum value.
856
965
  */
857
966
  function createOAuthEndpoint(path, options, handler) {
858
- const { redirectOnError, onValidationError: userHook, errorCodesByField, defaultError = "invalid_request", ...rest } = options;
859
- if (!redirectOnError) return createAuthEndpoint(path, {
860
- ...rest,
861
- onValidationError: async (args) => {
862
- if (userHook) await userHook(args);
863
- throw new APIError$1("BAD_REQUEST", { ...mapIssuesToOAuthError(args.issues, errorCodesByField, defaultError) });
864
- }
865
- }, handler);
866
- const redirect = redirectOnError;
967
+ const { redirectOnError, onValidationError: userHook, errorCodesByField, defaultError = "invalid_request", formParams, ...rest } = options;
968
+ if (!redirectOnError && !formParams) {
969
+ const noStore = rest.metadata?.noStore === true;
970
+ return createAuthEndpoint(path, {
971
+ ...rest,
972
+ onValidationError: async (args) => {
973
+ if (userHook) await userHook(args);
974
+ throw new APIError$1("BAD_REQUEST", mapIssuesToOAuthError(args.issues, errorCodesByField, defaultError), noStore ? NO_STORE_HEADERS : void 0);
975
+ }
976
+ }, handler);
977
+ }
867
978
  const { body: bodySchema, query: querySchema, ...forwarded } = rest;
979
+ if (formParams) forwarded.cloneRequest = true;
868
980
  async function validateSlot(ctx, slot, schema) {
869
981
  if (!schema) return { ok: true };
870
982
  const result = await schema.safeParseAsync(ctx[slot] ?? {});
@@ -876,15 +988,19 @@ function createOAuthEndpoint(path, options, handler) {
876
988
  message: result.error.message,
877
989
  issues: result.error.issues
878
990
  });
991
+ const error = mapIssuesToOAuthError(result.error.issues, errorCodesByField, defaultError);
992
+ if (!redirectOnError) throw new APIError$1("BAD_REQUEST", error);
879
993
  return {
880
994
  ok: false,
881
- response: redirect({
882
- ...mapIssuesToOAuthError(result.error.issues, errorCodesByField, defaultError),
995
+ response: redirectOnError({
996
+ ...error,
883
997
  ctx
884
998
  })
885
999
  };
886
1000
  }
887
1001
  return createAuthEndpoint(path, forwarded, async (ctx) => {
1002
+ const form = formParams && ctx.request ? await readFormParams(ctx.request) : void 0;
1003
+ if (form) ctx.body = applyFormParams(form, ctx.body ?? {}, formParams?.repeatable);
888
1004
  const body = await validateSlot(ctx, "body", bodySchema);
889
1005
  if (!body.ok) return body.response;
890
1006
  const query = await validateSlot(ctx, "query", querySchema);
@@ -902,18 +1018,32 @@ function mapIssuesToOAuthError(issues, errorCodesByField, defaultError = "invali
902
1018
  const fieldKey = typeof first === "string" ? first : void 0;
903
1019
  const mapping = fieldKey ? errorCodesByField?.[fieldKey] : void 0;
904
1020
  const field = issue.path?.length ? z.core.toDotPath(issue.path) : "";
1021
+ const invalidValue = findInvalidValueIssue(issue);
905
1022
  return {
906
- error: resolveErrorCode(issue, mapping, defaultError),
907
- error_description: describeIssue(issue, field)
1023
+ error: resolveErrorCode(issue, invalidValue, mapping, defaultError),
1024
+ error_description: describeIssue(invalidValue ?? issue, field)
908
1025
  };
909
1026
  }
910
- function resolveErrorCode(issue, mapping, defaultError) {
1027
+ function resolveErrorCode(issue, invalidValue, mapping, defaultError) {
911
1028
  if (typeof mapping === "string") return mapping;
912
1029
  if (isMissingValueIssue(issue)) return mapping?.missing ?? defaultError;
913
- if (issue.code === "invalid_value") return mapping?.invalid ?? defaultError;
1030
+ if (invalidValue) return mapping?.invalid ?? defaultError;
914
1031
  return defaultError;
915
1032
  }
916
1033
  /**
1034
+ * Returns the issue that routes to `errorCodesByField[field].invalid`: an
1035
+ * unsupported enum value, or a value of the expected type that a refinement
1036
+ * rejected, such as a resource URI with a fragment. A union reports a
1037
+ * refinement failure as `invalid_union`, so its branches are searched too.
1038
+ */
1039
+ function findInvalidValueIssue(issue) {
1040
+ if (issue.code === "invalid_value" || issue.code === "custom") return issue;
1041
+ if (issue.code === "invalid_union") for (const branch of issue.errors) {
1042
+ const rejected = branch.find((nested) => nested.code === "custom");
1043
+ if (rejected) return rejected;
1044
+ }
1045
+ }
1046
+ /**
917
1047
  * Returns `true` for issues that represent an absent required value. Zod v4
918
1048
  * strips `input` from published issues, so the signal is the `invalid_type`
919
1049
  * code combined with a message suffix of "received undefined". The suffix is
@@ -1032,136 +1162,16 @@ function validatePublicClientJwks(input) {
1032
1162
  jwks: { keys }
1033
1163
  };
1034
1164
  }
1035
- //#endregion
1036
- //#region src/client-metadata.ts
1037
- const OAUTH_CLIENT_RECORD_FIELD_NAMES = Object.freeze(Object.keys({
1038
- clientId: true,
1039
- clientSecret: true,
1040
- clientDiscoveryId: true,
1041
- disabled: true,
1042
- scopes: true,
1043
- clientCredentialsScopes: true,
1044
- userId: true,
1045
- createdAt: true,
1046
- updatedAt: true,
1047
- expiresAt: true,
1048
- name: true,
1049
- uri: true,
1050
- icon: true,
1051
- contacts: true,
1052
- tos: true,
1053
- policy: true,
1054
- softwareId: true,
1055
- softwareVersion: true,
1056
- softwareStatement: true,
1057
- redirectUris: true,
1058
- postLogoutRedirectUris: true,
1059
- backchannelLogoutUri: true,
1060
- backchannelLogoutSessionRequired: true,
1061
- tokenEndpointAuthMethod: true,
1062
- grantTypes: true,
1063
- responseTypes: true,
1064
- applicationType: true,
1065
- jwks: true,
1066
- jwksUri: true,
1067
- requirePKCE: true,
1068
- dpopBoundAccessTokens: true,
1069
- skipConsent: true,
1070
- enableEndSession: true,
1071
- subjectType: true,
1072
- referenceId: true,
1073
- metadata: true
1074
- }));
1075
1165
  /**
1076
- * Canonical wire fields emitted by schemaToOAuth. They are mapped explicitly
1077
- * and therefore must never also survive inside the opaque metadata envelope.
1166
+ * Throws `invalid_client_metadata` unless `jwks` is a public JWK Set that
1167
+ * {@link validatePublicClientJwks} accepts.
1078
1168
  */
1079
- const OAUTH_CLIENT_WIRE_FIELD_NAMES = [
1080
- "client_id",
1081
- "client_secret",
1082
- "client_secret_expires_at",
1083
- "scope",
1084
- "user_id",
1085
- "client_id_issued_at",
1086
- "client_name",
1087
- "client_uri",
1088
- "logo_uri",
1089
- "contacts",
1090
- "tos_uri",
1091
- "policy_uri",
1092
- "jwks",
1093
- "jwks_uri",
1094
- "software_id",
1095
- "software_version",
1096
- "software_statement",
1097
- "redirect_uris",
1098
- "post_logout_redirect_uris",
1099
- "backchannel_logout_uri",
1100
- "backchannel_logout_session_required",
1101
- "token_endpoint_auth_method",
1102
- "grant_types",
1103
- "response_types",
1104
- "application_type",
1105
- "disabled",
1106
- "skip_consent",
1107
- "enable_end_session",
1108
- "require_pkce",
1109
- "dpop_bound_access_tokens",
1110
- "subject_type",
1111
- "reference_id"
1112
- ];
1113
- const OPAQUE_METADATA_RESERVED_FIELDS = new Set([
1114
- ...OAUTH_CLIENT_RECORD_FIELD_NAMES,
1115
- ...OAUTH_CLIENT_WIRE_FIELD_NAMES,
1116
- "public",
1117
- "type",
1118
- "resources",
1119
- "client_credentials_scopes"
1120
- ]);
1121
- const CIMD_FORBIDDEN_SERVER_FIELD_NAMES = new Set([
1122
- "disabled",
1123
- "client_secret",
1124
- "client_secret_expires_at",
1125
- "client_id_issued_at",
1126
- "skip_consent",
1127
- "enable_end_session",
1128
- "require_pkce",
1129
- "reference_id",
1130
- "user_id",
1131
- "resources",
1132
- "clientSecret",
1133
- "clientDiscoveryId",
1134
- "skipConsent",
1135
- "enableEndSession",
1136
- "requirePKCE",
1137
- "referenceId",
1138
- "userId",
1139
- "clientId",
1140
- "applicationType",
1141
- "tokenEndpointAuthMethod",
1142
- "redirectUris",
1143
- "postLogoutRedirectUris",
1144
- "grantTypes",
1145
- "responseTypes",
1146
- "scopes",
1147
- "expiresAt",
1148
- "createdAt",
1149
- "updatedAt",
1150
- "softwareId",
1151
- "softwareVersion",
1152
- "softwareStatement",
1153
- "backchannelLogoutUri",
1154
- "backchannelLogoutSessionRequired",
1155
- "jwksUri",
1156
- "dpopBoundAccessTokens",
1157
- "subjectType"
1158
- ]);
1159
- function isForbiddenCimdClientMetadataField(field) {
1160
- return CIMD_FORBIDDEN_SERVER_FIELD_NAMES.has(field);
1161
- }
1162
- function stripReservedOAuthClientMetadataExtensions(metadata) {
1163
- if (!metadata) return void 0;
1164
- return Object.fromEntries(Object.entries(metadata).filter(([field]) => !OPAQUE_METADATA_RESERVED_FIELDS.has(field)));
1169
+ function assertValidClientJwks(jwks) {
1170
+ const result = validatePublicClientJwks(jwks);
1171
+ if (!result.valid) throw new APIError("BAD_REQUEST", {
1172
+ error: "invalid_client_metadata",
1173
+ error_description: result.error
1174
+ });
1165
1175
  }
1166
1176
  //#endregion
1167
1177
  //#region src/oauthClient/privileges.ts
@@ -1492,8 +1502,20 @@ async function consumeClientAssertion(ctx, opts, params) {
1492
1502
  * Validates: signature, iss=client_id, sub=client_id, aud=the endpoint serving
1493
1503
  * the assertion or the normalized OP issuer, exp, assertion max lifetime,
1494
1504
  * jti uniqueness (replay prevention).
1505
+ *
1506
+ * Returns the client record the signature was checked against, so a caller that
1507
+ * mutates the client can guard its write on exactly that snapshot.
1508
+ *
1509
+ * With `endpointAudienceOnly`, the issuer is not an accepted audience, so only
1510
+ * an assertion whose `aud` includes `expectedAudience` verifies. Endpoints that
1511
+ * change client credentials use it, so an assertion minted for the token
1512
+ * endpoint or the issuer cannot be redirected to them.
1513
+ *
1514
+ * `assertClient` runs on the looked-up client before any key is fetched or the
1515
+ * jti is consumed, so a caller can refuse a client it does not serve without
1516
+ * spending the assertion or reaching out to a `jwks_uri`.
1495
1517
  */
1496
- async function verifyClientAssertion(ctx, opts, clientAssertion, clientAssertionType, clientIdHint, expectedAudience) {
1518
+ async function verifyClientAssertion(ctx, opts, clientAssertion, clientAssertionType, clientIdHint, expectedAudience, options) {
1497
1519
  if (clientAssertionType !== CLIENT_ASSERTION_TYPE) throw new APIError$1("BAD_REQUEST", {
1498
1520
  error_description: "unsupported client_assertion_type",
1499
1521
  error: "invalid_client"
@@ -1531,7 +1553,7 @@ async function verifyClientAssertion(ctx, opts, clientAssertion, clientAssertion
1531
1553
  });
1532
1554
  const client = await getClient(ctx, opts, clientId);
1533
1555
  if (!client) throw new APIError$1("BAD_REQUEST", {
1534
- error_description: "unknown client",
1556
+ error_description: "client not found",
1535
1557
  error: "invalid_client"
1536
1558
  });
1537
1559
  if (client.disabled) throw new APIError$1("BAD_REQUEST", {
@@ -1542,10 +1564,11 @@ async function verifyClientAssertion(ctx, opts, clientAssertion, clientAssertion
1542
1564
  error_description: "client is not registered for private_key_jwt authentication",
1543
1565
  error: "invalid_client"
1544
1566
  });
1567
+ options?.assertClient?.(client);
1545
1568
  const jwks = await fetchClientJwks(ctx, opts, client);
1546
1569
  const endpointAudience = expectedAudience ?? `${ctx.context.baseURL}/oauth2/token`;
1547
1570
  const issuerAudience = getIssuer(ctx, opts);
1548
- const acceptedAudiences = [...new Set([endpointAudience, issuerAudience])];
1571
+ const acceptedAudiences = options?.endpointAudienceOnly ? [endpointAudience] : [...new Set([endpointAudience, issuerAudience])];
1549
1572
  const verifyOpts = {
1550
1573
  issuer: clientId,
1551
1574
  subject: clientId,
@@ -1581,7 +1604,10 @@ async function verifyClientAssertion(ctx, opts, clientAssertion, clientAssertion
1581
1604
  payload,
1582
1605
  expectedAudience: acceptedAudience
1583
1606
  });
1584
- return { clientId };
1607
+ return {
1608
+ clientId,
1609
+ client
1610
+ };
1585
1611
  }
1586
1612
  //#endregion
1587
1613
  //#region src/utils/initial-access-token.ts
@@ -1659,6 +1685,21 @@ function resolveRegistrationResponseTypes(client, grantTypes) {
1659
1685
  if (client.response_types) return client.response_types;
1660
1686
  return grantTypes.includes("authorization_code") ? ["code"] : void 0;
1661
1687
  }
1688
+ /**
1689
+ * The requested grants this server supports. Clients such as MCP connectors
1690
+ * send one grant union to every server, so a metadata document or a dynamic
1691
+ * registration may list grants this server does not issue. Throws when the
1692
+ * request shares no grant with the server.
1693
+ */
1694
+ function resolveMutuallySupportedGrantTypes(opts, grantTypes) {
1695
+ const supportedGrantTypes = new Set(getSupportedGrantTypes(opts));
1696
+ const mutualGrantTypes = grantTypes.filter((grantType) => supportedGrantTypes.has(grantType));
1697
+ if (mutualGrantTypes.length === 0) throw new APIError("BAD_REQUEST", {
1698
+ error: "invalid_client_metadata",
1699
+ error_description: `no mutually supported grant_type among ${grantTypes.join(", ")}`
1700
+ });
1701
+ return mutualGrantTypes;
1702
+ }
1662
1703
  function applyOAuthClientRegistrationDefaults(client, defaultApplicationType = "web") {
1663
1704
  const grantTypes = resolveRegistrationGrantTypes(client);
1664
1705
  return {
@@ -1684,15 +1725,15 @@ function isNonHttpRedirectUri(redirectUri) {
1684
1725
  /**
1685
1726
  * Server-owned default for dynamic registrations that omit `application_type`.
1686
1727
  * `"infer"` classifies as native when any redirect URI uses a non-http(s)
1687
- * scheme, because a web client can never register one. Every URI is then
1688
- * validated against the native rules. A sent `application_type` is never
1689
- * overridden because `applyOAuthClientRegistrationDefaults` only fills the
1690
- * absent field.
1728
+ * scheme or http on an exact loopback host, because a web client can never
1729
+ * register either. Every URI is then validated against the native rules. A
1730
+ * sent `application_type` is never overridden because
1731
+ * `applyOAuthClientRegistrationDefaults` only fills the absent field.
1691
1732
  */
1692
1733
  function resolveDynamicRegistrationDefaultApplicationType(opts, client) {
1693
1734
  const configured = opts.clientRegistrationDefaultApplicationType ?? "web";
1694
1735
  if (configured !== "infer") return configured;
1695
- return (client.redirect_uris ?? []).some(isNonHttpRedirectUri) ? "native" : "web";
1736
+ return (client.redirect_uris ?? []).some((uri) => isNonHttpRedirectUri(uri) || isExactLoopbackHttpRedirectUri(uri)) ? "native" : "web";
1696
1737
  }
1697
1738
  function invalidRedirectUri(description) {
1698
1739
  throw new APIError("BAD_REQUEST", {
@@ -1710,6 +1751,10 @@ function getRawHttpHostname(redirectUri) {
1710
1751
  }
1711
1752
  return (hostAndPort.split(":")[0] ?? "").toLowerCase();
1712
1753
  }
1754
+ function isExactLoopbackHttpRedirectUri(redirectUri) {
1755
+ const hostname = getRawHttpHostname(redirectUri);
1756
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
1757
+ }
1713
1758
  function assertDynamicRegistrationResourcesAllowed(opts, requestedResources) {
1714
1759
  const allowedResources = new Set([...opts.clientRegistrationDefaultResources ?? [], ...opts.clientRegistrationAllowedResources ?? []]);
1715
1760
  for (const identifier of requestedResources) if (!allowedResources.has(identifier)) throw new APIError("BAD_REQUEST", {
@@ -1745,8 +1790,6 @@ function validateClientRedirectUri(redirectUri, applicationType) {
1745
1790
  const isHttps = url.protocol === "https:";
1746
1791
  if (/^localhost\.+$/i.test(url.hostname)) invalidRedirectUri(`redirect URI localhost must not include trailing dots: ${redirectUri}`);
1747
1792
  const isRedirectLoopback = isLoopbackIP(url.hostname) || url.hostname === "localhost";
1748
- const rawHttpHostname = getRawHttpHostname(redirectUri);
1749
- const isAllowedNativeHttpLoopback = rawHttpHostname === "localhost" || rawHttpHostname === "127.0.0.1" || rawHttpHostname === "[::1]";
1750
1793
  if (applicationType === "web") {
1751
1794
  if (!isHttps || isRedirectLoopback) invalidRedirectUri(`web clients require https redirect URIs on non-loopback hosts: ${redirectUri}`);
1752
1795
  return;
@@ -1756,20 +1799,59 @@ function validateClientRedirectUri(redirectUri, applicationType) {
1756
1799
  return;
1757
1800
  }
1758
1801
  if (isHttp) {
1759
- if (!isAllowedNativeHttpLoopback) invalidRedirectUri(`native clients may use http only on the exact loopback hosts localhost, 127.0.0.1, or [::1]: ${redirectUri}`);
1802
+ if (!isExactLoopbackHttpRedirectUri(redirectUri)) invalidRedirectUri(`native clients may use http only on the exact loopback hosts localhost, 127.0.0.1, or [::1]: ${redirectUri}`);
1760
1803
  return;
1761
1804
  }
1762
1805
  if (!isNativePrivateUseRedirectUri(url)) invalidRedirectUri(`native private-use redirect URI schemes must not use a reserved scheme; they must be an authority-free reverse-domain URI or a custom-scheme URI with a host and a path: ${redirectUri}`);
1763
1806
  }
1764
- function assertValidRegistrationJwks(jwks) {
1765
- const result = validatePublicClientJwks(jwks);
1766
- if (!result.valid) throw new APIError("BAD_REQUEST", {
1767
- error: "invalid_client_metadata",
1768
- error_description: result.error
1769
- });
1807
+ /**
1808
+ * Rejects a URI the server will POST to unless it is an absolute https URL
1809
+ * with no fragment or credentials on a publicly routable host. Back-channel
1810
+ * endpoints use it as an SSRF guard, since the server, not the browser, calls
1811
+ * them.
1812
+ */
1813
+ function assertPublicRoutableHttpsUri(field, uri) {
1814
+ const reject = (description) => {
1815
+ throw new APIError("BAD_REQUEST", {
1816
+ error: "invalid_client_metadata",
1817
+ error_description: `${field} ${description}`
1818
+ });
1819
+ };
1820
+ let url;
1821
+ try {
1822
+ url = new URL(uri);
1823
+ } catch {
1824
+ return reject("must be an absolute URL");
1825
+ }
1826
+ if (uri.includes("#")) reject("must not include a fragment component");
1827
+ if (url.protocol !== "https:") reject("must use https");
1828
+ if (url.username || url.password) reject("must not contain credentials");
1829
+ if (isPrivateHostname(url.hostname)) reject("must not point to a private or reserved address");
1830
+ }
1831
+ const REGISTRATION_REQUEST_FIELDS = new Set(Object.keys(clientRegistrationRequestSchema.shape));
1832
+ /**
1833
+ * Splits a dynamic registration request into its core fields and the fields
1834
+ * extensions define, which are stored in the client's `metadata`. Any other
1835
+ * unrecognized field is ignored (RFC 7591 §2), so a registrant cannot set
1836
+ * server-only fields or arbitrary `metadata`.
1837
+ */
1838
+ function splitRegistrationRequest(input, opts) {
1839
+ return {
1840
+ body: Object.fromEntries(Object.entries(input).filter(([field]) => REGISTRATION_REQUEST_FIELDS.has(field))),
1841
+ metadata: pickExtensionClientMetadata(input, opts)
1842
+ };
1843
+ }
1844
+ /**
1845
+ * The top-level fields of a registration that extensions declare, which are
1846
+ * stored in the client's `metadata`.
1847
+ */
1848
+ function pickExtensionClientMetadata(input, opts) {
1849
+ const extensionFields = getExtensionClientMetadataFields(opts);
1850
+ const metadata = Object.fromEntries(Object.entries(input).filter(([field]) => extensionFields.has(field)));
1851
+ return Object.keys(metadata).length ? metadata : void 0;
1770
1852
  }
1771
1853
  async function registerEndpoint(ctx, opts) {
1772
- const body = ctx.body;
1854
+ const { body, metadata } = splitRegistrationRequest(ctx.body, opts);
1773
1855
  if (!opts.allowDynamicClientRegistration) throw new APIError("FORBIDDEN", {
1774
1856
  error: "access_denied",
1775
1857
  error_description: "Client registration is disabled"
@@ -1781,8 +1863,15 @@ async function registerEndpoint(ctx, opts) {
1781
1863
  "WWW-Authenticate": "Bearer",
1782
1864
  ...NO_STORE_HEADERS
1783
1865
  });
1866
+ const grantTypes = body.grant_types?.length ? resolveMutuallySupportedGrantTypes(opts, body.grant_types) : body.grant_types;
1867
+ const droppedAuthorizationCode = body.grant_types?.includes("authorization_code") && !grantTypes?.includes("authorization_code");
1868
+ const registrationMetadata = {
1869
+ ...body,
1870
+ grant_types: grantTypes,
1871
+ response_types: droppedAuthorizationCode ? void 0 : body.response_types
1872
+ };
1784
1873
  if (!session && !isTokenAuthorized) {
1785
- if (body.grant_types?.includes("client_credentials")) throw new APIError("BAD_REQUEST", {
1874
+ if (registrationMetadata.grant_types?.includes("client_credentials")) throw new APIError("BAD_REQUEST", {
1786
1875
  error: "invalid_client_metadata",
1787
1876
  error_description: "client_credentials grant requires authenticated registration"
1788
1877
  });
@@ -1794,7 +1883,10 @@ async function registerEndpoint(ctx, opts) {
1794
1883
  session: session.session
1795
1884
  }) : void 0);
1796
1885
  const response = await createOAuthClientRegistration(ctx, opts, {
1797
- metadata: body,
1886
+ metadata: {
1887
+ ...registrationMetadata,
1888
+ metadata
1889
+ },
1798
1890
  registrationSource: "dynamic",
1799
1891
  userId: referenceId ? void 0 : session?.session.userId,
1800
1892
  referenceId,
@@ -1827,16 +1919,14 @@ async function checkOAuthClient(client, opts, settings) {
1827
1919
  error_description: "Redirect URIs are required for authorization_code and implicit grant types"
1828
1920
  });
1829
1921
  for (const uri of clientWithDefaults.redirect_uris ?? []) validateClientRedirectUri(uri, applicationType ?? (isClientMetadataDocument ? "native" : "web"));
1830
- const supportedGrantTypes = new Set(getSupportedGrantTypes(opts));
1831
- if (isClientMetadataDocument) {
1832
- if (!grantTypes.some((grantType) => supportedGrantTypes.has(grantType))) throw new APIError("BAD_REQUEST", {
1922
+ if (isClientMetadataDocument) resolveMutuallySupportedGrantTypes(opts, grantTypes);
1923
+ else {
1924
+ const supportedGrantTypes = new Set(getSupportedGrantTypes(opts));
1925
+ for (const grantType of grantTypes) if (!supportedGrantTypes.has(grantType)) throw new APIError("BAD_REQUEST", {
1833
1926
  error: "invalid_client_metadata",
1834
- error_description: `no mutually supported grant_type among ${grantTypes.join(", ")}`
1927
+ error_description: `unsupported grant_type ${grantType}`
1835
1928
  });
1836
- } else for (const grantType of grantTypes) if (!supportedGrantTypes.has(grantType)) throw new APIError("BAD_REQUEST", {
1837
- error: "invalid_client_metadata",
1838
- error_description: `unsupported grant_type ${grantType}`
1839
- });
1929
+ }
1840
1930
  if (grantTypes.includes("authorization_code") && !responseTypes?.includes("code")) throw new APIError("BAD_REQUEST", {
1841
1931
  error: "invalid_client_metadata",
1842
1932
  error_description: "When 'authorization_code' grant type is used, 'code' response type must be included"
@@ -1916,7 +2006,7 @@ async function checkOAuthClient(client, opts, settings) {
1916
2006
  error_description: "jwks_uri must be a valid URL"
1917
2007
  });
1918
2008
  }
1919
- if (clientWithDefaults.jwks) assertValidRegistrationJwks(clientWithDefaults.jwks);
2009
+ if (clientWithDefaults.jwks) assertValidClientJwks(clientWithDefaults.jwks);
1920
2010
  }
1921
2011
  if (tokenEndpointAuthMethod === "private_key_jwt" && !clientWithDefaults.jwks && !clientWithDefaults.jwks_uri) throw new APIError("BAD_REQUEST", {
1922
2012
  error: "invalid_client_metadata",
@@ -1927,32 +2017,40 @@ async function checkOAuthClient(client, opts, settings) {
1927
2017
  error: "invalid_client_metadata",
1928
2018
  error_description: "backchannel_logout_uri requires the jwt plugin (disableJwtPlugin must be false)"
1929
2019
  });
2020
+ assertPublicRoutableHttpsUri("backchannel_logout_uri", clientWithDefaults.backchannel_logout_uri);
2021
+ }
2022
+ if (clientWithDefaults.frontchannel_logout_uri !== void 0) {
1930
2023
  let url;
1931
2024
  try {
1932
- url = new URL(clientWithDefaults.backchannel_logout_uri);
2025
+ url = new URL(clientWithDefaults.frontchannel_logout_uri);
1933
2026
  } catch {
1934
2027
  throw new APIError("BAD_REQUEST", {
1935
2028
  error: "invalid_client_metadata",
1936
- error_description: "backchannel_logout_uri must be an absolute URL"
2029
+ error_description: "frontchannel_logout_uri must be an absolute URL"
1937
2030
  });
1938
2031
  }
1939
- if (clientWithDefaults.backchannel_logout_uri.includes("#")) throw new APIError("BAD_REQUEST", {
1940
- error: "invalid_client_metadata",
1941
- error_description: "backchannel_logout_uri must not include a fragment component"
1942
- });
1943
- if (url.protocol !== "https:") throw new APIError("BAD_REQUEST", {
2032
+ if (clientWithDefaults.frontchannel_logout_uri.includes("#")) throw new APIError("BAD_REQUEST", {
1944
2033
  error: "invalid_client_metadata",
1945
- error_description: "backchannel_logout_uri must use https"
2034
+ error_description: "frontchannel_logout_uri must not include a fragment component"
1946
2035
  });
1947
2036
  if (url.username || url.password) throw new APIError("BAD_REQUEST", {
1948
2037
  error: "invalid_client_metadata",
1949
- error_description: "backchannel_logout_uri must not contain credentials"
2038
+ error_description: "frontchannel_logout_uri must not contain credentials"
2039
+ });
2040
+ const isHttpOrHttps = url.protocol === "https:" || url.protocol === "http:";
2041
+ const sharesRedirectOrigin = (clientWithDefaults.redirect_uris ?? []).some((redirectUri) => {
2042
+ try {
2043
+ return new URL(redirectUri).origin === url.origin;
2044
+ } catch {
2045
+ return false;
2046
+ }
1950
2047
  });
1951
- if (isPrivateHostname(url.hostname)) throw new APIError("BAD_REQUEST", {
2048
+ if (!isHttpOrHttps || !sharesRedirectOrigin) throw new APIError("BAD_REQUEST", {
1952
2049
  error: "invalid_client_metadata",
1953
- error_description: "backchannel_logout_uri must not point to a private or reserved address"
2050
+ error_description: "frontchannel_logout_uri must use the scheme, host, and port of a registered redirect_uri"
1954
2051
  });
1955
2052
  }
2053
+ if (!settings?.skipExtensionValidation) await validateExtensionClientMetadata(opts, clientWithDefaults, client.metadata);
1956
2054
  }
1957
2055
  const CLIENT_REGISTRATION_COLLISION = Symbol("client-registration-collision");
1958
2056
  function isClientRegistrationCollision(error) {
@@ -1979,7 +2077,8 @@ async function persistOAuthClientRegistration(ctx, opts, input) {
1979
2077
  const isExtensionAuthMethod = isExtensionTokenEndpointAuthMethod(opts, body.token_endpoint_auth_method);
1980
2078
  await checkOAuthClient(body, opts, {
1981
2079
  registrationSource: input.registrationSource,
1982
- ctx
2080
+ ctx,
2081
+ skipExtensionValidation: input.registrationSource === "clientMetadataDocument" && Boolean(input.existingClient)
1983
2082
  });
1984
2083
  const clientId = input.clientId ?? opts.generateClientId?.() ?? generateRandomString(32, "a-z", "A-Z");
1985
2084
  const clientSecret = isPublic || isPrivateKeyJwt || isExtensionAuthMethod ? void 0 : opts.generateClientSecret?.() || generateRandomString(32, "a-z", "A-Z");
@@ -2009,6 +2108,7 @@ async function persistOAuthClientRegistration(ctx, opts, input) {
2009
2108
  });
2010
2109
  schema.clientCredentialsScopes = input.registrationSource === "clientMetadataDocument" ? input.existingClient?.clientCredentialsScopes ?? [] : input.registrationSource === "managed" ? input.clientCredentialsScopes ?? [] : [];
2011
2110
  schema.clientDiscoveryId = input.registrationSource === "clientMetadataDocument" ? input.clientDiscoveryId : null;
2111
+ schema.sectorIdentifier = deriveSectorIdentifier(clientId, body.redirect_uris);
2012
2112
  if (input.registrationSource === "clientMetadataDocument" && body.application_type === void 0) schema.applicationType = null;
2013
2113
  const requestedResources = input.registrationSource === "clientMetadataDocument" ? [] : input.requestedResources ?? [];
2014
2114
  if (input.registrationSource === "dynamic") assertDynamicRegistrationResourcesAllowed(opts, requestedResources);
@@ -2022,6 +2122,19 @@ async function persistOAuthClientRegistration(ctx, opts, input) {
2022
2122
  let storedClient;
2023
2123
  let created = false;
2024
2124
  if (existingClient) {
2125
+ let metadata;
2126
+ if (hasExtensionClientMetadata(opts)) {
2127
+ const extensionFields = getExtensionClientMetadataFields(opts);
2128
+ const merged = stripReservedOAuthClientMetadataExtensions(mergeDocumentExtensionMetadata(extensionFields, (await adapter.findOne({
2129
+ model: clientModel,
2130
+ where: [{
2131
+ field: "clientId",
2132
+ value: clientId
2133
+ }]
2134
+ }))?.metadata, body.metadata));
2135
+ await validateExtensionClientMetadata(opts, body, merged);
2136
+ if (extensionFields.size > 0) metadata = merged && Object.keys(merged).length > 0 ? JSON.stringify(merged) : null;
2137
+ }
2025
2138
  const updatedClient = await adapter.update({
2026
2139
  model: clientModel,
2027
2140
  where: [{
@@ -2033,9 +2146,11 @@ async function persistOAuthClientRegistration(ctx, opts, input) {
2033
2146
  }],
2034
2147
  update: {
2035
2148
  ...schema,
2149
+ metadata,
2036
2150
  disabled: existingClient.disabled,
2037
2151
  skipConsent: existingClient.skipConsent,
2038
2152
  enableEndSession: existingClient.enableEndSession,
2153
+ sectorIdentifier: existingClient.sectorIdentifier ? schema.sectorIdentifier : null,
2039
2154
  createdAt: existingClient.createdAt,
2040
2155
  updatedAt: /* @__PURE__ */ new Date(iat * 1e3)
2041
2156
  }
@@ -2140,6 +2255,20 @@ function assertClientDiscoveryOwnership(client, clientDiscoveryId) {
2140
2255
  });
2141
2256
  }
2142
2257
  /**
2258
+ * The `metadata` a metadata document registration stores. The document owns the
2259
+ * fields extensions declare, so a refresh replaces them, including removing
2260
+ * ones it no longer declares. Other stored keys, such as ones an admin set, are
2261
+ * kept.
2262
+ */
2263
+ function mergeDocumentExtensionMetadata(extensionFields, storedMetadata, documentMetadata) {
2264
+ const owned = ([field]) => extensionFields.has(field);
2265
+ const metadata = {
2266
+ ...Object.fromEntries(Object.entries(parseClientMetadata(storedMetadata) ?? {}).filter((entry) => !owned(entry))),
2267
+ ...Object.fromEntries(Object.entries(documentMetadata ?? {}).filter(owned))
2268
+ };
2269
+ return Object.keys(metadata).length ? metadata : void 0;
2270
+ }
2271
+ /**
2143
2272
  * First-party persistence seam for a validated Client ID Metadata Document.
2144
2273
  *
2145
2274
  * This operation owns both initial creation and atomic replacement. It never
@@ -2159,18 +2288,20 @@ async function registerClientMetadataDocument(ctx, opts, input) {
2159
2288
  error: "invalid_client_metadata",
2160
2289
  error_description: "Client ID Metadata Document clients cannot use a shared secret"
2161
2290
  });
2162
- const registrationInput = {
2291
+ const toRegistrationInput = (existingClient) => ({
2163
2292
  ...input,
2293
+ existingClient,
2164
2294
  metadata: {
2165
2295
  ...input.metadata,
2296
+ metadata: pickExtensionClientMetadata(input.metadata, opts),
2166
2297
  token_endpoint_auth_method: input.metadata.token_endpoint_auth_method ?? "none",
2167
2298
  client_secret: void 0,
2168
2299
  client_secret_expires_at: void 0
2169
2300
  },
2170
2301
  registrationSource: "clientMetadataDocument"
2171
- };
2302
+ });
2172
2303
  try {
2173
- return await persistOAuthClientRegistration(ctx, opts, registrationInput);
2304
+ return await persistOAuthClientRegistration(ctx, opts, toRegistrationInput(input.existingClient));
2174
2305
  } catch (error) {
2175
2306
  if (!isClientRegistrationCollision(error)) throw error;
2176
2307
  const clientModel = opts.schema?.oauthClient?.modelName ?? "oauthClient";
@@ -2196,10 +2327,7 @@ async function registerClientMetadataDocument(ctx, opts, input) {
2196
2327
  })) throw error.cause;
2197
2328
  }
2198
2329
  try {
2199
- return await persistOAuthClientRegistration(ctx, opts, {
2200
- ...registrationInput,
2201
- existingClient: currentClient
2202
- });
2330
+ return await persistOAuthClientRegistration(ctx, opts, toRegistrationInput(currentClient));
2203
2331
  } catch (retryError) {
2204
2332
  if (isClientRegistrationCollision(retryError)) throw retryError.cause;
2205
2333
  throw retryError;
@@ -2248,7 +2376,7 @@ async function createOAuthClientEndpoint(ctx, opts, settings) {
2248
2376
  return ctx.json(responseBody);
2249
2377
  }
2250
2378
  function oauthToSchema(input) {
2251
- const { client_id: clientId, client_secret: clientSecret, client_secret_expires_at: _expiresAt, scope: _scope, user_id: userId, client_id_issued_at: _createdAt, client_name: name, client_uri: uri, logo_uri: icon, contacts, tos_uri: tos, policy_uri: policy, jwks: inputJwks, jwks_uri: jwksUri, software_id: softwareId, software_version: softwareVersion, software_statement: softwareStatement, redirect_uris: redirectUris, post_logout_redirect_uris: postLogoutRedirectUris, backchannel_logout_uri: backchannelLogoutUri, backchannel_logout_session_required: backchannelLogoutSessionRequired, token_endpoint_auth_method: tokenEndpointAuthMethod, grant_types: grantTypes, response_types: responseTypes, application_type: applicationType, disabled, skip_consent: skipConsent, enable_end_session: enableEndSession, require_pkce: requirePKCE, dpop_bound_access_tokens: dpopBoundAccessTokens, subject_type: subjectType, reference_id: referenceId, metadata: inputMetadata } = input;
2379
+ const { client_id: clientId, client_secret: clientSecret, client_secret_expires_at: _expiresAt, scope: _scope, user_id: userId, client_id_issued_at: _createdAt, client_name: name, client_uri: uri, logo_uri: icon, contacts, tos_uri: tos, policy_uri: policy, jwks: inputJwks, jwks_uri: jwksUri, software_id: softwareId, software_version: softwareVersion, software_statement: softwareStatement, redirect_uris: redirectUris, post_logout_redirect_uris: postLogoutRedirectUris, backchannel_logout_uri: backchannelLogoutUri, backchannel_logout_session_required: backchannelLogoutSessionRequired, frontchannel_logout_uri: frontchannelLogoutUri, frontchannel_logout_session_required: frontchannelLogoutSessionRequired, token_endpoint_auth_method: tokenEndpointAuthMethod, grant_types: grantTypes, response_types: responseTypes, application_type: applicationType, disabled, skip_consent: skipConsent, enable_end_session: enableEndSession, require_pkce: requirePKCE, dpop_bound_access_tokens: dpopBoundAccessTokens, subject_type: subjectType, reference_id: referenceId, metadata: inputMetadata } = input;
2252
2380
  const expiresAt = _expiresAt ? /* @__PURE__ */ new Date(Number(_expiresAt) * 1e3) : void 0;
2253
2381
  const createdAt = _createdAt ? /* @__PURE__ */ new Date(_createdAt * 1e3) : void 0;
2254
2382
  const scopes = _scope?.split(" ");
@@ -2275,6 +2403,8 @@ function oauthToSchema(input) {
2275
2403
  postLogoutRedirectUris,
2276
2404
  backchannelLogoutUri,
2277
2405
  backchannelLogoutSessionRequired,
2406
+ frontchannelLogoutUri,
2407
+ frontchannelLogoutSessionRequired,
2278
2408
  tokenEndpointAuthMethod,
2279
2409
  grantTypes,
2280
2410
  responseTypes,
@@ -2297,7 +2427,7 @@ function oauthToSchema(input) {
2297
2427
  * @returns
2298
2428
  */
2299
2429
  function schemaToOAuth(input) {
2300
- const { clientId, clientSecret, disabled, scopes, userId, createdAt, updatedAt: _updatedAt, expiresAt, name, uri, icon, contacts, tos, policy, softwareId, softwareVersion, softwareStatement, redirectUris, postLogoutRedirectUris, backchannelLogoutUri, backchannelLogoutSessionRequired, tokenEndpointAuthMethod, grantTypes, responseTypes, applicationType, jwks, jwksUri, skipConsent, enableEndSession, requirePKCE, dpopBoundAccessTokens, subjectType, referenceId, metadata } = input;
2430
+ const { clientId, clientSecret, disabled, scopes, userId, createdAt, updatedAt: _updatedAt, expiresAt, name, uri, icon, contacts, tos, policy, softwareId, softwareVersion, softwareStatement, redirectUris, postLogoutRedirectUris, backchannelLogoutUri, backchannelLogoutSessionRequired, frontchannelLogoutUri, frontchannelLogoutSessionRequired, tokenEndpointAuthMethod, grantTypes, responseTypes, applicationType, jwks, jwksUri, skipConsent, enableEndSession, requirePKCE, dpopBoundAccessTokens, subjectType, referenceId, metadata } = input;
2301
2431
  const _expiresAt = expiresAt ? Math.round(new Date(expiresAt).getTime() / 1e3) : void 0;
2302
2432
  const _createdAt = createdAt ? Math.round(new Date(createdAt).getTime() / 1e3) : void 0;
2303
2433
  const _scopes = scopes?.join(" ");
@@ -2324,6 +2454,8 @@ function schemaToOAuth(input) {
2324
2454
  post_logout_redirect_uris: postLogoutRedirectUris ?? void 0,
2325
2455
  backchannel_logout_uri: backchannelLogoutUri ?? void 0,
2326
2456
  backchannel_logout_session_required: backchannelLogoutSessionRequired ?? void 0,
2457
+ frontchannel_logout_uri: frontchannelLogoutUri ?? void 0,
2458
+ frontchannel_logout_session_required: frontchannelLogoutSessionRequired ?? void 0,
2327
2459
  token_endpoint_auth_method: tokenEndpointAuthMethod ?? void 0,
2328
2460
  grant_types: grantTypes ?? void 0,
2329
2461
  response_types: responseTypes ?? void 0,
@@ -2421,10 +2553,7 @@ async function deleteClientEndpoint(ctx, opts) {
2421
2553
  await assertClientPrivileges(ctx, session, opts, "delete", ctx.body.client_id);
2422
2554
  if (!session) throw new APIError("UNAUTHORIZED");
2423
2555
  const clientId = ctx.body.client_id;
2424
- if (opts.cachedTrustedClients?.has(clientId)) throw new APIError("INTERNAL_SERVER_ERROR", {
2425
- error_description: "trusted clients must be updated manually",
2426
- error: "invalid_client"
2427
- });
2556
+ assertNotTrustedClient(opts, clientId);
2428
2557
  const client = await getClient(ctx, opts, clientId);
2429
2558
  if (!client) throw new APIError("NOT_FOUND", {
2430
2559
  error_description: "client not found",
@@ -2448,21 +2577,26 @@ async function updateClientEndpoint(ctx, opts, settings) {
2448
2577
  await assertClientPrivileges(ctx, session, opts, "update", ctx.body.client_id);
2449
2578
  if (!session) throw new APIError("UNAUTHORIZED");
2450
2579
  const clientId = ctx.body.client_id;
2451
- if (opts.cachedTrustedClients?.has(clientId)) throw new APIError("INTERNAL_SERVER_ERROR", {
2452
- error_description: "trusted clients must be updated manually",
2453
- error: "invalid_client"
2454
- });
2580
+ assertNotTrustedClient(opts, clientId);
2455
2581
  const client = await getClient(ctx, opts, clientId);
2456
2582
  if (!client) throw new APIError("NOT_FOUND", {
2457
2583
  error_description: "client not found",
2458
2584
  error: "not_found"
2459
2585
  });
2460
- const { client_credentials_scopes: rawClientCredentialsScopes, ...updates } = ctx.body.update;
2586
+ const { client_credentials_scopes: rawClientCredentialsScopes, backchannel_logout_uri: backchannelLogoutUri, frontchannel_logout_uri: frontchannelLogoutUri, ...otherUpdates } = ctx.body.update;
2587
+ const clearsBackchannelLogout = backchannelLogoutUri === null;
2588
+ const clearsFrontchannelLogout = frontchannelLogoutUri === null;
2589
+ const updates = {
2590
+ ...otherUpdates,
2591
+ ...backchannelLogoutUri != null ? { backchannel_logout_uri: backchannelLogoutUri } : {},
2592
+ ...frontchannelLogoutUri != null ? { frontchannel_logout_uri: frontchannelLogoutUri } : {}
2593
+ };
2594
+ const hasUpdates = Object.keys(updates).length > 0 || clearsBackchannelLogout || clearsFrontchannelLogout;
2461
2595
  const ownsClient = client.userId ? client.userId === session.user.id : client.referenceId && opts.clientReference ? client.referenceId === await opts.clientReference(session) : false;
2462
- const isCrossOwnerScopeConfiguration = !ownsClient && settings?.admin && rawClientCredentialsScopes !== void 0 && Object.keys(updates).length === 0;
2596
+ const isCrossOwnerScopeConfiguration = !ownsClient && settings?.admin && rawClientCredentialsScopes !== void 0 && !hasUpdates;
2463
2597
  if (!ownsClient && !isCrossOwnerScopeConfiguration) throw new APIError("UNAUTHORIZED");
2464
2598
  if (isCrossOwnerScopeConfiguration) await assertClientPrivileges(ctx, session, opts, "configure-client-credentials-scopes", clientId);
2465
- if (Object.keys(updates).length === 0 && rawClientCredentialsScopes === void 0) {
2599
+ if (!hasUpdates && rawClientCredentialsScopes === void 0) {
2466
2600
  const res = schemaToOAuth(client);
2467
2601
  res.client_secret = void 0;
2468
2602
  if (settings?.admin) return {
@@ -2478,11 +2612,34 @@ async function updateClientEndpoint(ctx, opts, settings) {
2478
2612
  validateClientCredentialsScopes(clientCredentialsScopes, finalGrantTypes, finalTokenEndpointAuthMethod, opts);
2479
2613
  if (clientCredentialsScopes.length > 0 && !isCrossOwnerScopeConfiguration) await assertClientPrivileges(ctx, session, opts, "configure-client-credentials-scopes", clientId);
2480
2614
  }
2481
- await checkOAuthClient({
2482
- ...schemaToOAuth(client),
2483
- ...updates
2484
- }, opts, { ctx });
2615
+ const updatedMetadata = {
2616
+ ...schemaToOAuth({
2617
+ ...client,
2618
+ metadata: void 0
2619
+ }),
2620
+ ...updates,
2621
+ metadata: updates.metadata ?? parseClientMetadata(client.metadata)
2622
+ };
2623
+ if (clearsBackchannelLogout) {
2624
+ updatedMetadata.backchannel_logout_uri = void 0;
2625
+ updatedMetadata.backchannel_logout_session_required = void 0;
2626
+ }
2627
+ if (clearsFrontchannelLogout) {
2628
+ updatedMetadata.frontchannel_logout_uri = void 0;
2629
+ updatedMetadata.frontchannel_logout_session_required = void 0;
2630
+ }
2631
+ await checkOAuthClient(updatedMetadata, opts, { ctx });
2485
2632
  const schemaUpdates = { ...oauthToSchema(updates) };
2633
+ if (updates.redirect_uris && client.sectorIdentifier) schemaUpdates.sectorIdentifier = deriveSectorIdentifier(client.clientId, updates.redirect_uris);
2634
+ if (clearsBackchannelLogout) {
2635
+ schemaUpdates.backchannelLogoutUri = null;
2636
+ schemaUpdates.backchannelLogoutSessionRequired = null;
2637
+ }
2638
+ if (clearsFrontchannelLogout) {
2639
+ schemaUpdates.frontchannelLogoutUri = null;
2640
+ schemaUpdates.frontchannelLogoutSessionRequired = null;
2641
+ }
2642
+ if (updates.metadata !== void 0 && schemaUpdates.metadata === void 0) schemaUpdates.metadata = null;
2486
2643
  if (!finalGrantTypes.includes("client_credentials") || finalTokenEndpointAuthMethod === "none") schemaUpdates.clientCredentialsScopes = [];
2487
2644
  else if (clientCredentialsScopes !== void 0) schemaUpdates.clientCredentialsScopes = clientCredentialsScopes;
2488
2645
  if (updates.token_endpoint_auth_method) if (updates.token_endpoint_auth_method === "private_key_jwt") schemaUpdates.clientSecret = null;
@@ -2519,10 +2676,7 @@ async function rotateClientSecretEndpoint(ctx, opts) {
2519
2676
  await assertClientPrivileges(ctx, session, opts, "rotate", ctx.body.client_id);
2520
2677
  if (!session) throw new APIError("UNAUTHORIZED");
2521
2678
  const clientId = ctx.body.client_id;
2522
- if (opts.cachedTrustedClients?.has(clientId)) throw new APIError("INTERNAL_SERVER_ERROR", {
2523
- error_description: "trusted clients must be updated manually",
2524
- error: "invalid_client"
2525
- });
2679
+ assertNotTrustedClient(opts, clientId);
2526
2680
  const client = await getClient(ctx, opts, clientId);
2527
2681
  if (!client) throw new APIError("NOT_FOUND", {
2528
2682
  error_description: "client not found",
@@ -2559,6 +2713,57 @@ async function rotateClientSecretEndpoint(ctx, opts) {
2559
2713
  clientSecret: (opts.prefix?.clientSecret ?? "") + clientSecret
2560
2714
  });
2561
2715
  }
2716
+ /**
2717
+ * The write is guarded on the exact key set the assertion was verified against.
2718
+ * A concurrent rotation changes that value, so the stale request loses instead
2719
+ * of overwriting a newer key set.
2720
+ *
2721
+ * @see https://datatracker.ietf.org/doc/html/rfc7523#section-3
2722
+ */
2723
+ async function rotateClientKeysEndpoint(ctx, opts) {
2724
+ if (!opts.allowClientKeyRotation) throw new APIError("FORBIDDEN", {
2725
+ error: "access_denied",
2726
+ error_description: "Client key rotation is disabled"
2727
+ });
2728
+ const { body } = ctx;
2729
+ assertValidClientJwks(body.jwks);
2730
+ const { client } = await verifyClientAssertion(ctx, opts, body.client_assertion, body.client_assertion_type, void 0, getEndpointUrl({
2731
+ context: ctx.context,
2732
+ request: void 0
2733
+ }, "/oauth2/client/rotate-keys"), {
2734
+ endpointAudienceOnly: true,
2735
+ assertClient: (candidate) => assertRotatable(opts, candidate)
2736
+ });
2737
+ if (!await ctx.context.adapter.incrementOne({
2738
+ model: "oauthClient",
2739
+ where: [{
2740
+ field: "clientId",
2741
+ value: client.clientId
2742
+ }, {
2743
+ field: "jwks",
2744
+ value: client.jwks
2745
+ }],
2746
+ increment: {},
2747
+ set: {
2748
+ jwks: JSON.stringify(body.jwks),
2749
+ updatedAt: /* @__PURE__ */ new Date()
2750
+ }
2751
+ })) throw new APIError("CONFLICT", {
2752
+ error: "invalid_request",
2753
+ error_description: "client keys changed while rotating; retry with a fresh client assertion"
2754
+ });
2755
+ return {
2756
+ client_id: client.clientId,
2757
+ jwks: body.jwks
2758
+ };
2759
+ }
2760
+ function assertRotatable(opts, client) {
2761
+ assertNotTrustedClient(opts, client.clientId);
2762
+ if (client.clientDiscoveryId || client.jwksUri || !client.jwks) throw new APIError("BAD_REQUEST", {
2763
+ error: "invalid_client",
2764
+ error_description: "client keys are not stored inline; rotate them where the client publishes them"
2765
+ });
2766
+ }
2562
2767
  //#endregion
2563
2768
  //#region src/oauthClient/index.ts
2564
2769
  const tokenEndpointAuthMethodSchema = z.string().trim().min(1);
@@ -2580,6 +2785,8 @@ const adminCreateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/creat
2580
2785
  post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
2581
2786
  backchannel_logout_uri: SafeUrlSchema.optional(),
2582
2787
  backchannel_logout_session_required: z.boolean().optional(),
2788
+ frontchannel_logout_uri: SafeUrlSchema.optional(),
2789
+ frontchannel_logout_session_required: z.boolean().optional(),
2583
2790
  token_endpoint_auth_method: tokenEndpointAuthMethodSchema.optional(),
2584
2791
  application_type: z.enum(["web", "native"]).optional(),
2585
2792
  jwks: clientJwksSchema.optional(),
@@ -2753,6 +2960,8 @@ const createOAuthClient = (opts) => createAuthEndpoint("/oauth2/create-client",
2753
2960
  post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
2754
2961
  backchannel_logout_uri: SafeUrlSchema.optional(),
2755
2962
  backchannel_logout_session_required: z.boolean().optional(),
2963
+ frontchannel_logout_uri: SafeUrlSchema.optional(),
2964
+ frontchannel_logout_session_required: z.boolean().optional(),
2756
2965
  token_endpoint_auth_method: tokenEndpointAuthMethodSchema.optional(),
2757
2966
  application_type: z.enum(["web", "native"]).optional(),
2758
2967
  jwks: clientJwksSchema.optional(),
@@ -2942,8 +3151,10 @@ const adminUpdateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/updat
2942
3151
  software_version: z.string().optional(),
2943
3152
  software_statement: z.string().optional(),
2944
3153
  post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
2945
- backchannel_logout_uri: SafeUrlSchema.optional(),
3154
+ backchannel_logout_uri: SafeUrlSchema.nullable().optional(),
2946
3155
  backchannel_logout_session_required: z.boolean().optional(),
3156
+ frontchannel_logout_uri: SafeUrlSchema.nullable().optional(),
3157
+ frontchannel_logout_session_required: z.boolean().optional(),
2947
3158
  application_type: z.enum(["web", "native"]).optional(),
2948
3159
  grant_types: grantTypesSchema.optional(),
2949
3160
  client_credentials_scopes: z.array(z.string().trim().min(1)).optional(),
@@ -2981,8 +3192,10 @@ const updateOAuthClient = (opts) => createAuthEndpoint("/oauth2/update-client",
2981
3192
  software_version: z.string().optional(),
2982
3193
  software_statement: z.string().optional(),
2983
3194
  post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
2984
- backchannel_logout_uri: SafeUrlSchema.optional(),
3195
+ backchannel_logout_uri: SafeUrlSchema.nullable().optional(),
2985
3196
  backchannel_logout_session_required: z.boolean().optional(),
3197
+ frontchannel_logout_uri: SafeUrlSchema.nullable().optional(),
3198
+ frontchannel_logout_session_required: z.boolean().optional(),
2986
3199
  application_type: z.enum(["web", "native"]).optional(),
2987
3200
  grant_types: grantTypesSchema.optional(),
2988
3201
  response_types: z.array(z.enum(["code"])).optional()
@@ -3003,6 +3216,29 @@ const rotateClientSecret = (opts) => createAuthEndpoint("/oauth2/client/rotate-s
3003
3216
  }, async (ctx) => {
3004
3217
  return rotateClientSecretEndpoint(ctx, opts);
3005
3218
  });
3219
+ /**
3220
+ * Replaces a `private_key_jwt` client's own signing keys. The client proves it
3221
+ * holds a current key with a client assertion, so no session is needed.
3222
+ */
3223
+ const rotateClientKeys = (opts) => createOAuthEndpoint("/oauth2/client/rotate-keys", {
3224
+ method: "POST",
3225
+ body: z.object({
3226
+ client_assertion: z.string(),
3227
+ client_assertion_type: z.string(),
3228
+ jwks: clientJwksSchema
3229
+ }),
3230
+ errorCodesByField: {
3231
+ client_assertion: "invalid_client",
3232
+ client_assertion_type: "invalid_client",
3233
+ jwks: "invalid_client_metadata"
3234
+ },
3235
+ metadata: {
3236
+ noStore: true,
3237
+ openapi: { description: "Replaces the signing keys of a private_key_jwt client. The request is authenticated by a client assertion signed with one of the client's current keys and addressed to this endpoint. Keys left out of the new set stop verifying at once." }
3238
+ }
3239
+ }, async (ctx) => {
3240
+ return rotateClientKeysEndpoint(ctx, opts);
3241
+ });
3006
3242
  const deleteOAuthClient = (opts) => createAuthEndpoint("/oauth2/delete-client", {
3007
3243
  method: "POST",
3008
3244
  use: [sessionMiddleware],
@@ -3449,112 +3685,47 @@ const adminUnlinkClientResource = (opts) => createAuthEndpoint("/admin/oauth2/re
3449
3685
  //#endregion
3450
3686
  //#region src/revoke.ts
3451
3687
  /**
3452
- * IMPORTANT NOTES:
3453
- * Revocation follows RFC7009
3688
+ * Revocation follows RFC 7009
3454
3689
  * https://datatracker.ietf.org/doc/html/rfc7009
3455
- * - APIError: Continue catches (returnable to client)
3456
- * - Error: Should immediately stop catches (internal error)
3690
+ *
3691
+ * A token this server cannot find is already unusable, so it resolves as a
3692
+ * successful no-op (§2.2). Only explicit protocol errors surface.
3457
3693
  */
3458
3694
  /**
3459
- * Revokes a JWT access token against the configured JWKs.
3460
- *
3461
3695
  * A JWT access token is self-contained and never stored, so there is nothing to
3462
3696
  * delete. Once the token is confirmed to be a valid JWT for this server, the
3463
3697
  * endpoint reports `unsupported_token_type` (RFC 7009 §2.2.1) instead of a
3464
3698
  * silent success, so callers can tell that no server-side revocation happened.
3465
- * An expired JWT or a JWT with an audience rejected by the OAuth resource model
3466
- * is already inactive and still resolves as a successful no-op. Session-bound
3467
- * tokens (carrying `sid`) are cut off early by the session-liveness check in
3468
- * introspection and userinfo.
3699
+ * A JWT with an audience rejected by the OAuth resource model is already
3700
+ * inactive and resolves as a successful no-op. Session-bound tokens (carrying
3701
+ * `sid`) are cut off early by the session-liveness check in introspection and
3702
+ * userinfo.
3469
3703
  */
3470
- async function revokeJwtAccessToken(ctx, opts, token) {
3471
- const jwtPlugin = opts.disableJwtPlugin ? void 0 : getJwtPlugin(ctx.context);
3472
- const jwtPluginOptions = jwtPlugin?.options;
3473
- try {
3474
- const verified = await jwtVerify(token, createLocalJWKSet(await getJwks(token, {
3475
- jwksFetch: jwtPluginOptions?.jwks?.remoteUrl ? jwtPluginOptions.jwks.remoteUrl : async () => {
3476
- return (await jwtPlugin?.endpoints.getJwks(ctx))?.response;
3477
- },
3478
- jwksCacheKey: jwtPlugin
3479
- })), { issuer: jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL });
3480
- const userInfoAudience = `${ctx.context.baseURL}/oauth2/userinfo`;
3481
- if (!verified.payload.azp || !await isAudienceClaimAllowed(ctx, opts, verified.payload.aud, [userInfoAudience])) return null;
3482
- } catch (error) {
3483
- if (error instanceof Error) {
3484
- if (error.name === "TypeError" || error.name === "JWSInvalid") throw new APIError$1("BAD_REQUEST", {
3485
- error_description: "invalid JWT signature",
3486
- error: "invalid_request"
3487
- });
3488
- else if (error.name === "JWTExpired") return null;
3489
- else if (error.name === "JWTInvalid") return null;
3490
- throw error;
3491
- }
3492
- throw new Error(error);
3493
- }
3704
+ async function revokeJwtAccessToken(ctx, opts, payload) {
3705
+ if (!await isAudienceClaimAllowed(ctx, opts, payload.aud, [userInfoResource(ctx.context.baseURL)])) return null;
3494
3706
  throw new APIError$1("BAD_REQUEST", {
3495
3707
  error_description: "JWT access tokens are self-contained and cannot be revoked server-side",
3496
3708
  error: "unsupported_token_type"
3497
3709
  });
3498
3710
  }
3499
- /**
3500
- * Searches for an opaque access token in the database and validates it
3501
- */
3502
- async function revokeOpaqueAccessToken(ctx, opts, token, clientId) {
3503
- let tokenValue = token;
3504
- if (opts.prefix?.opaqueAccessToken) if (tokenValue.startsWith(opts.prefix.opaqueAccessToken)) tokenValue = tokenValue.replace(opts.prefix.opaqueAccessToken, "");
3505
- else throw new APIError$1("BAD_REQUEST", {
3506
- error_description: "opaque access token not found",
3507
- error: "invalid_request"
3508
- });
3509
- const accessToken = await ctx.context.adapter.findOne({
3510
- model: "oauthAccessToken",
3511
- where: [{
3512
- field: "token",
3513
- value: await getStoredToken(opts.storeTokens, tokenValue, "access_token")
3514
- }]
3515
- });
3516
- if (!accessToken) throw new APIError$1("BAD_REQUEST", {
3517
- error_description: "opaque access token not found",
3518
- error: "invalid_request"
3519
- });
3520
- if (!accessToken.clientId || accessToken.clientId !== clientId) return null;
3521
- accessToken.id ? await ctx.context.adapter.delete({
3711
+ async function revokeOpaqueAccessToken(ctx, accessToken) {
3712
+ await ctx.context.adapter.delete({
3522
3713
  model: "oauthAccessToken",
3523
- where: [{
3714
+ where: accessToken.id ? [{
3524
3715
  field: "id",
3525
3716
  value: accessToken.id
3526
- }]
3527
- }) : await ctx.context.adapter.delete({
3528
- model: "oauthAccessToken",
3529
- where: [{
3717
+ }] : [{
3530
3718
  field: "token",
3531
3719
  value: accessToken.token
3532
3720
  }]
3533
3721
  });
3534
3722
  }
3535
- /**
3536
- * Validates a refresh token in the session store.
3537
- */
3538
- async function revokeRefreshToken(ctx, opts, token, clientId) {
3539
- const refreshToken = await ctx.context.adapter.findOne({
3540
- model: "oauthRefreshToken",
3541
- where: [{
3542
- field: "token",
3543
- value: await getStoredToken(opts.storeTokens, token, "refresh_token")
3544
- }]
3545
- });
3546
- if (!refreshToken) throw new APIError$1("BAD_REQUEST", {
3547
- error_description: "token not found",
3548
- error: "invalid_request"
3549
- });
3723
+ async function revokeRefreshToken(ctx, refreshToken, clientId) {
3724
+ const { userId } = refreshToken;
3550
3725
  if (refreshToken.revoked) {
3551
- await invalidateRefreshFamily(ctx, clientId, refreshToken.userId);
3552
- throw new APIError$1("BAD_REQUEST", {
3553
- error_description: "refresh token revoked",
3554
- error: "invalid_request"
3555
- });
3726
+ await invalidateRefreshFamily(ctx, clientId, userId);
3727
+ return;
3556
3728
  }
3557
- if (!refreshToken.clientId || refreshToken.clientId !== clientId) return null;
3558
3729
  const iat = Math.floor(Date.now() / 1e3);
3559
3730
  if (!await ctx.context.adapter.incrementOne({
3560
3731
  model: "oauthRefreshToken",
@@ -3569,11 +3740,8 @@ async function revokeRefreshToken(ctx, opts, token, clientId) {
3569
3740
  increment: {},
3570
3741
  set: { revoked: /* @__PURE__ */ new Date(iat * 1e3) }
3571
3742
  })) {
3572
- await invalidateRefreshFamily(ctx, clientId, refreshToken.userId);
3573
- throw new APIError$1("BAD_REQUEST", {
3574
- error_description: "refresh token revoked",
3575
- error: "invalid_request"
3576
- });
3743
+ await invalidateRefreshFamily(ctx, clientId, userId);
3744
+ return;
3577
3745
  }
3578
3746
  await ctx.context.adapter.deleteMany({
3579
3747
  model: "oauthAccessToken",
@@ -3583,33 +3751,8 @@ async function revokeRefreshToken(ctx, opts, token, clientId) {
3583
3751
  }]
3584
3752
  });
3585
3753
  }
3586
- /**
3587
- * We don't know the access token format so we try to validate it
3588
- * as a JWT first, then as an opaque token.
3589
- */
3590
- async function revokeAccessToken(ctx, opts, clientId, token) {
3591
- try {
3592
- return await revokeJwtAccessToken(ctx, opts, token);
3593
- } catch (err) {
3594
- if (err instanceof APIError$1) {
3595
- if (err.body?.error === "unsupported_token_type") throw err;
3596
- } else if (err instanceof Error) throw err;
3597
- else throw new Error(err);
3598
- }
3599
- try {
3600
- return await revokeOpaqueAccessToken(ctx, opts, token, clientId);
3601
- } catch (err) {
3602
- if (err instanceof APIError$1) {} else if (err instanceof Error) throw err;
3603
- else throw new Error("Unknown error validating access token");
3604
- }
3605
- throw new APIError$1("BAD_REQUEST", {
3606
- error_description: "Invalid access token",
3607
- error: "invalid_request"
3608
- });
3609
- }
3610
3754
  async function revokeEndpoint(ctx, opts) {
3611
3755
  let { token, token_type_hint } = ctx.body;
3612
- if (token_type_hint !== "access_token" && token_type_hint !== "refresh_token") token_type_hint = void 0;
3613
3756
  const { clientId: client_id, clientSecret: client_secret, preVerified, authMethod } = destructureCredentials(await extractClientCredentials(ctx, opts, `${ctx.context.baseURL}/oauth2/revoke`));
3614
3757
  if (!client_id) throw new APIError$1("UNAUTHORIZED", {
3615
3758
  error_description: "missing required credentials",
@@ -3622,38 +3765,23 @@ async function revokeEndpoint(ctx, opts) {
3622
3765
  });
3623
3766
  const client = await validateClientCredentials(ctx, opts, client_id, client_secret, void 0, preVerified, void 0, authMethod);
3624
3767
  try {
3625
- if (token_type_hint === void 0 || token_type_hint === "access_token") try {
3626
- return await revokeAccessToken(ctx, opts, client.clientId, token);
3627
- } catch (error) {
3628
- if (error instanceof APIError$1) {
3629
- if (token_type_hint === "access_token" || error.body?.error === "unsupported_token_type") throw error;
3630
- } else if (error instanceof Error) throw error;
3631
- else throw new Error(error);
3632
- }
3633
- if (token_type_hint === void 0 || token_type_hint === "refresh_token") try {
3634
- return await revokeRefreshToken(ctx, opts, (await decodeRefreshToken(opts, token)).token, client.clientId);
3635
- } catch (error) {
3636
- if (error instanceof APIError$1) {
3637
- if (token_type_hint === "refresh_token") throw error;
3638
- } else if (error instanceof Error) throw error;
3639
- else throw new Error(error);
3768
+ const found = await findToken(ctx, opts, token, token_type_hint);
3769
+ if (!found) return null;
3770
+ if ((found.type === "jwt" ? found.payload.azp : found.record.clientId) !== client.clientId) {
3771
+ if (client.tokenEndpointAuthMethod === "none") return null;
3772
+ throw new APIError$1("BAD_REQUEST", {
3773
+ error_description: "client is not authorized to revoke the presented token",
3774
+ error: "invalid_request"
3775
+ });
3640
3776
  }
3641
- throw new APIError$1("BAD_REQUEST", {
3642
- error_description: "token not found",
3643
- error: "invalid_request"
3644
- });
3777
+ if (found.type === "jwt") return await revokeJwtAccessToken(ctx, opts, found.payload);
3778
+ if (found.type === "opaque") await revokeOpaqueAccessToken(ctx, found.record);
3779
+ else await revokeRefreshToken(ctx, found.record, client.clientId);
3780
+ return null;
3645
3781
  } catch (error) {
3646
- if (error instanceof APIError$1) {
3647
- if (error.body?.error === "unsupported_token_type") throw error;
3648
- if (error.name === "BAD_REQUEST") return null;
3649
- throw error;
3650
- } else if (error instanceof Error) {
3651
- logger.error("Introspection error:", error.message, error.stack);
3652
- throw new APIError$1("INTERNAL_SERVER_ERROR");
3653
- } else {
3654
- logger.error("Introspection error:", error);
3655
- throw new APIError$1("INTERNAL_SERVER_ERROR");
3656
- }
3782
+ if (error instanceof APIError$1) throw error;
3783
+ logger.error("Revocation error:", error);
3784
+ throw new APIError$1("INTERNAL_SERVER_ERROR");
3657
3785
  }
3658
3786
  }
3659
3787
  //#endregion
@@ -3692,6 +3820,10 @@ const schema = {
3692
3820
  type: "string",
3693
3821
  required: false
3694
3822
  },
3823
+ sectorIdentifier: {
3824
+ type: "string",
3825
+ required: false
3826
+ },
3695
3827
  scopes: {
3696
3828
  type: "string[]",
3697
3829
  required: false
@@ -3770,6 +3902,14 @@ const schema = {
3770
3902
  type: "boolean",
3771
3903
  required: false
3772
3904
  },
3905
+ frontchannelLogoutUri: {
3906
+ type: "string",
3907
+ required: false
3908
+ },
3909
+ frontchannelLogoutSessionRequired: {
3910
+ type: "boolean",
3911
+ required: false
3912
+ },
3773
3913
  tokenEndpointAuthMethod: {
3774
3914
  type: "string",
3775
3915
  required: false
@@ -4018,6 +4158,14 @@ const schema = {
4018
4158
  type: "date",
4019
4159
  required: false
4020
4160
  },
4161
+ acr: {
4162
+ type: "string",
4163
+ required: false
4164
+ },
4165
+ amr: {
4166
+ type: "string[]",
4167
+ required: false
4168
+ },
4021
4169
  confirmation: {
4022
4170
  type: "json",
4023
4171
  required: false
@@ -4198,7 +4346,7 @@ const rpInitiatedLogoutRequestSchema = z.object({
4198
4346
  });
4199
4347
  const oAuthState = defineRequestState(() => null);
4200
4348
  const getOAuthProviderState = oAuthState.get;
4201
- const backchannelLogoutPlansByContext = /* @__PURE__ */ new WeakMap();
4349
+ const sessionLogoutPlansByContext = /* @__PURE__ */ new WeakMap();
4202
4350
  const signedQueryIssuedAtMsKey = "signedQueryIssuedAtMs";
4203
4351
  function getServerContextSignedQueryIssuedAt(value) {
4204
4352
  const issuedAtMs = typeof value === "number" ? value : typeof value === "string" ? Number(value) : void 0;
@@ -4222,6 +4370,8 @@ const oauthProvider = (options) => {
4222
4370
  if (clientRegistrationAllowedScopes) {
4223
4371
  for (const sc of clientRegistrationAllowedScopes) if (!scopes.has(sc)) throw new BetterAuthError(`clientRegistrationAllowedScope ${sc} not found in scopes`);
4224
4372
  }
4373
+ const acrValuesSupported = options.authenticationContext?.acrValuesSupported;
4374
+ if (acrValuesSupported && (acrValuesSupported.length === 0 || acrValuesSupported.includes(""))) throw new BetterAuthError("authenticationContext.acrValuesSupported must list at least one non-empty acr value");
4225
4375
  for (const sc of options.advertisedMetadata?.scopes_supported ?? []) if (!scopes?.has(sc)) throw new BetterAuthError(`advertisedMetadata.scopes_supported ${sc} not found in scopes`);
4226
4376
  const configuredResourceIdentifiers = new Set((options.resources ?? []).map((resource) => typeof resource === "string" ? resource : resource.identifier));
4227
4377
  for (const [optionName, identifiers] of [["clientRegistrationDefaultResources", options.clientRegistrationDefaultResources], ["clientRegistrationAllowedResources", options.clientRegistrationAllowedResources]]) for (const identifier of identifiers ?? []) if (!configuredResourceIdentifiers.has(identifier)) throw new BetterAuthError(`${optionName} resource ${identifier} not found in resources`);
@@ -4313,12 +4463,19 @@ const oauthProvider = (options) => {
4313
4463
  schema: { type: "string" },
4314
4464
  description: "OAuth 2.1 response type (e.g., 'code')"
4315
4465
  },
4466
+ {
4467
+ name: "user_code",
4468
+ in: "query",
4469
+ required: false,
4470
+ schema: { type: "string" },
4471
+ description: "Device user code to approve instead of an authorization request (requires oauthDeviceAuthorization())"
4472
+ },
4316
4473
  {
4317
4474
  name: "client_id",
4318
4475
  in: "query",
4319
- required: true,
4476
+ required: false,
4320
4477
  schema: { type: "string" },
4321
- description: "OAuth 2.1 client ID"
4478
+ description: "OAuth 2.1 client ID, required for authorization requests (not for user_code)"
4322
4479
  },
4323
4480
  {
4324
4481
  name: "redirect_uri",
@@ -4443,6 +4600,10 @@ const oauthProvider = (options) => {
4443
4600
  onRequest: handleIssuerMetadataRequest,
4444
4601
  init: async (ctx) => {
4445
4602
  if (ctx.options.secondaryStorage && ctx.options.session?.storeSessionInDatabase !== true) throw new BetterAuthError("OAuth Provider requires `session.storeSessionInDatabase: true` when using secondaryStorage");
4603
+ if (opts.allowClientKeyRotation) {
4604
+ const gap = ctx.adapter.id === "memory" ? "the memory adapter" : ctx.options.advanced?.database?.generateId === "serial" ? "`advanced.database.generateId: \"serial\"`" : void 0;
4605
+ if (gap) throw new BetterAuthError(`\`allowClientKeyRotation\` requires single-use client assertions, which ${gap} does not enforce`);
4606
+ }
4446
4607
  await seedResourcesOnce(ctx, opts).catch((error) => logStartupSeedFailure(ctx, error));
4447
4608
  logEnforcePerClientResourcesResolution(opts);
4448
4609
  if (!opts.disableJwtPlugin) {
@@ -4458,25 +4619,26 @@ const oauthProvider = (options) => {
4458
4619
  return { options: { databaseHooks: { session: { delete: {
4459
4620
  async before(session, hookCtx) {
4460
4621
  if (!hookCtx) return;
4461
- const plan = await prepareBackchannelLogoutPlan(hookCtx, opts, {
4622
+ const plan = await prepareSessionLogoutPlan(hookCtx, opts, {
4462
4623
  sessionId: session.id,
4463
4624
  userId: session.userId
4464
4625
  });
4465
4626
  if (!plan) return;
4466
- const logoutPlanBySessionId = backchannelLogoutPlansByContext.get(hookCtx) ?? /* @__PURE__ */ new Map();
4627
+ const logoutPlanBySessionId = sessionLogoutPlansByContext.get(hookCtx) ?? /* @__PURE__ */ new Map();
4467
4628
  logoutPlanBySessionId.set(session.id, plan);
4468
- backchannelLogoutPlansByContext.set(hookCtx, logoutPlanBySessionId);
4629
+ sessionLogoutPlansByContext.set(hookCtx, logoutPlanBySessionId);
4469
4630
  },
4470
4631
  async after(session, hookCtx) {
4471
4632
  if (!hookCtx) return;
4472
- const logoutPlanBySessionId = backchannelLogoutPlansByContext.get(hookCtx);
4633
+ const logoutPlanBySessionId = sessionLogoutPlansByContext.get(hookCtx);
4473
4634
  if (!logoutPlanBySessionId) return;
4474
4635
  const plan = logoutPlanBySessionId.get(session.id);
4475
4636
  logoutPlanBySessionId.delete(session.id);
4476
- if (logoutPlanBySessionId.size === 0) backchannelLogoutPlansByContext.delete(hookCtx);
4637
+ if (logoutPlanBySessionId.size === 0) sessionLogoutPlansByContext.delete(hookCtx);
4477
4638
  if (!plan) return;
4478
- const logoutTask = applyBackchannelLogoutPlan(hookCtx, plan).catch((error) => {
4479
- hookCtx.context.logger.error("Back-channel logout failed after session deletion", error);
4639
+ await recordFrontchannelLogoutUris(plan.frontchannelLogoutUris);
4640
+ const logoutTask = applySessionLogoutPlan(hookCtx, plan).catch((error) => {
4641
+ hookCtx.context.logger.error("Logout notification failed after session deletion", error);
4480
4642
  });
4481
4643
  await hookCtx.context.runInBackgroundOrAwait(logoutTask);
4482
4644
  }
@@ -4620,7 +4782,7 @@ const oauthProvider = (options) => {
4620
4782
  }),
4621
4783
  oauth2Token: createOAuthEndpoint("/oauth2/token", {
4622
4784
  method: "POST",
4623
- cloneRequest: true,
4785
+ formParams: { repeatable: ["resource", "audience"] },
4624
4786
  body: z.object({
4625
4787
  grant_type: z.string().trim().min(1),
4626
4788
  client_id: z.string().optional(),
@@ -4761,15 +4923,11 @@ const oauthProvider = (options) => {
4761
4923
  }
4762
4924
  }
4763
4925
  }, async (ctx) => {
4764
- if (ctx.request) {
4765
- const repeated = await extractRepeatedResourceFromForm(ctx.request);
4766
- if (repeated && repeated.length > 1) ctx.body.resource = repeated;
4767
- }
4768
4926
  return tokenEndpoint(ctx, opts);
4769
4927
  }),
4770
4928
  oauth2Introspect: createOAuthEndpoint("/oauth2/introspect", {
4771
4929
  method: "POST",
4772
- cloneRequest: true,
4930
+ formParams: {},
4773
4931
  body: z.object({
4774
4932
  client_id: z.string().optional(),
4775
4933
  client_secret: z.string().optional(),
@@ -4886,7 +5044,7 @@ const oauthProvider = (options) => {
4886
5044
  }),
4887
5045
  oauth2Revoke: createOAuthEndpoint("/oauth2/revoke", {
4888
5046
  method: "POST",
4889
- cloneRequest: true,
5047
+ formParams: {},
4890
5048
  body: z.object({
4891
5049
  client_id: z.string().optional(),
4892
5050
  client_secret: z.string().optional(),
@@ -5064,7 +5222,7 @@ const oauthProvider = (options) => {
5064
5222
  openapi: {
5065
5223
  description: "RP-Initiated Logout endpoint. Accepts standard logout parameters through GET or an application/x-www-form-urlencoded POST body, plus JSON for Better Auth generated-client calls. Requests without a usable id_token_hint may require explicit OP confirmation.",
5066
5224
  responses: { "200": {
5067
- description: "Logout completed, or an HTML confirmation page was returned for browser navigation. A redirect is used only for an exact registered post_logout_redirect_uri.",
5225
+ description: "Logout completed, or an HTML page was returned for browser navigation — a confirmation page, or the OIDC front-channel logout page when clients with a registered frontchannel_logout_uri hold tokens on the session. A redirect is used only for an exact registered post_logout_redirect_uri.",
5068
5226
  content: {
5069
5227
  "application/json": { schema: {
5070
5228
  type: "object",
@@ -5080,7 +5238,10 @@ const oauthProvider = (options) => {
5080
5238
  }
5081
5239
  }
5082
5240
  } },
5083
- "text/html": { schema: { type: "string" } }
5241
+ "text/html": { schema: {
5242
+ type: "string",
5243
+ description: "Logout confirmation page, or the OIDC Front-Channel Logout page with one hidden iframe per registered RP"
5244
+ } }
5084
5245
  }
5085
5246
  } }
5086
5247
  }
@@ -5101,7 +5262,7 @@ const oauthProvider = (options) => {
5101
5262
  }),
5102
5263
  registerOAuthClient: createOAuthEndpoint("/oauth2/register", {
5103
5264
  method: "POST",
5104
- body: clientRegistrationRequestSchema,
5265
+ body: clientRegistrationRequestSchema.loose(),
5105
5266
  errorCodesByField: {
5106
5267
  redirect_uris: "invalid_redirect_uri",
5107
5268
  post_logout_redirect_uris: "invalid_redirect_uri",
@@ -5205,6 +5366,15 @@ const oauthProvider = (options) => {
5205
5366
  type: "boolean",
5206
5367
  description: "Whether the RP requires a `sid` claim in every Logout Token"
5207
5368
  },
5369
+ frontchannel_logout_uri: {
5370
+ type: "string",
5371
+ format: "uri",
5372
+ description: "RP URL rendered in a hidden iframe on the OP's logout page when the end-user's OP session terminates"
5373
+ },
5374
+ frontchannel_logout_session_required: {
5375
+ type: "boolean",
5376
+ description: "Whether the RP requires `iss` and `sid` query parameters on its front-channel logout URI. The OP always sends both."
5377
+ },
5208
5378
  token_endpoint_auth_method: {
5209
5379
  type: "string",
5210
5380
  description: "Requested authentication method for the token endpoint"
@@ -5257,6 +5427,7 @@ const oauthProvider = (options) => {
5257
5427
  adminUpdateOAuthClient: adminUpdateOAuthClient(opts),
5258
5428
  updateOAuthClient: updateOAuthClient(opts),
5259
5429
  rotateClientSecret: rotateClientSecret(opts),
5430
+ rotateClientKeys: rotateClientKeys(opts),
5260
5431
  deleteOAuthClient: deleteOAuthClient(opts),
5261
5432
  getOAuthConsent: getOAuthConsent(opts),
5262
5433
  getOAuthConsents: getOAuthConsents(opts),
@@ -5297,6 +5468,11 @@ const oauthProvider = (options) => {
5297
5468
  window: opts.rateLimit?.register?.window ?? 60,
5298
5469
  max: opts.rateLimit?.register?.max ?? 5
5299
5470
  }] : [],
5471
+ ...opts.rateLimit?.rotateClientKeys !== false ? [{
5472
+ pathMatcher: (path) => path === "/oauth2/client/rotate-keys",
5473
+ window: opts.rateLimit?.rotateClientKeys?.window ?? 60,
5474
+ max: opts.rateLimit?.rotateClientKeys?.max ?? 5
5475
+ }] : [],
5300
5476
  ...opts.rateLimit?.userinfo !== false ? [{
5301
5477
  pathMatcher: (path) => path === "/oauth2/userinfo",
5302
5478
  window: opts.rateLimit?.userinfo?.window ?? 60,
@@ -5375,8 +5551,20 @@ const handleRedirect = (ctx, uri) => {
5375
5551
  };
5376
5552
  else throw ctx.redirect(uri);
5377
5553
  };
5378
- function redirectWithPromptNoneError(ctx, opts, query, error, description) {
5379
- return handleRedirect(ctx, formatErrorURL(query.redirect_uri, error, description, query.state, getIssuer(ctx, opts)));
5554
+ /** Sends an OAuth error to the client's redirect URI, or to the error page without one. */
5555
+ function redirectWithAuthorizationError(ctx, opts, query, error, description) {
5556
+ return handleRedirect(ctx, query.redirect_uri ? formatErrorURL(query.redirect_uri, error, description, query.state, getIssuer(ctx, opts)) : getErrorURL(ctx, error, description));
5557
+ }
5558
+ /**
5559
+ * Returns the URL that reports an `APIError`: `errorPage` when given, otherwise
5560
+ * the provider error page. Rethrows anything else.
5561
+ */
5562
+ function getAPIErrorURL(ctx, error, errorPage) {
5563
+ if (!(error instanceof APIError$1)) throw error;
5564
+ const body = error.body;
5565
+ const code = body?.error ?? "invalid_request";
5566
+ const description = body?.error_description ?? "invalid request";
5567
+ return errorPage ? formatErrorURL(errorPage, code, description) : getErrorURL(ctx, code, description);
5380
5568
  }
5381
5569
  /**
5382
5570
  * Validates that the issuer URL
@@ -5538,13 +5726,45 @@ function authorizeRedirectOnError(opts) {
5538
5726
  };
5539
5727
  }
5540
5728
  async function authorizeEndpoint(ctx, opts, settings) {
5541
- if (opts.grantTypes && !opts.grantTypes.includes("authorization_code")) throw new APIError$1("NOT_FOUND");
5542
5729
  if (!ctx.request) throw new APIError$1("UNAUTHORIZED", {
5543
5730
  error_description: "request not found",
5544
5731
  error: "invalid_request"
5545
5732
  });
5546
5733
  const request = ctx.request;
5547
5734
  let query = getAuthorizationRequestParameters(ctx, settings);
5735
+ const interaction = getExtensionInteractionRequest(opts, query);
5736
+ if (interaction) {
5737
+ if (settings?.isAuthorize === true && !await getSessionFromCtx(ctx)) {
5738
+ ctx.query = { [interaction.parameter]: interaction.value };
5739
+ return redirectWithPromptCode(ctx, opts, "login");
5740
+ }
5741
+ const errorPage = interaction.handler.errorPage?.({
5742
+ ctx,
5743
+ value: interaction.value
5744
+ });
5745
+ const toErrorURL = (error) => getAPIErrorURL(ctx, error, errorPage);
5746
+ let interactionRequest;
5747
+ try {
5748
+ interactionRequest = await interaction.handler.load({
5749
+ ctx,
5750
+ opts,
5751
+ value: interaction.value,
5752
+ signedQuery: settings?.isAuthorize === true ? void 0 : query
5753
+ });
5754
+ if (interactionRequest.client.disabled) throw new APIError$1("BAD_REQUEST", {
5755
+ error: "client_disabled",
5756
+ error_description: "client is disabled"
5757
+ });
5758
+ } catch (error) {
5759
+ return handleRedirect(ctx, toErrorURL(error));
5760
+ }
5761
+ return runAuthorizationInteraction(ctx, opts, request.headers, settings, {
5762
+ ...interactionRequest,
5763
+ approve: (approval) => interactionRequest.approve(approval).catch(toErrorURL),
5764
+ errorURL: toErrorURL
5765
+ });
5766
+ }
5767
+ if (opts.grantTypes && !opts.grantTypes.includes("authorization_code")) throw new APIError$1("NOT_FOUND");
5548
5768
  ctx.query = query;
5549
5769
  const requestObject = getStringParameter(query.request);
5550
5770
  const requestUri = getStringParameter(query.request_uri);
@@ -5601,12 +5821,10 @@ async function authorizeEndpoint(ctx, opts, settings) {
5601
5821
  error_description: "response_type is required",
5602
5822
  ctx
5603
5823
  });
5604
- const promptSet = ctx.query?.prompt ? parsePrompt(ctx.query?.prompt) : void 0;
5605
- const promptNone = promptSet?.has("none") ?? false;
5606
- if (promptSet?.has("select_account") && !opts.selectAccount?.page) return handleRedirect(ctx, getErrorURL(ctx, `unsupported_prompt_select_account`, "unsupported prompt type"));
5824
+ if ((ctx.query?.prompt ? parsePrompt(ctx.query?.prompt) : void 0)?.has("select_account") && !opts.selectAccount?.page) return handleRedirect(ctx, getErrorURL(ctx, `unsupported_prompt_select_account`, "unsupported prompt type"));
5607
5825
  if (!(query.response_type === "code")) return handleRedirect(ctx, getErrorURL(ctx, "unsupported_response_type", "unsupported response type"));
5608
5826
  const client = await getClient(ctx, opts, query.client_id);
5609
- if (!client) return handleRedirect(ctx, getErrorURL(ctx, "invalid_client", "client_id is required"));
5827
+ if (!client) return handleRedirect(ctx, getErrorURL(ctx, "invalid_client", "client not found"));
5610
5828
  if (client.disabled) return handleRedirect(ctx, getErrorURL(ctx, "client_disabled", "client is disabled"));
5611
5829
  if (!clientAllowsGrant(client, "authorization_code")) return handleRedirect(ctx, getErrorURL(ctx, "unauthorized_client", "client is not authorized to use the authorization_code grant"));
5612
5830
  const registeredUris = client.redirectUris ?? [];
@@ -5636,8 +5854,7 @@ async function authorizeEndpoint(ctx, opts, settings) {
5636
5854
  const openidRequested = requestedScopes.includes("openid");
5637
5855
  if (query.claims !== void 0 && !openidRequested) return handleRedirect(ctx, formatErrorURL(query.redirect_uri, "invalid_request", "openid scope must be requested when using the claims parameter", query.state, getIssuer(ctx, opts)));
5638
5856
  if (openidRequested && !isValidOidcClaimsRequest(query.claims)) return handleRedirect(ctx, formatErrorURL(query.redirect_uri, "invalid_request", "claims must be a valid Claims request object", query.state, getIssuer(ctx, opts)));
5639
- if (openidRequested && !canSatisfyEssentialAcrRequest(query.claims, "0")) return handleRedirect(ctx, formatErrorURL(query.redirect_uri, "access_denied", "essential acr requirement cannot be met", query.state, getIssuer(ctx, opts)));
5640
- const requestedUserInfoClaims = getRequestedUserInfoClaims(query.claims, getSupportedClaims(opts));
5857
+ if (openidRequested && !getAcrValuesSupported(opts).some((acr) => canSatisfyEssentialAcrRequest(query.claims, acr))) return handleRedirect(ctx, formatErrorURL(query.redirect_uri, "access_denied", "essential acr requirement cannot be met", query.state, getIssuer(ctx, opts)));
5641
5858
  if (query.resource !== void 0) try {
5642
5859
  await resolveResourcePolicy(ctx, opts, {
5643
5860
  resource: query.resource,
@@ -5663,12 +5880,35 @@ async function authorizeEndpoint(ctx, opts, settings) {
5663
5880
  if (!query.code_challenge || !query.code_challenge_method) return handleRedirect(ctx, formatErrorURL(query.redirect_uri, "invalid_request", "code_challenge and code_challenge_method must both be provided", query.state, getIssuer(ctx, opts)));
5664
5881
  if (!["S256"].includes(query.code_challenge_method)) return handleRedirect(ctx, formatErrorURL(query.redirect_uri, "invalid_request", "invalid code_challenge method, only S256 is supported", query.state, getIssuer(ctx, opts)));
5665
5882
  }
5666
- const requestedResources = toResourceList(query.resource) ?? [];
5883
+ return runAuthorizationInteraction(ctx, opts, request.headers, settings, {
5884
+ query,
5885
+ client,
5886
+ approve: (approval) => createAuthorizationCodeURL(ctx, opts, {
5887
+ ...approval,
5888
+ query: approval.query,
5889
+ clientId: client.clientId,
5890
+ resource: toResourceList(approval.query.resource) ?? []
5891
+ })
5892
+ });
5893
+ }
5894
+ /**
5895
+ * Sends the user to the next required interaction page, or approves the
5896
+ * request once none remain.
5897
+ */
5898
+ async function runAuthorizationInteraction(ctx, opts, headers, settings, request) {
5899
+ let { query } = request;
5900
+ const { client } = request;
5901
+ ctx.query = query;
5902
+ const scopes = query.scope?.split(" ").filter((scope) => scope) ?? [];
5903
+ const userInfoClaims = getRequestedUserInfoClaims(query.claims, getSupportedClaims(opts));
5904
+ const resources = toResourceList(query.resource) ?? [];
5905
+ const promptSet = query.prompt ? parsePrompt(query.prompt) : void 0;
5906
+ const promptNone = promptSet?.has("none") ?? false;
5667
5907
  const session = await getSessionFromCtx(ctx);
5668
5908
  const maxAgeSeconds = query.max_age;
5669
5909
  const hasSatisfiedMaxAge = session != null && maxAgeSeconds !== void 0 && isWithinMaxAge(new Date(session.session.createdAt), maxAgeSeconds, /* @__PURE__ */ new Date());
5670
5910
  if (!session || session != null && maxAgeSeconds !== void 0 && !hasSatisfiedMaxAge || promptSet?.has("login") || promptSet?.has("create")) {
5671
- if (promptNone) return redirectWithPromptNoneError(ctx, opts, query, "login_required", "authentication required");
5911
+ if (promptNone) return redirectWithAuthorizationError(ctx, opts, query, "login_required", "authentication required");
5672
5912
  return redirectWithPromptCode(ctx, opts, promptSet?.has("create") ? "create" : "login");
5673
5913
  }
5674
5914
  if (hasSatisfiedMaxAge) {
@@ -5678,89 +5918,79 @@ async function authorizeEndpoint(ctx, opts, settings) {
5678
5918
  if (settings?.isAuthorize && promptSet?.has("select_account")) return redirectWithPromptCode(ctx, opts, "select_account");
5679
5919
  if (settings?.isAuthorize && opts.selectAccount) {
5680
5920
  if (await opts.selectAccount.shouldRedirect({
5681
- headers: request.headers,
5921
+ headers,
5682
5922
  user: session.user,
5683
5923
  session: session.session,
5684
- scopes: requestedScopes
5924
+ scopes
5685
5925
  })) {
5686
- if (promptNone) return redirectWithPromptNoneError(ctx, opts, query, "account_selection_required", "End-User account selection is required");
5926
+ if (promptNone) return redirectWithAuthorizationError(ctx, opts, query, "account_selection_required", "End-User account selection is required");
5687
5927
  return redirectWithPromptCode(ctx, opts, "select_account");
5688
5928
  }
5689
5929
  }
5690
5930
  if (opts.signup?.shouldRedirect) {
5691
5931
  const signupRedirect = await opts.signup.shouldRedirect({
5692
- headers: request.headers,
5932
+ headers,
5693
5933
  user: session.user,
5694
5934
  session: session.session,
5695
- scopes: requestedScopes
5935
+ scopes
5696
5936
  });
5697
5937
  if (signupRedirect) {
5698
- if (promptNone) return redirectWithPromptNoneError(ctx, opts, query, "interaction_required", "End-User interaction is required");
5938
+ if (promptNone) return redirectWithAuthorizationError(ctx, opts, query, "interaction_required", "End-User interaction is required");
5699
5939
  return redirectWithPromptCode(ctx, opts, "create", { page: typeof signupRedirect === "string" ? signupRedirect : void 0 });
5700
5940
  }
5701
5941
  }
5702
5942
  if (!settings?.postLogin && opts.postLogin) {
5703
5943
  if (await opts.postLogin.shouldRedirect({
5704
- headers: request.headers,
5944
+ headers,
5705
5945
  user: session.user,
5706
5946
  session: session.session,
5707
- scopes: requestedScopes,
5947
+ scopes,
5708
5948
  isContinuation: settings?.isPostLoginContinuation ?? false
5709
5949
  })) {
5710
- if (promptNone) return redirectWithPromptNoneError(ctx, opts, query, "interaction_required", "End-User interaction is required");
5950
+ if (promptNone) return redirectWithAuthorizationError(ctx, opts, query, "interaction_required", "End-User interaction is required");
5711
5951
  return redirectWithPromptCode(ctx, opts, "post_login");
5712
5952
  }
5713
5953
  }
5714
- if (promptSet?.has("consent")) return redirectWithPromptCode(ctx, opts, "consent", { sessionId: session.session.id });
5715
- const referenceId = await opts.postLogin?.consentReferenceId?.({
5954
+ if (promptSet?.has("consent") || request.requireConsent && !settings?.consentAccepted) return redirectWithPromptCode(ctx, opts, "consent", { sessionId: session.session.id });
5955
+ const referenceId = await getConsentReferenceId(opts, {
5716
5956
  user: session.user,
5717
5957
  session: session.session,
5718
- scopes: requestedScopes
5958
+ scopes
5719
5959
  });
5720
- if (client.skipConsent) return redirectWithAuthorizationCode(ctx, opts, {
5721
- query,
5722
- clientId: client.clientId,
5723
- userId: session.user.id,
5724
- sessionId: session.session.id,
5725
- authTime: new Date(session.session.createdAt).getTime(),
5726
- referenceId,
5727
- resource: requestedResources
5728
- });
5729
- const consent = await ctx.context.adapter.findOne({
5730
- model: "oauthConsent",
5731
- where: [
5732
- {
5733
- field: "clientId",
5734
- value: client.clientId
5735
- },
5736
- {
5737
- field: "userId",
5738
- value: session.user.id
5739
- },
5740
- ...referenceId ? [{
5741
- field: "referenceId",
5742
- value: referenceId
5743
- }] : []
5744
- ]
5745
- });
5746
- if (!consent || !requestedScopes.every((val) => consent.scopes.includes(val)) || !requestedUserInfoClaims.every((claim) => (consent.requestedUserInfoClaims ?? []).includes(claim))) {
5747
- if (promptNone) return redirectWithPromptNoneError(ctx, opts, query, "consent_required", "End-User consent is required");
5748
- return redirectWithPromptCode(ctx, opts, "consent", { sessionId: session.session.id });
5749
- }
5750
- const consentedResources = consent?.resources ?? [];
5751
- if (requestedResources.some((requestedResource) => !consentedResources.includes(requestedResource))) {
5752
- if (promptNone) return redirectWithPromptNoneError(ctx, opts, query, "consent_required", "End-User consent is required");
5960
+ const approve = async () => {
5961
+ let authenticationContext;
5962
+ try {
5963
+ authenticationContext = await resolveAuthenticationContext(opts, {
5964
+ user: session.user,
5965
+ session: session.session,
5966
+ client,
5967
+ scopes,
5968
+ headers,
5969
+ query
5970
+ });
5971
+ } catch (error) {
5972
+ if (!(error instanceof APIError$1)) throw error;
5973
+ if (request.errorURL) return handleRedirect(ctx, request.errorURL(error));
5974
+ const body = error.body;
5975
+ return redirectWithAuthorizationError(ctx, opts, query, body?.error ?? "server_error", body?.error_description ?? "authentication context unavailable");
5976
+ }
5977
+ if (!canSatisfyEssentialAcrRequest(query.claims, authenticationContext.acr ?? "0")) return redirectWithAuthorizationError(ctx, opts, query, "access_denied", "essential acr requirement cannot be met");
5978
+ return handleRedirect(ctx, await request.approve({
5979
+ query,
5980
+ userId: session.user.id,
5981
+ sessionId: session.session.id,
5982
+ authTime: new Date(session.session.createdAt).getTime(),
5983
+ ...authenticationContext,
5984
+ referenceId
5985
+ }));
5986
+ };
5987
+ if (client.skipConsent) return approve();
5988
+ const consent = await findConsent(ctx, client.clientId, session.user.id, referenceId);
5989
+ if (!consent || !consentCovers(consent, scopes, resources) || !userInfoClaims.every((claim) => (consent.requestedUserInfoClaims ?? []).includes(claim))) {
5990
+ if (promptNone) return redirectWithAuthorizationError(ctx, opts, query, "consent_required", "End-User consent is required");
5753
5991
  return redirectWithPromptCode(ctx, opts, "consent", { sessionId: session.session.id });
5754
5992
  }
5755
- return redirectWithAuthorizationCode(ctx, opts, {
5756
- query,
5757
- clientId: client.clientId,
5758
- userId: session.user.id,
5759
- sessionId: session.session.id,
5760
- authTime: new Date(session.session.createdAt).getTime(),
5761
- referenceId,
5762
- resource: requestedResources
5763
- });
5993
+ return approve();
5764
5994
  }
5765
5995
  function serializeAuthorizationQuery(query) {
5766
5996
  const params = new URLSearchParams();
@@ -5772,7 +6002,8 @@ function serializeAuthorizationQuery(query) {
5772
6002
  }
5773
6003
  return params;
5774
6004
  }
5775
- async function redirectWithAuthorizationCode(ctx, opts, verificationValue) {
6005
+ /** Stores a single-use authorization code and returns the client redirect URL carrying it. */
6006
+ async function createAuthorizationCodeURL(ctx, opts, verificationValue) {
5776
6007
  const code = generateRandomString(32, "a-z", "A-Z", "0-9");
5777
6008
  const iat = Math.floor(Date.now() / 1e3);
5778
6009
  const exp = iat + (opts.codeExpiresIn ?? 600);
@@ -5787,6 +6018,8 @@ async function redirectWithAuthorizationCode(ctx, opts, verificationValue) {
5787
6018
  sessionId: verificationValue?.sessionId,
5788
6019
  referenceId: verificationValue.referenceId,
5789
6020
  authTime: verificationValue.authTime,
6021
+ acr: verificationValue.acr,
6022
+ amr: verificationValue.amr,
5790
6023
  resource: verificationValue.resource
5791
6024
  })
5792
6025
  };
@@ -5798,7 +6031,7 @@ async function redirectWithAuthorizationCode(ctx, opts, verificationValue) {
5798
6031
  redirectUriWithCode.searchParams.set("code", code);
5799
6032
  if (verificationValue.query.state) redirectUriWithCode.searchParams.set("state", verificationValue.query.state);
5800
6033
  redirectUriWithCode.searchParams.set("iss", getIssuer(ctx, opts));
5801
- return handleRedirect(ctx, redirectUriWithCode.toString());
6034
+ return redirectUriWithCode.toString();
5802
6035
  }
5803
6036
  async function redirectWithPromptCode(ctx, opts, type, options) {
5804
6037
  const queryParams = await signParams(ctx, opts, { postLoginClearedForSession: type === "consent" && opts.postLogin ? options?.sessionId : void 0 });
@@ -5826,4 +6059,4 @@ async function signParams(ctx, opts, flags) {
5826
6059
  return params.toString();
5827
6060
  }
5828
6061
  //#endregion
5829
- export { registerClientMetadataDocument as a, isForbiddenCimdClientMetadataField as c, metadataResponse as d, oauthAuthorizationServerMetadata as f, oidcServerMetadata as h, oauthProvider as i, validatePublicClientJwks as l, oauthProviderOpenIdConfigMetadata as m, DEFAULT_OAUTH_SCOPES as n, client_assertion_exports as o, oauthProviderAuthServerMetadata as p, getOAuthProviderState as r, consumeClientAssertion as s, getIssuer as t, authServerMetadata as u };
6062
+ export { assertPublicRoutableHttpsUri as a, consumeClientAssertion as c, authServerMetadata as d, metadataResponse as f, oidcServerMetadata as g, oauthProviderOpenIdConfigMetadata as h, oauthProvider as i, validatePublicClientJwks as l, oauthProviderAuthServerMetadata as m, DEFAULT_OAUTH_SCOPES as n, registerClientMetadataDocument as o, oauthAuthorizationServerMetadata as p, getOAuthProviderState as r, client_assertion_exports as s, getIssuer as t, createOAuthEndpoint as u };