@better-auth/oauth-provider 1.7.10 → 1.8.0-beta.1

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-DdmCPEHN.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-D-CUrvd1.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-BwHks7Wg.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
  });
@@ -1821,12 +1882,18 @@ async function registerEndpoint(ctx, opts) {
1821
1882
  user: session.user,
1822
1883
  session: session.session
1823
1884
  }) : void 0);
1885
+ const clientCredentialsScopes = normalizeClientCredentialsScopes(tokenAuthorization?.clientCredentialsScopes ?? []);
1886
+ validateClientCredentialsScopes(clientCredentialsScopes, resolveRegistrationGrantTypes(registrationMetadata), registrationMetadata.token_endpoint_auth_method, opts);
1824
1887
  const response = await createOAuthClientRegistration(ctx, opts, {
1825
- metadata,
1888
+ metadata: {
1889
+ ...registrationMetadata,
1890
+ metadata
1891
+ },
1826
1892
  registrationSource: "dynamic",
1827
1893
  userId: referenceId ? void 0 : session?.session.userId,
1828
1894
  referenceId,
1829
- requestedResources
1895
+ requestedResources,
1896
+ clientCredentialsScopes
1830
1897
  });
1831
1898
  ctx.setStatus(201);
1832
1899
  return ctx.json(response);
@@ -1953,32 +2020,40 @@ async function checkOAuthClient(client, opts, settings) {
1953
2020
  error: "invalid_client_metadata",
1954
2021
  error_description: "backchannel_logout_uri requires the jwt plugin (disableJwtPlugin must be false)"
1955
2022
  });
2023
+ assertPublicRoutableHttpsUri("backchannel_logout_uri", clientWithDefaults.backchannel_logout_uri);
2024
+ }
2025
+ if (clientWithDefaults.frontchannel_logout_uri !== void 0) {
1956
2026
  let url;
1957
2027
  try {
1958
- url = new URL(clientWithDefaults.backchannel_logout_uri);
2028
+ url = new URL(clientWithDefaults.frontchannel_logout_uri);
1959
2029
  } catch {
1960
2030
  throw new APIError("BAD_REQUEST", {
1961
2031
  error: "invalid_client_metadata",
1962
- error_description: "backchannel_logout_uri must be an absolute URL"
2032
+ error_description: "frontchannel_logout_uri must be an absolute URL"
1963
2033
  });
1964
2034
  }
1965
- if (clientWithDefaults.backchannel_logout_uri.includes("#")) throw new APIError("BAD_REQUEST", {
2035
+ if (clientWithDefaults.frontchannel_logout_uri.includes("#")) throw new APIError("BAD_REQUEST", {
1966
2036
  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"
2037
+ error_description: "frontchannel_logout_uri must not include a fragment component"
1972
2038
  });
1973
2039
  if (url.username || url.password) throw new APIError("BAD_REQUEST", {
1974
2040
  error: "invalid_client_metadata",
1975
- error_description: "backchannel_logout_uri must not contain credentials"
2041
+ error_description: "frontchannel_logout_uri must not contain credentials"
1976
2042
  });
1977
- if (isPrivateHostname(url.hostname)) throw new APIError("BAD_REQUEST", {
2043
+ const isHttpOrHttps = url.protocol === "https:" || url.protocol === "http:";
2044
+ const sharesRedirectOrigin = (clientWithDefaults.redirect_uris ?? []).some((redirectUri) => {
2045
+ try {
2046
+ return new URL(redirectUri).origin === url.origin;
2047
+ } catch {
2048
+ return false;
2049
+ }
2050
+ });
2051
+ if (!isHttpOrHttps || !sharesRedirectOrigin) throw new APIError("BAD_REQUEST", {
1978
2052
  error: "invalid_client_metadata",
1979
- error_description: "backchannel_logout_uri must not point to a private or reserved address"
2053
+ error_description: "frontchannel_logout_uri must use the scheme, host, and port of a registered redirect_uri"
1980
2054
  });
1981
2055
  }
2056
+ if (!settings?.skipExtensionValidation) await validateExtensionClientMetadata(opts, clientWithDefaults, client.metadata);
1982
2057
  }
1983
2058
  const CLIENT_REGISTRATION_COLLISION = Symbol("client-registration-collision");
1984
2059
  function isClientRegistrationCollision(error) {
@@ -2005,7 +2080,8 @@ async function persistOAuthClientRegistration(ctx, opts, input) {
2005
2080
  const isExtensionAuthMethod = isExtensionTokenEndpointAuthMethod(opts, body.token_endpoint_auth_method);
2006
2081
  await checkOAuthClient(body, opts, {
2007
2082
  registrationSource: input.registrationSource,
2008
- ctx
2083
+ ctx,
2084
+ skipExtensionValidation: input.registrationSource === "clientMetadataDocument" && Boolean(input.existingClient)
2009
2085
  });
2010
2086
  const clientId = input.clientId ?? opts.generateClientId?.() ?? generateRandomString(32, "a-z", "A-Z");
2011
2087
  const clientSecret = isPublic || isPrivateKeyJwt || isExtensionAuthMethod ? void 0 : opts.generateClientSecret?.() || generateRandomString(32, "a-z", "A-Z");
@@ -2033,8 +2109,9 @@ async function persistOAuthClientRegistration(ctx, opts, input) {
2033
2109
  user_id: owner?.referenceId ? void 0 : owner?.userId,
2034
2110
  reference_id: owner?.referenceId
2035
2111
  });
2036
- schema.clientCredentialsScopes = input.registrationSource === "clientMetadataDocument" ? input.existingClient?.clientCredentialsScopes ?? [] : input.registrationSource === "managed" ? input.clientCredentialsScopes ?? [] : [];
2112
+ schema.clientCredentialsScopes = input.registrationSource === "clientMetadataDocument" ? input.existingClient?.clientCredentialsScopes ?? [] : input.clientCredentialsScopes ?? [];
2037
2113
  schema.clientDiscoveryId = input.registrationSource === "clientMetadataDocument" ? input.clientDiscoveryId : null;
2114
+ schema.sectorIdentifier = deriveSectorIdentifier(clientId, body.redirect_uris);
2038
2115
  if (input.registrationSource === "clientMetadataDocument" && body.application_type === void 0) schema.applicationType = null;
2039
2116
  const requestedResources = input.registrationSource === "clientMetadataDocument" ? [] : input.requestedResources ?? [];
2040
2117
  if (input.registrationSource === "dynamic") assertDynamicRegistrationResourcesAllowed(opts, requestedResources);
@@ -2048,6 +2125,19 @@ async function persistOAuthClientRegistration(ctx, opts, input) {
2048
2125
  let storedClient;
2049
2126
  let created = false;
2050
2127
  if (existingClient) {
2128
+ let metadata;
2129
+ if (hasExtensionClientMetadata(opts)) {
2130
+ const extensionFields = getExtensionClientMetadataFields(opts);
2131
+ const merged = stripReservedOAuthClientMetadataExtensions(mergeDocumentExtensionMetadata(extensionFields, (await adapter.findOne({
2132
+ model: clientModel,
2133
+ where: [{
2134
+ field: "clientId",
2135
+ value: clientId
2136
+ }]
2137
+ }))?.metadata, body.metadata));
2138
+ await validateExtensionClientMetadata(opts, body, merged);
2139
+ if (extensionFields.size > 0) metadata = merged && Object.keys(merged).length > 0 ? JSON.stringify(merged) : null;
2140
+ }
2051
2141
  const updatedClient = await adapter.update({
2052
2142
  model: clientModel,
2053
2143
  where: [{
@@ -2059,9 +2149,11 @@ async function persistOAuthClientRegistration(ctx, opts, input) {
2059
2149
  }],
2060
2150
  update: {
2061
2151
  ...schema,
2152
+ metadata,
2062
2153
  disabled: existingClient.disabled,
2063
2154
  skipConsent: existingClient.skipConsent,
2064
2155
  enableEndSession: existingClient.enableEndSession,
2156
+ sectorIdentifier: existingClient.sectorIdentifier ? schema.sectorIdentifier : null,
2065
2157
  createdAt: existingClient.createdAt,
2066
2158
  updatedAt: /* @__PURE__ */ new Date(iat * 1e3)
2067
2159
  }
@@ -2166,6 +2258,20 @@ function assertClientDiscoveryOwnership(client, clientDiscoveryId) {
2166
2258
  });
2167
2259
  }
2168
2260
  /**
2261
+ * The `metadata` a metadata document registration stores. The document owns the
2262
+ * fields extensions declare, so a refresh replaces them, including removing
2263
+ * ones it no longer declares. Other stored keys, such as ones an admin set, are
2264
+ * kept.
2265
+ */
2266
+ function mergeDocumentExtensionMetadata(extensionFields, storedMetadata, documentMetadata) {
2267
+ const owned = ([field]) => extensionFields.has(field);
2268
+ const metadata = {
2269
+ ...Object.fromEntries(Object.entries(parseClientMetadata(storedMetadata) ?? {}).filter((entry) => !owned(entry))),
2270
+ ...Object.fromEntries(Object.entries(documentMetadata ?? {}).filter(owned))
2271
+ };
2272
+ return Object.keys(metadata).length ? metadata : void 0;
2273
+ }
2274
+ /**
2169
2275
  * First-party persistence seam for a validated Client ID Metadata Document.
2170
2276
  *
2171
2277
  * This operation owns both initial creation and atomic replacement. It never
@@ -2185,18 +2291,20 @@ async function registerClientMetadataDocument(ctx, opts, input) {
2185
2291
  error: "invalid_client_metadata",
2186
2292
  error_description: "Client ID Metadata Document clients cannot use a shared secret"
2187
2293
  });
2188
- const registrationInput = {
2294
+ const toRegistrationInput = (existingClient) => ({
2189
2295
  ...input,
2296
+ existingClient,
2190
2297
  metadata: {
2191
2298
  ...input.metadata,
2299
+ metadata: pickExtensionClientMetadata(input.metadata, opts),
2192
2300
  token_endpoint_auth_method: input.metadata.token_endpoint_auth_method ?? "none",
2193
2301
  client_secret: void 0,
2194
2302
  client_secret_expires_at: void 0
2195
2303
  },
2196
2304
  registrationSource: "clientMetadataDocument"
2197
- };
2305
+ });
2198
2306
  try {
2199
- return await persistOAuthClientRegistration(ctx, opts, registrationInput);
2307
+ return await persistOAuthClientRegistration(ctx, opts, toRegistrationInput(input.existingClient));
2200
2308
  } catch (error) {
2201
2309
  if (!isClientRegistrationCollision(error)) throw error;
2202
2310
  const clientModel = opts.schema?.oauthClient?.modelName ?? "oauthClient";
@@ -2222,10 +2330,7 @@ async function registerClientMetadataDocument(ctx, opts, input) {
2222
2330
  })) throw error.cause;
2223
2331
  }
2224
2332
  try {
2225
- return await persistOAuthClientRegistration(ctx, opts, {
2226
- ...registrationInput,
2227
- existingClient: currentClient
2228
- });
2333
+ return await persistOAuthClientRegistration(ctx, opts, toRegistrationInput(currentClient));
2229
2334
  } catch (retryError) {
2230
2335
  if (isClientRegistrationCollision(retryError)) throw retryError.cause;
2231
2336
  throw retryError;
@@ -2274,7 +2379,7 @@ async function createOAuthClientEndpoint(ctx, opts, settings) {
2274
2379
  return ctx.json(responseBody);
2275
2380
  }
2276
2381
  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;
2382
+ 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
2383
  const expiresAt = _expiresAt ? /* @__PURE__ */ new Date(Number(_expiresAt) * 1e3) : void 0;
2279
2384
  const createdAt = _createdAt ? /* @__PURE__ */ new Date(_createdAt * 1e3) : void 0;
2280
2385
  const scopes = _scope?.split(" ");
@@ -2301,6 +2406,8 @@ function oauthToSchema(input) {
2301
2406
  postLogoutRedirectUris,
2302
2407
  backchannelLogoutUri,
2303
2408
  backchannelLogoutSessionRequired,
2409
+ frontchannelLogoutUri,
2410
+ frontchannelLogoutSessionRequired,
2304
2411
  tokenEndpointAuthMethod,
2305
2412
  grantTypes,
2306
2413
  responseTypes,
@@ -2323,7 +2430,7 @@ function oauthToSchema(input) {
2323
2430
  * @returns
2324
2431
  */
2325
2432
  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;
2433
+ 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
2434
  const _expiresAt = expiresAt ? Math.round(new Date(expiresAt).getTime() / 1e3) : void 0;
2328
2435
  const _createdAt = createdAt ? Math.round(new Date(createdAt).getTime() / 1e3) : void 0;
2329
2436
  const _scopes = scopes?.join(" ");
@@ -2350,6 +2457,8 @@ function schemaToOAuth(input) {
2350
2457
  post_logout_redirect_uris: postLogoutRedirectUris ?? void 0,
2351
2458
  backchannel_logout_uri: backchannelLogoutUri ?? void 0,
2352
2459
  backchannel_logout_session_required: backchannelLogoutSessionRequired ?? void 0,
2460
+ frontchannel_logout_uri: frontchannelLogoutUri ?? void 0,
2461
+ frontchannel_logout_session_required: frontchannelLogoutSessionRequired ?? void 0,
2353
2462
  token_endpoint_auth_method: tokenEndpointAuthMethod ?? void 0,
2354
2463
  grant_types: grantTypes ?? void 0,
2355
2464
  response_types: responseTypes ?? void 0,
@@ -2477,12 +2586,20 @@ async function updateClientEndpoint(ctx, opts, settings) {
2477
2586
  error_description: "client not found",
2478
2587
  error: "not_found"
2479
2588
  });
2480
- const { client_credentials_scopes: rawClientCredentialsScopes, ...updates } = ctx.body.update;
2589
+ const { client_credentials_scopes: rawClientCredentialsScopes, backchannel_logout_uri: backchannelLogoutUri, frontchannel_logout_uri: frontchannelLogoutUri, ...otherUpdates } = ctx.body.update;
2590
+ const clearsBackchannelLogout = backchannelLogoutUri === null;
2591
+ const clearsFrontchannelLogout = frontchannelLogoutUri === null;
2592
+ const updates = {
2593
+ ...otherUpdates,
2594
+ ...backchannelLogoutUri != null ? { backchannel_logout_uri: backchannelLogoutUri } : {},
2595
+ ...frontchannelLogoutUri != null ? { frontchannel_logout_uri: frontchannelLogoutUri } : {}
2596
+ };
2597
+ const hasUpdates = Object.keys(updates).length > 0 || clearsBackchannelLogout || clearsFrontchannelLogout;
2481
2598
  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;
2599
+ const isCrossOwnerScopeConfiguration = !ownsClient && settings?.admin && rawClientCredentialsScopes !== void 0 && !hasUpdates;
2483
2600
  if (!ownsClient && !isCrossOwnerScopeConfiguration) throw new APIError("UNAUTHORIZED");
2484
2601
  if (isCrossOwnerScopeConfiguration) await assertClientPrivileges(ctx, session, opts, "configure-client-credentials-scopes", clientId);
2485
- if (Object.keys(updates).length === 0 && rawClientCredentialsScopes === void 0) {
2602
+ if (!hasUpdates && rawClientCredentialsScopes === void 0) {
2486
2603
  const res = schemaToOAuth(client);
2487
2604
  res.client_secret = void 0;
2488
2605
  if (settings?.admin) return {
@@ -2498,11 +2615,34 @@ async function updateClientEndpoint(ctx, opts, settings) {
2498
2615
  validateClientCredentialsScopes(clientCredentialsScopes, finalGrantTypes, finalTokenEndpointAuthMethod, opts);
2499
2616
  if (clientCredentialsScopes.length > 0 && !isCrossOwnerScopeConfiguration) await assertClientPrivileges(ctx, session, opts, "configure-client-credentials-scopes", clientId);
2500
2617
  }
2501
- await checkOAuthClient({
2502
- ...schemaToOAuth(client),
2503
- ...updates
2504
- }, opts, { ctx });
2618
+ const updatedMetadata = {
2619
+ ...schemaToOAuth({
2620
+ ...client,
2621
+ metadata: void 0
2622
+ }),
2623
+ ...updates,
2624
+ metadata: updates.metadata ?? parseClientMetadata(client.metadata)
2625
+ };
2626
+ if (clearsBackchannelLogout) {
2627
+ updatedMetadata.backchannel_logout_uri = void 0;
2628
+ updatedMetadata.backchannel_logout_session_required = void 0;
2629
+ }
2630
+ if (clearsFrontchannelLogout) {
2631
+ updatedMetadata.frontchannel_logout_uri = void 0;
2632
+ updatedMetadata.frontchannel_logout_session_required = void 0;
2633
+ }
2634
+ await checkOAuthClient(updatedMetadata, opts, { ctx });
2505
2635
  const schemaUpdates = { ...oauthToSchema(updates) };
2636
+ if (updates.redirect_uris && client.sectorIdentifier) schemaUpdates.sectorIdentifier = deriveSectorIdentifier(client.clientId, updates.redirect_uris);
2637
+ if (clearsBackchannelLogout) {
2638
+ schemaUpdates.backchannelLogoutUri = null;
2639
+ schemaUpdates.backchannelLogoutSessionRequired = null;
2640
+ }
2641
+ if (clearsFrontchannelLogout) {
2642
+ schemaUpdates.frontchannelLogoutUri = null;
2643
+ schemaUpdates.frontchannelLogoutSessionRequired = null;
2644
+ }
2645
+ if (updates.metadata !== void 0 && schemaUpdates.metadata === void 0) schemaUpdates.metadata = null;
2506
2646
  if (!finalGrantTypes.includes("client_credentials") || finalTokenEndpointAuthMethod === "none") schemaUpdates.clientCredentialsScopes = [];
2507
2647
  else if (clientCredentialsScopes !== void 0) schemaUpdates.clientCredentialsScopes = clientCredentialsScopes;
2508
2648
  if (updates.token_endpoint_auth_method) if (updates.token_endpoint_auth_method === "private_key_jwt") schemaUpdates.clientSecret = null;
@@ -2648,6 +2788,8 @@ const adminCreateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/creat
2648
2788
  post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
2649
2789
  backchannel_logout_uri: SafeUrlSchema.optional(),
2650
2790
  backchannel_logout_session_required: z.boolean().optional(),
2791
+ frontchannel_logout_uri: SafeUrlSchema.optional(),
2792
+ frontchannel_logout_session_required: z.boolean().optional(),
2651
2793
  token_endpoint_auth_method: tokenEndpointAuthMethodSchema.optional(),
2652
2794
  application_type: z.enum(["web", "native"]).optional(),
2653
2795
  jwks: clientJwksSchema.optional(),
@@ -2821,6 +2963,8 @@ const createOAuthClient = (opts) => createAuthEndpoint("/oauth2/create-client",
2821
2963
  post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
2822
2964
  backchannel_logout_uri: SafeUrlSchema.optional(),
2823
2965
  backchannel_logout_session_required: z.boolean().optional(),
2966
+ frontchannel_logout_uri: SafeUrlSchema.optional(),
2967
+ frontchannel_logout_session_required: z.boolean().optional(),
2824
2968
  token_endpoint_auth_method: tokenEndpointAuthMethodSchema.optional(),
2825
2969
  application_type: z.enum(["web", "native"]).optional(),
2826
2970
  jwks: clientJwksSchema.optional(),
@@ -3010,8 +3154,10 @@ const adminUpdateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/updat
3010
3154
  software_version: z.string().optional(),
3011
3155
  software_statement: z.string().optional(),
3012
3156
  post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
3013
- backchannel_logout_uri: SafeUrlSchema.optional(),
3157
+ backchannel_logout_uri: SafeUrlSchema.nullable().optional(),
3014
3158
  backchannel_logout_session_required: z.boolean().optional(),
3159
+ frontchannel_logout_uri: SafeUrlSchema.nullable().optional(),
3160
+ frontchannel_logout_session_required: z.boolean().optional(),
3015
3161
  application_type: z.enum(["web", "native"]).optional(),
3016
3162
  grant_types: grantTypesSchema.optional(),
3017
3163
  client_credentials_scopes: z.array(z.string().trim().min(1)).optional(),
@@ -3049,8 +3195,10 @@ const updateOAuthClient = (opts) => createAuthEndpoint("/oauth2/update-client",
3049
3195
  software_version: z.string().optional(),
3050
3196
  software_statement: z.string().optional(),
3051
3197
  post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
3052
- backchannel_logout_uri: SafeUrlSchema.optional(),
3198
+ backchannel_logout_uri: SafeUrlSchema.nullable().optional(),
3053
3199
  backchannel_logout_session_required: z.boolean().optional(),
3200
+ frontchannel_logout_uri: SafeUrlSchema.nullable().optional(),
3201
+ frontchannel_logout_session_required: z.boolean().optional(),
3054
3202
  application_type: z.enum(["web", "native"]).optional(),
3055
3203
  grant_types: grantTypesSchema.optional(),
3056
3204
  response_types: z.array(z.enum(["code"])).optional()
@@ -3540,113 +3688,47 @@ const adminUnlinkClientResource = (opts) => createAuthEndpoint("/admin/oauth2/re
3540
3688
  //#endregion
3541
3689
  //#region src/revoke.ts
3542
3690
  /**
3543
- * IMPORTANT NOTES:
3544
- * Revocation follows RFC7009
3691
+ * Revocation follows RFC 7009
3545
3692
  * https://datatracker.ietf.org/doc/html/rfc7009
3546
- * - APIError: Continue catches (returnable to client)
3547
- * - Error: Should immediately stop catches (internal error)
3693
+ *
3694
+ * A token this server cannot find is already unusable, so it resolves as a
3695
+ * successful no-op (§2.2). Only explicit protocol errors surface.
3548
3696
  */
3549
3697
  /**
3550
- * Revokes a JWT access token against the configured JWKs.
3551
- *
3552
3698
  * A JWT access token is self-contained and never stored, so there is nothing to
3553
3699
  * delete. Once the token is confirmed to be a valid JWT for this server, the
3554
3700
  * endpoint reports `unsupported_token_type` (RFC 7009 §2.2.1) instead of a
3555
3701
  * 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.
3702
+ * A JWT with an audience rejected by the OAuth resource model is already
3703
+ * inactive and resolves as a successful no-op. Session-bound tokens (carrying
3704
+ * `sid`) are cut off early by the session-liveness check in introspection and
3705
+ * userinfo.
3560
3706
  */
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;
3707
+ async function revokeJwtAccessToken(ctx, opts, payload) {
3708
+ if (!await isAudienceClaimAllowed(ctx, opts, payload.aud, [userInfoResource(ctx.context.baseURL)])) return null;
3586
3709
  throw new APIError$1("BAD_REQUEST", {
3587
3710
  error_description: "JWT access tokens are self-contained and cannot be revoked server-side",
3588
3711
  error: "unsupported_token_type"
3589
3712
  });
3590
3713
  }
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({
3714
+ async function revokeOpaqueAccessToken(ctx, accessToken) {
3715
+ await ctx.context.adapter.delete({
3614
3716
  model: "oauthAccessToken",
3615
- where: [{
3717
+ where: accessToken.id ? [{
3616
3718
  field: "id",
3617
3719
  value: accessToken.id
3618
- }]
3619
- }) : await ctx.context.adapter.delete({
3620
- model: "oauthAccessToken",
3621
- where: [{
3720
+ }] : [{
3622
3721
  field: "token",
3623
3722
  value: accessToken.token
3624
3723
  }]
3625
3724
  });
3626
3725
  }
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
- });
3726
+ async function revokeRefreshToken(ctx, refreshToken, clientId) {
3727
+ const { userId } = refreshToken;
3642
3728
  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
- });
3729
+ await invalidateRefreshFamily(ctx, clientId, userId);
3730
+ return;
3648
3731
  }
3649
- if (!refreshToken.clientId || refreshToken.clientId !== clientId) return null;
3650
3732
  const iat = Math.floor(Date.now() / 1e3);
3651
3733
  if (!await ctx.context.adapter.incrementOne({
3652
3734
  model: "oauthRefreshToken",
@@ -3661,11 +3743,8 @@ async function revokeRefreshToken(ctx, opts, token, clientId) {
3661
3743
  increment: {},
3662
3744
  set: { revoked: /* @__PURE__ */ new Date(iat * 1e3) }
3663
3745
  })) {
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
- });
3746
+ await invalidateRefreshFamily(ctx, clientId, userId);
3747
+ return;
3669
3748
  }
3670
3749
  await ctx.context.adapter.deleteMany({
3671
3750
  model: "oauthAccessToken",
@@ -3675,33 +3754,8 @@ async function revokeRefreshToken(ctx, opts, token, clientId) {
3675
3754
  }]
3676
3755
  });
3677
3756
  }
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
3757
  async function revokeEndpoint(ctx, opts) {
3703
3758
  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
3759
  const { clientId: client_id, clientSecret: client_secret, preVerified, authMethod } = destructureCredentials(await extractClientCredentials(ctx, opts, `${ctx.context.baseURL}/oauth2/revoke`));
3706
3760
  if (!client_id) throw new APIError$1("UNAUTHORIZED", {
3707
3761
  error_description: "missing required credentials",
@@ -3714,38 +3768,23 @@ async function revokeEndpoint(ctx, opts) {
3714
3768
  });
3715
3769
  const client = await validateClientCredentials(ctx, opts, client_id, client_secret, void 0, preVerified, void 0, authMethod);
3716
3770
  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);
3771
+ const found = await findToken(ctx, opts, token, token_type_hint);
3772
+ if (!found) return null;
3773
+ if ((found.type === "jwt" ? found.payload.azp : found.record.clientId) !== client.clientId) {
3774
+ if (client.tokenEndpointAuthMethod === "none") return null;
3775
+ throw new APIError$1("BAD_REQUEST", {
3776
+ error_description: "client is not authorized to revoke the presented token",
3777
+ error: "invalid_request"
3778
+ });
3732
3779
  }
3733
- throw new APIError$1("BAD_REQUEST", {
3734
- error_description: "token not found",
3735
- error: "invalid_request"
3736
- });
3780
+ if (found.type === "jwt") return await revokeJwtAccessToken(ctx, opts, found.payload);
3781
+ if (found.type === "opaque") await revokeOpaqueAccessToken(ctx, found.record);
3782
+ else await revokeRefreshToken(ctx, found.record, client.clientId);
3783
+ return null;
3737
3784
  } 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
- }
3785
+ if (error instanceof APIError$1) throw error;
3786
+ logger.error("Revocation error:", error);
3787
+ throw new APIError$1("INTERNAL_SERVER_ERROR");
3749
3788
  }
3750
3789
  }
3751
3790
  //#endregion
@@ -3784,6 +3823,10 @@ const schema = {
3784
3823
  type: "string",
3785
3824
  required: false
3786
3825
  },
3826
+ sectorIdentifier: {
3827
+ type: "string",
3828
+ required: false
3829
+ },
3787
3830
  scopes: {
3788
3831
  type: "string[]",
3789
3832
  required: false
@@ -3862,6 +3905,14 @@ const schema = {
3862
3905
  type: "boolean",
3863
3906
  required: false
3864
3907
  },
3908
+ frontchannelLogoutUri: {
3909
+ type: "string",
3910
+ required: false
3911
+ },
3912
+ frontchannelLogoutSessionRequired: {
3913
+ type: "boolean",
3914
+ required: false
3915
+ },
3865
3916
  tokenEndpointAuthMethod: {
3866
3917
  type: "string",
3867
3918
  required: false
@@ -4110,6 +4161,14 @@ const schema = {
4110
4161
  type: "date",
4111
4162
  required: false
4112
4163
  },
4164
+ acr: {
4165
+ type: "string",
4166
+ required: false
4167
+ },
4168
+ amr: {
4169
+ type: "string[]",
4170
+ required: false
4171
+ },
4113
4172
  confirmation: {
4114
4173
  type: "json",
4115
4174
  required: false
@@ -4290,7 +4349,7 @@ const rpInitiatedLogoutRequestSchema = z.object({
4290
4349
  });
4291
4350
  const oAuthState = defineRequestState(() => null);
4292
4351
  const getOAuthProviderState = oAuthState.get;
4293
- const backchannelLogoutPlansByContext = /* @__PURE__ */ new WeakMap();
4352
+ const sessionLogoutPlansByContext = /* @__PURE__ */ new WeakMap();
4294
4353
  const signedQueryIssuedAtMsKey = "signedQueryIssuedAtMs";
4295
4354
  function getServerContextSignedQueryIssuedAt(value) {
4296
4355
  const issuedAtMs = typeof value === "number" ? value : typeof value === "string" ? Number(value) : void 0;
@@ -4314,6 +4373,8 @@ const oauthProvider = (options) => {
4314
4373
  if (clientRegistrationAllowedScopes) {
4315
4374
  for (const sc of clientRegistrationAllowedScopes) if (!scopes.has(sc)) throw new BetterAuthError(`clientRegistrationAllowedScope ${sc} not found in scopes`);
4316
4375
  }
4376
+ const acrValuesSupported = options.authenticationContext?.acrValuesSupported;
4377
+ if (acrValuesSupported && (acrValuesSupported.length === 0 || acrValuesSupported.includes(""))) throw new BetterAuthError("authenticationContext.acrValuesSupported must list at least one non-empty acr value");
4317
4378
  for (const sc of options.advertisedMetadata?.scopes_supported ?? []) if (!scopes?.has(sc)) throw new BetterAuthError(`advertisedMetadata.scopes_supported ${sc} not found in scopes`);
4318
4379
  const configuredResourceIdentifiers = new Set((options.resources ?? []).map((resource) => typeof resource === "string" ? resource : resource.identifier));
4319
4380
  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 +4466,19 @@ const oauthProvider = (options) => {
4405
4466
  schema: { type: "string" },
4406
4467
  description: "OAuth 2.1 response type (e.g., 'code')"
4407
4468
  },
4469
+ {
4470
+ name: "user_code",
4471
+ in: "query",
4472
+ required: false,
4473
+ schema: { type: "string" },
4474
+ description: "Device user code to approve instead of an authorization request (requires oauthDeviceAuthorization())"
4475
+ },
4408
4476
  {
4409
4477
  name: "client_id",
4410
4478
  in: "query",
4411
- required: true,
4479
+ required: false,
4412
4480
  schema: { type: "string" },
4413
- description: "OAuth 2.1 client ID"
4481
+ description: "OAuth 2.1 client ID, required for authorization requests (not for user_code)"
4414
4482
  },
4415
4483
  {
4416
4484
  name: "redirect_uri",
@@ -4554,25 +4622,26 @@ const oauthProvider = (options) => {
4554
4622
  return { options: { databaseHooks: { session: { delete: {
4555
4623
  async before(session, hookCtx) {
4556
4624
  if (!hookCtx) return;
4557
- const plan = await prepareBackchannelLogoutPlan(hookCtx, opts, {
4625
+ const plan = await prepareSessionLogoutPlan(hookCtx, opts, {
4558
4626
  sessionId: session.id,
4559
4627
  userId: session.userId
4560
4628
  });
4561
4629
  if (!plan) return;
4562
- const logoutPlanBySessionId = backchannelLogoutPlansByContext.get(hookCtx) ?? /* @__PURE__ */ new Map();
4630
+ const logoutPlanBySessionId = sessionLogoutPlansByContext.get(hookCtx) ?? /* @__PURE__ */ new Map();
4563
4631
  logoutPlanBySessionId.set(session.id, plan);
4564
- backchannelLogoutPlansByContext.set(hookCtx, logoutPlanBySessionId);
4632
+ sessionLogoutPlansByContext.set(hookCtx, logoutPlanBySessionId);
4565
4633
  },
4566
4634
  async after(session, hookCtx) {
4567
4635
  if (!hookCtx) return;
4568
- const logoutPlanBySessionId = backchannelLogoutPlansByContext.get(hookCtx);
4636
+ const logoutPlanBySessionId = sessionLogoutPlansByContext.get(hookCtx);
4569
4637
  if (!logoutPlanBySessionId) return;
4570
4638
  const plan = logoutPlanBySessionId.get(session.id);
4571
4639
  logoutPlanBySessionId.delete(session.id);
4572
- if (logoutPlanBySessionId.size === 0) backchannelLogoutPlansByContext.delete(hookCtx);
4640
+ if (logoutPlanBySessionId.size === 0) sessionLogoutPlansByContext.delete(hookCtx);
4573
4641
  if (!plan) return;
4574
- const logoutTask = applyBackchannelLogoutPlan(hookCtx, plan).catch((error) => {
4575
- hookCtx.context.logger.error("Back-channel logout failed after session deletion", error);
4642
+ await recordFrontchannelLogoutUris(plan.frontchannelLogoutUris);
4643
+ const logoutTask = applySessionLogoutPlan(hookCtx, plan).catch((error) => {
4644
+ hookCtx.context.logger.error("Logout notification failed after session deletion", error);
4576
4645
  });
4577
4646
  await hookCtx.context.runInBackgroundOrAwait(logoutTask);
4578
4647
  }
@@ -4716,7 +4785,7 @@ const oauthProvider = (options) => {
4716
4785
  }),
4717
4786
  oauth2Token: createOAuthEndpoint("/oauth2/token", {
4718
4787
  method: "POST",
4719
- cloneRequest: true,
4788
+ formParams: { repeatable: ["resource", "audience"] },
4720
4789
  body: z.object({
4721
4790
  grant_type: z.string().trim().min(1),
4722
4791
  client_id: z.string().optional(),
@@ -4857,15 +4926,11 @@ const oauthProvider = (options) => {
4857
4926
  }
4858
4927
  }
4859
4928
  }, 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
4929
  return tokenEndpoint(ctx, opts);
4865
4930
  }),
4866
4931
  oauth2Introspect: createOAuthEndpoint("/oauth2/introspect", {
4867
4932
  method: "POST",
4868
- cloneRequest: true,
4933
+ formParams: {},
4869
4934
  body: z.object({
4870
4935
  client_id: z.string().optional(),
4871
4936
  client_secret: z.string().optional(),
@@ -4982,7 +5047,7 @@ const oauthProvider = (options) => {
4982
5047
  }),
4983
5048
  oauth2Revoke: createOAuthEndpoint("/oauth2/revoke", {
4984
5049
  method: "POST",
4985
- cloneRequest: true,
5050
+ formParams: {},
4986
5051
  body: z.object({
4987
5052
  client_id: z.string().optional(),
4988
5053
  client_secret: z.string().optional(),
@@ -5160,7 +5225,7 @@ const oauthProvider = (options) => {
5160
5225
  openapi: {
5161
5226
  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
5227
  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.",
5228
+ 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
5229
  content: {
5165
5230
  "application/json": { schema: {
5166
5231
  type: "object",
@@ -5176,7 +5241,10 @@ const oauthProvider = (options) => {
5176
5241
  }
5177
5242
  }
5178
5243
  } },
5179
- "text/html": { schema: { type: "string" } }
5244
+ "text/html": { schema: {
5245
+ type: "string",
5246
+ description: "Logout confirmation page, or the OIDC Front-Channel Logout page with one hidden iframe per registered RP"
5247
+ } }
5180
5248
  }
5181
5249
  } }
5182
5250
  }
@@ -5197,7 +5265,7 @@ const oauthProvider = (options) => {
5197
5265
  }),
5198
5266
  registerOAuthClient: createOAuthEndpoint("/oauth2/register", {
5199
5267
  method: "POST",
5200
- body: clientRegistrationRequestSchema,
5268
+ body: clientRegistrationRequestSchema.loose(),
5201
5269
  errorCodesByField: {
5202
5270
  redirect_uris: "invalid_redirect_uri",
5203
5271
  post_logout_redirect_uris: "invalid_redirect_uri",
@@ -5301,6 +5369,15 @@ const oauthProvider = (options) => {
5301
5369
  type: "boolean",
5302
5370
  description: "Whether the RP requires a `sid` claim in every Logout Token"
5303
5371
  },
5372
+ frontchannel_logout_uri: {
5373
+ type: "string",
5374
+ format: "uri",
5375
+ description: "RP URL rendered in a hidden iframe on the OP's logout page when the end-user's OP session terminates"
5376
+ },
5377
+ frontchannel_logout_session_required: {
5378
+ type: "boolean",
5379
+ description: "Whether the RP requires `iss` and `sid` query parameters on its front-channel logout URI. The OP always sends both."
5380
+ },
5304
5381
  token_endpoint_auth_method: {
5305
5382
  type: "string",
5306
5383
  description: "Requested authentication method for the token endpoint"
@@ -5477,8 +5554,20 @@ const handleRedirect = (ctx, uri) => {
5477
5554
  };
5478
5555
  else throw ctx.redirect(uri);
5479
5556
  };
5480
- function redirectWithPromptNoneError(ctx, opts, query, error, description) {
5481
- return handleRedirect(ctx, formatErrorURL(query.redirect_uri, error, description, query.state, getIssuer(ctx, opts)));
5557
+ /** Sends an OAuth error to the client's redirect URI, or to the error page without one. */
5558
+ function redirectWithAuthorizationError(ctx, opts, query, error, description) {
5559
+ return handleRedirect(ctx, query.redirect_uri ? formatErrorURL(query.redirect_uri, error, description, query.state, getIssuer(ctx, opts)) : getErrorURL(ctx, error, description));
5560
+ }
5561
+ /**
5562
+ * Returns the URL that reports an `APIError`: `errorPage` when given, otherwise
5563
+ * the provider error page. Rethrows anything else.
5564
+ */
5565
+ function getAPIErrorURL(ctx, error, errorPage) {
5566
+ if (!(error instanceof APIError$1)) throw error;
5567
+ const body = error.body;
5568
+ const code = body?.error ?? "invalid_request";
5569
+ const description = body?.error_description ?? "invalid request";
5570
+ return errorPage ? formatErrorURL(errorPage, code, description) : getErrorURL(ctx, code, description);
5482
5571
  }
5483
5572
  /**
5484
5573
  * Validates that the issuer URL
@@ -5640,13 +5729,45 @@ function authorizeRedirectOnError(opts) {
5640
5729
  };
5641
5730
  }
5642
5731
  async function authorizeEndpoint(ctx, opts, settings) {
5643
- if (opts.grantTypes && !opts.grantTypes.includes("authorization_code")) throw new APIError$1("NOT_FOUND");
5644
5732
  if (!ctx.request) throw new APIError$1("UNAUTHORIZED", {
5645
5733
  error_description: "request not found",
5646
5734
  error: "invalid_request"
5647
5735
  });
5648
5736
  const request = ctx.request;
5649
5737
  let query = getAuthorizationRequestParameters(ctx, settings);
5738
+ const interaction = getExtensionInteractionRequest(opts, query);
5739
+ if (interaction) {
5740
+ if (settings?.isAuthorize === true && !await getSessionFromCtx(ctx)) {
5741
+ ctx.query = { [interaction.parameter]: interaction.value };
5742
+ return redirectWithPromptCode(ctx, opts, "login");
5743
+ }
5744
+ const errorPage = interaction.handler.errorPage?.({
5745
+ ctx,
5746
+ value: interaction.value
5747
+ });
5748
+ const toErrorURL = (error) => getAPIErrorURL(ctx, error, errorPage);
5749
+ let interactionRequest;
5750
+ try {
5751
+ interactionRequest = await interaction.handler.load({
5752
+ ctx,
5753
+ opts,
5754
+ value: interaction.value,
5755
+ signedQuery: settings?.isAuthorize === true ? void 0 : query
5756
+ });
5757
+ if (interactionRequest.client.disabled) throw new APIError$1("BAD_REQUEST", {
5758
+ error: "client_disabled",
5759
+ error_description: "client is disabled"
5760
+ });
5761
+ } catch (error) {
5762
+ return handleRedirect(ctx, toErrorURL(error));
5763
+ }
5764
+ return runAuthorizationInteraction(ctx, opts, request.headers, settings, {
5765
+ ...interactionRequest,
5766
+ approve: (approval) => interactionRequest.approve(approval).catch(toErrorURL),
5767
+ errorURL: toErrorURL
5768
+ });
5769
+ }
5770
+ if (opts.grantTypes && !opts.grantTypes.includes("authorization_code")) throw new APIError$1("NOT_FOUND");
5650
5771
  ctx.query = query;
5651
5772
  const requestObject = getStringParameter(query.request);
5652
5773
  const requestUri = getStringParameter(query.request_uri);
@@ -5703,9 +5824,7 @@ async function authorizeEndpoint(ctx, opts, settings) {
5703
5824
  error_description: "response_type is required",
5704
5825
  ctx
5705
5826
  });
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"));
5827
+ 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
5828
  if (!(query.response_type === "code")) return handleRedirect(ctx, getErrorURL(ctx, "unsupported_response_type", "unsupported response type"));
5710
5829
  const client = await getClient(ctx, opts, query.client_id);
5711
5830
  if (!client) return handleRedirect(ctx, getErrorURL(ctx, "invalid_client", "client not found"));
@@ -5738,8 +5857,7 @@ async function authorizeEndpoint(ctx, opts, settings) {
5738
5857
  const openidRequested = requestedScopes.includes("openid");
5739
5858
  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
5859
  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));
5860
+ 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
5861
  if (query.resource !== void 0) try {
5744
5862
  await resolveResourcePolicy(ctx, opts, {
5745
5863
  resource: query.resource,
@@ -5765,12 +5883,35 @@ async function authorizeEndpoint(ctx, opts, settings) {
5765
5883
  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
5884
  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
5885
  }
5768
- const requestedResources = toResourceList(query.resource) ?? [];
5886
+ return runAuthorizationInteraction(ctx, opts, request.headers, settings, {
5887
+ query,
5888
+ client,
5889
+ approve: (approval) => createAuthorizationCodeURL(ctx, opts, {
5890
+ ...approval,
5891
+ query: approval.query,
5892
+ clientId: client.clientId,
5893
+ resource: toResourceList(approval.query.resource) ?? []
5894
+ })
5895
+ });
5896
+ }
5897
+ /**
5898
+ * Sends the user to the next required interaction page, or approves the
5899
+ * request once none remain.
5900
+ */
5901
+ async function runAuthorizationInteraction(ctx, opts, headers, settings, request) {
5902
+ let { query } = request;
5903
+ const { client } = request;
5904
+ ctx.query = query;
5905
+ const scopes = query.scope?.split(" ").filter((scope) => scope) ?? [];
5906
+ const userInfoClaims = getRequestedUserInfoClaims(query.claims, getSupportedClaims(opts));
5907
+ const resources = toResourceList(query.resource) ?? [];
5908
+ const promptSet = query.prompt ? parsePrompt(query.prompt) : void 0;
5909
+ const promptNone = promptSet?.has("none") ?? false;
5769
5910
  const session = await getSessionFromCtx(ctx);
5770
5911
  const maxAgeSeconds = query.max_age;
5771
5912
  const hasSatisfiedMaxAge = session != null && maxAgeSeconds !== void 0 && isWithinMaxAge(new Date(session.session.createdAt), maxAgeSeconds, /* @__PURE__ */ new Date());
5772
5913
  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");
5914
+ if (promptNone) return redirectWithAuthorizationError(ctx, opts, query, "login_required", "authentication required");
5774
5915
  return redirectWithPromptCode(ctx, opts, promptSet?.has("create") ? "create" : "login");
5775
5916
  }
5776
5917
  if (hasSatisfiedMaxAge) {
@@ -5780,73 +5921,79 @@ async function authorizeEndpoint(ctx, opts, settings) {
5780
5921
  if (settings?.isAuthorize && promptSet?.has("select_account")) return redirectWithPromptCode(ctx, opts, "select_account");
5781
5922
  if (settings?.isAuthorize && opts.selectAccount) {
5782
5923
  if (await opts.selectAccount.shouldRedirect({
5783
- headers: request.headers,
5924
+ headers,
5784
5925
  user: session.user,
5785
5926
  session: session.session,
5786
- scopes: requestedScopes
5927
+ scopes
5787
5928
  })) {
5788
- if (promptNone) return redirectWithPromptNoneError(ctx, opts, query, "account_selection_required", "End-User account selection is required");
5929
+ if (promptNone) return redirectWithAuthorizationError(ctx, opts, query, "account_selection_required", "End-User account selection is required");
5789
5930
  return redirectWithPromptCode(ctx, opts, "select_account");
5790
5931
  }
5791
5932
  }
5792
5933
  if (opts.signup?.shouldRedirect) {
5793
5934
  const signupRedirect = await opts.signup.shouldRedirect({
5794
- headers: request.headers,
5935
+ headers,
5795
5936
  user: session.user,
5796
5937
  session: session.session,
5797
- scopes: requestedScopes
5938
+ scopes
5798
5939
  });
5799
5940
  if (signupRedirect) {
5800
- if (promptNone) return redirectWithPromptNoneError(ctx, opts, query, "interaction_required", "End-User interaction is required");
5941
+ if (promptNone) return redirectWithAuthorizationError(ctx, opts, query, "interaction_required", "End-User interaction is required");
5801
5942
  return redirectWithPromptCode(ctx, opts, "create", { page: typeof signupRedirect === "string" ? signupRedirect : void 0 });
5802
5943
  }
5803
5944
  }
5804
5945
  if (!settings?.postLogin && opts.postLogin) {
5805
5946
  if (await opts.postLogin.shouldRedirect({
5806
- headers: request.headers,
5947
+ headers,
5807
5948
  user: session.user,
5808
5949
  session: session.session,
5809
- scopes: requestedScopes,
5950
+ scopes,
5810
5951
  isContinuation: settings?.isPostLoginContinuation ?? false
5811
5952
  })) {
5812
- if (promptNone) return redirectWithPromptNoneError(ctx, opts, query, "interaction_required", "End-User interaction is required");
5953
+ if (promptNone) return redirectWithAuthorizationError(ctx, opts, query, "interaction_required", "End-User interaction is required");
5813
5954
  return redirectWithPromptCode(ctx, opts, "post_login");
5814
5955
  }
5815
5956
  }
5816
- if (promptSet?.has("consent")) return redirectWithPromptCode(ctx, opts, "consent", { sessionId: session.session.id });
5957
+ if (promptSet?.has("consent") || request.requireConsent && !settings?.consentAccepted) return redirectWithPromptCode(ctx, opts, "consent", { sessionId: session.session.id });
5817
5958
  const referenceId = await getConsentReferenceId(opts, {
5818
5959
  user: session.user,
5819
5960
  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
5961
+ scopes
5830
5962
  });
5963
+ const approve = async () => {
5964
+ let authenticationContext;
5965
+ try {
5966
+ authenticationContext = await resolveAuthenticationContext(opts, {
5967
+ user: session.user,
5968
+ session: session.session,
5969
+ client,
5970
+ scopes,
5971
+ headers,
5972
+ query
5973
+ });
5974
+ } catch (error) {
5975
+ if (!(error instanceof APIError$1)) throw error;
5976
+ if (request.errorURL) return handleRedirect(ctx, request.errorURL(error));
5977
+ const body = error.body;
5978
+ return redirectWithAuthorizationError(ctx, opts, query, body?.error ?? "server_error", body?.error_description ?? "authentication context unavailable");
5979
+ }
5980
+ if (!canSatisfyEssentialAcrRequest(query.claims, authenticationContext.acr ?? "0")) return redirectWithAuthorizationError(ctx, opts, query, "access_denied", "essential acr requirement cannot be met");
5981
+ return handleRedirect(ctx, await request.approve({
5982
+ query,
5983
+ userId: session.user.id,
5984
+ sessionId: session.session.id,
5985
+ authTime: new Date(session.session.createdAt).getTime(),
5986
+ ...authenticationContext,
5987
+ referenceId
5988
+ }));
5989
+ };
5990
+ if (client.skipConsent) return approve();
5831
5991
  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");
5992
+ if (!consent || !consentCovers(consent, scopes, resources) || !userInfoClaims.every((claim) => (consent.requestedUserInfoClaims ?? []).includes(claim))) {
5993
+ if (promptNone) return redirectWithAuthorizationError(ctx, opts, query, "consent_required", "End-User consent is required");
5839
5994
  return redirectWithPromptCode(ctx, opts, "consent", { sessionId: session.session.id });
5840
5995
  }
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
- });
5996
+ return approve();
5850
5997
  }
5851
5998
  function serializeAuthorizationQuery(query) {
5852
5999
  const params = new URLSearchParams();
@@ -5858,7 +6005,8 @@ function serializeAuthorizationQuery(query) {
5858
6005
  }
5859
6006
  return params;
5860
6007
  }
5861
- async function redirectWithAuthorizationCode(ctx, opts, verificationValue) {
6008
+ /** Stores a single-use authorization code and returns the client redirect URL carrying it. */
6009
+ async function createAuthorizationCodeURL(ctx, opts, verificationValue) {
5862
6010
  const code = generateRandomString(32, "a-z", "A-Z", "0-9");
5863
6011
  const iat = Math.floor(Date.now() / 1e3);
5864
6012
  const exp = iat + (opts.codeExpiresIn ?? 600);
@@ -5873,6 +6021,8 @@ async function redirectWithAuthorizationCode(ctx, opts, verificationValue) {
5873
6021
  sessionId: verificationValue?.sessionId,
5874
6022
  referenceId: verificationValue.referenceId,
5875
6023
  authTime: verificationValue.authTime,
6024
+ acr: verificationValue.acr,
6025
+ amr: verificationValue.amr,
5876
6026
  resource: verificationValue.resource
5877
6027
  })
5878
6028
  };
@@ -5884,7 +6034,7 @@ async function redirectWithAuthorizationCode(ctx, opts, verificationValue) {
5884
6034
  redirectUriWithCode.searchParams.set("code", code);
5885
6035
  if (verificationValue.query.state) redirectUriWithCode.searchParams.set("state", verificationValue.query.state);
5886
6036
  redirectUriWithCode.searchParams.set("iss", getIssuer(ctx, opts));
5887
- return handleRedirect(ctx, redirectUriWithCode.toString());
6037
+ return redirectUriWithCode.toString();
5888
6038
  }
5889
6039
  async function redirectWithPromptCode(ctx, opts, type, options) {
5890
6040
  const queryParams = await signParams(ctx, opts, { postLoginClearedForSession: type === "consent" && opts.postLogin ? options?.sessionId : void 0 });
@@ -5912,4 +6062,4 @@ async function signParams(ctx, opts, flags) {
5912
6062
  return params.toString();
5913
6063
  }
5914
6064
  //#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 };
6065
+ 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 };