@better-auth/oauth-provider 1.7.10 → 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 canSatisfyEssentialAcrRequest, C as authorizationQuerySchema, D as STANDARD_CLAIMS, M as filterClaimsRequestUserInfoClaims, N as getRequestedUserInfoClaims, O as STANDARD_CLAIM_NAMES, P as isValidOidcClaimsRequest, S as SafeUrlSchema, T as clientRegistrationRequestSchema, _ as logEnforcePerClientResourcesResolution, a as invalidateRefreshFamily, b as seedResourcesOnce, c as normalizeClientCredentialsScopes, d as JWS_ALGORITHMS, f as assertIdentifierValid, g as isAudienceClaimAllowed, h as invalidateResourceCache, j as claimsRequestParameterSchema, k as getSupportedClaims, l as validateClientCredentialsScopes, m as getResource, o as tokenEndpoint, p as extractRepeatedResourceFromForm, r as decodeRefreshToken, s as userInfoEndpoint, t as introspectEndpoint, u as getEndpointUrl, v as logStartupSeedFailure, w as clientJwksSchema, x as ResourceUriSchema, y as resolveResourcePolicy } from "./introspect-BVm_zFQC.mjs";
3
- import { A as validateClientCredentials, B as getSupportedAuthMethods, D as storeToken, E as storeClientSecret, M as verifyOAuthQueryParams, N as applyOAuthProviderMetadataExtensions, R as getClientDiscoveries, S as removePromptFromQuery, T as searchParamsToQuery, U as isExtensionTokenEndpointAuthMethod, V as getSupportedGrantTypes, W as validateOAuthProviderExtensions, a as extractClientCredentials, b as parsePrompt, c as getConsentReferenceId, d as getStoredToken, f as isInvalidJwtError, h as mergeDiscoveryMetadata, i as destructureCredentials, k as toResourceList, l as getJwtPlugin, m as isSessionFreshForSignedQuery, n as clientAllowsGrant, o as findConsent, p as isPKCERequired, r as decryptStoredClientSecret, s as getClient, t as assertNotTrustedClient, v as parseBearerToken, w as resolveSubjectIdentifier, x as removeMaxAgeFromQuery, y as parseClientMetadata } from "./utils-DfMNRh3J.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-aKfgWHzo.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
- import { compactVerify, createLocalJWKSet, decodeJwt, decodeProtectedHeader, errors, jwtVerify } from "jose";
21
20
  import { getJwks, stripAccessTokenAuthorizationScheme } from "better-auth/oauth2";
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) {
@@ -114,7 +128,10 @@ async function consentEndpoint(ctx, opts, authorize) {
114
128
  authorizationQuery = removeMaxAgeFromQuery(authorizationQuery);
115
129
  }
116
130
  ctx.query = searchParamsToQuery(authorizationQuery);
117
- 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
+ });
118
135
  }
119
136
  //#endregion
120
137
  //#region src/continue.ts
@@ -170,6 +187,7 @@ const BACKCHANNEL_LOGOUT_EVENT_URI = "http://schemas.openid.net/event/backchanne
170
187
  const LOGOUT_TOKEN_JWT_TYP = "logout+jwt";
171
188
  const LOGOUT_TOKEN_LIFETIME_SECONDS = 120;
172
189
  const BACKCHANNEL_DISPATCH_TIMEOUT_MS = 5e3;
190
+ const FRONTCHANNEL_LOGOUT_TIMEOUT_MS = 3e3;
173
191
  /**
174
192
  * Signs a Back-Channel Logout Token per OIDC Back-Channel Logout 1.0 §2.4.
175
193
  *
@@ -211,11 +229,12 @@ async function signLogoutToken(ctx, options, resolvedKey, claims) {
211
229
  * Token revocation runs regardless of the JWT plugin (refresh-token revocation
212
230
  * has no dependency on signing). Only Logout Token delivery needs the JWT
213
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.
214
233
  *
215
234
  * Returns `null` when there is nothing to do, so the caller can skip the
216
235
  * background handoff entirely.
217
236
  */
218
- async function prepareBackchannelLogoutPlan(ctx, opts, input) {
237
+ async function prepareSessionLogoutPlan(ctx, opts, input) {
219
238
  const { sessionId, userId } = input;
220
239
  if (!userId) return null;
221
240
  const logger = ctx.context.logger;
@@ -247,24 +266,38 @@ async function prepareBackchannelLogoutPlan(ctx, opts, input) {
247
266
  const accessToRevokeIds = accessTokens.filter((t) => !t.revoked).map((t) => t.id);
248
267
  const refreshToRevokeIds = refreshTokens.filter((t) => !t.revoked && !t.scopes?.includes("offline_access")).map((t) => t.id);
249
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);
250
281
  return {
251
282
  accessTokenIds: accessToRevokeIds,
252
283
  refreshTokenIds: refreshToRevokeIds,
253
284
  sessionId,
254
- targets: (await Promise.all(eligibleClients.map(async (client) => {
285
+ backchannelTargets,
286
+ frontchannelLogoutUris: clients.flatMap((client) => {
287
+ if (!client.frontchannelLogoutUri || client.disabled) return [];
255
288
  try {
256
- return {
257
- client,
258
- sub: await resolveSubjectIdentifier(userId, client, opts)
259
- };
289
+ const uri = new URL(client.frontchannelLogoutUri);
290
+ uri.searchParams.set("iss", issuer);
291
+ uri.searchParams.set("sid", sessionId);
292
+ return [uri.toString()];
260
293
  } catch (error) {
261
- logger.warn(`back-channel logout: unable to resolve subject for client ${client.clientId}`, error);
262
- return null;
294
+ logger.warn(`front-channel logout: invalid frontchannel_logout_uri for client ${client.clientId}`, error);
295
+ return [];
263
296
  }
264
- }))).filter((target) => target !== null)
297
+ })
265
298
  };
266
299
  } catch (error) {
267
- logger.error("back-channel logout planning failed", error);
300
+ logger.error("logout planning failed", error);
268
301
  return null;
269
302
  }
270
303
  }
@@ -273,7 +306,7 @@ async function prepareBackchannelLogoutPlan(ctx, opts, input) {
273
306
  * `session.delete.after` after the row is consumed and, for transactional
274
307
  * callers, only after the transaction commits.
275
308
  */
276
- async function applyBackchannelLogoutPlan(ctx, plan) {
309
+ async function applySessionLogoutPlan(ctx, plan) {
277
310
  const revokedAt = /* @__PURE__ */ new Date();
278
311
  const adapter = await getCurrentAdapter(ctx.context.adapter);
279
312
  const revocations = await Promise.allSettled([plan.accessTokenIds.length > 0 ? adapter.updateMany({
@@ -294,7 +327,7 @@ async function applyBackchannelLogoutPlan(ctx, plan) {
294
327
  update: { revoked: revokedAt }
295
328
  }) : Promise.resolve()]);
296
329
  for (const result of revocations) if (result.status === "rejected") ctx.context.logger.error("back-channel logout: token revocation update failed", result.reason);
297
- if (plan.targets.length > 0) await deliverBackchannelLogoutTokens(ctx, plan);
330
+ if (plan.backchannelTargets.length > 0) await deliverBackchannelLogoutTokens(ctx, plan);
298
331
  }
299
332
  /**
300
333
  * Asynchronous phase: sign one Logout Token per target client and POST it to
@@ -315,7 +348,7 @@ async function deliverBackchannelLogoutTokens(ctx, plan) {
315
348
  const iat = Math.floor(Date.now() / 1e3);
316
349
  const exp = iat + LOGOUT_TOKEN_LIFETIME_SECONDS;
317
350
  const resolvedKey = jwtPluginOptions?.jwt?.sign ? null : await resolveSigningKey(ctx, jwtPluginOptions);
318
- await Promise.allSettled(plan.targets.map(async ({ client, sub }) => {
351
+ await Promise.allSettled(plan.backchannelTargets.map(async ({ client, sub }) => {
319
352
  try {
320
353
  const jti = generateRandomString(32, "a-z", "A-Z", "0-9");
321
354
  const token = await signLogoutToken(ctx, jwtPluginOptions, resolvedKey, {
@@ -343,6 +376,30 @@ async function deliverBackchannelLogoutTokens(ctx, plan) {
343
376
  }
344
377
  }));
345
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
+ }
346
403
  const LOGOUT_CONFIRMATION_TTL_SECONDS = 300;
347
404
  const LOGOUT_CONFIRMATION_COOKIE_SUFFIX = ".oauth_logout_confirmation";
348
405
  function escapeHtml(value) {
@@ -363,14 +420,16 @@ function isBrowserNavigation(ctx) {
363
420
  const accept = headers.get("accept") ?? "";
364
421
  return headers.get("sec-fetch-mode") === "navigate" || accept.includes("text/html") || accept.includes("application/xhtml+xml");
365
422
  }
366
- function logoutPage(title, body, status = 200) {
367
- 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>`, {
368
426
  status,
369
427
  headers: {
370
428
  "cache-control": "no-store",
371
- "content-security-policy": "default-src 'none'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
429
+ "content-security-policy": page.contentSecurityPolicy ?? LOGOUT_PAGE_CSP,
372
430
  "content-type": "text/html; charset=utf-8",
373
431
  pragma: "no-cache",
432
+ "referrer-policy": "no-referrer",
374
433
  "x-content-type-options": "nosniff"
375
434
  }
376
435
  });
@@ -391,6 +450,53 @@ function logoutConfirmationPage(ctx) {
391
450
  function logoutSuccessPage(note) {
392
451
  return logoutPage("Logged out", `<main><p data-oidc-logout-state="logged-out">${note ? `Logged out. ${escapeHtml(note)}` : "Logged out."}</p></main>`);
393
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
+ }
394
500
  function logoutErrorPage(description, status) {
395
501
  return logoutPage("Logout error", `<main><h1>Logout error</h1><p data-oidc-logout-state="error">${escapeHtml(description)}</p></main>`, status);
396
502
  }
@@ -588,6 +694,22 @@ async function confirmationRequired(ctx, currentSession, confirmation = {}) {
588
694
  await setLogoutConfirmationState(ctx, currentSession.session.id, confirmation);
589
695
  return logoutConfirmationPage(ctx);
590
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
+ }
591
713
  async function completeConfirmedLogout(ctx, opts) {
592
714
  const state = await getLogoutConfirmationState(ctx);
593
715
  const currentSession = await getCurrentBrowserSession(ctx);
@@ -596,14 +718,13 @@ async function completeConfirmedLogout(ctx, opts) {
596
718
  if (!currentSession) {
597
719
  clearLogoutConfirmationState(ctx);
598
720
  if (redirect.uri) return handleRedirect(ctx, redirect.uri);
599
- 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");
600
722
  }
601
723
  if (state.sessionId && state.sessionId !== currentSession.session.id) return logoutProtocolError(ctx, "BAD_REQUEST", "invalid_request", "The logout confirmation is invalid or expired");
602
724
  await deleteLogoutSession(ctx, currentSession.session);
603
725
  deleteSessionCookie(ctx);
604
726
  clearLogoutConfirmationState(ctx);
605
- if (redirect.uri) return handleRedirect(ctx, redirect.uri);
606
- return isBrowserNavigation(ctx) ? logoutSuccessPage(redirect.invalid ? "The requested post-logout redirect was not registered." : void 0) : null;
727
+ return loggedOutResponse(ctx, redirect);
607
728
  }
608
729
  /**
609
730
  * RP-Initiated Logout (OIDC RP-Initiated Logout 1.0). The RP presents a signed
@@ -614,7 +735,9 @@ async function completeConfirmedLogout(ctx, opts) {
614
735
  *
615
736
  * Session termination goes through `internalAdapter.deleteSession`, which fires
616
737
  * `session.delete.after` so the hook drives revocation and back-channel
617
- * 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.
618
741
  *
619
742
  * @see https://openid.net/specs/openid-connect-rpinitiated-1_0.html
620
743
  */
@@ -658,8 +781,7 @@ async function rpInitiatedLogoutEndpoint(ctx, opts) {
658
781
  if (sessionToDelete) await deleteLogoutSession(ctx, sessionToDelete);
659
782
  if (matchesCurrentSession) deleteSessionCookie(ctx);
660
783
  clearLogoutConfirmationState(ctx);
661
- if (redirect.uri) return handleRedirect(ctx, redirect.uri);
662
- return isBrowserNavigation(ctx) ? logoutSuccessPage(redirect.invalid ? "The requested post-logout redirect was not registered." : void 0) : null;
784
+ return loggedOutResponse(ctx, redirect);
663
785
  }
664
786
  /**
665
787
  * Completes the browser confirmation flow for RP-Initiated Logout. This route
@@ -713,7 +835,9 @@ function authServerMetadata(ctx, opts, overrides) {
713
835
  authorization_response_iss_parameter_supported: true,
714
836
  dpop_signing_alg_values_supported: overrides?.dpop_signing_alg_values_supported ?? [...DPOP_SIGNING_ALGORITHMS],
715
837
  backchannel_logout_supported: backchannelSupported,
716
- backchannel_logout_session_supported: backchannelSupported
838
+ backchannel_logout_session_supported: backchannelSupported,
839
+ frontchannel_logout_supported: true,
840
+ frontchannel_logout_session_supported: true
717
841
  };
718
842
  }
719
843
  /**
@@ -756,7 +880,7 @@ function oidcServerMetadata(ctx, opts) {
756
880
  claims_parameter_supported: true,
757
881
  userinfo_endpoint: `${baseURL}/oauth2/userinfo`,
758
882
  subject_types_supported: opts.pairwiseSecret ? ["public", "pairwise"] : ["public"],
759
- acr_values_supported: ["0"],
883
+ acr_values_supported: getAcrValuesSupported(opts),
760
884
  id_token_signing_alg_values_supported: (() => {
761
885
  if (opts.disableJwtPlugin) return ["HS256"];
762
886
  const primary = jwtPluginOptions?.jwks?.keyPairConfig?.alg ?? "EdDSA";
@@ -830,7 +954,8 @@ const oauthProviderOpenIdConfigMetadata = (auth, opts) => {
830
954
  *
831
955
  * A failing issue is routed by its first path segment via `errorCodesByField`:
832
956
  * - missing required (`invalid_type` + "received undefined") → `.missing`
833
- * - unsupported value (`invalid_value`) → `.invalid`
957
+ * - unsupported value (`invalid_value`) or a value a refinement rejected
958
+ * (`custom`, also inside a union branch) → `.invalid`
834
959
  * - anything else (wrong type, duplicated params, bad format) → `defaultError`
835
960
  *
836
961
  * For enum fields that need to distinguish missing from unsupported, compose
@@ -839,16 +964,19 @@ const oauthProviderOpenIdConfigMetadata = (auth, opts) => {
839
964
  * enum value.
840
965
  */
841
966
  function createOAuthEndpoint(path, options, handler) {
842
- const { redirectOnError, onValidationError: userHook, errorCodesByField, defaultError = "invalid_request", ...rest } = options;
843
- if (!redirectOnError) return createAuthEndpoint(path, {
844
- ...rest,
845
- onValidationError: async (args) => {
846
- if (userHook) await userHook(args);
847
- throw new APIError$1("BAD_REQUEST", { ...mapIssuesToOAuthError(args.issues, errorCodesByField, defaultError) });
848
- }
849
- }, handler);
850
- 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
+ }
851
978
  const { body: bodySchema, query: querySchema, ...forwarded } = rest;
979
+ if (formParams) forwarded.cloneRequest = true;
852
980
  async function validateSlot(ctx, slot, schema) {
853
981
  if (!schema) return { ok: true };
854
982
  const result = await schema.safeParseAsync(ctx[slot] ?? {});
@@ -860,15 +988,19 @@ function createOAuthEndpoint(path, options, handler) {
860
988
  message: result.error.message,
861
989
  issues: result.error.issues
862
990
  });
991
+ const error = mapIssuesToOAuthError(result.error.issues, errorCodesByField, defaultError);
992
+ if (!redirectOnError) throw new APIError$1("BAD_REQUEST", error);
863
993
  return {
864
994
  ok: false,
865
- response: redirect({
866
- ...mapIssuesToOAuthError(result.error.issues, errorCodesByField, defaultError),
995
+ response: redirectOnError({
996
+ ...error,
867
997
  ctx
868
998
  })
869
999
  };
870
1000
  }
871
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);
872
1004
  const body = await validateSlot(ctx, "body", bodySchema);
873
1005
  if (!body.ok) return body.response;
874
1006
  const query = await validateSlot(ctx, "query", querySchema);
@@ -886,18 +1018,32 @@ function mapIssuesToOAuthError(issues, errorCodesByField, defaultError = "invali
886
1018
  const fieldKey = typeof first === "string" ? first : void 0;
887
1019
  const mapping = fieldKey ? errorCodesByField?.[fieldKey] : void 0;
888
1020
  const field = issue.path?.length ? z.core.toDotPath(issue.path) : "";
1021
+ const invalidValue = findInvalidValueIssue(issue);
889
1022
  return {
890
- error: resolveErrorCode(issue, mapping, defaultError),
891
- error_description: describeIssue(issue, field)
1023
+ error: resolveErrorCode(issue, invalidValue, mapping, defaultError),
1024
+ error_description: describeIssue(invalidValue ?? issue, field)
892
1025
  };
893
1026
  }
894
- function resolveErrorCode(issue, mapping, defaultError) {
1027
+ function resolveErrorCode(issue, invalidValue, mapping, defaultError) {
895
1028
  if (typeof mapping === "string") return mapping;
896
1029
  if (isMissingValueIssue(issue)) return mapping?.missing ?? defaultError;
897
- if (issue.code === "invalid_value") return mapping?.invalid ?? defaultError;
1030
+ if (invalidValue) return mapping?.invalid ?? defaultError;
898
1031
  return defaultError;
899
1032
  }
900
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
+ /**
901
1047
  * Returns `true` for issues that represent an absent required value. Zod v4
902
1048
  * strips `input` from published issues, so the signal is the `invalid_type`
903
1049
  * code combined with a message suffix of "received undefined". The suffix is
@@ -1028,137 +1174,6 @@ function assertValidClientJwks(jwks) {
1028
1174
  });
1029
1175
  }
1030
1176
  //#endregion
1031
- //#region src/client-metadata.ts
1032
- const OAUTH_CLIENT_RECORD_FIELD_NAMES = Object.freeze(Object.keys({
1033
- clientId: true,
1034
- clientSecret: true,
1035
- clientDiscoveryId: true,
1036
- disabled: true,
1037
- scopes: true,
1038
- clientCredentialsScopes: true,
1039
- userId: true,
1040
- createdAt: true,
1041
- updatedAt: true,
1042
- expiresAt: true,
1043
- name: true,
1044
- uri: true,
1045
- icon: true,
1046
- contacts: true,
1047
- tos: true,
1048
- policy: true,
1049
- softwareId: true,
1050
- softwareVersion: true,
1051
- softwareStatement: true,
1052
- redirectUris: true,
1053
- postLogoutRedirectUris: true,
1054
- backchannelLogoutUri: true,
1055
- backchannelLogoutSessionRequired: true,
1056
- tokenEndpointAuthMethod: true,
1057
- grantTypes: true,
1058
- responseTypes: true,
1059
- applicationType: true,
1060
- jwks: true,
1061
- jwksUri: true,
1062
- requirePKCE: true,
1063
- dpopBoundAccessTokens: true,
1064
- skipConsent: true,
1065
- enableEndSession: true,
1066
- subjectType: true,
1067
- referenceId: true,
1068
- metadata: true
1069
- }));
1070
- /**
1071
- * Canonical wire fields emitted by schemaToOAuth. They are mapped explicitly
1072
- * and therefore must never also survive inside the opaque metadata envelope.
1073
- */
1074
- const OAUTH_CLIENT_WIRE_FIELD_NAMES = [
1075
- "client_id",
1076
- "client_secret",
1077
- "client_secret_expires_at",
1078
- "scope",
1079
- "user_id",
1080
- "client_id_issued_at",
1081
- "client_name",
1082
- "client_uri",
1083
- "logo_uri",
1084
- "contacts",
1085
- "tos_uri",
1086
- "policy_uri",
1087
- "jwks",
1088
- "jwks_uri",
1089
- "software_id",
1090
- "software_version",
1091
- "software_statement",
1092
- "redirect_uris",
1093
- "post_logout_redirect_uris",
1094
- "backchannel_logout_uri",
1095
- "backchannel_logout_session_required",
1096
- "token_endpoint_auth_method",
1097
- "grant_types",
1098
- "response_types",
1099
- "application_type",
1100
- "disabled",
1101
- "skip_consent",
1102
- "enable_end_session",
1103
- "require_pkce",
1104
- "dpop_bound_access_tokens",
1105
- "subject_type",
1106
- "reference_id"
1107
- ];
1108
- const OPAQUE_METADATA_RESERVED_FIELDS = new Set([
1109
- ...OAUTH_CLIENT_RECORD_FIELD_NAMES,
1110
- ...OAUTH_CLIENT_WIRE_FIELD_NAMES,
1111
- "public",
1112
- "type",
1113
- "resources",
1114
- "client_credentials_scopes"
1115
- ]);
1116
- const CIMD_FORBIDDEN_SERVER_FIELD_NAMES = new Set([
1117
- "disabled",
1118
- "client_secret",
1119
- "client_secret_expires_at",
1120
- "client_id_issued_at",
1121
- "skip_consent",
1122
- "enable_end_session",
1123
- "require_pkce",
1124
- "reference_id",
1125
- "user_id",
1126
- "resources",
1127
- "clientSecret",
1128
- "clientDiscoveryId",
1129
- "skipConsent",
1130
- "enableEndSession",
1131
- "requirePKCE",
1132
- "referenceId",
1133
- "userId",
1134
- "clientId",
1135
- "applicationType",
1136
- "tokenEndpointAuthMethod",
1137
- "redirectUris",
1138
- "postLogoutRedirectUris",
1139
- "grantTypes",
1140
- "responseTypes",
1141
- "scopes",
1142
- "expiresAt",
1143
- "createdAt",
1144
- "updatedAt",
1145
- "softwareId",
1146
- "softwareVersion",
1147
- "softwareStatement",
1148
- "backchannelLogoutUri",
1149
- "backchannelLogoutSessionRequired",
1150
- "jwksUri",
1151
- "dpopBoundAccessTokens",
1152
- "subjectType"
1153
- ]);
1154
- function isForbiddenCimdClientMetadataField(field) {
1155
- return CIMD_FORBIDDEN_SERVER_FIELD_NAMES.has(field);
1156
- }
1157
- function stripReservedOAuthClientMetadataExtensions(metadata) {
1158
- if (!metadata) return void 0;
1159
- return Object.fromEntries(Object.entries(metadata).filter(([field]) => !OPAQUE_METADATA_RESERVED_FIELDS.has(field)));
1160
- }
1161
- //#endregion
1162
1177
  //#region src/oauthClient/privileges.ts
1163
1178
  /**
1164
1179
  * Authorizes a client action against the configured `clientPrivileges` hook.
@@ -1789,8 +1804,54 @@ function validateClientRedirectUri(redirectUri, applicationType) {
1789
1804
  }
1790
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}`);
1791
1806
  }
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;
1852
+ }
1792
1853
  async function registerEndpoint(ctx, opts) {
1793
- const body = ctx.body;
1854
+ const { body, metadata } = splitRegistrationRequest(ctx.body, opts);
1794
1855
  if (!opts.allowDynamicClientRegistration) throw new APIError("FORBIDDEN", {
1795
1856
  error: "access_denied",
1796
1857
  error_description: "Client registration is disabled"
@@ -1804,13 +1865,13 @@ async function registerEndpoint(ctx, opts) {
1804
1865
  });
1805
1866
  const grantTypes = body.grant_types?.length ? resolveMutuallySupportedGrantTypes(opts, body.grant_types) : body.grant_types;
1806
1867
  const droppedAuthorizationCode = body.grant_types?.includes("authorization_code") && !grantTypes?.includes("authorization_code");
1807
- const metadata = {
1868
+ const registrationMetadata = {
1808
1869
  ...body,
1809
1870
  grant_types: grantTypes,
1810
1871
  response_types: droppedAuthorizationCode ? void 0 : body.response_types
1811
1872
  };
1812
1873
  if (!session && !isTokenAuthorized) {
1813
- if (metadata.grant_types?.includes("client_credentials")) throw new APIError("BAD_REQUEST", {
1874
+ if (registrationMetadata.grant_types?.includes("client_credentials")) throw new APIError("BAD_REQUEST", {
1814
1875
  error: "invalid_client_metadata",
1815
1876
  error_description: "client_credentials grant requires authenticated registration"
1816
1877
  });
@@ -1822,7 +1883,10 @@ async function registerEndpoint(ctx, opts) {
1822
1883
  session: session.session
1823
1884
  }) : void 0);
1824
1885
  const response = await createOAuthClientRegistration(ctx, opts, {
1825
- metadata,
1886
+ metadata: {
1887
+ ...registrationMetadata,
1888
+ metadata
1889
+ },
1826
1890
  registrationSource: "dynamic",
1827
1891
  userId: referenceId ? void 0 : session?.session.userId,
1828
1892
  referenceId,
@@ -1953,32 +2017,40 @@ async function checkOAuthClient(client, opts, settings) {
1953
2017
  error: "invalid_client_metadata",
1954
2018
  error_description: "backchannel_logout_uri requires the jwt plugin (disableJwtPlugin must be false)"
1955
2019
  });
2020
+ assertPublicRoutableHttpsUri("backchannel_logout_uri", clientWithDefaults.backchannel_logout_uri);
2021
+ }
2022
+ if (clientWithDefaults.frontchannel_logout_uri !== void 0) {
1956
2023
  let url;
1957
2024
  try {
1958
- url = new URL(clientWithDefaults.backchannel_logout_uri);
2025
+ url = new URL(clientWithDefaults.frontchannel_logout_uri);
1959
2026
  } catch {
1960
2027
  throw new APIError("BAD_REQUEST", {
1961
2028
  error: "invalid_client_metadata",
1962
- error_description: "backchannel_logout_uri must be an absolute URL"
2029
+ error_description: "frontchannel_logout_uri must be an absolute URL"
1963
2030
  });
1964
2031
  }
1965
- if (clientWithDefaults.backchannel_logout_uri.includes("#")) throw new APIError("BAD_REQUEST", {
2032
+ if (clientWithDefaults.frontchannel_logout_uri.includes("#")) throw new APIError("BAD_REQUEST", {
1966
2033
  error: "invalid_client_metadata",
1967
- error_description: "backchannel_logout_uri must not include a fragment component"
1968
- });
1969
- if (url.protocol !== "https:") throw new APIError("BAD_REQUEST", {
1970
- error: "invalid_client_metadata",
1971
- error_description: "backchannel_logout_uri must use https"
2034
+ error_description: "frontchannel_logout_uri must not include a fragment component"
1972
2035
  });
1973
2036
  if (url.username || url.password) throw new APIError("BAD_REQUEST", {
1974
2037
  error: "invalid_client_metadata",
1975
- error_description: "backchannel_logout_uri must not contain credentials"
2038
+ error_description: "frontchannel_logout_uri must not contain credentials"
1976
2039
  });
1977
- if (isPrivateHostname(url.hostname)) throw new APIError("BAD_REQUEST", {
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
+ }
2047
+ });
2048
+ if (!isHttpOrHttps || !sharesRedirectOrigin) throw new APIError("BAD_REQUEST", {
1978
2049
  error: "invalid_client_metadata",
1979
- 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"
1980
2051
  });
1981
2052
  }
2053
+ if (!settings?.skipExtensionValidation) await validateExtensionClientMetadata(opts, clientWithDefaults, client.metadata);
1982
2054
  }
1983
2055
  const CLIENT_REGISTRATION_COLLISION = Symbol("client-registration-collision");
1984
2056
  function isClientRegistrationCollision(error) {
@@ -2005,7 +2077,8 @@ async function persistOAuthClientRegistration(ctx, opts, input) {
2005
2077
  const isExtensionAuthMethod = isExtensionTokenEndpointAuthMethod(opts, body.token_endpoint_auth_method);
2006
2078
  await checkOAuthClient(body, opts, {
2007
2079
  registrationSource: input.registrationSource,
2008
- ctx
2080
+ ctx,
2081
+ skipExtensionValidation: input.registrationSource === "clientMetadataDocument" && Boolean(input.existingClient)
2009
2082
  });
2010
2083
  const clientId = input.clientId ?? opts.generateClientId?.() ?? generateRandomString(32, "a-z", "A-Z");
2011
2084
  const clientSecret = isPublic || isPrivateKeyJwt || isExtensionAuthMethod ? void 0 : opts.generateClientSecret?.() || generateRandomString(32, "a-z", "A-Z");
@@ -2035,6 +2108,7 @@ async function persistOAuthClientRegistration(ctx, opts, input) {
2035
2108
  });
2036
2109
  schema.clientCredentialsScopes = input.registrationSource === "clientMetadataDocument" ? input.existingClient?.clientCredentialsScopes ?? [] : input.registrationSource === "managed" ? input.clientCredentialsScopes ?? [] : [];
2037
2110
  schema.clientDiscoveryId = input.registrationSource === "clientMetadataDocument" ? input.clientDiscoveryId : null;
2111
+ schema.sectorIdentifier = deriveSectorIdentifier(clientId, body.redirect_uris);
2038
2112
  if (input.registrationSource === "clientMetadataDocument" && body.application_type === void 0) schema.applicationType = null;
2039
2113
  const requestedResources = input.registrationSource === "clientMetadataDocument" ? [] : input.requestedResources ?? [];
2040
2114
  if (input.registrationSource === "dynamic") assertDynamicRegistrationResourcesAllowed(opts, requestedResources);
@@ -2048,6 +2122,19 @@ async function persistOAuthClientRegistration(ctx, opts, input) {
2048
2122
  let storedClient;
2049
2123
  let created = false;
2050
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
+ }
2051
2138
  const updatedClient = await adapter.update({
2052
2139
  model: clientModel,
2053
2140
  where: [{
@@ -2059,9 +2146,11 @@ async function persistOAuthClientRegistration(ctx, opts, input) {
2059
2146
  }],
2060
2147
  update: {
2061
2148
  ...schema,
2149
+ metadata,
2062
2150
  disabled: existingClient.disabled,
2063
2151
  skipConsent: existingClient.skipConsent,
2064
2152
  enableEndSession: existingClient.enableEndSession,
2153
+ sectorIdentifier: existingClient.sectorIdentifier ? schema.sectorIdentifier : null,
2065
2154
  createdAt: existingClient.createdAt,
2066
2155
  updatedAt: /* @__PURE__ */ new Date(iat * 1e3)
2067
2156
  }
@@ -2166,6 +2255,20 @@ function assertClientDiscoveryOwnership(client, clientDiscoveryId) {
2166
2255
  });
2167
2256
  }
2168
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
+ /**
2169
2272
  * First-party persistence seam for a validated Client ID Metadata Document.
2170
2273
  *
2171
2274
  * This operation owns both initial creation and atomic replacement. It never
@@ -2185,18 +2288,20 @@ async function registerClientMetadataDocument(ctx, opts, input) {
2185
2288
  error: "invalid_client_metadata",
2186
2289
  error_description: "Client ID Metadata Document clients cannot use a shared secret"
2187
2290
  });
2188
- const registrationInput = {
2291
+ const toRegistrationInput = (existingClient) => ({
2189
2292
  ...input,
2293
+ existingClient,
2190
2294
  metadata: {
2191
2295
  ...input.metadata,
2296
+ metadata: pickExtensionClientMetadata(input.metadata, opts),
2192
2297
  token_endpoint_auth_method: input.metadata.token_endpoint_auth_method ?? "none",
2193
2298
  client_secret: void 0,
2194
2299
  client_secret_expires_at: void 0
2195
2300
  },
2196
2301
  registrationSource: "clientMetadataDocument"
2197
- };
2302
+ });
2198
2303
  try {
2199
- return await persistOAuthClientRegistration(ctx, opts, registrationInput);
2304
+ return await persistOAuthClientRegistration(ctx, opts, toRegistrationInput(input.existingClient));
2200
2305
  } catch (error) {
2201
2306
  if (!isClientRegistrationCollision(error)) throw error;
2202
2307
  const clientModel = opts.schema?.oauthClient?.modelName ?? "oauthClient";
@@ -2222,10 +2327,7 @@ async function registerClientMetadataDocument(ctx, opts, input) {
2222
2327
  })) throw error.cause;
2223
2328
  }
2224
2329
  try {
2225
- return await persistOAuthClientRegistration(ctx, opts, {
2226
- ...registrationInput,
2227
- existingClient: currentClient
2228
- });
2330
+ return await persistOAuthClientRegistration(ctx, opts, toRegistrationInput(currentClient));
2229
2331
  } catch (retryError) {
2230
2332
  if (isClientRegistrationCollision(retryError)) throw retryError.cause;
2231
2333
  throw retryError;
@@ -2274,7 +2376,7 @@ async function createOAuthClientEndpoint(ctx, opts, settings) {
2274
2376
  return ctx.json(responseBody);
2275
2377
  }
2276
2378
  function oauthToSchema(input) {
2277
- 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;
2278
2380
  const expiresAt = _expiresAt ? /* @__PURE__ */ new Date(Number(_expiresAt) * 1e3) : void 0;
2279
2381
  const createdAt = _createdAt ? /* @__PURE__ */ new Date(_createdAt * 1e3) : void 0;
2280
2382
  const scopes = _scope?.split(" ");
@@ -2301,6 +2403,8 @@ function oauthToSchema(input) {
2301
2403
  postLogoutRedirectUris,
2302
2404
  backchannelLogoutUri,
2303
2405
  backchannelLogoutSessionRequired,
2406
+ frontchannelLogoutUri,
2407
+ frontchannelLogoutSessionRequired,
2304
2408
  tokenEndpointAuthMethod,
2305
2409
  grantTypes,
2306
2410
  responseTypes,
@@ -2323,7 +2427,7 @@ function oauthToSchema(input) {
2323
2427
  * @returns
2324
2428
  */
2325
2429
  function schemaToOAuth(input) {
2326
- 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;
2327
2431
  const _expiresAt = expiresAt ? Math.round(new Date(expiresAt).getTime() / 1e3) : void 0;
2328
2432
  const _createdAt = createdAt ? Math.round(new Date(createdAt).getTime() / 1e3) : void 0;
2329
2433
  const _scopes = scopes?.join(" ");
@@ -2350,6 +2454,8 @@ function schemaToOAuth(input) {
2350
2454
  post_logout_redirect_uris: postLogoutRedirectUris ?? void 0,
2351
2455
  backchannel_logout_uri: backchannelLogoutUri ?? void 0,
2352
2456
  backchannel_logout_session_required: backchannelLogoutSessionRequired ?? void 0,
2457
+ frontchannel_logout_uri: frontchannelLogoutUri ?? void 0,
2458
+ frontchannel_logout_session_required: frontchannelLogoutSessionRequired ?? void 0,
2353
2459
  token_endpoint_auth_method: tokenEndpointAuthMethod ?? void 0,
2354
2460
  grant_types: grantTypes ?? void 0,
2355
2461
  response_types: responseTypes ?? void 0,
@@ -2477,12 +2583,20 @@ async function updateClientEndpoint(ctx, opts, settings) {
2477
2583
  error_description: "client not found",
2478
2584
  error: "not_found"
2479
2585
  });
2480
- 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;
2481
2595
  const ownsClient = client.userId ? client.userId === session.user.id : client.referenceId && opts.clientReference ? client.referenceId === await opts.clientReference(session) : false;
2482
- const isCrossOwnerScopeConfiguration = !ownsClient && settings?.admin && rawClientCredentialsScopes !== void 0 && Object.keys(updates).length === 0;
2596
+ const isCrossOwnerScopeConfiguration = !ownsClient && settings?.admin && rawClientCredentialsScopes !== void 0 && !hasUpdates;
2483
2597
  if (!ownsClient && !isCrossOwnerScopeConfiguration) throw new APIError("UNAUTHORIZED");
2484
2598
  if (isCrossOwnerScopeConfiguration) await assertClientPrivileges(ctx, session, opts, "configure-client-credentials-scopes", clientId);
2485
- if (Object.keys(updates).length === 0 && rawClientCredentialsScopes === void 0) {
2599
+ if (!hasUpdates && rawClientCredentialsScopes === void 0) {
2486
2600
  const res = schemaToOAuth(client);
2487
2601
  res.client_secret = void 0;
2488
2602
  if (settings?.admin) return {
@@ -2498,11 +2612,34 @@ async function updateClientEndpoint(ctx, opts, settings) {
2498
2612
  validateClientCredentialsScopes(clientCredentialsScopes, finalGrantTypes, finalTokenEndpointAuthMethod, opts);
2499
2613
  if (clientCredentialsScopes.length > 0 && !isCrossOwnerScopeConfiguration) await assertClientPrivileges(ctx, session, opts, "configure-client-credentials-scopes", clientId);
2500
2614
  }
2501
- await checkOAuthClient({
2502
- ...schemaToOAuth(client),
2503
- ...updates
2504
- }, 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 });
2505
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;
2506
2643
  if (!finalGrantTypes.includes("client_credentials") || finalTokenEndpointAuthMethod === "none") schemaUpdates.clientCredentialsScopes = [];
2507
2644
  else if (clientCredentialsScopes !== void 0) schemaUpdates.clientCredentialsScopes = clientCredentialsScopes;
2508
2645
  if (updates.token_endpoint_auth_method) if (updates.token_endpoint_auth_method === "private_key_jwt") schemaUpdates.clientSecret = null;
@@ -2648,6 +2785,8 @@ const adminCreateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/creat
2648
2785
  post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
2649
2786
  backchannel_logout_uri: SafeUrlSchema.optional(),
2650
2787
  backchannel_logout_session_required: z.boolean().optional(),
2788
+ frontchannel_logout_uri: SafeUrlSchema.optional(),
2789
+ frontchannel_logout_session_required: z.boolean().optional(),
2651
2790
  token_endpoint_auth_method: tokenEndpointAuthMethodSchema.optional(),
2652
2791
  application_type: z.enum(["web", "native"]).optional(),
2653
2792
  jwks: clientJwksSchema.optional(),
@@ -2821,6 +2960,8 @@ const createOAuthClient = (opts) => createAuthEndpoint("/oauth2/create-client",
2821
2960
  post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
2822
2961
  backchannel_logout_uri: SafeUrlSchema.optional(),
2823
2962
  backchannel_logout_session_required: z.boolean().optional(),
2963
+ frontchannel_logout_uri: SafeUrlSchema.optional(),
2964
+ frontchannel_logout_session_required: z.boolean().optional(),
2824
2965
  token_endpoint_auth_method: tokenEndpointAuthMethodSchema.optional(),
2825
2966
  application_type: z.enum(["web", "native"]).optional(),
2826
2967
  jwks: clientJwksSchema.optional(),
@@ -3010,8 +3151,10 @@ const adminUpdateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/updat
3010
3151
  software_version: z.string().optional(),
3011
3152
  software_statement: z.string().optional(),
3012
3153
  post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
3013
- backchannel_logout_uri: SafeUrlSchema.optional(),
3154
+ backchannel_logout_uri: SafeUrlSchema.nullable().optional(),
3014
3155
  backchannel_logout_session_required: z.boolean().optional(),
3156
+ frontchannel_logout_uri: SafeUrlSchema.nullable().optional(),
3157
+ frontchannel_logout_session_required: z.boolean().optional(),
3015
3158
  application_type: z.enum(["web", "native"]).optional(),
3016
3159
  grant_types: grantTypesSchema.optional(),
3017
3160
  client_credentials_scopes: z.array(z.string().trim().min(1)).optional(),
@@ -3049,8 +3192,10 @@ const updateOAuthClient = (opts) => createAuthEndpoint("/oauth2/update-client",
3049
3192
  software_version: z.string().optional(),
3050
3193
  software_statement: z.string().optional(),
3051
3194
  post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
3052
- backchannel_logout_uri: SafeUrlSchema.optional(),
3195
+ backchannel_logout_uri: SafeUrlSchema.nullable().optional(),
3053
3196
  backchannel_logout_session_required: z.boolean().optional(),
3197
+ frontchannel_logout_uri: SafeUrlSchema.nullable().optional(),
3198
+ frontchannel_logout_session_required: z.boolean().optional(),
3054
3199
  application_type: z.enum(["web", "native"]).optional(),
3055
3200
  grant_types: grantTypesSchema.optional(),
3056
3201
  response_types: z.array(z.enum(["code"])).optional()
@@ -3540,113 +3685,47 @@ const adminUnlinkClientResource = (opts) => createAuthEndpoint("/admin/oauth2/re
3540
3685
  //#endregion
3541
3686
  //#region src/revoke.ts
3542
3687
  /**
3543
- * IMPORTANT NOTES:
3544
- * Revocation follows RFC7009
3688
+ * Revocation follows RFC 7009
3545
3689
  * https://datatracker.ietf.org/doc/html/rfc7009
3546
- * - APIError: Continue catches (returnable to client)
3547
- * - 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.
3548
3693
  */
3549
3694
  /**
3550
- * Revokes a JWT access token against the configured JWKs.
3551
- *
3552
3695
  * A JWT access token is self-contained and never stored, so there is nothing to
3553
3696
  * delete. Once the token is confirmed to be a valid JWT for this server, the
3554
3697
  * endpoint reports `unsupported_token_type` (RFC 7009 §2.2.1) instead of a
3555
3698
  * silent success, so callers can tell that no server-side revocation happened.
3556
- * An expired JWT or a JWT with an audience rejected by the OAuth resource model
3557
- * is already inactive and still resolves as a successful no-op. Session-bound
3558
- * tokens (carrying `sid`) are cut off early by the session-liveness check in
3559
- * 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.
3560
3703
  */
3561
- async function revokeJwtAccessToken(ctx, opts, token) {
3562
- if (opts.disableJwtPlugin) throw new APIError$1("BAD_REQUEST", {
3563
- error_description: "JWT access tokens are disabled",
3564
- error: "invalid_request"
3565
- });
3566
- const jwtPlugin = getJwtPlugin(ctx.context);
3567
- const jwtPluginOptions = jwtPlugin.options;
3568
- let jwtPayload;
3569
- try {
3570
- jwtPayload = (await jwtVerify(token, createLocalJWKSet(await getJwks(token, {
3571
- jwksFetch: jwtPluginOptions?.jwks?.remoteUrl ? jwtPluginOptions.jwks.remoteUrl : async () => {
3572
- return (await jwtPlugin.endpoints.getJwks(ctx))?.response;
3573
- },
3574
- jwksCacheKey: jwtPlugin
3575
- })), { issuer: jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL })).payload;
3576
- } catch (error) {
3577
- if (error instanceof errors.JWTExpired || error instanceof errors.JWTInvalid) return null;
3578
- if (error instanceof TypeError || isInvalidJwtError(error)) throw new APIError$1("BAD_REQUEST", {
3579
- error_description: "invalid JWT signature",
3580
- error: "invalid_request"
3581
- });
3582
- throw error;
3583
- }
3584
- const userInfoAudience = `${ctx.context.baseURL}/oauth2/userinfo`;
3585
- if (!jwtPayload.azp || !await isAudienceClaimAllowed(ctx, opts, jwtPayload.aud, [userInfoAudience])) return null;
3704
+ async function revokeJwtAccessToken(ctx, opts, payload) {
3705
+ if (!await isAudienceClaimAllowed(ctx, opts, payload.aud, [userInfoResource(ctx.context.baseURL)])) return null;
3586
3706
  throw new APIError$1("BAD_REQUEST", {
3587
3707
  error_description: "JWT access tokens are self-contained and cannot be revoked server-side",
3588
3708
  error: "unsupported_token_type"
3589
3709
  });
3590
3710
  }
3591
- /**
3592
- * Searches for an opaque access token in the database and validates it
3593
- */
3594
- async function revokeOpaqueAccessToken(ctx, opts, token, clientId) {
3595
- let tokenValue = token;
3596
- if (opts.prefix?.opaqueAccessToken) if (tokenValue.startsWith(opts.prefix.opaqueAccessToken)) tokenValue = tokenValue.replace(opts.prefix.opaqueAccessToken, "");
3597
- else throw new APIError$1("BAD_REQUEST", {
3598
- error_description: "opaque access token not found",
3599
- error: "invalid_request"
3600
- });
3601
- const accessToken = await ctx.context.adapter.findOne({
3602
- model: "oauthAccessToken",
3603
- where: [{
3604
- field: "token",
3605
- value: await getStoredToken(opts.storeTokens, tokenValue, "access_token")
3606
- }]
3607
- });
3608
- if (!accessToken) throw new APIError$1("BAD_REQUEST", {
3609
- error_description: "opaque access token not found",
3610
- error: "invalid_request"
3611
- });
3612
- if (!accessToken.clientId || accessToken.clientId !== clientId) return null;
3613
- accessToken.id ? await ctx.context.adapter.delete({
3711
+ async function revokeOpaqueAccessToken(ctx, accessToken) {
3712
+ await ctx.context.adapter.delete({
3614
3713
  model: "oauthAccessToken",
3615
- where: [{
3714
+ where: accessToken.id ? [{
3616
3715
  field: "id",
3617
3716
  value: accessToken.id
3618
- }]
3619
- }) : await ctx.context.adapter.delete({
3620
- model: "oauthAccessToken",
3621
- where: [{
3717
+ }] : [{
3622
3718
  field: "token",
3623
3719
  value: accessToken.token
3624
3720
  }]
3625
3721
  });
3626
3722
  }
3627
- /**
3628
- * Validates a refresh token in the session store.
3629
- */
3630
- async function revokeRefreshToken(ctx, opts, token, clientId) {
3631
- const refreshToken = await ctx.context.adapter.findOne({
3632
- model: "oauthRefreshToken",
3633
- where: [{
3634
- field: "token",
3635
- value: await getStoredToken(opts.storeTokens, token, "refresh_token")
3636
- }]
3637
- });
3638
- if (!refreshToken) throw new APIError$1("BAD_REQUEST", {
3639
- error_description: "token not found",
3640
- error: "invalid_request"
3641
- });
3723
+ async function revokeRefreshToken(ctx, refreshToken, clientId) {
3724
+ const { userId } = refreshToken;
3642
3725
  if (refreshToken.revoked) {
3643
- await invalidateRefreshFamily(ctx, clientId, refreshToken.userId);
3644
- throw new APIError$1("BAD_REQUEST", {
3645
- error_description: "refresh token revoked",
3646
- error: "invalid_request"
3647
- });
3726
+ await invalidateRefreshFamily(ctx, clientId, userId);
3727
+ return;
3648
3728
  }
3649
- if (!refreshToken.clientId || refreshToken.clientId !== clientId) return null;
3650
3729
  const iat = Math.floor(Date.now() / 1e3);
3651
3730
  if (!await ctx.context.adapter.incrementOne({
3652
3731
  model: "oauthRefreshToken",
@@ -3661,11 +3740,8 @@ async function revokeRefreshToken(ctx, opts, token, clientId) {
3661
3740
  increment: {},
3662
3741
  set: { revoked: /* @__PURE__ */ new Date(iat * 1e3) }
3663
3742
  })) {
3664
- await invalidateRefreshFamily(ctx, clientId, refreshToken.userId);
3665
- throw new APIError$1("BAD_REQUEST", {
3666
- error_description: "refresh token revoked",
3667
- error: "invalid_request"
3668
- });
3743
+ await invalidateRefreshFamily(ctx, clientId, userId);
3744
+ return;
3669
3745
  }
3670
3746
  await ctx.context.adapter.deleteMany({
3671
3747
  model: "oauthAccessToken",
@@ -3675,33 +3751,8 @@ async function revokeRefreshToken(ctx, opts, token, clientId) {
3675
3751
  }]
3676
3752
  });
3677
3753
  }
3678
- /**
3679
- * We don't know the access token format so we try to validate it
3680
- * as a JWT first, then as an opaque token.
3681
- */
3682
- async function revokeAccessToken(ctx, opts, clientId, token) {
3683
- try {
3684
- return await revokeJwtAccessToken(ctx, opts, token);
3685
- } catch (err) {
3686
- if (err instanceof APIError$1) {
3687
- if (err.body?.error === "unsupported_token_type") throw err;
3688
- } else if (err instanceof Error) throw err;
3689
- else throw new Error(err);
3690
- }
3691
- try {
3692
- return await revokeOpaqueAccessToken(ctx, opts, token, clientId);
3693
- } catch (err) {
3694
- if (err instanceof APIError$1) {} else if (err instanceof Error) throw err;
3695
- else throw new Error("Unknown error validating access token");
3696
- }
3697
- throw new APIError$1("BAD_REQUEST", {
3698
- error_description: "Invalid access token",
3699
- error: "invalid_request"
3700
- });
3701
- }
3702
3754
  async function revokeEndpoint(ctx, opts) {
3703
3755
  let { token, token_type_hint } = ctx.body;
3704
- if (token_type_hint !== "access_token" && token_type_hint !== "refresh_token") token_type_hint = void 0;
3705
3756
  const { clientId: client_id, clientSecret: client_secret, preVerified, authMethod } = destructureCredentials(await extractClientCredentials(ctx, opts, `${ctx.context.baseURL}/oauth2/revoke`));
3706
3757
  if (!client_id) throw new APIError$1("UNAUTHORIZED", {
3707
3758
  error_description: "missing required credentials",
@@ -3714,38 +3765,23 @@ async function revokeEndpoint(ctx, opts) {
3714
3765
  });
3715
3766
  const client = await validateClientCredentials(ctx, opts, client_id, client_secret, void 0, preVerified, void 0, authMethod);
3716
3767
  try {
3717
- if (token_type_hint === void 0 || token_type_hint === "access_token") try {
3718
- return await revokeAccessToken(ctx, opts, client.clientId, token);
3719
- } catch (error) {
3720
- if (error instanceof APIError$1) {
3721
- if (token_type_hint === "access_token" || error.body?.error === "unsupported_token_type") throw error;
3722
- } else if (error instanceof Error) throw error;
3723
- else throw new Error(error);
3724
- }
3725
- if (token_type_hint === void 0 || token_type_hint === "refresh_token") try {
3726
- return await revokeRefreshToken(ctx, opts, (await decodeRefreshToken(opts, token)).token, client.clientId);
3727
- } catch (error) {
3728
- if (error instanceof APIError$1) {
3729
- if (token_type_hint === "refresh_token") throw error;
3730
- } else if (error instanceof Error) throw error;
3731
- 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
+ });
3732
3776
  }
3733
- throw new APIError$1("BAD_REQUEST", {
3734
- error_description: "token not found",
3735
- error: "invalid_request"
3736
- });
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;
3737
3781
  } catch (error) {
3738
- if (error instanceof APIError$1) {
3739
- if (error.body?.error === "unsupported_token_type") throw error;
3740
- if (error.name === "BAD_REQUEST") return null;
3741
- throw error;
3742
- } else if (error instanceof Error) {
3743
- logger.error("Introspection error:", error.message, error.stack);
3744
- throw new APIError$1("INTERNAL_SERVER_ERROR");
3745
- } else {
3746
- logger.error("Introspection error:", error);
3747
- throw new APIError$1("INTERNAL_SERVER_ERROR");
3748
- }
3782
+ if (error instanceof APIError$1) throw error;
3783
+ logger.error("Revocation error:", error);
3784
+ throw new APIError$1("INTERNAL_SERVER_ERROR");
3749
3785
  }
3750
3786
  }
3751
3787
  //#endregion
@@ -3784,6 +3820,10 @@ const schema = {
3784
3820
  type: "string",
3785
3821
  required: false
3786
3822
  },
3823
+ sectorIdentifier: {
3824
+ type: "string",
3825
+ required: false
3826
+ },
3787
3827
  scopes: {
3788
3828
  type: "string[]",
3789
3829
  required: false
@@ -3862,6 +3902,14 @@ const schema = {
3862
3902
  type: "boolean",
3863
3903
  required: false
3864
3904
  },
3905
+ frontchannelLogoutUri: {
3906
+ type: "string",
3907
+ required: false
3908
+ },
3909
+ frontchannelLogoutSessionRequired: {
3910
+ type: "boolean",
3911
+ required: false
3912
+ },
3865
3913
  tokenEndpointAuthMethod: {
3866
3914
  type: "string",
3867
3915
  required: false
@@ -4110,6 +4158,14 @@ const schema = {
4110
4158
  type: "date",
4111
4159
  required: false
4112
4160
  },
4161
+ acr: {
4162
+ type: "string",
4163
+ required: false
4164
+ },
4165
+ amr: {
4166
+ type: "string[]",
4167
+ required: false
4168
+ },
4113
4169
  confirmation: {
4114
4170
  type: "json",
4115
4171
  required: false
@@ -4290,7 +4346,7 @@ const rpInitiatedLogoutRequestSchema = z.object({
4290
4346
  });
4291
4347
  const oAuthState = defineRequestState(() => null);
4292
4348
  const getOAuthProviderState = oAuthState.get;
4293
- const backchannelLogoutPlansByContext = /* @__PURE__ */ new WeakMap();
4349
+ const sessionLogoutPlansByContext = /* @__PURE__ */ new WeakMap();
4294
4350
  const signedQueryIssuedAtMsKey = "signedQueryIssuedAtMs";
4295
4351
  function getServerContextSignedQueryIssuedAt(value) {
4296
4352
  const issuedAtMs = typeof value === "number" ? value : typeof value === "string" ? Number(value) : void 0;
@@ -4314,6 +4370,8 @@ const oauthProvider = (options) => {
4314
4370
  if (clientRegistrationAllowedScopes) {
4315
4371
  for (const sc of clientRegistrationAllowedScopes) if (!scopes.has(sc)) throw new BetterAuthError(`clientRegistrationAllowedScope ${sc} not found in scopes`);
4316
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");
4317
4375
  for (const sc of options.advertisedMetadata?.scopes_supported ?? []) if (!scopes?.has(sc)) throw new BetterAuthError(`advertisedMetadata.scopes_supported ${sc} not found in scopes`);
4318
4376
  const configuredResourceIdentifiers = new Set((options.resources ?? []).map((resource) => typeof resource === "string" ? resource : resource.identifier));
4319
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`);
@@ -4405,12 +4463,19 @@ const oauthProvider = (options) => {
4405
4463
  schema: { type: "string" },
4406
4464
  description: "OAuth 2.1 response type (e.g., 'code')"
4407
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
+ },
4408
4473
  {
4409
4474
  name: "client_id",
4410
4475
  in: "query",
4411
- required: true,
4476
+ required: false,
4412
4477
  schema: { type: "string" },
4413
- description: "OAuth 2.1 client ID"
4478
+ description: "OAuth 2.1 client ID, required for authorization requests (not for user_code)"
4414
4479
  },
4415
4480
  {
4416
4481
  name: "redirect_uri",
@@ -4554,25 +4619,26 @@ const oauthProvider = (options) => {
4554
4619
  return { options: { databaseHooks: { session: { delete: {
4555
4620
  async before(session, hookCtx) {
4556
4621
  if (!hookCtx) return;
4557
- const plan = await prepareBackchannelLogoutPlan(hookCtx, opts, {
4622
+ const plan = await prepareSessionLogoutPlan(hookCtx, opts, {
4558
4623
  sessionId: session.id,
4559
4624
  userId: session.userId
4560
4625
  });
4561
4626
  if (!plan) return;
4562
- const logoutPlanBySessionId = backchannelLogoutPlansByContext.get(hookCtx) ?? /* @__PURE__ */ new Map();
4627
+ const logoutPlanBySessionId = sessionLogoutPlansByContext.get(hookCtx) ?? /* @__PURE__ */ new Map();
4563
4628
  logoutPlanBySessionId.set(session.id, plan);
4564
- backchannelLogoutPlansByContext.set(hookCtx, logoutPlanBySessionId);
4629
+ sessionLogoutPlansByContext.set(hookCtx, logoutPlanBySessionId);
4565
4630
  },
4566
4631
  async after(session, hookCtx) {
4567
4632
  if (!hookCtx) return;
4568
- const logoutPlanBySessionId = backchannelLogoutPlansByContext.get(hookCtx);
4633
+ const logoutPlanBySessionId = sessionLogoutPlansByContext.get(hookCtx);
4569
4634
  if (!logoutPlanBySessionId) return;
4570
4635
  const plan = logoutPlanBySessionId.get(session.id);
4571
4636
  logoutPlanBySessionId.delete(session.id);
4572
- if (logoutPlanBySessionId.size === 0) backchannelLogoutPlansByContext.delete(hookCtx);
4637
+ if (logoutPlanBySessionId.size === 0) sessionLogoutPlansByContext.delete(hookCtx);
4573
4638
  if (!plan) return;
4574
- const logoutTask = applyBackchannelLogoutPlan(hookCtx, plan).catch((error) => {
4575
- 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);
4576
4642
  });
4577
4643
  await hookCtx.context.runInBackgroundOrAwait(logoutTask);
4578
4644
  }
@@ -4716,7 +4782,7 @@ const oauthProvider = (options) => {
4716
4782
  }),
4717
4783
  oauth2Token: createOAuthEndpoint("/oauth2/token", {
4718
4784
  method: "POST",
4719
- cloneRequest: true,
4785
+ formParams: { repeatable: ["resource", "audience"] },
4720
4786
  body: z.object({
4721
4787
  grant_type: z.string().trim().min(1),
4722
4788
  client_id: z.string().optional(),
@@ -4857,15 +4923,11 @@ const oauthProvider = (options) => {
4857
4923
  }
4858
4924
  }
4859
4925
  }, async (ctx) => {
4860
- if (ctx.request) {
4861
- const repeated = await extractRepeatedResourceFromForm(ctx.request);
4862
- if (repeated && repeated.length > 1) ctx.body.resource = repeated;
4863
- }
4864
4926
  return tokenEndpoint(ctx, opts);
4865
4927
  }),
4866
4928
  oauth2Introspect: createOAuthEndpoint("/oauth2/introspect", {
4867
4929
  method: "POST",
4868
- cloneRequest: true,
4930
+ formParams: {},
4869
4931
  body: z.object({
4870
4932
  client_id: z.string().optional(),
4871
4933
  client_secret: z.string().optional(),
@@ -4982,7 +5044,7 @@ const oauthProvider = (options) => {
4982
5044
  }),
4983
5045
  oauth2Revoke: createOAuthEndpoint("/oauth2/revoke", {
4984
5046
  method: "POST",
4985
- cloneRequest: true,
5047
+ formParams: {},
4986
5048
  body: z.object({
4987
5049
  client_id: z.string().optional(),
4988
5050
  client_secret: z.string().optional(),
@@ -5160,7 +5222,7 @@ const oauthProvider = (options) => {
5160
5222
  openapi: {
5161
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.",
5162
5224
  responses: { "200": {
5163
- 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.",
5164
5226
  content: {
5165
5227
  "application/json": { schema: {
5166
5228
  type: "object",
@@ -5176,7 +5238,10 @@ const oauthProvider = (options) => {
5176
5238
  }
5177
5239
  }
5178
5240
  } },
5179
- "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
+ } }
5180
5245
  }
5181
5246
  } }
5182
5247
  }
@@ -5197,7 +5262,7 @@ const oauthProvider = (options) => {
5197
5262
  }),
5198
5263
  registerOAuthClient: createOAuthEndpoint("/oauth2/register", {
5199
5264
  method: "POST",
5200
- body: clientRegistrationRequestSchema,
5265
+ body: clientRegistrationRequestSchema.loose(),
5201
5266
  errorCodesByField: {
5202
5267
  redirect_uris: "invalid_redirect_uri",
5203
5268
  post_logout_redirect_uris: "invalid_redirect_uri",
@@ -5301,6 +5366,15 @@ const oauthProvider = (options) => {
5301
5366
  type: "boolean",
5302
5367
  description: "Whether the RP requires a `sid` claim in every Logout Token"
5303
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
+ },
5304
5378
  token_endpoint_auth_method: {
5305
5379
  type: "string",
5306
5380
  description: "Requested authentication method for the token endpoint"
@@ -5477,8 +5551,20 @@ const handleRedirect = (ctx, uri) => {
5477
5551
  };
5478
5552
  else throw ctx.redirect(uri);
5479
5553
  };
5480
- function redirectWithPromptNoneError(ctx, opts, query, error, description) {
5481
- 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);
5482
5568
  }
5483
5569
  /**
5484
5570
  * Validates that the issuer URL
@@ -5640,13 +5726,45 @@ function authorizeRedirectOnError(opts) {
5640
5726
  };
5641
5727
  }
5642
5728
  async function authorizeEndpoint(ctx, opts, settings) {
5643
- if (opts.grantTypes && !opts.grantTypes.includes("authorization_code")) throw new APIError$1("NOT_FOUND");
5644
5729
  if (!ctx.request) throw new APIError$1("UNAUTHORIZED", {
5645
5730
  error_description: "request not found",
5646
5731
  error: "invalid_request"
5647
5732
  });
5648
5733
  const request = ctx.request;
5649
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");
5650
5768
  ctx.query = query;
5651
5769
  const requestObject = getStringParameter(query.request);
5652
5770
  const requestUri = getStringParameter(query.request_uri);
@@ -5703,9 +5821,7 @@ async function authorizeEndpoint(ctx, opts, settings) {
5703
5821
  error_description: "response_type is required",
5704
5822
  ctx
5705
5823
  });
5706
- const promptSet = ctx.query?.prompt ? parsePrompt(ctx.query?.prompt) : void 0;
5707
- const promptNone = promptSet?.has("none") ?? false;
5708
- 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"));
5709
5825
  if (!(query.response_type === "code")) return handleRedirect(ctx, getErrorURL(ctx, "unsupported_response_type", "unsupported response type"));
5710
5826
  const client = await getClient(ctx, opts, query.client_id);
5711
5827
  if (!client) return handleRedirect(ctx, getErrorURL(ctx, "invalid_client", "client not found"));
@@ -5738,8 +5854,7 @@ async function authorizeEndpoint(ctx, opts, settings) {
5738
5854
  const openidRequested = requestedScopes.includes("openid");
5739
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)));
5740
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)));
5741
- 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)));
5742
- 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)));
5743
5858
  if (query.resource !== void 0) try {
5744
5859
  await resolveResourcePolicy(ctx, opts, {
5745
5860
  resource: query.resource,
@@ -5765,12 +5880,35 @@ async function authorizeEndpoint(ctx, opts, settings) {
5765
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)));
5766
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)));
5767
5882
  }
5768
- 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;
5769
5907
  const session = await getSessionFromCtx(ctx);
5770
5908
  const maxAgeSeconds = query.max_age;
5771
5909
  const hasSatisfiedMaxAge = session != null && maxAgeSeconds !== void 0 && isWithinMaxAge(new Date(session.session.createdAt), maxAgeSeconds, /* @__PURE__ */ new Date());
5772
5910
  if (!session || session != null && maxAgeSeconds !== void 0 && !hasSatisfiedMaxAge || promptSet?.has("login") || promptSet?.has("create")) {
5773
- if (promptNone) return redirectWithPromptNoneError(ctx, opts, query, "login_required", "authentication required");
5911
+ if (promptNone) return redirectWithAuthorizationError(ctx, opts, query, "login_required", "authentication required");
5774
5912
  return redirectWithPromptCode(ctx, opts, promptSet?.has("create") ? "create" : "login");
5775
5913
  }
5776
5914
  if (hasSatisfiedMaxAge) {
@@ -5780,73 +5918,79 @@ async function authorizeEndpoint(ctx, opts, settings) {
5780
5918
  if (settings?.isAuthorize && promptSet?.has("select_account")) return redirectWithPromptCode(ctx, opts, "select_account");
5781
5919
  if (settings?.isAuthorize && opts.selectAccount) {
5782
5920
  if (await opts.selectAccount.shouldRedirect({
5783
- headers: request.headers,
5921
+ headers,
5784
5922
  user: session.user,
5785
5923
  session: session.session,
5786
- scopes: requestedScopes
5924
+ scopes
5787
5925
  })) {
5788
- 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");
5789
5927
  return redirectWithPromptCode(ctx, opts, "select_account");
5790
5928
  }
5791
5929
  }
5792
5930
  if (opts.signup?.shouldRedirect) {
5793
5931
  const signupRedirect = await opts.signup.shouldRedirect({
5794
- headers: request.headers,
5932
+ headers,
5795
5933
  user: session.user,
5796
5934
  session: session.session,
5797
- scopes: requestedScopes
5935
+ scopes
5798
5936
  });
5799
5937
  if (signupRedirect) {
5800
- 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");
5801
5939
  return redirectWithPromptCode(ctx, opts, "create", { page: typeof signupRedirect === "string" ? signupRedirect : void 0 });
5802
5940
  }
5803
5941
  }
5804
5942
  if (!settings?.postLogin && opts.postLogin) {
5805
5943
  if (await opts.postLogin.shouldRedirect({
5806
- headers: request.headers,
5944
+ headers,
5807
5945
  user: session.user,
5808
5946
  session: session.session,
5809
- scopes: requestedScopes,
5947
+ scopes,
5810
5948
  isContinuation: settings?.isPostLoginContinuation ?? false
5811
5949
  })) {
5812
- 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");
5813
5951
  return redirectWithPromptCode(ctx, opts, "post_login");
5814
5952
  }
5815
5953
  }
5816
- if (promptSet?.has("consent")) return redirectWithPromptCode(ctx, opts, "consent", { sessionId: session.session.id });
5954
+ if (promptSet?.has("consent") || request.requireConsent && !settings?.consentAccepted) return redirectWithPromptCode(ctx, opts, "consent", { sessionId: session.session.id });
5817
5955
  const referenceId = await getConsentReferenceId(opts, {
5818
5956
  user: session.user,
5819
5957
  session: session.session,
5820
- scopes: requestedScopes
5821
- });
5822
- if (client.skipConsent) return redirectWithAuthorizationCode(ctx, opts, {
5823
- query,
5824
- clientId: client.clientId,
5825
- userId: session.user.id,
5826
- sessionId: session.session.id,
5827
- authTime: new Date(session.session.createdAt).getTime(),
5828
- referenceId,
5829
- resource: requestedResources
5958
+ scopes
5830
5959
  });
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();
5831
5988
  const consent = await findConsent(ctx, client.clientId, session.user.id, referenceId);
5832
- if (!consent || !requestedScopes.every((val) => consent.scopes.includes(val)) || !requestedUserInfoClaims.every((claim) => (consent.requestedUserInfoClaims ?? []).includes(claim))) {
5833
- if (promptNone) return redirectWithPromptNoneError(ctx, opts, query, "consent_required", "End-User consent is required");
5834
- return redirectWithPromptCode(ctx, opts, "consent", { sessionId: session.session.id });
5835
- }
5836
- const consentedResources = consent?.resources ?? [];
5837
- if (requestedResources.some((requestedResource) => !consentedResources.includes(requestedResource))) {
5838
- if (promptNone) return redirectWithPromptNoneError(ctx, opts, query, "consent_required", "End-User consent is required");
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");
5839
5991
  return redirectWithPromptCode(ctx, opts, "consent", { sessionId: session.session.id });
5840
5992
  }
5841
- return redirectWithAuthorizationCode(ctx, opts, {
5842
- query,
5843
- clientId: client.clientId,
5844
- userId: session.user.id,
5845
- sessionId: session.session.id,
5846
- authTime: new Date(session.session.createdAt).getTime(),
5847
- referenceId,
5848
- resource: requestedResources
5849
- });
5993
+ return approve();
5850
5994
  }
5851
5995
  function serializeAuthorizationQuery(query) {
5852
5996
  const params = new URLSearchParams();
@@ -5858,7 +6002,8 @@ function serializeAuthorizationQuery(query) {
5858
6002
  }
5859
6003
  return params;
5860
6004
  }
5861
- 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) {
5862
6007
  const code = generateRandomString(32, "a-z", "A-Z", "0-9");
5863
6008
  const iat = Math.floor(Date.now() / 1e3);
5864
6009
  const exp = iat + (opts.codeExpiresIn ?? 600);
@@ -5873,6 +6018,8 @@ async function redirectWithAuthorizationCode(ctx, opts, verificationValue) {
5873
6018
  sessionId: verificationValue?.sessionId,
5874
6019
  referenceId: verificationValue.referenceId,
5875
6020
  authTime: verificationValue.authTime,
6021
+ acr: verificationValue.acr,
6022
+ amr: verificationValue.amr,
5876
6023
  resource: verificationValue.resource
5877
6024
  })
5878
6025
  };
@@ -5884,7 +6031,7 @@ async function redirectWithAuthorizationCode(ctx, opts, verificationValue) {
5884
6031
  redirectUriWithCode.searchParams.set("code", code);
5885
6032
  if (verificationValue.query.state) redirectUriWithCode.searchParams.set("state", verificationValue.query.state);
5886
6033
  redirectUriWithCode.searchParams.set("iss", getIssuer(ctx, opts));
5887
- return handleRedirect(ctx, redirectUriWithCode.toString());
6034
+ return redirectUriWithCode.toString();
5888
6035
  }
5889
6036
  async function redirectWithPromptCode(ctx, opts, type, options) {
5890
6037
  const queryParams = await signParams(ctx, opts, { postLoginClearedForSession: type === "consent" && opts.postLogin ? options?.sessionId : void 0 });
@@ -5912,4 +6059,4 @@ async function signParams(ctx, opts, flags) {
5912
6059
  return params.toString();
5913
6060
  }
5914
6061
  //#endregion
5915
- 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 };