@better-auth/oauth-provider 1.7.0-beta.4 → 1.7.0-beta.5

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.
package/dist/index.mjs CHANGED
@@ -1,8 +1,8 @@
1
- import { n as isPrivateHostname } from "./client-assertion-DLMKVgoj.mjs";
1
+ import { n as isPrivateHostname } from "./client-assertion-DmT1B6_6.mjs";
2
2
  import { n as mcpHandler } from "./mcp-CYnz-MXn.mjs";
3
- import { C as toAudienceClaim, D as verifyOAuthQueryParams, E as validateClientCredentials, S as storeToken, T as toResourceList, _ as resolveSessionAuthTime, a as getClient, b as signedQueryIssuedAtParam, c as getSignedQueryIssuedAt, d as mergeDiscoveryMetadata, f as normalizeTimestampValue, g as removePromptFromQuery, h as postLoginClearedParam, i as extractClientCredentials, l as getStoredToken, m as parsePrompt, n as decryptStoredClientSecret, o as getJwtPlugin, p as parseClientMetadata, r as destructureCredentials, t as checkResource, u as isPKCERequired, v as resolveSubjectIdentifier, w as toClientDiscoveryArray, x as storeClientSecret, y as searchParamsToQuery } from "./utils-DKBWQ8fe.mjs";
4
- import { t as PACKAGE_VERSION } from "./version-nFnRm-a3.mjs";
5
- import { APIError, createAuthEndpoint, createAuthMiddleware, getOAuthState, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
3
+ import { A as verifyOAuthQueryParams, C as signedQueryIssuedAtParam, D as toClientDiscoveryArray, E as toAudienceClaim, O as toResourceList, S as searchParamsToQuery, T as storeToken, _ as postLoginClearedParam, a as extractClientCredentials, b as resolveSessionAuthTime, d as isPKCERequired, f as isSessionFreshForSignedQuery, g as parsePrompt, h as parseClientMetadata, i as destructureCredentials, k as validateClientCredentials, l as getSignedQueryIssuedAt, m as normalizeTimestampValue, n as clientAllowsGrant, o as getClient, p as mergeDiscoveryMetadata, r as decryptStoredClientSecret, s as getJwtPlugin, t as checkResource, u as getStoredToken, v as removeMaxAgeFromQuery, w as storeClientSecret, x as resolveSubjectIdentifier, y as removePromptFromQuery } from "./utils-D2dLqo7f.mjs";
4
+ import { t as PACKAGE_VERSION } from "./version-B1ZiRmxj.mjs";
5
+ import { APIError, addOAuthServerContext, createAuthEndpoint, createAuthMiddleware, dispatchAuthEndpoint, getOAuthState, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
6
6
  import { generateCodeChallenge, getJwks, verifyJwsAccessToken } from "better-auth/oauth2";
7
7
  import { APIError as APIError$1 } from "better-call";
8
8
  import { PRIVATE_KEY_JWT_SIGNING_ALGORITHMS } from "@better-auth/core/oauth2";
@@ -19,7 +19,7 @@ import { resolveSigningKey, signJWT, toExpJWT } from "better-auth/plugins";
19
19
  import { SignJWT, base64url, compactVerify, createLocalJWKSet, decodeJwt, decodeProtectedHeader } from "jose";
20
20
  import { SafeUrlSchema } from "@better-auth/core/utils/redirect-uri";
21
21
  //#region src/consent.ts
22
- async function consentEndpoint(ctx, opts) {
22
+ async function consentEndpoint(ctx, opts, authorize) {
23
23
  const oauthRequest = await oAuthState.get();
24
24
  const _query = oauthRequest?.query;
25
25
  if (!_query) throw new APIError("BAD_REQUEST", {
@@ -46,15 +46,11 @@ async function consentEndpoint(ctx, opts) {
46
46
  };
47
47
  const session = await getSessionFromCtx(ctx);
48
48
  const hasLoginPrompt = parsePrompt(query.get("prompt") ?? "").has("login");
49
- const hasSatisfiedLoginPrompt = hasLoginPrompt && sessionSatisfiesLoginPrompt(session?.session.createdAt, oauthRequest?.signedQueryIssuedAt);
49
+ const hasSatisfiedLoginPrompt = hasLoginPrompt && isSessionFreshForSignedQuery(session?.session.createdAt, oauthRequest?.signedQueryIssuedAt);
50
50
  if (hasLoginPrompt && !hasSatisfiedLoginPrompt) {
51
51
  ctx?.headers?.set("accept", "application/json");
52
52
  ctx.query = searchParamsToQuery(query);
53
- const { url } = await authorizeEndpoint(ctx, opts);
54
- return {
55
- redirect: true,
56
- url
57
- };
53
+ return await authorize(ctx);
58
54
  }
59
55
  const referenceId = await opts.postLogin?.consentReferenceId?.({
60
56
  user: session?.user,
@@ -110,32 +106,25 @@ async function consentEndpoint(ctx, opts) {
110
106
  if (requestedScopes) query.set("scope", consent.scopes.join(" "));
111
107
  ctx?.headers?.set("accept", "application/json");
112
108
  let authorizationQuery = removePromptFromQuery(query, "consent");
113
- if (hasSatisfiedLoginPrompt) authorizationQuery = removePromptFromQuery(authorizationQuery, "login");
109
+ if (hasSatisfiedLoginPrompt) {
110
+ authorizationQuery = removePromptFromQuery(authorizationQuery, "login");
111
+ authorizationQuery = removeMaxAgeFromQuery(authorizationQuery);
112
+ }
114
113
  ctx.query = searchParamsToQuery(authorizationQuery);
115
- const { url } = await authorizeEndpoint(ctx, opts, { postLogin: oauthRequest?.postLoginClearedForSession !== void 0 && oauthRequest.postLoginClearedForSession === session?.session.id });
116
- return {
117
- redirect: true,
118
- url
119
- };
120
- }
121
- function sessionSatisfiesLoginPrompt(sessionCreatedAt, signedQueryIssuedAt) {
122
- if (!signedQueryIssuedAt) return false;
123
- const normalized = normalizeTimestampValue(sessionCreatedAt);
124
- if (!normalized) return false;
125
- return normalized.getTime() >= signedQueryIssuedAt.getTime();
114
+ return await authorize(ctx, { postLogin: oauthRequest?.postLoginClearedForSession !== void 0 && oauthRequest.postLoginClearedForSession === session?.session.id });
126
115
  }
127
116
  //#endregion
128
117
  //#region src/continue.ts
129
- async function continueEndpoint(ctx, opts) {
130
- if (ctx.body.selected === true) return await selected(ctx, opts);
131
- else if (ctx.body.created === true) return await created(ctx, opts);
132
- else if (ctx.body.postLogin === true) return await postLogin(ctx, opts);
118
+ async function continueEndpoint(ctx, authorize) {
119
+ if (ctx.body.selected === true) return await selected(ctx, authorize);
120
+ else if (ctx.body.created === true) return await created(ctx, authorize);
121
+ else if (ctx.body.postLogin === true) return await postLogin(ctx, authorize);
133
122
  else throw new APIError("BAD_REQUEST", {
134
123
  error_description: "Missing parameters",
135
124
  error: "invalid_request"
136
125
  });
137
126
  }
138
- async function selected(ctx, opts) {
127
+ async function selected(ctx, authorize) {
139
128
  const _query = (await oAuthState.get())?.query;
140
129
  if (!_query) throw new APIError("BAD_REQUEST", {
141
130
  error_description: "missing oauth query",
@@ -143,13 +132,9 @@ async function selected(ctx, opts) {
143
132
  });
144
133
  ctx.headers?.set("accept", "application/json");
145
134
  ctx.query = searchParamsToQuery(removePromptFromQuery(new URLSearchParams(_query), "select_account"));
146
- const { url } = await authorizeEndpoint(ctx, opts);
147
- return {
148
- redirect: true,
149
- url
150
- };
135
+ return await authorize(ctx);
151
136
  }
152
- async function created(ctx, opts) {
137
+ async function created(ctx, authorize) {
153
138
  const _query = (await oAuthState.get())?.query;
154
139
  if (!_query) throw new APIError("BAD_REQUEST", {
155
140
  error_description: "missing oauth query",
@@ -158,14 +143,11 @@ async function created(ctx, opts) {
158
143
  const query = new URLSearchParams(_query);
159
144
  ctx.headers?.set("accept", "application/json");
160
145
  ctx.query = searchParamsToQuery(removePromptFromQuery(query, "create"));
161
- const { url } = await authorizeEndpoint(ctx, opts);
162
- return {
163
- redirect: true,
164
- url
165
- };
146
+ return await authorize(ctx);
166
147
  }
167
- async function postLogin(ctx, opts) {
168
- const _query = (await oAuthState.get())?.query;
148
+ async function postLogin(ctx, authorize) {
149
+ const state = await oAuthState.get();
150
+ const _query = state?.query;
169
151
  if (!_query) throw new APIError("BAD_REQUEST", {
170
152
  error_description: "missing oauth query",
171
153
  error: "invalid_request"
@@ -173,51 +155,11 @@ async function postLogin(ctx, opts) {
173
155
  const query = new URLSearchParams(_query);
174
156
  ctx.headers?.set("accept", "application/json");
175
157
  ctx.query = searchParamsToQuery(query);
176
- const { url } = await authorizeEndpoint(ctx, opts, { postLogin: true });
177
- return {
178
- redirect: true,
179
- url
180
- };
158
+ const session = await getSessionFromCtx(ctx);
159
+ return await authorize(ctx, { postLogin: state?.postLoginClearedForSession !== void 0 && state.postLoginClearedForSession === session?.session.id });
181
160
  }
182
161
  //#endregion
183
162
  //#region src/types/zod.ts
184
- /**
185
- * Runtime schema for OAuthAuthorizationQuery.
186
- * Uses passthrough to tolerate fields added by future extensions (PAR, FPA, etc.)
187
- */
188
- const oauthAuthorizationQuerySchema = z.object({
189
- response_type: z.literal("code").optional(),
190
- request_uri: z.string().optional(),
191
- redirect_uri: z.string(),
192
- scope: z.string().optional(),
193
- state: z.string().optional(),
194
- client_id: z.string(),
195
- prompt: z.string().optional(),
196
- display: z.string().optional(),
197
- ui_locales: z.string().optional(),
198
- max_age: z.coerce.number().optional(),
199
- acr_values: z.string().optional(),
200
- login_hint: z.string().optional(),
201
- id_token_hint: z.string().optional(),
202
- code_challenge: z.string().optional(),
203
- code_challenge_method: z.literal("S256").optional(),
204
- nonce: z.string().optional(),
205
- resource: z.union([z.string(), z.array(z.string())]).optional()
206
- }).passthrough();
207
- /**
208
- * Runtime schema for the authorization code verification value.
209
- * Validates structure on deserialization from the JSON blob stored in the DB.
210
- * Uses passthrough so future fields (e.g. from authorization challenge) don't break parsing.
211
- */
212
- const verificationValueSchema = z.object({
213
- type: z.literal("authorization_code"),
214
- query: oauthAuthorizationQuerySchema,
215
- sessionId: z.string(),
216
- userId: z.string(),
217
- referenceId: z.string().optional(),
218
- authTime: z.number().optional(),
219
- resource: z.array(z.string()).optional()
220
- }).passthrough();
221
163
  const DANGEROUS_SCHEMES = [
222
164
  "javascript:",
223
165
  "data:",
@@ -251,6 +193,88 @@ const ResourceUriSchema = z.string().superRefine((val, ctx) => {
251
193
  message: "resource cannot use javascript:, data:, or vbscript: scheme"
252
194
  });
253
195
  });
196
+ const authorizationPromptTokenSchema = z.enum([
197
+ "none",
198
+ "consent",
199
+ "login",
200
+ "create",
201
+ "select_account"
202
+ ]);
203
+ const authorizationPromptSchema = z.string().superRefine((value, ctx) => {
204
+ const promptTokens = value.split(" ").map((token) => token.trim()).filter(Boolean);
205
+ const promptSet = /* @__PURE__ */ new Set();
206
+ if (!promptTokens.length) {
207
+ ctx.addIssue({
208
+ code: "custom",
209
+ message: "prompt must include at least one value"
210
+ });
211
+ return;
212
+ }
213
+ for (const token of promptTokens) {
214
+ const result = authorizationPromptTokenSchema.safeParse(token);
215
+ if (!result.success) {
216
+ ctx.addIssue({
217
+ code: "custom",
218
+ message: `unsupported prompt value: ${token}`
219
+ });
220
+ continue;
221
+ }
222
+ promptSet.add(result.data);
223
+ }
224
+ if (promptSet.has("none") && promptSet.size > 1) ctx.addIssue({
225
+ code: "custom",
226
+ message: "prompt=none cannot be combined with other prompt values"
227
+ });
228
+ });
229
+ const maxAgeSchema = z.union([z.number(), z.string().trim().min(1)]).transform((value, ctx) => {
230
+ const maxAge = typeof value === "number" ? value : Number(value);
231
+ if (!Number.isInteger(maxAge) || maxAge < 0) {
232
+ ctx.addIssue({
233
+ code: "custom",
234
+ message: "max_age must be a non-negative integer"
235
+ });
236
+ return z.NEVER;
237
+ }
238
+ return maxAge;
239
+ });
240
+ /**
241
+ * Runtime schema for OAuthAuthorizationQuery.
242
+ * Uses passthrough to tolerate fields added by future extensions (PAR, FPA, etc.)
243
+ */
244
+ const authorizationQuerySchema = z.object({
245
+ response_type: z.string().pipe(z.enum(["code"])).optional(),
246
+ request_uri: z.string().optional(),
247
+ redirect_uri: SafeUrlSchema.optional(),
248
+ scope: z.string().optional(),
249
+ state: z.string().optional(),
250
+ client_id: z.string(),
251
+ prompt: authorizationPromptSchema.optional(),
252
+ display: z.string().optional(),
253
+ ui_locales: z.string().optional(),
254
+ max_age: maxAgeSchema.optional(),
255
+ acr_values: z.string().optional(),
256
+ login_hint: z.string().optional(),
257
+ id_token_hint: z.string().optional(),
258
+ code_challenge: z.string().optional(),
259
+ code_challenge_method: z.string().pipe(z.enum(["S256"])).optional(),
260
+ nonce: z.string().optional(),
261
+ resource: z.union([ResourceUriSchema, z.array(ResourceUriSchema).min(1)]).optional()
262
+ }).passthrough();
263
+ const storedAuthorizationQuerySchema = authorizationQuerySchema.extend({ redirect_uri: SafeUrlSchema });
264
+ /**
265
+ * Runtime schema for the authorization code verification value.
266
+ * Validates structure on deserialization from the JSON blob stored in the DB.
267
+ * Uses passthrough so future fields (e.g. from authorization challenge) don't break parsing.
268
+ */
269
+ const verificationValueSchema = z.object({
270
+ type: z.literal("authorization_code"),
271
+ query: storedAuthorizationQuerySchema,
272
+ sessionId: z.string(),
273
+ userId: z.string(),
274
+ referenceId: z.string().optional(),
275
+ authTime: z.number().optional(),
276
+ resource: z.array(z.string()).optional()
277
+ }).passthrough();
254
278
  //#endregion
255
279
  //#region src/userinfo.ts
256
280
  /**
@@ -287,6 +311,10 @@ async function userInfoEndpoint(ctx, opts) {
287
311
  error: "invalid_request"
288
312
  });
289
313
  const jwt = await validateAccessToken(ctx, opts, token);
314
+ if (!jwt.active) throw new APIError("UNAUTHORIZED", {
315
+ error_description: "the access token is invalid or has been revoked",
316
+ error: "invalid_token"
317
+ });
290
318
  const scopes = jwt.scope?.split(" ");
291
319
  if (!scopes?.includes("openid")) throw new APIError("BAD_REQUEST", {
292
320
  error_description: "Missing required scope",
@@ -396,6 +424,7 @@ async function createIdToken(ctx, opts, user, client, scopes, nonce, sessionId,
396
424
  const resolvedKey = !opts.disableJwtPlugin && !jwtPluginOptions?.jwt?.sign ? await resolveSigningKey(ctx, jwtPluginOptions) : void 0;
397
425
  const signingAlg = opts.disableJwtPlugin ? "HS256" : resolvedKey?.alg ?? jwtPluginOptions?.jwks?.keyPairConfig?.alg;
398
426
  const atHash = accessToken ? await computeOidcHash(accessToken, signingAlg) : void 0;
427
+ const emitSid = Boolean(client.enableEndSession || client.backchannelLogoutUri);
399
428
  const payload = {
400
429
  ...userClaims,
401
430
  auth_time: authTimeSec,
@@ -408,7 +437,7 @@ async function createIdToken(ctx, opts, user, client, scopes, nonce, sessionId,
408
437
  nonce,
409
438
  iat,
410
439
  exp,
411
- sid: client.enableEndSession ? sessionId : void 0
440
+ sid: emitSid ? sessionId : void 0
412
441
  };
413
442
  if (opts.disableJwtPlugin && !client.clientSecret) return;
414
443
  const idToken = opts.disableJwtPlugin ? await new SignJWT(payload).setProtectedHeader({ alg: "HS256" }).sign(new TextEncoder().encode(await decryptStoredClientSecret(ctx, opts.storeClientSecret, client.clientSecret))) : await signJWT(ctx, {
@@ -572,7 +601,7 @@ async function createUserTokens(ctx, opts, params) {
572
601
  error: "invalid_target"
573
602
  });
574
603
  const audience = resourceResult.audience;
575
- const isRefreshToken = user && (existingRefreshToken?.scopes?.includes("offline_access") || scopes.includes("offline_access"));
604
+ const isRefreshToken = user && clientAllowsGrant(client, "refresh_token") && (existingRefreshToken?.scopes?.includes("offline_access") || scopes.includes("offline_access"));
576
605
  const isJwtAccessToken = audience && !opts.disableJwtPlugin;
577
606
  const isIdToken = user && scopes.includes("openid");
578
607
  const customFields = opts.customTokenResponseFields ? await opts.customTokenResponseFields({
@@ -695,7 +724,7 @@ async function handleAuthorizationCodeGrant(ctx, opts) {
695
724
  error: "invalid_scope"
696
725
  });
697
726
  /** Verify Client */
698
- const client = await validateClientCredentials(ctx, opts, client_id, client_secret, scopes, preVerifiedClient);
727
+ const client = await validateClientCredentials(ctx, opts, client_id, client_secret, scopes, preVerifiedClient, "authorization_code");
699
728
  if (isPKCERequired(client, (verificationValue.query?.scope)?.split(" ") || [])) {
700
729
  if (!isAuthCodeWithPkce) throw new APIError("BAD_REQUEST", {
701
730
  error_description: "PKCE is required for this client",
@@ -776,7 +805,7 @@ async function handleClientCredentialsGrant(ctx, opts) {
776
805
  error_description: "Missing a required client_secret",
777
806
  error: "invalid_grant"
778
807
  });
779
- const client = await validateClientCredentials(ctx, opts, client_id, client_secret, void 0, preVerifiedClient);
808
+ const client = await validateClientCredentials(ctx, opts, client_id, client_secret, void 0, preVerifiedClient, "client_credentials");
780
809
  let requestedScopes = scope?.split(" ");
781
810
  if (requestedScopes) {
782
811
  const validScopes = new Set(client.scopes ?? opts.scopes);
@@ -860,7 +889,7 @@ async function handleRefreshTokenGrant(ctx, opts) {
860
889
  error: "invalid_scope"
861
890
  });
862
891
  }
863
- const client = await validateClientCredentials(ctx, opts, client_id, client_secret, requestedScopes ?? scopes, preVerifiedClient);
892
+ const client = await validateClientCredentials(ctx, opts, client_id, client_secret, requestedScopes ?? scopes, preVerifiedClient, "refresh_token");
864
893
  const user = await ctx.context.internalAdapter.findUserById(refreshToken.userId);
865
894
  if (!user) throw new APIError("BAD_REQUEST", {
866
895
  error_description: "user not found",
@@ -919,12 +948,10 @@ async function validateJwtAccessToken(ctx, opts, token, clientId) {
919
948
  }
920
949
  throw new Error(error);
921
950
  }
922
- let client;
923
- if (jwtPayload.azp) {
924
- client = await getClient(ctx, opts, jwtPayload.azp);
925
- if (!client || client?.disabled) return { active: false };
926
- if (clientId && jwtPayload.azp !== clientId) return { active: false };
927
- }
951
+ if (!jwtPayload.azp) return { active: false };
952
+ const client = await getClient(ctx, opts, jwtPayload.azp);
953
+ if (!client || client?.disabled) return { active: false };
954
+ if (clientId && jwtPayload.azp !== clientId) return { active: false };
928
955
  const sessionId = jwtPayload.sid;
929
956
  if (sessionId) {
930
957
  const session = await ctx.context.adapter.findOne({
@@ -934,9 +961,9 @@ async function validateJwtAccessToken(ctx, opts, token, clientId) {
934
961
  value: sessionId
935
962
  }]
936
963
  });
937
- if (!session || session.expiresAt < /* @__PURE__ */ new Date()) jwtPayload.sid = void 0;
964
+ if (!session || session.expiresAt < /* @__PURE__ */ new Date()) return { active: false };
938
965
  }
939
- if (jwtPayload.azp) jwtPayload.client_id = jwtPayload.azp;
966
+ jwtPayload.client_id = jwtPayload.azp;
940
967
  jwtPayload.active = true;
941
968
  return jwtPayload;
942
969
  }
@@ -964,13 +991,14 @@ async function validateOpaqueAccessToken(ctx, opts, token, clientId) {
964
991
  error: "invalid_token"
965
992
  });
966
993
  if (!accessToken.expiresAt || accessToken.expiresAt < /* @__PURE__ */ new Date()) return { active: false };
994
+ if (accessToken.revoked) return { active: false };
967
995
  let client;
968
996
  if (accessToken.clientId) {
969
997
  client = await getClient(ctx, opts, accessToken.clientId);
970
998
  if (!client || client?.disabled) return { active: false };
971
999
  if (clientId && accessToken.clientId !== clientId) return { active: false };
972
1000
  }
973
- let sessionId = accessToken.sessionId ?? void 0;
1001
+ const sessionId = accessToken.sessionId ?? void 0;
974
1002
  if (sessionId) {
975
1003
  const session = await ctx.context.adapter.findOne({
976
1004
  model: "session",
@@ -979,7 +1007,7 @@ async function validateOpaqueAccessToken(ctx, opts, token, clientId) {
979
1007
  value: sessionId
980
1008
  }]
981
1009
  });
982
- if (!session || session.expiresAt < /* @__PURE__ */ new Date()) sessionId = void 0;
1010
+ if (!session || session.expiresAt < /* @__PURE__ */ new Date()) return { active: false };
983
1011
  }
984
1012
  let user;
985
1013
  if (accessToken.userId) user = await ctx.context.internalAdapter.findUserById(accessToken?.userId);
@@ -1145,9 +1173,173 @@ async function introspectEndpoint(ctx, opts) {
1145
1173
  }
1146
1174
  //#endregion
1147
1175
  //#region src/logout.ts
1176
+ const BACKCHANNEL_LOGOUT_EVENT_URI = "http://schemas.openid.net/event/backchannel-logout";
1177
+ const LOGOUT_TOKEN_JWT_TYP = "logout+jwt";
1178
+ const LOGOUT_TOKEN_LIFETIME_SECONDS = 120;
1179
+ const BACKCHANNEL_DISPATCH_TIMEOUT_MS = 5e3;
1148
1180
  /**
1149
- * IMPORTANT NOTES:
1150
- * Follows OIDC RP-Initiated Logout
1181
+ * Signs a Back-Channel Logout Token per OIDC Back-Channel Logout 1.0 §2.4.
1182
+ *
1183
+ * The token reuses the ID Token signing key so any RP that validates ID Tokens
1184
+ * from this OP can validate Logout Tokens without extra configuration. The
1185
+ * caller resolves that key once and passes it in so a fan-out to many RPs does
1186
+ * not re-read it per target.
1187
+ *
1188
+ * §2.4 mandates `iss`, `aud`, `iat`, `exp`, `jti`, `events`, and at least one
1189
+ * of `sub` / `sid` (we send both). A `nonce` claim MUST NOT be present, and
1190
+ * `alg: none` is forbidden (§2.6).
1191
+ */
1192
+ async function signLogoutToken(ctx, options, resolvedKey, claims) {
1193
+ return signJWT(ctx, {
1194
+ options,
1195
+ payload: {
1196
+ ...claims,
1197
+ events: { [BACKCHANNEL_LOGOUT_EVENT_URI]: {} }
1198
+ },
1199
+ header: { typ: LOGOUT_TOKEN_JWT_TYP },
1200
+ resolvedKey: resolvedKey ?? void 0
1201
+ });
1202
+ }
1203
+ /**
1204
+ * Synchronous phase: enumerate tokens for the session being terminated, revoke
1205
+ * them, and return a plan for the asynchronous delivery phase. Runs inline in
1206
+ * the `session.delete.before` hook so the DB state is consistent before the
1207
+ * session row disappears.
1208
+ *
1209
+ * Revocation is the stored backstop, not the primary enforcement: introspection
1210
+ * and `/userinfo` already treat a token whose session has ended as inactive
1211
+ * (see `validateOpaqueAccessToken` / `validateJwtAccessToken`), so a missed
1212
+ * `revoked` write cannot keep a session-bound token alive on its own. Access
1213
+ * tokens bound to the session are revoked as OP hardening. Refresh tokens
1214
+ * follow OIDC Back-Channel Logout 1.0 §2.7: those without `offline_access` are
1215
+ * revoked; `offline_access` refresh tokens survive so long-lived API access can
1216
+ * outlive the browser session.
1217
+ *
1218
+ * Revocation runs regardless of the JWT plugin (refresh-token revocation has no
1219
+ * dependency on signing). Only the Logout Token delivery plan needs the JWT
1220
+ * plugin, so when it is disabled we still revoke but never build a plan.
1221
+ *
1222
+ * Returns `null` when there is nothing to do, so the caller can skip the
1223
+ * background handoff entirely.
1224
+ */
1225
+ async function revokeAndPlanBackchannelLogout(ctx, opts, input) {
1226
+ const { sessionId, userId } = input;
1227
+ if (!userId) return null;
1228
+ const logger = ctx.context.logger;
1229
+ try {
1230
+ const where = [{
1231
+ field: "sessionId",
1232
+ value: sessionId
1233
+ }];
1234
+ const [accessTokens, refreshTokens] = await Promise.all([ctx.context.adapter.findMany({
1235
+ model: "oauthAccessToken",
1236
+ where
1237
+ }), ctx.context.adapter.findMany({
1238
+ model: "oauthRefreshToken",
1239
+ where
1240
+ })]);
1241
+ const affectedClientIds = /* @__PURE__ */ new Set();
1242
+ for (const t of accessTokens) affectedClientIds.add(t.clientId);
1243
+ for (const t of refreshTokens) affectedClientIds.add(t.clientId);
1244
+ if (affectedClientIds.size === 0) return null;
1245
+ const clients = await ctx.context.adapter.findMany({
1246
+ model: "oauthClient",
1247
+ where: [{
1248
+ field: "clientId",
1249
+ operator: "in",
1250
+ value: Array.from(affectedClientIds)
1251
+ }]
1252
+ });
1253
+ const revokedAt = /* @__PURE__ */ new Date();
1254
+ const accessToRevokeIds = accessTokens.filter((t) => !t.revoked).map((t) => t.id);
1255
+ const refreshToRevokeIds = refreshTokens.filter((t) => !t.revoked && !t.scopes?.includes("offline_access")).map((t) => t.id);
1256
+ const revocations = await Promise.allSettled([accessToRevokeIds.length > 0 ? ctx.context.adapter.updateMany({
1257
+ model: "oauthAccessToken",
1258
+ where: [{
1259
+ field: "id",
1260
+ operator: "in",
1261
+ value: accessToRevokeIds
1262
+ }],
1263
+ update: { revoked: revokedAt }
1264
+ }) : Promise.resolve(), refreshToRevokeIds.length > 0 ? ctx.context.adapter.updateMany({
1265
+ model: "oauthRefreshToken",
1266
+ where: [{
1267
+ field: "id",
1268
+ operator: "in",
1269
+ value: refreshToRevokeIds
1270
+ }],
1271
+ update: { revoked: revokedAt }
1272
+ }) : Promise.resolve()]);
1273
+ for (const result of revocations) if (result.status === "rejected") logger.error("back-channel logout: token revocation update failed", result.reason);
1274
+ const eligibleClients = opts.disableJwtPlugin ? [] : clients.filter((c) => Boolean(c.backchannelLogoutUri) && !c.disabled);
1275
+ if (eligibleClients.length === 0) return null;
1276
+ return {
1277
+ sessionId,
1278
+ targets: await Promise.all(eligibleClients.map(async (client) => ({
1279
+ client,
1280
+ sub: await resolveSubjectIdentifier(userId, client, opts)
1281
+ })))
1282
+ };
1283
+ } catch (error) {
1284
+ logger.error("back-channel logout revocation failed", error);
1285
+ return null;
1286
+ }
1287
+ }
1288
+ /**
1289
+ * Asynchronous phase: sign one Logout Token per target client and POST it to
1290
+ * the registered `backchannel_logout_uri`. The caller hands this to
1291
+ * `runInBackgroundOrAwait`, so when a background handler is configured (Vercel
1292
+ * `waitUntil`, Cloudflare `ctx.waitUntil`) it runs after the response; without
1293
+ * one it completes inline so delivery is not lost on request teardown.
1294
+ *
1295
+ * Spec §2.5: "the OP SHOULD NOT retransmit", so each RP gets a single attempt
1296
+ * within `BACKCHANNEL_DISPATCH_TIMEOUT_MS`. Every per-client failure (fetch
1297
+ * error, non-2xx response, signing error, subject resolution error) is
1298
+ * logged; none of them can reject the outer promise.
1299
+ */
1300
+ async function deliverBackchannelLogoutTokens(ctx, plan) {
1301
+ const logger = ctx.context.logger;
1302
+ const jwtPluginOptions = getJwtPlugin(ctx.context)?.options;
1303
+ const iss = jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL;
1304
+ const iat = Math.floor(Date.now() / 1e3);
1305
+ const exp = iat + LOGOUT_TOKEN_LIFETIME_SECONDS;
1306
+ const resolvedKey = jwtPluginOptions?.jwt?.sign ? null : await resolveSigningKey(ctx, jwtPluginOptions);
1307
+ await Promise.allSettled(plan.targets.map(async ({ client, sub }) => {
1308
+ try {
1309
+ const jti = generateRandomString(32, "a-z", "A-Z", "0-9");
1310
+ const token = await signLogoutToken(ctx, jwtPluginOptions, resolvedKey, {
1311
+ iss,
1312
+ aud: client.clientId,
1313
+ sub,
1314
+ sid: plan.sessionId,
1315
+ iat,
1316
+ exp,
1317
+ jti
1318
+ });
1319
+ const response = await fetch(client.backchannelLogoutUri, {
1320
+ method: "POST",
1321
+ headers: {
1322
+ "Content-Type": "application/x-www-form-urlencoded",
1323
+ Accept: "application/json"
1324
+ },
1325
+ body: new URLSearchParams({ logout_token: token }),
1326
+ signal: AbortSignal.timeout(BACKCHANNEL_DISPATCH_TIMEOUT_MS),
1327
+ redirect: "error"
1328
+ });
1329
+ if (response.status !== 200 && response.status !== 204) logger.warn(`back-channel logout to client ${client.clientId} returned ${response.status}`);
1330
+ } catch (error) {
1331
+ logger.warn(`back-channel logout to client ${client.clientId} failed`, error);
1332
+ }
1333
+ }));
1334
+ }
1335
+ /**
1336
+ * RP-Initiated Logout (OIDC RP-Initiated Logout 1.0). The RP presents a signed
1337
+ * `id_token_hint`; after verification, the OP terminates the matching session
1338
+ * and optionally redirects to `post_logout_redirect_uri`.
1339
+ *
1340
+ * Session termination goes through `internalAdapter.deleteSession`, which fires
1341
+ * `session.delete.before` so the hook drives revocation and back-channel
1342
+ * notifications to every RP with tokens on the session.
1151
1343
  *
1152
1344
  * @see https://openid.net/specs/openid-connect-rpinitiated-1_0.html
1153
1345
  */
@@ -1195,12 +1387,10 @@ async function rpInitiatedLogoutEndpoint(ctx, opts) {
1195
1387
  });
1196
1388
  const secret = await decryptStoredClientSecret(ctx, opts.storeClientSecret, clientSecret);
1197
1389
  const { payload } = await compactVerify(id_token_hint, new TextEncoder().encode(secret));
1198
- const idToken = new TextDecoder().decode(payload);
1199
- idTokenPayload = JSON.parse(idToken);
1390
+ idTokenPayload = JSON.parse(new TextDecoder().decode(payload));
1200
1391
  } else {
1201
1392
  const { payload } = await compactVerify(id_token_hint, createLocalJWKSet(await getJwks(id_token_hint, { jwksFetch: jwksUrl })));
1202
- const idToken = new TextDecoder().decode(payload);
1203
- idTokenPayload = JSON.parse(idToken);
1393
+ idTokenPayload = JSON.parse(new TextDecoder().decode(payload));
1204
1394
  }
1205
1395
  if (!idTokenPayload) throw new APIError$1("INTERNAL_SERVER_ERROR", {
1206
1396
  error_description: "missing payload",
@@ -1232,18 +1422,13 @@ async function rpInitiatedLogoutEndpoint(ctx, opts) {
1232
1422
  value: sessionId
1233
1423
  }]
1234
1424
  });
1235
- session?.token ? await ctx.context.internalAdapter.deleteSession(session?.token) : session?.id ? await ctx.context.adapter.delete({
1425
+ if (session?.token) await ctx.context.internalAdapter.deleteSession(session.token);
1426
+ else if (session) await ctx.context.adapter.delete({
1236
1427
  model: "session",
1237
1428
  where: [{
1238
1429
  field: "id",
1239
1430
  value: session.id
1240
1431
  }]
1241
- }) : await ctx.context.adapter.delete({
1242
- model: "session",
1243
- where: [{
1244
- field: "id",
1245
- value: sessionId
1246
- }]
1247
1432
  });
1248
1433
  } catch {}
1249
1434
  if (post_logout_redirect_uri) {
@@ -1518,6 +1703,38 @@ async function checkOAuthClient(client, opts, settings) {
1518
1703
  error: "invalid_client_metadata",
1519
1704
  error_description: "jwks and jwks_uri are only allowed with private_key_jwt authentication"
1520
1705
  });
1706
+ if (client.backchannel_logout_uri !== void 0) {
1707
+ if (opts.disableJwtPlugin) throw new APIError("BAD_REQUEST", {
1708
+ error: "invalid_client_metadata",
1709
+ error_description: "backchannel_logout_uri requires the jwt plugin (disableJwtPlugin must be false)"
1710
+ });
1711
+ let url;
1712
+ try {
1713
+ url = new URL(client.backchannel_logout_uri);
1714
+ } catch {
1715
+ throw new APIError("BAD_REQUEST", {
1716
+ error: "invalid_client_metadata",
1717
+ error_description: "backchannel_logout_uri must be an absolute URL"
1718
+ });
1719
+ }
1720
+ if (url.protocol !== "https:" && url.protocol !== "http:") throw new APIError("BAD_REQUEST", {
1721
+ error: "invalid_client_metadata",
1722
+ error_description: "backchannel_logout_uri must use http or https"
1723
+ });
1724
+ if (client.backchannel_logout_uri.includes("#")) throw new APIError("BAD_REQUEST", {
1725
+ error: "invalid_client_metadata",
1726
+ error_description: "backchannel_logout_uri must not include a fragment component"
1727
+ });
1728
+ const loopback = isLoopbackHost(url.hostname);
1729
+ if (!isPublic && url.protocol !== "https:" && !loopback) throw new APIError("BAD_REQUEST", {
1730
+ error: "invalid_client_metadata",
1731
+ error_description: "backchannel_logout_uri must use https for confidential clients"
1732
+ });
1733
+ if (isPrivateHostname(url.hostname) && !loopback) throw new APIError("BAD_REQUEST", {
1734
+ error: "invalid_client_metadata",
1735
+ error_description: "backchannel_logout_uri must not point to a private or reserved address"
1736
+ });
1737
+ }
1521
1738
  }
1522
1739
  async function createOAuthClientEndpoint(ctx, opts, settings) {
1523
1740
  const body = ctx.body;
@@ -1576,7 +1793,7 @@ async function createOAuthClientEndpoint(ctx, opts, settings) {
1576
1793
  * @returns
1577
1794
  */
1578
1795
  function oauthToSchema(input) {
1579
- 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, token_endpoint_auth_method: tokenEndpointAuthMethod, grant_types: grantTypes, response_types: responseTypes, public: _public, type, disabled, skip_consent: skipConsent, enable_end_session: enableEndSession, require_pkce: requirePKCE, subject_type: subjectType, reference_id: referenceId, metadata: inputMetadata, ...rest } = input;
1796
+ 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, public: _public, type, disabled, skip_consent: skipConsent, enable_end_session: enableEndSession, require_pkce: requirePKCE, subject_type: subjectType, reference_id: referenceId, metadata: inputMetadata, ...rest } = input;
1580
1797
  const expiresAt = _expiresAt ? /* @__PURE__ */ new Date(_expiresAt * 1e3) : void 0;
1581
1798
  const createdAt = _createdAt ? /* @__PURE__ */ new Date(_createdAt * 1e3) : void 0;
1582
1799
  const scopes = _scope?.split(" ");
@@ -1604,6 +1821,8 @@ function oauthToSchema(input) {
1604
1821
  softwareStatement,
1605
1822
  redirectUris,
1606
1823
  postLogoutRedirectUris,
1824
+ backchannelLogoutUri,
1825
+ backchannelLogoutSessionRequired,
1607
1826
  tokenEndpointAuthMethod,
1608
1827
  grantTypes,
1609
1828
  responseTypes,
@@ -1626,7 +1845,7 @@ function oauthToSchema(input) {
1626
1845
  * @returns
1627
1846
  */
1628
1847
  function schemaToOAuth(input) {
1629
- const { clientId, clientSecret, disabled, scopes, userId, createdAt, updatedAt: _updatedAt, expiresAt, name, uri, icon, contacts, tos, policy, softwareId, softwareVersion, softwareStatement, redirectUris, postLogoutRedirectUris, tokenEndpointAuthMethod, grantTypes, responseTypes, public: _public, type, jwks, jwksUri, skipConsent, enableEndSession, requirePKCE, subjectType, referenceId, metadata } = input;
1848
+ 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, public: _public, type, jwks, jwksUri, skipConsent, enableEndSession, requirePKCE, subjectType, referenceId, metadata } = input;
1630
1849
  const _expiresAt = expiresAt ? Math.round(new Date(expiresAt).getTime() / 1e3) : void 0;
1631
1850
  const _createdAt = createdAt ? Math.round(new Date(createdAt).getTime() / 1e3) : void 0;
1632
1851
  const _scopes = scopes?.join(" ");
@@ -1651,6 +1870,8 @@ function schemaToOAuth(input) {
1651
1870
  software_statement: softwareStatement ?? void 0,
1652
1871
  redirect_uris: redirectUris ?? [],
1653
1872
  post_logout_redirect_uris: postLogoutRedirectUris ?? void 0,
1873
+ backchannel_logout_uri: backchannelLogoutUri ?? void 0,
1874
+ backchannel_logout_session_required: backchannelLogoutSessionRequired ?? void 0,
1654
1875
  token_endpoint_auth_method: tokenEndpointAuthMethod ?? void 0,
1655
1876
  grant_types: grantTypes ?? void 0,
1656
1877
  response_types: responseTypes ?? void 0,
@@ -1887,6 +2108,8 @@ const adminCreateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/creat
1887
2108
  software_version: z.string().optional(),
1888
2109
  software_statement: z.string().optional(),
1889
2110
  post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
2111
+ backchannel_logout_uri: SafeUrlSchema.optional(),
2112
+ backchannel_logout_session_required: z.boolean().optional(),
1890
2113
  token_endpoint_auth_method: z.enum([
1891
2114
  "none",
1892
2115
  "client_secret_basic",
@@ -2073,6 +2296,8 @@ const createOAuthClient = (opts) => createAuthEndpoint("/oauth2/create-client",
2073
2296
  software_version: z.string().optional(),
2074
2297
  software_statement: z.string().optional(),
2075
2298
  post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
2299
+ backchannel_logout_uri: SafeUrlSchema.optional(),
2300
+ backchannel_logout_session_required: z.boolean().optional(),
2076
2301
  token_endpoint_auth_method: z.enum([
2077
2302
  "none",
2078
2303
  "client_secret_basic",
@@ -2282,6 +2507,8 @@ const adminUpdateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/updat
2282
2507
  software_version: z.string().optional(),
2283
2508
  software_statement: z.string().optional(),
2284
2509
  post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
2510
+ backchannel_logout_uri: SafeUrlSchema.optional(),
2511
+ backchannel_logout_session_required: z.boolean().optional(),
2285
2512
  grant_types: z.array(z.enum([
2286
2513
  "authorization_code",
2287
2514
  "client_credentials",
@@ -2324,6 +2551,8 @@ const updateOAuthClient = (opts) => createAuthEndpoint("/oauth2/update-client",
2324
2551
  software_version: z.string().optional(),
2325
2552
  software_statement: z.string().optional(),
2326
2553
  post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
2554
+ backchannel_logout_uri: SafeUrlSchema.optional(),
2555
+ backchannel_logout_session_required: z.boolean().optional(),
2327
2556
  grant_types: z.array(z.enum([
2328
2557
  "authorization_code",
2329
2558
  "client_credentials",
@@ -2503,7 +2732,14 @@ const deleteOAuthConsent = (opts) => createAuthEndpoint("/oauth2/delete-consent"
2503
2732
  */
2504
2733
  /**
2505
2734
  * Revokes a JWT access token against the configured JWKs.
2506
- * (does nothing if successful since a JWT is not stored on the server)
2735
+ *
2736
+ * A JWT access token is self-contained and never stored, so there is nothing to
2737
+ * delete. Once the token is confirmed to be a valid JWT for this server, the
2738
+ * endpoint reports `unsupported_token_type` (RFC 7009 §2.2.1) instead of a
2739
+ * silent success, so callers can tell that no server-side revocation happened.
2740
+ * An expired or wrong-audience JWT is already inactive and still resolves as a
2741
+ * successful no-op. Session-bound tokens (carrying `sid`) are cut off early by
2742
+ * the session-liveness check in introspection and userinfo.
2507
2743
  */
2508
2744
  async function revokeJwtAccessToken(ctx, opts, token) {
2509
2745
  const jwtPlugin = opts.disableJwtPlugin ? void 0 : getJwtPlugin(ctx.context);
@@ -2530,6 +2766,10 @@ async function revokeJwtAccessToken(ctx, opts, token) {
2530
2766
  }
2531
2767
  throw new Error(error);
2532
2768
  }
2769
+ throw new APIError$1("BAD_REQUEST", {
2770
+ error_description: "JWT access tokens are self-contained and cannot be revoked server-side",
2771
+ error: "unsupported_token_type"
2772
+ });
2533
2773
  }
2534
2774
  /**
2535
2775
  * Searches for an opaque access token in the database and validates it
@@ -2625,7 +2865,9 @@ async function revokeAccessToken(ctx, opts, clientId, token) {
2625
2865
  try {
2626
2866
  return await revokeJwtAccessToken(ctx, opts, token);
2627
2867
  } catch (err) {
2628
- if (err instanceof APIError$1) {} else if (err instanceof Error) throw err;
2868
+ if (err instanceof APIError$1) {
2869
+ if (err.body?.error === "unsupported_token_type") throw err;
2870
+ } else if (err instanceof Error) throw err;
2629
2871
  else throw new Error(err);
2630
2872
  }
2631
2873
  try {
@@ -2658,7 +2900,7 @@ async function revokeEndpoint(ctx, opts) {
2658
2900
  return await revokeAccessToken(ctx, opts, client.clientId, token);
2659
2901
  } catch (error) {
2660
2902
  if (error instanceof APIError$1) {
2661
- if (token_type_hint === "access_token") throw error;
2903
+ if (token_type_hint === "access_token" || error.body?.error === "unsupported_token_type") throw error;
2662
2904
  } else if (error instanceof Error) throw error;
2663
2905
  else throw new Error(error);
2664
2906
  }
@@ -2676,6 +2918,7 @@ async function revokeEndpoint(ctx, opts) {
2676
2918
  });
2677
2919
  } catch (error) {
2678
2920
  if (error instanceof APIError$1) {
2921
+ if (error.body?.error === "unsupported_token_type") throw error;
2679
2922
  if (error.name === "BAD_REQUEST") return null;
2680
2923
  throw error;
2681
2924
  } else if (error instanceof Error) {
@@ -2784,6 +3027,14 @@ const schema = {
2784
3027
  type: "string[]",
2785
3028
  required: false
2786
3029
  },
3030
+ backchannelLogoutUri: {
3031
+ type: "string",
3032
+ required: false
3033
+ },
3034
+ backchannelLogoutSessionRequired: {
3035
+ type: "boolean",
3036
+ required: false
3037
+ },
2787
3038
  tokenEndpointAuthMethod: {
2788
3039
  type: "string",
2789
3040
  required: false
@@ -2937,6 +3188,10 @@ const schema = {
2937
3188
  },
2938
3189
  expiresAt: { type: "date" },
2939
3190
  createdAt: { type: "date" },
3191
+ revoked: {
3192
+ type: "date",
3193
+ required: false
3194
+ },
2940
3195
  scopes: {
2941
3196
  type: "string[]",
2942
3197
  required: true
@@ -2979,12 +3234,25 @@ const schema = {
2979
3234
  createdAt: { type: "date" },
2980
3235
  updatedAt: { type: "date" }
2981
3236
  }
3237
+ },
3238
+ oauthClientAssertion: {
3239
+ modelName: "oauthClientAssertion",
3240
+ fields: { expiresAt: {
3241
+ type: "date",
3242
+ required: true
3243
+ } }
2982
3244
  }
2983
3245
  };
2984
3246
  //#endregion
2985
3247
  //#region src/oauth.ts
2986
3248
  const oAuthState = defineRequestState(() => null);
2987
3249
  const getOAuthProviderState = oAuthState.get;
3250
+ const signedQueryIssuedAtMsKey = "signedQueryIssuedAtMs";
3251
+ function getServerContextSignedQueryIssuedAt(value) {
3252
+ const issuedAtMs = typeof value === "number" ? value : typeof value === "string" ? Number(value) : void 0;
3253
+ if (!issuedAtMs || !Number.isFinite(issuedAtMs) || issuedAtMs <= 0) return;
3254
+ return new Date(issuedAtMs);
3255
+ }
2988
3256
  /**
2989
3257
  * oAuth 2.1 provider plugin for Better Auth.
2990
3258
  *
@@ -3093,6 +3361,146 @@ const oauthProvider = (options) => {
3093
3361
  }
3094
3362
  if (isOpenIdConfigRequest) return { response: createMetadataResponse(oidcServerMetadata(endpointCtx, opts)) };
3095
3363
  };
3364
+ const oauth2AuthorizeEndpoint = createOAuthEndpoint("/oauth2/authorize", {
3365
+ method: "GET",
3366
+ query: authorizationQuerySchema,
3367
+ redirectOnError: authorizeRedirectOnError(opts),
3368
+ errorCodesByField: {
3369
+ response_type: { invalid: "unsupported_response_type" },
3370
+ resource: { invalid: "invalid_target" }
3371
+ },
3372
+ metadata: { openapi: {
3373
+ description: "Authorize an OAuth2 request",
3374
+ parameters: [
3375
+ {
3376
+ name: "response_type",
3377
+ in: "query",
3378
+ required: false,
3379
+ schema: { type: "string" },
3380
+ description: "OAuth2 response type (e.g., 'code')"
3381
+ },
3382
+ {
3383
+ name: "client_id",
3384
+ in: "query",
3385
+ required: true,
3386
+ schema: { type: "string" },
3387
+ description: "OAuth2 client ID"
3388
+ },
3389
+ {
3390
+ name: "redirect_uri",
3391
+ in: "query",
3392
+ required: false,
3393
+ schema: {
3394
+ type: "string",
3395
+ format: "uri"
3396
+ },
3397
+ description: "OAuth2 redirect URI"
3398
+ },
3399
+ {
3400
+ name: "scope",
3401
+ in: "query",
3402
+ required: false,
3403
+ schema: { type: "string" },
3404
+ description: "OAuth2 scopes (space-separated)"
3405
+ },
3406
+ {
3407
+ name: "state",
3408
+ in: "query",
3409
+ required: false,
3410
+ schema: { type: "string" },
3411
+ description: "OAuth2 state parameter"
3412
+ },
3413
+ {
3414
+ name: "request_uri",
3415
+ in: "query",
3416
+ required: false,
3417
+ schema: { type: "string" },
3418
+ description: "Pushed Authorization Request URI referencing stored parameters"
3419
+ },
3420
+ {
3421
+ name: "code_challenge",
3422
+ in: "query",
3423
+ required: false,
3424
+ schema: { type: "string" },
3425
+ description: "PKCE code challenge"
3426
+ },
3427
+ {
3428
+ name: "code_challenge_method",
3429
+ in: "query",
3430
+ required: false,
3431
+ schema: { type: "string" },
3432
+ description: "PKCE code challenge method"
3433
+ },
3434
+ {
3435
+ name: "nonce",
3436
+ in: "query",
3437
+ required: false,
3438
+ schema: { type: "string" },
3439
+ description: "OpenID Connect nonce"
3440
+ },
3441
+ {
3442
+ name: "max_age",
3443
+ in: "query",
3444
+ required: false,
3445
+ schema: {
3446
+ type: "integer",
3447
+ minimum: 0
3448
+ },
3449
+ description: "Maximum authentication age in seconds; forces re-authentication when exceeded"
3450
+ },
3451
+ {
3452
+ name: "resource",
3453
+ in: "query",
3454
+ required: false,
3455
+ schema: {
3456
+ type: "array",
3457
+ items: { type: "string" }
3458
+ },
3459
+ description: "Requested token resource(s) (ie audience) to obtain a JWT formatted access token. May be supplied multiple times as repeated 'resource' query parameters (RFC 8707) or as an array of strings."
3460
+ },
3461
+ {
3462
+ name: "prompt",
3463
+ in: "query",
3464
+ required: false,
3465
+ schema: { type: "string" },
3466
+ description: "OAuth2 prompt parameter"
3467
+ }
3468
+ ],
3469
+ responses: {
3470
+ "302": {
3471
+ description: "Redirect to client with code or error",
3472
+ headers: { Location: {
3473
+ description: "Redirect URI with code or error",
3474
+ schema: {
3475
+ type: "string",
3476
+ format: "uri"
3477
+ }
3478
+ } }
3479
+ },
3480
+ "400": {
3481
+ description: "Invalid request",
3482
+ content: { "application/json": { schema: {
3483
+ type: "object",
3484
+ properties: {
3485
+ error: { type: "string" },
3486
+ error_description: { type: "string" },
3487
+ state: { type: "string" }
3488
+ },
3489
+ required: ["error"]
3490
+ } } }
3491
+ }
3492
+ }
3493
+ } }
3494
+ }, async (ctx) => {
3495
+ return authorizeEndpoint(ctx, opts, ctx.authorizeSettings ?? { isAuthorize: true });
3496
+ });
3497
+ const runOAuth2Authorize = (ctx, settings) => dispatchAuthEndpoint(oauth2AuthorizeEndpoint, {
3498
+ ...ctx,
3499
+ asResponse: false,
3500
+ returnHeaders: false,
3501
+ returnStatus: false,
3502
+ authorizeSettings: settings ?? {}
3503
+ });
3096
3504
  return {
3097
3505
  id: "oauth-provider",
3098
3506
  version: PACKAGE_VERSION,
@@ -3108,12 +3516,20 @@ const oauthProvider = (options) => {
3108
3516
  try {
3109
3517
  issuerPath = new URL(issuer).pathname;
3110
3518
  } catch (error) {
3111
- if (isDynamicBaseURLInit && issuer === "") return;
3112
- throw error;
3519
+ if (!isDynamicBaseURLInit || issuer !== "") throw error;
3113
3520
  }
3114
- if (!opts.silenceWarnings?.oauthAuthServerConfig && !(ctx.options.basePath === "/" && issuerPath === "/")) logger.warn(`Please ensure '/.well-known/oauth-authorization-server${issuerPath === "/" ? "" : issuerPath}' exists. Upon completion, clear with silenceWarnings.oauthAuthServerConfig.`);
3115
- if (!opts.silenceWarnings?.openidConfig && ctx.options.basePath !== issuerPath && opts.scopes?.includes("openid")) logger.warn(`Please ensure '${issuerPath}${issuerPath.endsWith("/") ? "" : "/"}.well-known/openid-configuration' exists. Upon completion, clear with silenceWarnings.openidConfig.`);
3521
+ if (issuerPath !== void 0 && !opts.silenceWarnings?.oauthAuthServerConfig && !(ctx.options.basePath === "/" && issuerPath === "/")) logger.warn(`Please ensure '/.well-known/oauth-authorization-server${issuerPath === "/" ? "" : issuerPath}' exists. Upon completion, clear with silenceWarnings.oauthAuthServerConfig.`);
3522
+ if (issuerPath !== void 0 && !opts.silenceWarnings?.openidConfig && ctx.options.basePath !== issuerPath && opts.scopes?.includes("openid")) logger.warn(`Please ensure '${issuerPath}${issuerPath.endsWith("/") ? "" : "/"}.well-known/openid-configuration' exists. Upon completion, clear with silenceWarnings.openidConfig.`);
3116
3523
  }
3524
+ return { options: { databaseHooks: { session: { delete: { async before(session, hookCtx) {
3525
+ if (!hookCtx) return;
3526
+ const plan = await revokeAndPlanBackchannelLogout(hookCtx, opts, {
3527
+ sessionId: session.id,
3528
+ userId: session.userId
3529
+ });
3530
+ if (!plan) return;
3531
+ await hookCtx.context.runInBackgroundOrAwait(deliverBackchannelLogoutTokens(hookCtx, plan));
3532
+ } } } } } };
3117
3533
  },
3118
3534
  hooks: {
3119
3535
  before: [{
@@ -3135,11 +3551,10 @@ const oauthProvider = (options) => {
3135
3551
  signedQueryIssuedAt: signedQueryIssuedAt ?? void 0,
3136
3552
  postLoginClearedForSession
3137
3553
  });
3138
- if (ctx.path === "/sign-in/social") {
3139
- if (ctx.body.additionalData?.query) return;
3140
- if (!ctx.body.additionalData) ctx.body.additionalData = {};
3141
- ctx.body.additionalData.query = queryParams.toString();
3142
- }
3554
+ if (ctx.path === "/sign-in/social") await addOAuthServerContext({
3555
+ query: queryParams.toString(),
3556
+ ...signedQueryIssuedAt ? { [signedQueryIssuedAtMsKey]: signedQueryIssuedAt.getTime() } : {}
3557
+ });
3143
3558
  })
3144
3559
  }],
3145
3560
  after: [{
@@ -3149,7 +3564,9 @@ const oauthProvider = (options) => {
3149
3564
  handler: createAuthMiddleware(async (ctx) => {
3150
3565
  const sessionToken = parseSetCookieHeader(ctx.context.responseHeaders?.get("set-cookie") || "").get(ctx.context.authCookies.sessionToken.name)?.value.split(".")[0];
3151
3566
  if (!sessionToken) return;
3152
- const _query = (await oAuthState.get())?.query ?? (await getOAuthState())?.query;
3567
+ const oauthRequest = await oAuthState.get();
3568
+ const serverContext = (await getOAuthState())?.serverContext;
3569
+ const _query = oauthRequest?.query ?? serverContext?.query;
3153
3570
  if (!_query) return;
3154
3571
  const query = new URLSearchParams(_query);
3155
3572
  const session = await ctx.context.internalAdapter.findSession(sessionToken);
@@ -3158,8 +3575,11 @@ const oauthProvider = (options) => {
3158
3575
  const secFetchMode = ctx.request?.headers?.get("sec-fetch-mode")?.toLowerCase();
3159
3576
  const acceptHeader = ctx.request?.headers?.get("accept")?.toLowerCase() ?? "";
3160
3577
  if (!(secFetchMode === "navigate" || !secFetchMode && (acceptHeader.includes("text/html") || acceptHeader.includes("application/xhtml+xml")))) ctx.headers?.set("accept", "application/json");
3161
- ctx.query = searchParamsToQuery(removePromptFromQuery(query, "login"));
3162
- return await authorizeEndpoint(ctx, opts);
3578
+ const signedQueryIssuedAt = oauthRequest?.signedQueryIssuedAt ?? getServerContextSignedQueryIssuedAt(serverContext?.[signedQueryIssuedAtMsKey]);
3579
+ let authorizationQuery = removePromptFromQuery(query, "login");
3580
+ if (isSessionFreshForSignedQuery(session.session.createdAt, signedQueryIssuedAt)) authorizationQuery = removeMaxAgeFromQuery(authorizationQuery);
3581
+ ctx.query = searchParamsToQuery(authorizationQuery);
3582
+ return await runOAuth2Authorize(ctx);
3163
3583
  })
3164
3584
  }]
3165
3585
  },
@@ -3187,149 +3607,7 @@ const oauthProvider = (options) => {
3187
3607
  if (opts.scopes && !opts.scopes.includes("openid")) throw new APIError("NOT_FOUND");
3188
3608
  return oidcServerMetadata(ctx, opts);
3189
3609
  }),
3190
- oauth2Authorize: createOAuthEndpoint("/oauth2/authorize", {
3191
- method: "GET",
3192
- query: z.object({
3193
- response_type: z.string().pipe(z.enum(["code"])).optional(),
3194
- client_id: z.string(),
3195
- redirect_uri: SafeUrlSchema.optional(),
3196
- scope: z.string().optional(),
3197
- state: z.string().optional(),
3198
- request_uri: z.string().optional(),
3199
- code_challenge: z.string().optional(),
3200
- code_challenge_method: z.string().pipe(z.enum(["S256"])).optional(),
3201
- nonce: z.string().optional(),
3202
- resource: z.union([ResourceUriSchema, z.array(ResourceUriSchema).min(1)]).optional(),
3203
- prompt: z.string().pipe(z.enum([
3204
- "none",
3205
- "consent",
3206
- "login",
3207
- "create",
3208
- "select_account",
3209
- "login consent",
3210
- "select_account consent"
3211
- ])).optional()
3212
- }),
3213
- redirectOnError: authorizeRedirectOnError(opts),
3214
- errorCodesByField: {
3215
- response_type: { invalid: "unsupported_response_type" },
3216
- resource: { invalid: "invalid_target" }
3217
- },
3218
- metadata: { openapi: {
3219
- description: "Authorize an OAuth2 request",
3220
- parameters: [
3221
- {
3222
- name: "response_type",
3223
- in: "query",
3224
- required: false,
3225
- schema: { type: "string" },
3226
- description: "OAuth2 response type (e.g., 'code')"
3227
- },
3228
- {
3229
- name: "client_id",
3230
- in: "query",
3231
- required: true,
3232
- schema: { type: "string" },
3233
- description: "OAuth2 client ID"
3234
- },
3235
- {
3236
- name: "redirect_uri",
3237
- in: "query",
3238
- required: false,
3239
- schema: {
3240
- type: "string",
3241
- format: "uri"
3242
- },
3243
- description: "OAuth2 redirect URI"
3244
- },
3245
- {
3246
- name: "scope",
3247
- in: "query",
3248
- required: false,
3249
- schema: { type: "string" },
3250
- description: "OAuth2 scopes (space-separated)"
3251
- },
3252
- {
3253
- name: "state",
3254
- in: "query",
3255
- required: false,
3256
- schema: { type: "string" },
3257
- description: "OAuth2 state parameter"
3258
- },
3259
- {
3260
- name: "request_uri",
3261
- in: "query",
3262
- required: false,
3263
- schema: { type: "string" },
3264
- description: "Pushed Authorization Request URI referencing stored parameters"
3265
- },
3266
- {
3267
- name: "code_challenge",
3268
- in: "query",
3269
- required: false,
3270
- schema: { type: "string" },
3271
- description: "PKCE code challenge"
3272
- },
3273
- {
3274
- name: "code_challenge_method",
3275
- in: "query",
3276
- required: false,
3277
- schema: { type: "string" },
3278
- description: "PKCE code challenge method"
3279
- },
3280
- {
3281
- name: "nonce",
3282
- in: "query",
3283
- required: false,
3284
- schema: { type: "string" },
3285
- description: "OpenID Connect nonce"
3286
- },
3287
- {
3288
- name: "resource",
3289
- in: "query",
3290
- required: false,
3291
- schema: {
3292
- type: "array",
3293
- items: { type: "string" }
3294
- },
3295
- description: "Requested token resource(s) (ie audience) to obtain a JWT formatted access token. May be supplied multiple times as repeated 'resource' query parameters (RFC 8707) or as an array of strings."
3296
- },
3297
- {
3298
- name: "prompt",
3299
- in: "query",
3300
- required: false,
3301
- schema: { type: "string" },
3302
- description: "OAuth2 prompt parameter"
3303
- }
3304
- ],
3305
- responses: {
3306
- "302": {
3307
- description: "Redirect to client with code or error",
3308
- headers: { Location: {
3309
- description: "Redirect URI with code or error",
3310
- schema: {
3311
- type: "string",
3312
- format: "uri"
3313
- }
3314
- } }
3315
- },
3316
- "400": {
3317
- description: "Invalid request",
3318
- content: { "application/json": { schema: {
3319
- type: "object",
3320
- properties: {
3321
- error: { type: "string" },
3322
- error_description: { type: "string" },
3323
- state: { type: "string" }
3324
- },
3325
- required: ["error"]
3326
- } } }
3327
- }
3328
- }
3329
- } }
3330
- }, async (ctx) => {
3331
- return authorizeEndpoint(ctx, opts, { isAuthorize: true });
3332
- }),
3610
+ oauth2Authorize: oauth2AuthorizeEndpoint,
3333
3611
  oauth2Consent: createAuthEndpoint("/oauth2/consent", {
3334
3612
  method: "POST",
3335
3613
  body: z.object({
@@ -3354,7 +3632,7 @@ const oauthProvider = (options) => {
3354
3632
  } }
3355
3633
  } }
3356
3634
  }, async (ctx) => {
3357
- return consentEndpoint(ctx, opts);
3635
+ return consentEndpoint(ctx, opts, runOAuth2Authorize);
3358
3636
  }),
3359
3637
  oauth2Continue: createAuthEndpoint("/oauth2/continue", {
3360
3638
  method: "POST",
@@ -3381,7 +3659,7 @@ const oauthProvider = (options) => {
3381
3659
  } }
3382
3660
  } }
3383
3661
  }, async (ctx) => {
3384
- return continueEndpoint(ctx, opts);
3662
+ return continueEndpoint(ctx, runOAuth2Authorize);
3385
3663
  }),
3386
3664
  oauth2Token: createOAuthEndpoint("/oauth2/token", {
3387
3665
  method: "POST",
@@ -3709,7 +3987,7 @@ const oauthProvider = (options) => {
3709
3987
  return revokeEndpoint(ctx, opts);
3710
3988
  }),
3711
3989
  oauth2UserInfo: createAuthEndpoint("/oauth2/userinfo", {
3712
- method: "GET",
3990
+ method: ["GET", "POST"],
3713
3991
  metadata: { openapi: {
3714
3992
  description: "Get OpenID Connect user information (UserInfo endpoint)",
3715
3993
  security: [{ bearerAuth: [] }, { OAuth2: [
@@ -3843,6 +4121,8 @@ const oauthProvider = (options) => {
3843
4121
  software_version: z.string().optional(),
3844
4122
  software_statement: z.string().optional(),
3845
4123
  post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
4124
+ backchannel_logout_uri: SafeUrlSchema.optional(),
4125
+ backchannel_logout_session_required: z.boolean().optional(),
3846
4126
  token_endpoint_auth_method: z.enum([
3847
4127
  "none",
3848
4128
  "client_secret_basic",
@@ -3955,6 +4235,15 @@ const oauthProvider = (options) => {
3955
4235
  },
3956
4236
  description: "List of allowed logout redirect uris"
3957
4237
  },
4238
+ backchannel_logout_uri: {
4239
+ type: "string",
4240
+ format: "uri",
4241
+ description: "RP URL to receive signed Logout Tokens when the end-user's OP session terminates"
4242
+ },
4243
+ backchannel_logout_session_required: {
4244
+ type: "boolean",
4245
+ description: "Whether the RP requires a `sid` claim in every Logout Token"
4246
+ },
3958
4247
  token_endpoint_auth_method: {
3959
4248
  type: "string",
3960
4249
  description: "Requested authentication method for the token endpoint",
@@ -4063,6 +4352,19 @@ const oauthProvider = (options) => {
4063
4352
  //#endregion
4064
4353
  //#region src/authorize.ts
4065
4354
  /**
4355
+ * Whether a past authentication is still fresh enough for an OIDC `max_age`
4356
+ * request: true when no more than `maxAge` seconds have elapsed since the user
4357
+ * last authenticated. The caller supplies `now`, keeping this pure.
4358
+ */
4359
+ function isWithinMaxAge(sessionCreatedAt, maxAgeSeconds, now) {
4360
+ if (maxAgeSeconds === 0) return false;
4361
+ return now.getTime() - sessionCreatedAt.getTime() <= maxAgeSeconds * 1e3;
4362
+ }
4363
+ function removeMaxAgeFromAuthorizationQuery(query) {
4364
+ const { max_age: _maxAge, ...queryWithoutMaxAge } = query;
4365
+ return queryWithoutMaxAge;
4366
+ }
4367
+ /**
4066
4368
  * Formats an error url. Per OIDC Core 1.0 §5 / RFC 6749 §4.2.2.1, errors on
4067
4369
  * implicit and hybrid flows are delivered in the URL fragment, not the query.
4068
4370
  * Callers on the code flow (default) omit `mode` and get query delivery.
@@ -4209,6 +4511,7 @@ async function authorizeEndpoint(ctx, opts, settings) {
4209
4511
  error_description: "request not found",
4210
4512
  error: "invalid_request"
4211
4513
  });
4514
+ const request = ctx.request;
4212
4515
  let query = ctx.query;
4213
4516
  if (query.request_uri) {
4214
4517
  if (!opts.requestUriResolver) return handleRedirect(ctx, getErrorURL(ctx, "invalid_request_uri", "request_uri not supported"));
@@ -4223,6 +4526,14 @@ async function authorizeEndpoint(ctx, opts, settings) {
4223
4526
  if (urlClientId) query.client_id = urlClientId;
4224
4527
  }
4225
4528
  ctx.query = query;
4529
+ const parsedQuery = authorizationQuerySchema.safeParse(query);
4530
+ if (!parsedQuery.success) return authorizeRedirectOnError(opts)({
4531
+ error: "invalid_request",
4532
+ error_description: "invalid authorization request",
4533
+ ctx
4534
+ });
4535
+ query = parsedQuery.data;
4536
+ ctx.query = query;
4226
4537
  await oAuthState.set({ query: serializeAuthorizationQuery(query).toString() });
4227
4538
  if (!query.client_id) return handleRedirect(ctx, getErrorURL(ctx, "invalid_client", "client_id is required"));
4228
4539
  if (!query.response_type) return handleRedirect(ctx, getErrorURL(ctx, "invalid_request", "response_type is required"));
@@ -4233,6 +4544,7 @@ async function authorizeEndpoint(ctx, opts, settings) {
4233
4544
  const client = await getClient(ctx, opts, query.client_id);
4234
4545
  if (!client) return handleRedirect(ctx, getErrorURL(ctx, "invalid_client", "client_id is required"));
4235
4546
  if (client.disabled) return handleRedirect(ctx, getErrorURL(ctx, "client_disabled", "client is disabled"));
4547
+ if (!clientAllowsGrant(client, "authorization_code")) return handleRedirect(ctx, getErrorURL(ctx, "unauthorized_client", "client is not authorized to use the authorization_code grant"));
4236
4548
  if (!findRegisteredRedirectUri(client.redirectUris, query.redirect_uri) || !query.redirect_uri) return handleRedirect(ctx, getErrorURL(ctx, "invalid_redirect", "invalid redirect uri"));
4237
4549
  let requestedScopes = query.scope?.split(" ").filter((s) => s);
4238
4550
  if (requestedScopes) {
@@ -4258,14 +4570,20 @@ async function authorizeEndpoint(ctx, opts, settings) {
4258
4570
  if (!(await checkResource(ctx, opts, resource, requestedScopes)).success) return handleRedirect(ctx, formatErrorURL(query.redirect_uri, "invalid_target", "requested resource invalid", query.state, getIssuer(ctx, opts)));
4259
4571
  const requestedResources = toResourceList(resource) ?? [];
4260
4572
  const session = await getSessionFromCtx(ctx);
4261
- if (!session || promptSet?.has("login") || promptSet?.has("create")) {
4573
+ const maxAgeSeconds = query.max_age;
4574
+ const hasSatisfiedMaxAge = session != null && maxAgeSeconds !== void 0 && isWithinMaxAge(new Date(session.session.createdAt), maxAgeSeconds, /* @__PURE__ */ new Date());
4575
+ if (!session || session != null && maxAgeSeconds !== void 0 && !hasSatisfiedMaxAge || promptSet?.has("login") || promptSet?.has("create")) {
4262
4576
  if (promptNone) return redirectWithPromptNoneError(ctx, opts, query, "login_required", "authentication required");
4263
4577
  return redirectWithPromptCode(ctx, opts, promptSet?.has("create") ? "create" : "login");
4264
4578
  }
4579
+ if (hasSatisfiedMaxAge) {
4580
+ query = removeMaxAgeFromAuthorizationQuery(query);
4581
+ ctx.query = query;
4582
+ }
4265
4583
  if (settings?.isAuthorize && promptSet?.has("select_account")) return redirectWithPromptCode(ctx, opts, "select_account");
4266
4584
  if (settings?.isAuthorize && opts.selectAccount) {
4267
4585
  if (await opts.selectAccount.shouldRedirect({
4268
- headers: ctx.request.headers,
4586
+ headers: request.headers,
4269
4587
  user: session.user,
4270
4588
  session: session.session,
4271
4589
  scopes: requestedScopes
@@ -4276,7 +4594,7 @@ async function authorizeEndpoint(ctx, opts, settings) {
4276
4594
  }
4277
4595
  if (opts.signup?.shouldRedirect) {
4278
4596
  const signupRedirect = await opts.signup.shouldRedirect({
4279
- headers: ctx.request.headers,
4597
+ headers: request.headers,
4280
4598
  user: session.user,
4281
4599
  session: session.session,
4282
4600
  scopes: requestedScopes
@@ -4288,7 +4606,7 @@ async function authorizeEndpoint(ctx, opts, settings) {
4288
4606
  }
4289
4607
  if (!settings?.postLogin && opts.postLogin) {
4290
4608
  if (await opts.postLogin.shouldRedirect({
4291
- headers: ctx.request.headers,
4609
+ headers: request.headers,
4292
4610
  user: session.user,
4293
4611
  session: session.session,
4294
4612
  scopes: requestedScopes
@@ -4412,6 +4730,7 @@ async function signParams(ctx, opts, flags) {
4412
4730
  //#region src/metadata.ts
4413
4731
  function authServerMetadata(ctx, opts, overrides) {
4414
4732
  const baseURL = ctx.context.baseURL;
4733
+ const backchannelSupported = !overrides?.jwt_disabled;
4415
4734
  return {
4416
4735
  scopes_supported: overrides?.scopes_supported,
4417
4736
  issuer: validateIssuerUrl(opts?.jwt?.issuer ?? baseURL),
@@ -4448,7 +4767,9 @@ function authServerMetadata(ctx, opts, overrides) {
4448
4767
  ],
4449
4768
  revocation_endpoint_auth_signing_alg_values_supported: [...PRIVATE_KEY_JWT_SIGNING_ALGORITHMS],
4450
4769
  code_challenge_methods_supported: ["S256"],
4451
- authorization_response_iss_parameter_supported: true
4770
+ authorization_response_iss_parameter_supported: true,
4771
+ backchannel_logout_supported: backchannelSupported,
4772
+ backchannel_logout_session_supported: backchannelSupported
4452
4773
  };
4453
4774
  }
4454
4775
  function oidcServerMetadata(ctx, opts) {