@better-auth/oauth-provider 1.7.0-beta.3 → 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,11 +1,11 @@
1
- import { n as isPrivateHostname } from "./client-assertion-BYtMWGCE.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 validateClientCredentials, S as toClientDiscoveryArray, _ as resolveSubjectIdentifier, a as getJwtPlugin, b as storeClientSecret, c as getStoredToken, d as normalizeTimestampValue, f as parseClientMetadata, g as resolveSessionAuthTime, h as removePromptFromQuery, i as getClient, l as isPKCERequired, m as postLoginClearedParam, n as destructureCredentials, p as parsePrompt, r as extractClientCredentials, s as getSignedQueryIssuedAt, t as decryptStoredClientSecret, u as mergeDiscoveryMetadata, v as searchParamsToQuery, w as verifyOAuthQueryParams, x as storeToken, y as signedQueryIssuedAtParam } from "./utils-_Jr_enAe.mjs";
4
- import { t as PACKAGE_VERSION } from "./version-CG1YnCiF.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
- import { ASSERTION_SIGNING_ALGORITHMS } from "@better-auth/core/oauth2";
8
+ import { PRIVATE_KEY_JWT_SIGNING_ALGORITHMS } from "@better-auth/core/oauth2";
9
9
  import { isBrowserFetchRequest } from "@better-auth/core/utils/fetch-metadata";
10
10
  import { isLoopbackHost, isLoopbackIP } from "@better-auth/core/utils/host";
11
11
  import { generateRandomString, makeSignature } from "better-auth/crypto";
@@ -17,8 +17,9 @@ import { mergeSchema } from "better-auth/db";
17
17
  import * as z from "zod";
18
18
  import { resolveSigningKey, signJWT, toExpJWT } from "better-auth/plugins";
19
19
  import { SignJWT, base64url, compactVerify, createLocalJWKSet, decodeJwt, decodeProtectedHeader } from "jose";
20
+ import { SafeUrlSchema } from "@better-auth/core/utils/redirect-uri";
20
21
  //#region src/consent.ts
21
- async function consentEndpoint(ctx, opts) {
22
+ async function consentEndpoint(ctx, opts, authorize) {
22
23
  const oauthRequest = await oAuthState.get();
23
24
  const _query = oauthRequest?.query;
24
25
  if (!_query) throw new APIError("BAD_REQUEST", {
@@ -45,15 +46,11 @@ async function consentEndpoint(ctx, opts) {
45
46
  };
46
47
  const session = await getSessionFromCtx(ctx);
47
48
  const hasLoginPrompt = parsePrompt(query.get("prompt") ?? "").has("login");
48
- const hasSatisfiedLoginPrompt = hasLoginPrompt && sessionSatisfiesLoginPrompt(session?.session.createdAt, oauthRequest?.signedQueryIssuedAt);
49
+ const hasSatisfiedLoginPrompt = hasLoginPrompt && isSessionFreshForSignedQuery(session?.session.createdAt, oauthRequest?.signedQueryIssuedAt);
49
50
  if (hasLoginPrompt && !hasSatisfiedLoginPrompt) {
50
51
  ctx?.headers?.set("accept", "application/json");
51
52
  ctx.query = searchParamsToQuery(query);
52
- const { url } = await authorizeEndpoint(ctx, opts);
53
- return {
54
- redirect: true,
55
- url
56
- };
53
+ return await authorize(ctx);
57
54
  }
58
55
  const referenceId = await opts.postLogin?.consentReferenceId?.({
59
56
  user: session?.user,
@@ -78,12 +75,14 @@ async function consentEndpoint(ctx, opts) {
78
75
  ]
79
76
  });
80
77
  const iat = Math.floor(Date.now() / 1e3);
78
+ const resource = query.getAll("resource");
81
79
  const consent = {
82
80
  clientId,
83
81
  userId: session?.user.id,
84
82
  scopes: requestedScopes ?? originalRequestedScopes,
85
83
  createdAt: /* @__PURE__ */ new Date(iat * 1e3),
86
84
  updatedAt: /* @__PURE__ */ new Date(iat * 1e3),
85
+ resources: resource.length ? resource : void 0,
87
86
  referenceId
88
87
  };
89
88
  foundConsent?.id ? await ctx.context.adapter.update({
@@ -93,6 +92,7 @@ async function consentEndpoint(ctx, opts) {
93
92
  value: foundConsent.id
94
93
  }],
95
94
  update: {
95
+ resources: consent.resources,
96
96
  scopes: consent.scopes,
97
97
  updatedAt: /* @__PURE__ */ new Date(iat * 1e3)
98
98
  }
@@ -106,32 +106,25 @@ async function consentEndpoint(ctx, opts) {
106
106
  if (requestedScopes) query.set("scope", consent.scopes.join(" "));
107
107
  ctx?.headers?.set("accept", "application/json");
108
108
  let authorizationQuery = removePromptFromQuery(query, "consent");
109
- if (hasSatisfiedLoginPrompt) authorizationQuery = removePromptFromQuery(authorizationQuery, "login");
109
+ if (hasSatisfiedLoginPrompt) {
110
+ authorizationQuery = removePromptFromQuery(authorizationQuery, "login");
111
+ authorizationQuery = removeMaxAgeFromQuery(authorizationQuery);
112
+ }
110
113
  ctx.query = searchParamsToQuery(authorizationQuery);
111
- const { url } = await authorizeEndpoint(ctx, opts, { postLogin: oauthRequest?.postLoginClearedForSession !== void 0 && oauthRequest.postLoginClearedForSession === session?.session.id });
112
- return {
113
- redirect: true,
114
- url
115
- };
116
- }
117
- function sessionSatisfiesLoginPrompt(sessionCreatedAt, signedQueryIssuedAt) {
118
- if (!signedQueryIssuedAt) return false;
119
- const normalized = normalizeTimestampValue(sessionCreatedAt);
120
- if (!normalized) return false;
121
- return normalized.getTime() >= signedQueryIssuedAt.getTime();
114
+ return await authorize(ctx, { postLogin: oauthRequest?.postLoginClearedForSession !== void 0 && oauthRequest.postLoginClearedForSession === session?.session.id });
122
115
  }
123
116
  //#endregion
124
117
  //#region src/continue.ts
125
- async function continueEndpoint(ctx, opts) {
126
- if (ctx.body.selected === true) return await selected(ctx, opts);
127
- else if (ctx.body.created === true) return await created(ctx, opts);
128
- 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);
129
122
  else throw new APIError("BAD_REQUEST", {
130
123
  error_description: "Missing parameters",
131
124
  error: "invalid_request"
132
125
  });
133
126
  }
134
- async function selected(ctx, opts) {
127
+ async function selected(ctx, authorize) {
135
128
  const _query = (await oAuthState.get())?.query;
136
129
  if (!_query) throw new APIError("BAD_REQUEST", {
137
130
  error_description: "missing oauth query",
@@ -139,13 +132,9 @@ async function selected(ctx, opts) {
139
132
  });
140
133
  ctx.headers?.set("accept", "application/json");
141
134
  ctx.query = searchParamsToQuery(removePromptFromQuery(new URLSearchParams(_query), "select_account"));
142
- const { url } = await authorizeEndpoint(ctx, opts);
143
- return {
144
- redirect: true,
145
- url
146
- };
135
+ return await authorize(ctx);
147
136
  }
148
- async function created(ctx, opts) {
137
+ async function created(ctx, authorize) {
149
138
  const _query = (await oAuthState.get())?.query;
150
139
  if (!_query) throw new APIError("BAD_REQUEST", {
151
140
  error_description: "missing oauth query",
@@ -154,14 +143,11 @@ async function created(ctx, opts) {
154
143
  const query = new URLSearchParams(_query);
155
144
  ctx.headers?.set("accept", "application/json");
156
145
  ctx.query = searchParamsToQuery(removePromptFromQuery(query, "create"));
157
- const { url } = await authorizeEndpoint(ctx, opts);
158
- return {
159
- redirect: true,
160
- url
161
- };
146
+ return await authorize(ctx);
162
147
  }
163
- async function postLogin(ctx, opts) {
164
- const _query = (await oAuthState.get())?.query;
148
+ async function postLogin(ctx, authorize) {
149
+ const state = await oAuthState.get();
150
+ const _query = state?.query;
165
151
  if (!_query) throw new APIError("BAD_REQUEST", {
166
152
  error_description: "missing oauth query",
167
153
  error: "invalid_request"
@@ -169,11 +155,8 @@ async function postLogin(ctx, opts) {
169
155
  const query = new URLSearchParams(_query);
170
156
  ctx.headers?.set("accept", "application/json");
171
157
  ctx.query = searchParamsToQuery(query);
172
- const { url } = await authorizeEndpoint(ctx, opts, { postLogin: true });
173
- return {
174
- redirect: true,
175
- url
176
- };
158
+ const session = await getSessionFromCtx(ctx);
159
+ return await authorize(ctx, { postLogin: state?.postLoginClearedForSession !== void 0 && state.postLoginClearedForSession === session?.session.id });
177
160
  }
178
161
  //#endregion
179
162
  //#region src/types/zod.ts
@@ -183,27 +166,101 @@ const DANGEROUS_SCHEMES = [
183
166
  "vbscript:"
184
167
  ];
185
168
  /**
169
+ * Validates an RFC 8707 resource indicator. The value must be an absolute URI
170
+ * with no fragment (RFC 8707 §2). Unlike a redirect URI it is not restricted to
171
+ * HTTPS, because a resource server identifier may use any absolute URI scheme;
172
+ * the configured `validAudiences` allowlist is the authoritative control over
173
+ * which resources a token may target.
174
+ */
175
+ const ResourceUriSchema = z.string().superRefine((val, ctx) => {
176
+ if (!URL.canParse(val)) {
177
+ ctx.addIssue({
178
+ code: "custom",
179
+ message: "resource must be an absolute URI",
180
+ fatal: true
181
+ });
182
+ return z.NEVER;
183
+ }
184
+ if (val.includes("#")) {
185
+ ctx.addIssue({
186
+ code: "custom",
187
+ message: "resource must not contain a fragment"
188
+ });
189
+ return;
190
+ }
191
+ if (DANGEROUS_SCHEMES.includes(new URL(val).protocol)) ctx.addIssue({
192
+ code: "custom",
193
+ message: "resource cannot use javascript:, data:, or vbscript: scheme"
194
+ });
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
+ /**
186
241
  * Runtime schema for OAuthAuthorizationQuery.
187
242
  * Uses passthrough to tolerate fields added by future extensions (PAR, FPA, etc.)
188
243
  */
189
- const oauthAuthorizationQuerySchema = z.object({
190
- response_type: z.literal("code").optional(),
244
+ const authorizationQuerySchema = z.object({
245
+ response_type: z.string().pipe(z.enum(["code"])).optional(),
191
246
  request_uri: z.string().optional(),
192
- redirect_uri: z.string(),
247
+ redirect_uri: SafeUrlSchema.optional(),
193
248
  scope: z.string().optional(),
194
249
  state: z.string().optional(),
195
250
  client_id: z.string(),
196
- prompt: z.string().optional(),
251
+ prompt: authorizationPromptSchema.optional(),
197
252
  display: z.string().optional(),
198
253
  ui_locales: z.string().optional(),
199
- max_age: z.coerce.number().optional(),
254
+ max_age: maxAgeSchema.optional(),
200
255
  acr_values: z.string().optional(),
201
256
  login_hint: z.string().optional(),
202
257
  id_token_hint: z.string().optional(),
203
258
  code_challenge: z.string().optional(),
204
- code_challenge_method: z.literal("S256").optional(),
205
- nonce: 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()
206
262
  }).passthrough();
263
+ const storedAuthorizationQuerySchema = authorizationQuerySchema.extend({ redirect_uri: SafeUrlSchema });
207
264
  /**
208
265
  * Runtime schema for the authorization code verification value.
209
266
  * Validates structure on deserialization from the JSON blob stored in the DB.
@@ -211,40 +268,13 @@ const oauthAuthorizationQuerySchema = z.object({
211
268
  */
212
269
  const verificationValueSchema = z.object({
213
270
  type: z.literal("authorization_code"),
214
- query: oauthAuthorizationQuerySchema,
271
+ query: storedAuthorizationQuerySchema,
215
272
  sessionId: z.string(),
216
273
  userId: z.string(),
217
274
  referenceId: z.string().optional(),
218
- authTime: z.number().optional()
275
+ authTime: z.number().optional(),
276
+ resource: z.array(z.string()).optional()
219
277
  }).passthrough();
220
- /**
221
- * Reusable URL validation for OAuth redirect URIs.
222
- * - Blocks dangerous schemes (javascript:, data:, vbscript:)
223
- * - For http/https: requires HTTPS (HTTP allowed only for loopback hosts: 127.0.0.0/8, [::1], *.localhost per RFC 6761)
224
- * - Allows custom schemes for mobile apps (e.g., myapp://callback)
225
- */
226
- const SafeUrlSchema = z.url().superRefine((val, ctx) => {
227
- if (!URL.canParse(val)) {
228
- ctx.addIssue({
229
- code: "custom",
230
- message: "URL must be parseable",
231
- fatal: true
232
- });
233
- return z.NEVER;
234
- }
235
- const u = new URL(val);
236
- if (DANGEROUS_SCHEMES.includes(u.protocol)) {
237
- ctx.addIssue({
238
- code: "custom",
239
- message: "URL cannot use javascript:, data:, or vbscript: scheme"
240
- });
241
- return;
242
- }
243
- if (u.protocol === "http:" && !isLoopbackHost(u.host)) ctx.addIssue({
244
- code: "custom",
245
- message: "Redirect URI must use HTTPS (HTTP allowed only for loopback hosts)"
246
- });
247
- });
248
278
  //#endregion
249
279
  //#region src/userinfo.ts
250
280
  /**
@@ -281,6 +311,10 @@ async function userInfoEndpoint(ctx, opts) {
281
311
  error: "invalid_request"
282
312
  });
283
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
+ });
284
318
  const scopes = jwt.scope?.split(" ");
285
319
  if (!scopes?.includes("openid")) throw new APIError("BAD_REQUEST", {
286
320
  error_description: "Missing required scope",
@@ -331,13 +365,13 @@ async function tokenEndpoint(ctx, opts) {
331
365
  case "refresh_token": return handleRefreshTokenGrant(ctx, opts);
332
366
  }
333
367
  }
334
- async function createJwtAccessToken(ctx, opts, user, client, audience, scopes, referenceId, overrides) {
368
+ async function createJwtAccessToken(ctx, opts, user, client, audience, scopes, resources, referenceId, overrides) {
335
369
  const iat = overrides?.iat ?? Math.floor(Date.now() / 1e3);
336
370
  const exp = overrides?.exp ?? iat + (opts.accessTokenExpiresIn ?? 3600);
337
371
  const customClaims = opts.customAccessTokenClaims ? await opts.customAccessTokenClaims({
338
372
  user,
339
373
  scopes,
340
- resource: ctx.body.resource,
374
+ resources,
341
375
  referenceId,
342
376
  metadata: parseClientMetadata(client.metadata)
343
377
  }) : {};
@@ -347,7 +381,7 @@ async function createJwtAccessToken(ctx, opts, user, client, audience, scopes, r
347
381
  payload: {
348
382
  ...customClaims,
349
383
  sub: user?.id,
350
- aud: typeof audience === "string" ? audience : audience?.length === 1 ? audience.at(0) : audience,
384
+ aud: toAudienceClaim(audience),
351
385
  azp: client.clientId,
352
386
  scope: scopes.join(" "),
353
387
  sid: overrides?.sid,
@@ -390,6 +424,7 @@ async function createIdToken(ctx, opts, user, client, scopes, nonce, sessionId,
390
424
  const resolvedKey = !opts.disableJwtPlugin && !jwtPluginOptions?.jwt?.sign ? await resolveSigningKey(ctx, jwtPluginOptions) : void 0;
391
425
  const signingAlg = opts.disableJwtPlugin ? "HS256" : resolvedKey?.alg ?? jwtPluginOptions?.jwks?.keyPairConfig?.alg;
392
426
  const atHash = accessToken ? await computeOidcHash(accessToken, signingAlg) : void 0;
427
+ const emitSid = Boolean(client.enableEndSession || client.backchannelLogoutUri);
393
428
  const payload = {
394
429
  ...userClaims,
395
430
  auth_time: authTimeSec,
@@ -402,7 +437,7 @@ async function createIdToken(ctx, opts, user, client, scopes, nonce, sessionId,
402
437
  nonce,
403
438
  iat,
404
439
  exp,
405
- sid: client.enableEndSession ? sessionId : void 0
440
+ sid: emitSid ? sessionId : void 0
406
441
  };
407
442
  if (opts.disableJwtPlugin && !client.clientSecret) return;
408
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, {
@@ -438,7 +473,7 @@ async function decodeRefreshToken(opts, token) {
438
473
  });
439
474
  return opts.formatRefreshToken?.decrypt ? opts.formatRefreshToken?.decrypt(token) : { token };
440
475
  }
441
- async function createOpaqueAccessToken(ctx, opts, user, client, scopes, payload, referenceId, refreshId) {
476
+ async function createOpaqueAccessToken(ctx, opts, user, client, scopes, payload, resources, referenceId, refreshId) {
442
477
  const iat = payload.iat ?? Math.floor(Date.now() / 1e3);
443
478
  const exp = payload?.exp ?? iat + (opts.accessTokenExpiresIn ?? 3600);
444
479
  const token = opts.generateOpaqueAccessToken ? await opts.generateOpaqueAccessToken() : generateRandomString(32, "A-Z", "a-z");
@@ -450,6 +485,7 @@ async function createOpaqueAccessToken(ctx, opts, user, client, scopes, payload,
450
485
  sessionId: payload?.sid,
451
486
  userId: user?.id,
452
487
  referenceId,
488
+ resources,
453
489
  refreshId,
454
490
  scopes,
455
491
  createdAt: /* @__PURE__ */ new Date(iat * 1e3),
@@ -458,54 +494,100 @@ async function createOpaqueAccessToken(ctx, opts, user, client, scopes, payload,
458
494
  });
459
495
  return (opts.prefix?.opaqueAccessToken ?? "") + token;
460
496
  }
461
- async function createRefreshToken(ctx, opts, user, referenceId, client, scopes, payload, originalRefresh, authTime) {
497
+ /**
498
+ * Tear down the entire refresh-token family for a (client, user) pair, plus
499
+ * any access tokens that reference those refresh rows, per RFC 9700 §4.14.
500
+ * Access tokens are deleted first so the parent rows' foreign-key children
501
+ * do not block the refresh-row delete.
502
+ *
503
+ * TODO(invalidate-family-race): the two `deleteMany` calls are not atomic
504
+ * with respect to each other. Between them, a concurrent rotation in a
505
+ * different worker can `create` a fresh refresh row (and, immediately after,
506
+ * an access-token row referencing it) for the same (client, user) pair,
507
+ * leaving the family partially rebuilt and the new refresh row orphaned of
508
+ * any deletion. Closing this window requires the same transactional adapter
509
+ * contract tracked under FIXME(strict-family-invalidation) in
510
+ * `createRefreshToken`.
511
+ *
512
+ * @internal
513
+ */
514
+ async function invalidateRefreshFamily(ctx, clientId, userId) {
515
+ const refreshTokens = await ctx.context.adapter.findMany({
516
+ model: "oauthRefreshToken",
517
+ where: [{
518
+ field: "clientId",
519
+ value: clientId
520
+ }, {
521
+ field: "userId",
522
+ value: userId
523
+ }]
524
+ });
525
+ if (refreshTokens.length) await ctx.context.adapter.deleteMany({
526
+ model: "oauthAccessToken",
527
+ where: [{
528
+ field: "refreshId",
529
+ operator: "in",
530
+ value: refreshTokens.map((r) => r.id)
531
+ }]
532
+ });
533
+ await ctx.context.adapter.deleteMany({
534
+ model: "oauthRefreshToken",
535
+ where: [{
536
+ field: "clientId",
537
+ value: clientId
538
+ }, {
539
+ field: "userId",
540
+ value: userId
541
+ }]
542
+ });
543
+ }
544
+ async function createRefreshToken(ctx, opts, user, referenceId, client, scopes, payload, originalRefresh, authTime, resources) {
462
545
  const iat = payload.iat ?? Math.floor(Date.now() / 1e3);
463
546
  const exp = payload?.exp ?? iat + (opts.refreshTokenExpiresIn ?? 2592e3);
464
547
  const token = opts.generateRefreshToken ? await opts.generateRefreshToken() : generateRandomString(32, "A-Z", "a-z");
465
548
  const sessionId = payload?.sid;
466
- if (originalRefresh?.id) await ctx.context.adapter.update({
549
+ const newRow = {
550
+ token: await storeToken(opts.storeTokens, token, "refresh_token"),
551
+ clientId: client.clientId,
552
+ sessionId,
553
+ userId: user.id,
554
+ referenceId,
555
+ authTime,
556
+ scopes,
557
+ resources,
558
+ createdAt: /* @__PURE__ */ new Date(iat * 1e3),
559
+ expiresAt: /* @__PURE__ */ new Date(exp * 1e3)
560
+ };
561
+ if (!originalRefresh?.id) return {
562
+ id: (await ctx.context.adapter.create({
563
+ model: "oauthRefreshToken",
564
+ data: newRow
565
+ })).id,
566
+ token: await encodeRefreshToken(opts, token, sessionId)
567
+ };
568
+ if (!await ctx.context.adapter.update({
467
569
  model: "oauthRefreshToken",
468
570
  where: [{
469
571
  field: "id",
470
572
  value: originalRefresh.id
573
+ }, {
574
+ field: "revoked",
575
+ operator: "eq",
576
+ value: null
471
577
  }],
472
578
  update: { revoked: /* @__PURE__ */ new Date(iat * 1e3) }
579
+ })) throw new APIError("BAD_REQUEST", {
580
+ error_description: "invalid refresh token",
581
+ error: "invalid_grant"
473
582
  });
474
583
  return {
475
584
  id: (await ctx.context.adapter.create({
476
585
  model: "oauthRefreshToken",
477
- data: {
478
- token: await storeToken(opts.storeTokens, token, "refresh_token"),
479
- clientId: client.clientId,
480
- sessionId,
481
- userId: user.id,
482
- referenceId,
483
- authTime,
484
- scopes,
485
- createdAt: /* @__PURE__ */ new Date(iat * 1e3),
486
- expiresAt: /* @__PURE__ */ new Date(exp * 1e3)
487
- }
586
+ data: newRow
488
587
  })).id,
489
588
  token: await encodeRefreshToken(opts, token, sessionId)
490
589
  };
491
590
  }
492
- /**
493
- * Checks the resource parameter, if provided,
494
- * and returns a valid audience based on the request
495
- */
496
- async function checkResource(ctx, opts, scopes) {
497
- const resource = ctx.body.resource;
498
- const audience = typeof resource === "string" ? [resource] : resource ? [...resource] : void 0;
499
- if (audience) {
500
- if (scopes.includes("openid")) audience.push(`${ctx.context.baseURL}/oauth2/userinfo`);
501
- const validAudiences = new Set([...opts.validAudiences ?? [ctx.context.baseURL], scopes?.includes("openid") ? `${ctx.context.baseURL}/oauth2/userinfo` : void 0].flat().filter((v) => v?.length));
502
- for (const aud of audience) if (!validAudiences.has(aud)) throw new APIError("BAD_REQUEST", {
503
- error_description: "requested resource invalid",
504
- error: "invalid_request"
505
- });
506
- }
507
- return audience?.length === 1 ? audience.at(0) : audience;
508
- }
509
591
  async function createUserTokens(ctx, opts, params) {
510
592
  const { client, scopes, user, grantType, referenceId, sessionId, nonce, refreshToken: existingRefreshToken, authTime, verificationValue } = params;
511
593
  const iat = Math.floor(Date.now() / 1e3);
@@ -513,8 +595,13 @@ async function createUserTokens(ctx, opts, params) {
513
595
  const exp = opts.scopeExpirations ? scopes.map((sc) => opts.scopeExpirations?.[sc] ? toExpJWT(opts.scopeExpirations[sc], iat) : defaultExp).reduce((prev, curr) => {
514
596
  return prev < curr ? prev : curr;
515
597
  }, defaultExp) : defaultExp;
516
- const audience = await checkResource(ctx, opts, scopes);
517
- const isRefreshToken = user && (existingRefreshToken?.scopes?.includes("offline_access") || scopes.includes("offline_access"));
598
+ const resourceResult = await checkResource(ctx, opts, params?.resources, scopes);
599
+ if (!resourceResult.success) throw new APIError("BAD_REQUEST", {
600
+ error_description: "requested resource invalid",
601
+ error: "invalid_target"
602
+ });
603
+ const audience = resourceResult.audience;
604
+ const isRefreshToken = user && clientAllowsGrant(client, "refresh_token") && (existingRefreshToken?.scopes?.includes("offline_access") || scopes.includes("offline_access"));
518
605
  const isJwtAccessToken = audience && !opts.disableJwtPlugin;
519
606
  const isIdToken = user && scopes.includes("openid");
520
607
  const customFields = opts.customTokenResponseFields ? await opts.customTokenResponseFields({
@@ -524,12 +611,13 @@ async function createUserTokens(ctx, opts, params) {
524
611
  metadata: parseClientMetadata(client.metadata),
525
612
  verificationValue
526
613
  }) : void 0;
614
+ const refreshResources = params?.refreshToken?.resources ?? params?.originalResources ?? params?.resources;
527
615
  const earlyRefreshToken = isRefreshToken && user && !isJwtAccessToken ? await createRefreshToken(ctx, opts, user, referenceId, client, scopes, {
528
616
  iat,
529
617
  exp: iat + (opts.refreshTokenExpiresIn ?? 2592e3),
530
618
  sid: sessionId
531
- }, existingRefreshToken, authTime) : void 0;
532
- const [accessToken, refreshToken] = await Promise.all([isJwtAccessToken ? createJwtAccessToken(ctx, opts, user, client, audience, scopes, referenceId, {
619
+ }, existingRefreshToken, authTime, refreshResources) : void 0;
620
+ const [accessToken, refreshToken] = await Promise.all([isJwtAccessToken ? createJwtAccessToken(ctx, opts, user, client, audience, scopes, params?.resources, referenceId, {
533
621
  iat,
534
622
  exp,
535
623
  sid: sessionId
@@ -537,11 +625,11 @@ async function createUserTokens(ctx, opts, params) {
537
625
  iat,
538
626
  exp,
539
627
  sid: sessionId
540
- }, referenceId, earlyRefreshToken?.id), earlyRefreshToken ? earlyRefreshToken : isRefreshToken && user ? createRefreshToken(ctx, opts, user, referenceId, client, scopes, {
628
+ }, params?.resources, referenceId, earlyRefreshToken?.id), earlyRefreshToken ? earlyRefreshToken : isRefreshToken && user ? createRefreshToken(ctx, opts, user, referenceId, client, scopes, {
541
629
  iat,
542
630
  exp: iat + (opts.refreshTokenExpiresIn ?? 2592e3),
543
631
  sid: sessionId
544
- }, existingRefreshToken, authTime) : void 0]);
632
+ }, existingRefreshToken, authTime, refreshResources) : void 0]);
545
633
  const idToken = isIdToken ? await createIdToken(ctx, opts, user, client, scopes, nonce, sessionId, authTime, accessToken) : void 0;
546
634
  return ctx.json({
547
635
  ...customFields,
@@ -558,16 +646,11 @@ async function createUserTokens(ctx, opts, params) {
558
646
  } });
559
647
  }
560
648
  /** Checks verification value */
561
- async function checkVerificationValue(ctx, opts, code, client_id, redirect_uri) {
562
- const verification = await ctx.context.internalAdapter.findVerificationValue(await storeToken(opts.storeTokens, code, "authorization_code"));
649
+ async function checkVerificationValue(ctx, opts, code, client_id, redirect_uri, resource) {
650
+ const verification = await ctx.context.internalAdapter.consumeVerificationValue(await storeToken(opts.storeTokens, code, "authorization_code"));
563
651
  if (!verification) throw new APIError("UNAUTHORIZED", {
564
- error_description: "Invalid code",
565
- error: "invalid_verification"
566
- });
567
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(await storeToken(opts.storeTokens, code, "authorization_code"));
568
- if (!verification.expiresAt || verification.expiresAt < /* @__PURE__ */ new Date()) throw new APIError("UNAUTHORIZED", {
569
- error_description: "code expired",
570
- error: "invalid_verification"
652
+ error_description: "invalid code",
653
+ error: "invalid_grant"
571
654
  });
572
655
  let rawValue;
573
656
  try {
@@ -575,13 +658,13 @@ async function checkVerificationValue(ctx, opts, code, client_id, redirect_uri)
575
658
  } catch {
576
659
  throw new APIError("UNAUTHORIZED", {
577
660
  error_description: "malformed verification value",
578
- error: "invalid_verification"
661
+ error: "invalid_grant"
579
662
  });
580
663
  }
581
664
  const parsed = verificationValueSchema.safeParse(rawValue);
582
665
  if (!parsed.success) throw new APIError("UNAUTHORIZED", {
583
666
  error_description: "malformed verification value",
584
- error: "invalid_verification"
667
+ error: "invalid_grant"
585
668
  });
586
669
  const verificationValue = parsed.data;
587
670
  if (verificationValue.query.client_id !== client_id) throw new APIError("UNAUTHORIZED", {
@@ -592,14 +675,29 @@ async function checkVerificationValue(ctx, opts, code, client_id, redirect_uri)
592
675
  error_description: "redirect_uri mismatch",
593
676
  error: "invalid_request"
594
677
  });
595
- return verificationValue;
678
+ const storedResources = toResourceList(verificationValue.resource) ?? toResourceList(verificationValue.query.resource);
679
+ const effectiveResources = resource ?? storedResources;
680
+ if (resource && storedResources) {
681
+ const requestedSet = new Set(resource);
682
+ const authorizedSet = new Set(storedResources);
683
+ for (const r of requestedSet) if (!authorizedSet.has(r)) throw new APIError("BAD_REQUEST", {
684
+ error_description: "requested resource not authorized",
685
+ error: "invalid_target"
686
+ });
687
+ }
688
+ return {
689
+ verificationValue,
690
+ effectiveResources,
691
+ authorizedResources: storedResources
692
+ };
596
693
  }
597
694
  /**
598
695
  * Obtains new Session Jwt and Refresh Tokens using a code
599
696
  */
600
697
  async function handleAuthorizationCodeGrant(ctx, opts) {
601
698
  const { clientId: client_id, clientSecret: client_secret, preVerifiedClient } = destructureCredentials(await extractClientCredentials(ctx, opts, `${ctx.context.baseURL}/oauth2/token`));
602
- const { code, code_verifier, redirect_uri } = ctx.body;
699
+ const { code, code_verifier, redirect_uri, resource } = ctx.body;
700
+ const resources = toResourceList(resource);
603
701
  if (!client_id) throw new APIError("BAD_REQUEST", {
604
702
  error_description: "client_id is required",
605
703
  error: "invalid_request"
@@ -619,14 +717,14 @@ async function handleAuthorizationCodeGrant(ctx, opts) {
619
717
  error: "invalid_request"
620
718
  });
621
719
  /** Get and check Verification Value */
622
- const verificationValue = await checkVerificationValue(ctx, opts, code, client_id, redirect_uri);
720
+ const { verificationValue, effectiveResources, authorizedResources } = await checkVerificationValue(ctx, opts, code, client_id, redirect_uri, resources);
623
721
  const scopes = verificationValue.query.scope?.split(" ");
624
722
  if (!scopes) throw new APIError("INTERNAL_SERVER_ERROR", {
625
723
  error_description: "verification scope unset",
626
724
  error: "invalid_scope"
627
725
  });
628
726
  /** Verify Client */
629
- 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");
630
728
  if (isPKCERequired(client, (verificationValue.query?.scope)?.split(" ") || [])) {
631
729
  if (!isAuthCodeWithPkce) throw new APIError("BAD_REQUEST", {
632
730
  error_description: "PKCE is required for this client",
@@ -684,7 +782,9 @@ async function handleAuthorizationCodeGrant(ctx, opts) {
684
782
  sessionId: session.id,
685
783
  nonce: verificationValue.query?.nonce,
686
784
  authTime,
687
- verificationValue
785
+ verificationValue,
786
+ resources: effectiveResources,
787
+ originalResources: authorizedResources
688
788
  });
689
789
  }
690
790
  /**
@@ -695,7 +795,8 @@ async function handleAuthorizationCodeGrant(ctx, opts) {
695
795
  */
696
796
  async function handleClientCredentialsGrant(ctx, opts) {
697
797
  const { clientId: client_id, clientSecret: client_secret, preVerifiedClient } = destructureCredentials(await extractClientCredentials(ctx, opts, `${ctx.context.baseURL}/oauth2/token`));
698
- const { scope } = ctx.body;
798
+ const { scope, resource } = ctx.body;
799
+ const resources = toResourceList(resource);
699
800
  if (!client_id) throw new APIError("BAD_REQUEST", {
700
801
  error_description: "Missing required client_id",
701
802
  error: "invalid_grant"
@@ -704,7 +805,7 @@ async function handleClientCredentialsGrant(ctx, opts) {
704
805
  error_description: "Missing a required client_secret",
705
806
  error: "invalid_grant"
706
807
  });
707
- 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");
708
809
  let requestedScopes = scope?.split(" ");
709
810
  if (requestedScopes) {
710
811
  const validScopes = new Set(client.scopes ?? opts.scopes);
@@ -726,7 +827,8 @@ async function handleClientCredentialsGrant(ctx, opts) {
726
827
  return createUserTokens(ctx, opts, {
727
828
  client,
728
829
  scopes: requestedScopes,
729
- grantType: "client_credentials"
830
+ grantType: "client_credentials",
831
+ resources
730
832
  });
731
833
  }
732
834
  /**
@@ -737,7 +839,8 @@ async function handleClientCredentialsGrant(ctx, opts) {
737
839
  */
738
840
  async function handleRefreshTokenGrant(ctx, opts) {
739
841
  const { clientId: client_id, clientSecret: client_secret, preVerifiedClient } = destructureCredentials(await extractClientCredentials(ctx, opts, `${ctx.context.baseURL}/oauth2/token`));
740
- const { refresh_token, scope } = ctx.body;
842
+ const { refresh_token, scope, resource } = ctx.body;
843
+ const resources = toResourceList(resource);
741
844
  if (!client_id) throw new APIError("BAD_REQUEST", {
742
845
  error_description: "Missing required client_id",
743
846
  error: "invalid_grant"
@@ -767,21 +870,16 @@ async function handleRefreshTokenGrant(ctx, opts) {
767
870
  error: "invalid_grant"
768
871
  });
769
872
  if (refreshToken.revoked) {
770
- await ctx.context.adapter.deleteMany({
771
- model: "oauthRefreshToken",
772
- where: [{
773
- field: "clientId",
774
- value: client_id
775
- }, {
776
- field: "userId",
777
- value: refreshToken.userId
778
- }]
779
- });
873
+ await invalidateRefreshFamily(ctx, client_id, refreshToken.userId);
780
874
  throw new APIError("BAD_REQUEST", {
781
875
  error_description: "invalid refresh token",
782
876
  error: "invalid_grant"
783
877
  });
784
878
  }
879
+ if (resources && refreshToken.resources && !resources.every((v) => refreshToken.resources?.includes(v))) throw new APIError("BAD_REQUEST", {
880
+ error_description: "requested resource invalid",
881
+ error: "invalid_target"
882
+ });
785
883
  const scopes = refreshToken?.scopes;
786
884
  const requestedScopes = scope?.split(" ");
787
885
  if (requestedScopes) {
@@ -791,7 +889,7 @@ async function handleRefreshTokenGrant(ctx, opts) {
791
889
  error: "invalid_scope"
792
890
  });
793
891
  }
794
- 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");
795
893
  const user = await ctx.context.internalAdapter.findUserById(refreshToken.userId);
796
894
  if (!user) throw new APIError("BAD_REQUEST", {
797
895
  error_description: "user not found",
@@ -806,6 +904,7 @@ async function handleRefreshTokenGrant(ctx, opts) {
806
904
  referenceId: refreshToken.referenceId,
807
905
  sessionId: refreshToken.sessionId,
808
906
  refreshToken,
907
+ resources: resources ?? refreshToken.resources,
809
908
  authTime
810
909
  });
811
910
  }
@@ -849,12 +948,10 @@ async function validateJwtAccessToken(ctx, opts, token, clientId) {
849
948
  }
850
949
  throw new Error(error);
851
950
  }
852
- let client;
853
- if (jwtPayload.azp) {
854
- client = await getClient(ctx, opts, jwtPayload.azp);
855
- if (!client || client?.disabled) return { active: false };
856
- if (clientId && jwtPayload.azp !== clientId) return { active: false };
857
- }
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 };
858
955
  const sessionId = jwtPayload.sid;
859
956
  if (sessionId) {
860
957
  const session = await ctx.context.adapter.findOne({
@@ -864,9 +961,9 @@ async function validateJwtAccessToken(ctx, opts, token, clientId) {
864
961
  value: sessionId
865
962
  }]
866
963
  });
867
- if (!session || session.expiresAt < /* @__PURE__ */ new Date()) jwtPayload.sid = void 0;
964
+ if (!session || session.expiresAt < /* @__PURE__ */ new Date()) return { active: false };
868
965
  }
869
- if (jwtPayload.azp) jwtPayload.client_id = jwtPayload.azp;
966
+ jwtPayload.client_id = jwtPayload.azp;
870
967
  jwtPayload.active = true;
871
968
  return jwtPayload;
872
969
  }
@@ -894,13 +991,14 @@ async function validateOpaqueAccessToken(ctx, opts, token, clientId) {
894
991
  error: "invalid_token"
895
992
  });
896
993
  if (!accessToken.expiresAt || accessToken.expiresAt < /* @__PURE__ */ new Date()) return { active: false };
994
+ if (accessToken.revoked) return { active: false };
897
995
  let client;
898
996
  if (accessToken.clientId) {
899
997
  client = await getClient(ctx, opts, accessToken.clientId);
900
998
  if (!client || client?.disabled) return { active: false };
901
999
  if (clientId && accessToken.clientId !== clientId) return { active: false };
902
1000
  }
903
- let sessionId = accessToken.sessionId ?? void 0;
1001
+ const sessionId = accessToken.sessionId ?? void 0;
904
1002
  if (sessionId) {
905
1003
  const session = await ctx.context.adapter.findOne({
906
1004
  model: "session",
@@ -909,14 +1007,21 @@ async function validateOpaqueAccessToken(ctx, opts, token, clientId) {
909
1007
  value: sessionId
910
1008
  }]
911
1009
  });
912
- if (!session || session.expiresAt < /* @__PURE__ */ new Date()) sessionId = void 0;
1010
+ if (!session || session.expiresAt < /* @__PURE__ */ new Date()) return { active: false };
913
1011
  }
914
1012
  let user;
915
1013
  if (accessToken.userId) user = await ctx.context.internalAdapter.findUserById(accessToken?.userId);
1014
+ const resources = Array.isArray(accessToken.resources) ? accessToken.resources : void 0;
1015
+ const audience = resources ? [...resources] : void 0;
1016
+ if (audience?.length && accessToken.scopes?.includes("openid")) {
1017
+ const userInfoEndpoint = `${ctx.context.baseURL}/oauth2/userinfo`;
1018
+ if (!audience.includes(userInfoEndpoint)) audience.push(userInfoEndpoint);
1019
+ }
916
1020
  const customClaims = opts.customAccessTokenClaims ? await opts.customAccessTokenClaims({
917
1021
  user,
918
1022
  scopes: accessToken.scopes,
919
1023
  referenceId: accessToken?.referenceId,
1024
+ resources,
920
1025
  metadata: parseClientMetadata(client?.metadata)
921
1026
  }) : {};
922
1027
  const jwtPluginOptions = (opts.disableJwtPlugin ? void 0 : getJwtPlugin(ctx.context))?.options;
@@ -924,6 +1029,7 @@ async function validateOpaqueAccessToken(ctx, opts, token, clientId) {
924
1029
  ...customClaims,
925
1030
  active: true,
926
1031
  iss: jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL,
1032
+ aud: toAudienceClaim(audience),
927
1033
  client_id: accessToken.clientId,
928
1034
  sub: user?.id,
929
1035
  sid: sessionId,
@@ -1067,9 +1173,173 @@ async function introspectEndpoint(ctx, opts) {
1067
1173
  }
1068
1174
  //#endregion
1069
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;
1070
1180
  /**
1071
- * IMPORTANT NOTES:
1072
- * 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.
1073
1343
  *
1074
1344
  * @see https://openid.net/specs/openid-connect-rpinitiated-1_0.html
1075
1345
  */
@@ -1117,12 +1387,10 @@ async function rpInitiatedLogoutEndpoint(ctx, opts) {
1117
1387
  });
1118
1388
  const secret = await decryptStoredClientSecret(ctx, opts.storeClientSecret, clientSecret);
1119
1389
  const { payload } = await compactVerify(id_token_hint, new TextEncoder().encode(secret));
1120
- const idToken = new TextDecoder().decode(payload);
1121
- idTokenPayload = JSON.parse(idToken);
1390
+ idTokenPayload = JSON.parse(new TextDecoder().decode(payload));
1122
1391
  } else {
1123
1392
  const { payload } = await compactVerify(id_token_hint, createLocalJWKSet(await getJwks(id_token_hint, { jwksFetch: jwksUrl })));
1124
- const idToken = new TextDecoder().decode(payload);
1125
- idTokenPayload = JSON.parse(idToken);
1393
+ idTokenPayload = JSON.parse(new TextDecoder().decode(payload));
1126
1394
  }
1127
1395
  if (!idTokenPayload) throw new APIError$1("INTERNAL_SERVER_ERROR", {
1128
1396
  error_description: "missing payload",
@@ -1154,18 +1422,13 @@ async function rpInitiatedLogoutEndpoint(ctx, opts) {
1154
1422
  value: sessionId
1155
1423
  }]
1156
1424
  });
1157
- 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({
1158
1427
  model: "session",
1159
1428
  where: [{
1160
1429
  field: "id",
1161
1430
  value: session.id
1162
1431
  }]
1163
- }) : await ctx.context.adapter.delete({
1164
- model: "session",
1165
- where: [{
1166
- field: "id",
1167
- value: sessionId
1168
- }]
1169
1432
  });
1170
1433
  } catch {}
1171
1434
  if (post_logout_redirect_uri) {
@@ -1287,6 +1550,28 @@ const publicSessionMiddleware = (opts) => createAuthMiddleware(async (ctx) => {
1287
1550
  if (!await verifyOAuthQueryParams(query, ctx.context.secret)) throw new APIError("UNAUTHORIZED", { error: "invalid_signature" });
1288
1551
  });
1289
1552
  //#endregion
1553
+ //#region src/oauthClient/privileges.ts
1554
+ /**
1555
+ * Authorizes a client action against the configured `clientPrivileges` hook.
1556
+ *
1557
+ * This is the single authorization helper for every OAuth client mutation. The
1558
+ * create path enforces it at the shared creation chokepoint so that no
1559
+ * registration route can reach client persistence without it.
1560
+ *
1561
+ * @throws APIError UNAUTHORIZED when there is no session or the hook denies the action.
1562
+ * @throws APIError BAD_REQUEST when the request carries no headers.
1563
+ */
1564
+ async function assertClientPrivileges(ctx, session, opts, action) {
1565
+ if (!session) throw new APIError("UNAUTHORIZED");
1566
+ if (!ctx.headers) throw new APIError("BAD_REQUEST");
1567
+ if (opts.clientPrivileges && !await opts.clientPrivileges({
1568
+ headers: ctx.headers,
1569
+ action,
1570
+ session: session.session,
1571
+ user: session.user
1572
+ })) throw new APIError("UNAUTHORIZED");
1573
+ }
1574
+ //#endregion
1290
1575
  //#region src/register.ts
1291
1576
  /**
1292
1577
  * Resolves the auth method and type for unauthenticated DCR.
@@ -1418,10 +1703,45 @@ async function checkOAuthClient(client, opts, settings) {
1418
1703
  error: "invalid_client_metadata",
1419
1704
  error_description: "jwks and jwks_uri are only allowed with private_key_jwt authentication"
1420
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
+ }
1421
1738
  }
1422
1739
  async function createOAuthClientEndpoint(ctx, opts, settings) {
1423
1740
  const body = ctx.body;
1424
1741
  const session = await getSessionFromCtx(ctx);
1742
+ if (settings.isRegister) {
1743
+ if (session) await assertClientPrivileges(ctx, session, opts, "create");
1744
+ } else await assertClientPrivileges(ctx, session, opts, "create");
1425
1745
  const isPublic = body.token_endpoint_auth_method === "none";
1426
1746
  const isPrivateKeyJwt = body.token_endpoint_auth_method === "private_key_jwt";
1427
1747
  await checkOAuthClient(ctx.body, opts, {
@@ -1473,7 +1793,7 @@ async function createOAuthClientEndpoint(ctx, opts, settings) {
1473
1793
  * @returns
1474
1794
  */
1475
1795
  function oauthToSchema(input) {
1476
- 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;
1477
1797
  const expiresAt = _expiresAt ? /* @__PURE__ */ new Date(_expiresAt * 1e3) : void 0;
1478
1798
  const createdAt = _createdAt ? /* @__PURE__ */ new Date(_createdAt * 1e3) : void 0;
1479
1799
  const scopes = _scope?.split(" ");
@@ -1501,6 +1821,8 @@ function oauthToSchema(input) {
1501
1821
  softwareStatement,
1502
1822
  redirectUris,
1503
1823
  postLogoutRedirectUris,
1824
+ backchannelLogoutUri,
1825
+ backchannelLogoutSessionRequired,
1504
1826
  tokenEndpointAuthMethod,
1505
1827
  grantTypes,
1506
1828
  responseTypes,
@@ -1523,7 +1845,7 @@ function oauthToSchema(input) {
1523
1845
  * @returns
1524
1846
  */
1525
1847
  function schemaToOAuth(input) {
1526
- 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;
1527
1849
  const _expiresAt = expiresAt ? Math.round(new Date(expiresAt).getTime() / 1e3) : void 0;
1528
1850
  const _createdAt = createdAt ? Math.round(new Date(createdAt).getTime() / 1e3) : void 0;
1529
1851
  const _scopes = scopes?.join(" ");
@@ -1548,6 +1870,8 @@ function schemaToOAuth(input) {
1548
1870
  software_statement: softwareStatement ?? void 0,
1549
1871
  redirect_uris: redirectUris ?? [],
1550
1872
  post_logout_redirect_uris: postLogoutRedirectUris ?? void 0,
1873
+ backchannel_logout_uri: backchannelLogoutUri ?? void 0,
1874
+ backchannel_logout_session_required: backchannelLogoutSessionRequired ?? void 0,
1551
1875
  token_endpoint_auth_method: tokenEndpointAuthMethod ?? void 0,
1552
1876
  grant_types: grantTypes ?? void 0,
1553
1877
  response_types: responseTypes ?? void 0,
@@ -1767,16 +2091,6 @@ async function rotateClientSecretEndpoint(ctx, opts) {
1767
2091
  clientSecret: (opts.prefix?.clientSecret ?? "") + clientSecret
1768
2092
  });
1769
2093
  }
1770
- async function assertClientPrivileges(ctx, session, opts, action) {
1771
- if (!session) throw new APIError("UNAUTHORIZED");
1772
- if (!ctx.headers) throw new APIError("BAD_REQUEST");
1773
- if (opts.clientPrivileges && !await opts.clientPrivileges({
1774
- headers: ctx.headers,
1775
- action,
1776
- session: session.session,
1777
- user: session.user
1778
- })) throw new APIError("UNAUTHORIZED");
1779
- }
1780
2094
  //#endregion
1781
2095
  //#region src/oauthClient/index.ts
1782
2096
  const adminCreateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/create-client", {
@@ -1794,13 +2108,15 @@ const adminCreateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/creat
1794
2108
  software_version: z.string().optional(),
1795
2109
  software_statement: z.string().optional(),
1796
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(),
1797
2113
  token_endpoint_auth_method: z.enum([
1798
2114
  "none",
1799
2115
  "client_secret_basic",
1800
2116
  "client_secret_post",
1801
2117
  "private_key_jwt"
1802
2118
  ]).default("client_secret_basic").optional(),
1803
- jwks: z.union([z.array(z.record(z.string(), z.unknown())), z.object({ keys: z.array(z.record(z.string(), z.unknown())) })]).optional(),
2119
+ jwks: z.union([z.array(z.record(z.string(), z.unknown())).min(1), z.object({ keys: z.array(z.record(z.string(), z.unknown())).min(1) })]).optional(),
1804
2120
  jwks_uri: z.string().optional(),
1805
2121
  grant_types: z.array(z.enum([
1806
2122
  "authorization_code",
@@ -1962,7 +2278,6 @@ const adminCreateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/creat
1962
2278
  }
1963
2279
  }
1964
2280
  }, async (ctx) => {
1965
- await assertClientPrivileges(ctx, await getSessionFromCtx(ctx), opts, "create");
1966
2281
  return createOAuthClientEndpoint(ctx, opts, { isRegister: false });
1967
2282
  });
1968
2283
  const createOAuthClient = (opts) => createAuthEndpoint("/oauth2/create-client", {
@@ -1981,13 +2296,15 @@ const createOAuthClient = (opts) => createAuthEndpoint("/oauth2/create-client",
1981
2296
  software_version: z.string().optional(),
1982
2297
  software_statement: z.string().optional(),
1983
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(),
1984
2301
  token_endpoint_auth_method: z.enum([
1985
2302
  "none",
1986
2303
  "client_secret_basic",
1987
2304
  "client_secret_post",
1988
2305
  "private_key_jwt"
1989
2306
  ]).default("client_secret_basic").optional(),
1990
- jwks: z.union([z.array(z.record(z.string(), z.unknown())), z.object({ keys: z.array(z.record(z.string(), z.unknown())) })]).optional(),
2307
+ jwks: z.union([z.array(z.record(z.string(), z.unknown())).min(1), z.object({ keys: z.array(z.record(z.string(), z.unknown())).min(1) })]).optional(),
1991
2308
  jwks_uri: z.string().optional(),
1992
2309
  grant_types: z.array(z.enum([
1993
2310
  "authorization_code",
@@ -2135,7 +2452,6 @@ const createOAuthClient = (opts) => createAuthEndpoint("/oauth2/create-client",
2135
2452
  } }
2136
2453
  } }
2137
2454
  }, async (ctx) => {
2138
- await assertClientPrivileges(ctx, await getSessionFromCtx(ctx), opts, "create");
2139
2455
  return createOAuthClientEndpoint(ctx, opts, { isRegister: false });
2140
2456
  });
2141
2457
  const getOAuthClient = (opts) => createAuthEndpoint("/oauth2/get-client", {
@@ -2191,6 +2507,8 @@ const adminUpdateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/updat
2191
2507
  software_version: z.string().optional(),
2192
2508
  software_statement: z.string().optional(),
2193
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(),
2194
2512
  grant_types: z.array(z.enum([
2195
2513
  "authorization_code",
2196
2514
  "client_credentials",
@@ -2233,6 +2551,8 @@ const updateOAuthClient = (opts) => createAuthEndpoint("/oauth2/update-client",
2233
2551
  software_version: z.string().optional(),
2234
2552
  software_statement: z.string().optional(),
2235
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(),
2236
2556
  grant_types: z.array(z.enum([
2237
2557
  "authorization_code",
2238
2558
  "client_credentials",
@@ -2339,12 +2659,12 @@ async function updateConsentEndpoint(ctx, opts) {
2339
2659
  error_description: "no consent",
2340
2660
  error: "not_found"
2341
2661
  });
2662
+ if (consent.userId !== session.user.id) throw new APIError("UNAUTHORIZED");
2342
2663
  const client = await getClient(ctx, opts, consent.clientId);
2343
- if (!consent) throw new APIError("NOT_FOUND", {
2344
- error_description: "no consent",
2664
+ if (!client) throw new APIError("NOT_FOUND", {
2665
+ error_description: "client not found",
2345
2666
  error: "not_found"
2346
2667
  });
2347
- if (consent.userId !== session.user.id) throw new APIError("UNAUTHORIZED");
2348
2668
  const allowedScopes = client?.scopes ?? opts.scopes ?? [];
2349
2669
  const updates = ctx.body.update;
2350
2670
  const scopes = updates.scopes;
@@ -2412,7 +2732,14 @@ const deleteOAuthConsent = (opts) => createAuthEndpoint("/oauth2/delete-consent"
2412
2732
  */
2413
2733
  /**
2414
2734
  * Revokes a JWT access token against the configured JWKs.
2415
- * (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.
2416
2743
  */
2417
2744
  async function revokeJwtAccessToken(ctx, opts, token) {
2418
2745
  const jwtPlugin = opts.disableJwtPlugin ? void 0 : getJwtPlugin(ctx.context);
@@ -2439,6 +2766,10 @@ async function revokeJwtAccessToken(ctx, opts, token) {
2439
2766
  }
2440
2767
  throw new Error(error);
2441
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
+ });
2442
2773
  }
2443
2774
  /**
2444
2775
  * Searches for an opaque access token in the database and validates it
@@ -2492,16 +2823,7 @@ async function revokeRefreshToken(ctx, opts, token, clientId) {
2492
2823
  error: "invalid_request"
2493
2824
  });
2494
2825
  if (refreshToken.revoked) {
2495
- await ctx.context.adapter.deleteMany({
2496
- model: "oauthRefreshToken",
2497
- where: [{
2498
- field: "clientId",
2499
- value: clientId
2500
- }, {
2501
- field: "userId",
2502
- value: refreshToken.userId
2503
- }]
2504
- });
2826
+ await invalidateRefreshFamily(ctx, clientId, refreshToken.userId);
2505
2827
  throw new APIError$1("BAD_REQUEST", {
2506
2828
  error_description: "refresh token revoked",
2507
2829
  error: "invalid_request"
@@ -2509,20 +2831,31 @@ async function revokeRefreshToken(ctx, opts, token, clientId) {
2509
2831
  }
2510
2832
  if (!refreshToken.clientId || refreshToken.clientId !== clientId) return null;
2511
2833
  const iat = Math.floor(Date.now() / 1e3);
2512
- await Promise.allSettled([ctx.context.adapter.deleteMany({
2513
- model: "oauthAccessToken",
2514
- where: [{
2515
- field: "refreshId",
2516
- value: refreshToken.id
2517
- }]
2518
- }), ctx.context.adapter.update({
2834
+ if (!await ctx.context.adapter.update({
2519
2835
  model: "oauthRefreshToken",
2520
2836
  where: [{
2521
2837
  field: "id",
2522
2838
  value: refreshToken.id
2839
+ }, {
2840
+ field: "revoked",
2841
+ operator: "eq",
2842
+ value: null
2523
2843
  }],
2524
2844
  update: { revoked: /* @__PURE__ */ new Date(iat * 1e3) }
2525
- })]);
2845
+ })) {
2846
+ await invalidateRefreshFamily(ctx, clientId, refreshToken.userId);
2847
+ throw new APIError$1("BAD_REQUEST", {
2848
+ error_description: "refresh token revoked",
2849
+ error: "invalid_request"
2850
+ });
2851
+ }
2852
+ await ctx.context.adapter.deleteMany({
2853
+ model: "oauthAccessToken",
2854
+ where: [{
2855
+ field: "refreshId",
2856
+ value: refreshToken.id
2857
+ }]
2858
+ });
2526
2859
  }
2527
2860
  /**
2528
2861
  * We don't know the access token format so we try to validate it
@@ -2532,7 +2865,9 @@ async function revokeAccessToken(ctx, opts, clientId, token) {
2532
2865
  try {
2533
2866
  return await revokeJwtAccessToken(ctx, opts, token);
2534
2867
  } catch (err) {
2535
- 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;
2536
2871
  else throw new Error(err);
2537
2872
  }
2538
2873
  try {
@@ -2565,7 +2900,7 @@ async function revokeEndpoint(ctx, opts) {
2565
2900
  return await revokeAccessToken(ctx, opts, client.clientId, token);
2566
2901
  } catch (error) {
2567
2902
  if (error instanceof APIError$1) {
2568
- if (token_type_hint === "access_token") throw error;
2903
+ if (token_type_hint === "access_token" || error.body?.error === "unsupported_token_type") throw error;
2569
2904
  } else if (error instanceof Error) throw error;
2570
2905
  else throw new Error(error);
2571
2906
  }
@@ -2583,6 +2918,7 @@ async function revokeEndpoint(ctx, opts) {
2583
2918
  });
2584
2919
  } catch (error) {
2585
2920
  if (error instanceof APIError$1) {
2921
+ if (error.body?.error === "unsupported_token_type") throw error;
2586
2922
  if (error.name === "BAD_REQUEST") return null;
2587
2923
  throw error;
2588
2924
  } else if (error instanceof Error) {
@@ -2691,6 +3027,14 @@ const schema = {
2691
3027
  type: "string[]",
2692
3028
  required: false
2693
3029
  },
3030
+ backchannelLogoutUri: {
3031
+ type: "string",
3032
+ required: false
3033
+ },
3034
+ backchannelLogoutSessionRequired: {
3035
+ type: "boolean",
3036
+ required: false
3037
+ },
2694
3038
  tokenEndpointAuthMethod: {
2695
3039
  type: "string",
2696
3040
  required: false
@@ -2736,7 +3080,8 @@ const schema = {
2736
3080
  oauthRefreshToken: { fields: {
2737
3081
  token: {
2738
3082
  type: "string",
2739
- required: true
3083
+ required: true,
3084
+ unique: true
2740
3085
  },
2741
3086
  clientId: {
2742
3087
  type: "string",
@@ -2770,6 +3115,10 @@ const schema = {
2770
3115
  type: "string",
2771
3116
  required: false
2772
3117
  },
3118
+ resources: {
3119
+ type: "string[]",
3120
+ required: false
3121
+ },
2773
3122
  expiresAt: { type: "date" },
2774
3123
  createdAt: { type: "date" },
2775
3124
  revoked: {
@@ -2824,6 +3173,10 @@ const schema = {
2824
3173
  type: "string",
2825
3174
  required: false
2826
3175
  },
3176
+ resources: {
3177
+ type: "string[]",
3178
+ required: false
3179
+ },
2827
3180
  refreshId: {
2828
3181
  type: "string",
2829
3182
  required: false,
@@ -2835,6 +3188,10 @@ const schema = {
2835
3188
  },
2836
3189
  expiresAt: { type: "date" },
2837
3190
  createdAt: { type: "date" },
3191
+ revoked: {
3192
+ type: "date",
3193
+ required: false
3194
+ },
2838
3195
  scopes: {
2839
3196
  type: "string[]",
2840
3197
  required: true
@@ -2866,6 +3223,10 @@ const schema = {
2866
3223
  type: "string",
2867
3224
  required: false
2868
3225
  },
3226
+ resources: {
3227
+ type: "string[]",
3228
+ required: false
3229
+ },
2869
3230
  scopes: {
2870
3231
  type: "string[]",
2871
3232
  required: true
@@ -2873,12 +3234,25 @@ const schema = {
2873
3234
  createdAt: { type: "date" },
2874
3235
  updatedAt: { type: "date" }
2875
3236
  }
3237
+ },
3238
+ oauthClientAssertion: {
3239
+ modelName: "oauthClientAssertion",
3240
+ fields: { expiresAt: {
3241
+ type: "date",
3242
+ required: true
3243
+ } }
2876
3244
  }
2877
3245
  };
2878
3246
  //#endregion
2879
3247
  //#region src/oauth.ts
2880
3248
  const oAuthState = defineRequestState(() => null);
2881
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
+ }
2882
3256
  /**
2883
3257
  * oAuth 2.1 provider plugin for Better Auth.
2884
3258
  *
@@ -2943,10 +3317,195 @@ const oauthProvider = (options) => {
2943
3317
  if (opts.grantTypes && opts.grantTypes.includes("refresh_token") && !opts.grantTypes.includes("authorization_code")) throw new BetterAuthError("refresh_token grant requires authorization_code grant");
2944
3318
  if (opts.disableJwtPlugin && (opts.storeClientSecret === "hashed" || typeof opts.storeClientSecret === "object" && "hash" in opts.storeClientSecret)) throw new BetterAuthError("unable to store hashed secrets because id tokens will be signed with secret");
2945
3319
  if (!opts.disableJwtPlugin && (opts.storeClientSecret === "encrypted" || typeof opts.storeClientSecret === "object" && ("encrypt" in opts.storeClientSecret || "decrypt" in opts.storeClientSecret))) throw new BetterAuthError("encryption method not recommended, please use 'hashed' or the 'hash' function");
3320
+ const handleIssuerMetadataRequest = async (request, ctx) => {
3321
+ const requestPathname = new URL(request.url).pathname;
3322
+ const requestPath = ctx.options.advanced?.skipTrailingSlashes ? requestPathname.replace(/\/+$/, "") || "/" : requestPathname;
3323
+ const issuer = opts.disableJwtPlugin ? ctx.baseURL : getJwtPlugin(ctx)?.options?.jwt?.issuer ?? ctx.baseURL;
3324
+ let issuerPath = "/";
3325
+ try {
3326
+ issuerPath = new URL(issuer).pathname.replace(/\/$/, "") || "";
3327
+ } catch {
3328
+ issuerPath = new URL(ctx.baseURL).pathname.replace(/\/$/, "") || "";
3329
+ }
3330
+ const endpointCtx = { context: ctx };
3331
+ const authServerMetadataPaths = new Set([`/.well-known/oauth-authorization-server${issuerPath}`, `${issuerPath}/.well-known/oauth-authorization-server`]);
3332
+ const openIdConfigPath = `${issuerPath}/.well-known/openid-configuration`;
3333
+ const isAuthServerMetadataRequest = authServerMetadataPaths.has(requestPath);
3334
+ const isOpenIdConfigRequest = opts.scopes?.includes("openid") && requestPath === openIdConfigPath;
3335
+ const createMetadataResponse = (metadata) => {
3336
+ const response = metadataResponse(metadata);
3337
+ if (request.method === "HEAD") return new Response(null, {
3338
+ status: response.status,
3339
+ headers: response.headers
3340
+ });
3341
+ return response;
3342
+ };
3343
+ if (isAuthServerMetadataRequest || isOpenIdConfigRequest) {
3344
+ if (request.method !== "GET" && request.method !== "HEAD") return { response: new Response(null, {
3345
+ status: 405,
3346
+ headers: { Allow: "GET, HEAD" }
3347
+ }) };
3348
+ }
3349
+ if (isAuthServerMetadataRequest) {
3350
+ if (opts.scopes?.includes("openid")) return { response: createMetadataResponse(oidcServerMetadata(endpointCtx, opts)) };
3351
+ return { response: createMetadataResponse({
3352
+ ...authServerMetadata(endpointCtx, opts.disableJwtPlugin ? void 0 : getJwtPlugin(ctx)?.options, {
3353
+ scopes_supported: opts.advertisedMetadata?.scopes_supported ?? opts.scopes,
3354
+ dynamic_client_registration_supported: opts.allowDynamicClientRegistration,
3355
+ public_client_supported: opts.allowUnauthenticatedClientRegistration || toClientDiscoveryArray(opts.clientDiscovery).length > 0,
3356
+ grant_types_supported: opts.grantTypes,
3357
+ jwt_disabled: opts.disableJwtPlugin
3358
+ }),
3359
+ ...mergeDiscoveryMetadata(opts.clientDiscovery)
3360
+ }) };
3361
+ }
3362
+ if (isOpenIdConfigRequest) return { response: createMetadataResponse(oidcServerMetadata(endpointCtx, opts)) };
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
+ });
2946
3504
  return {
2947
3505
  id: "oauth-provider",
2948
3506
  version: PACKAGE_VERSION,
2949
3507
  options: opts,
3508
+ onRequest: handleIssuerMetadataRequest,
2950
3509
  init: (ctx) => {
2951
3510
  if (ctx.options.secondaryStorage && ctx.options.session?.storeSessionInDatabase !== true) throw new BetterAuthError("OAuth Provider requires `session.storeSessionInDatabase: true` when using secondaryStorage");
2952
3511
  if (!opts.disableJwtPlugin) {
@@ -2957,12 +3516,20 @@ const oauthProvider = (options) => {
2957
3516
  try {
2958
3517
  issuerPath = new URL(issuer).pathname;
2959
3518
  } catch (error) {
2960
- if (isDynamicBaseURLInit && issuer === "") return;
2961
- throw error;
3519
+ if (!isDynamicBaseURLInit || issuer !== "") throw error;
2962
3520
  }
2963
- 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.`);
2964
- 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.`);
2965
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
+ } } } } } };
2966
3533
  },
2967
3534
  hooks: {
2968
3535
  before: [{
@@ -2984,11 +3551,10 @@ const oauthProvider = (options) => {
2984
3551
  signedQueryIssuedAt: signedQueryIssuedAt ?? void 0,
2985
3552
  postLoginClearedForSession
2986
3553
  });
2987
- if (ctx.path === "/sign-in/social") {
2988
- if (ctx.body.additionalData?.query) return;
2989
- if (!ctx.body.additionalData) ctx.body.additionalData = {};
2990
- ctx.body.additionalData.query = queryParams.toString();
2991
- }
3554
+ if (ctx.path === "/sign-in/social") await addOAuthServerContext({
3555
+ query: queryParams.toString(),
3556
+ ...signedQueryIssuedAt ? { [signedQueryIssuedAtMsKey]: signedQueryIssuedAt.getTime() } : {}
3557
+ });
2992
3558
  })
2993
3559
  }],
2994
3560
  after: [{
@@ -2998,7 +3564,9 @@ const oauthProvider = (options) => {
2998
3564
  handler: createAuthMiddleware(async (ctx) => {
2999
3565
  const sessionToken = parseSetCookieHeader(ctx.context.responseHeaders?.get("set-cookie") || "").get(ctx.context.authCookies.sessionToken.name)?.value.split(".")[0];
3000
3566
  if (!sessionToken) return;
3001
- 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;
3002
3570
  if (!_query) return;
3003
3571
  const query = new URLSearchParams(_query);
3004
3572
  const session = await ctx.context.internalAdapter.findSession(sessionToken);
@@ -3007,8 +3575,11 @@ const oauthProvider = (options) => {
3007
3575
  const secFetchMode = ctx.request?.headers?.get("sec-fetch-mode")?.toLowerCase();
3008
3576
  const acceptHeader = ctx.request?.headers?.get("accept")?.toLowerCase() ?? "";
3009
3577
  if (!(secFetchMode === "navigate" || !secFetchMode && (acceptHeader.includes("text/html") || acceptHeader.includes("application/xhtml+xml")))) ctx.headers?.set("accept", "application/json");
3010
- ctx.query = searchParamsToQuery(removePromptFromQuery(query, "login"));
3011
- 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);
3012
3583
  })
3013
3584
  }]
3014
3585
  },
@@ -3021,6 +3592,7 @@ const oauthProvider = (options) => {
3021
3592
  else return {
3022
3593
  ...authServerMetadata(ctx, opts.disableJwtPlugin ? void 0 : getJwtPlugin(ctx.context)?.options, {
3023
3594
  scopes_supported: opts.advertisedMetadata?.scopes_supported ?? opts.scopes,
3595
+ dynamic_client_registration_supported: opts.allowDynamicClientRegistration,
3024
3596
  public_client_supported: opts.allowUnauthenticatedClientRegistration || toClientDiscoveryArray(opts.clientDiscovery).length > 0,
3025
3597
  grant_types_supported: opts.grantTypes,
3026
3598
  jwt_disabled: opts.disableJwtPlugin
@@ -3035,135 +3607,7 @@ const oauthProvider = (options) => {
3035
3607
  if (opts.scopes && !opts.scopes.includes("openid")) throw new APIError("NOT_FOUND");
3036
3608
  return oidcServerMetadata(ctx, opts);
3037
3609
  }),
3038
- oauth2Authorize: createOAuthEndpoint("/oauth2/authorize", {
3039
- method: "GET",
3040
- query: z.object({
3041
- response_type: z.string().pipe(z.enum(["code"])).optional(),
3042
- client_id: z.string(),
3043
- redirect_uri: SafeUrlSchema.optional(),
3044
- scope: z.string().optional(),
3045
- state: z.string().optional(),
3046
- request_uri: z.string().optional(),
3047
- code_challenge: z.string().optional(),
3048
- code_challenge_method: z.string().pipe(z.enum(["S256"])).optional(),
3049
- nonce: z.string().optional(),
3050
- prompt: z.string().pipe(z.enum([
3051
- "none",
3052
- "consent",
3053
- "login",
3054
- "create",
3055
- "select_account",
3056
- "login consent",
3057
- "select_account consent"
3058
- ])).optional()
3059
- }),
3060
- redirectOnError: authorizeRedirectOnError(opts),
3061
- errorCodesByField: { response_type: { invalid: "unsupported_response_type" } },
3062
- metadata: { openapi: {
3063
- description: "Authorize an OAuth2 request",
3064
- parameters: [
3065
- {
3066
- name: "response_type",
3067
- in: "query",
3068
- required: false,
3069
- schema: { type: "string" },
3070
- description: "OAuth2 response type (e.g., 'code')"
3071
- },
3072
- {
3073
- name: "client_id",
3074
- in: "query",
3075
- required: true,
3076
- schema: { type: "string" },
3077
- description: "OAuth2 client ID"
3078
- },
3079
- {
3080
- name: "redirect_uri",
3081
- in: "query",
3082
- required: false,
3083
- schema: {
3084
- type: "string",
3085
- format: "uri"
3086
- },
3087
- description: "OAuth2 redirect URI"
3088
- },
3089
- {
3090
- name: "scope",
3091
- in: "query",
3092
- required: false,
3093
- schema: { type: "string" },
3094
- description: "OAuth2 scopes (space-separated)"
3095
- },
3096
- {
3097
- name: "state",
3098
- in: "query",
3099
- required: false,
3100
- schema: { type: "string" },
3101
- description: "OAuth2 state parameter"
3102
- },
3103
- {
3104
- name: "request_uri",
3105
- in: "query",
3106
- required: false,
3107
- schema: { type: "string" },
3108
- description: "Pushed Authorization Request URI referencing stored parameters"
3109
- },
3110
- {
3111
- name: "code_challenge",
3112
- in: "query",
3113
- required: false,
3114
- schema: { type: "string" },
3115
- description: "PKCE code challenge"
3116
- },
3117
- {
3118
- name: "code_challenge_method",
3119
- in: "query",
3120
- required: false,
3121
- schema: { type: "string" },
3122
- description: "PKCE code challenge method"
3123
- },
3124
- {
3125
- name: "nonce",
3126
- in: "query",
3127
- required: false,
3128
- schema: { type: "string" },
3129
- description: "OpenID Connect nonce"
3130
- },
3131
- {
3132
- name: "prompt",
3133
- in: "query",
3134
- required: false,
3135
- schema: { type: "string" },
3136
- description: "OAuth2 prompt parameter"
3137
- }
3138
- ],
3139
- responses: {
3140
- "302": {
3141
- description: "Redirect to client with code or error",
3142
- headers: { Location: {
3143
- description: "Redirect URI with code or error",
3144
- schema: {
3145
- type: "string",
3146
- format: "uri"
3147
- }
3148
- } }
3149
- },
3150
- "400": {
3151
- description: "Invalid request",
3152
- content: { "application/json": { schema: {
3153
- type: "object",
3154
- properties: {
3155
- error: { type: "string" },
3156
- error_description: { type: "string" },
3157
- state: { type: "string" }
3158
- },
3159
- required: ["error"]
3160
- } } }
3161
- }
3162
- }
3163
- } }
3164
- }, async (ctx) => {
3165
- return authorizeEndpoint(ctx, opts, { isAuthorize: true });
3166
- }),
3610
+ oauth2Authorize: oauth2AuthorizeEndpoint,
3167
3611
  oauth2Consent: createAuthEndpoint("/oauth2/consent", {
3168
3612
  method: "POST",
3169
3613
  body: z.object({
@@ -3188,7 +3632,7 @@ const oauthProvider = (options) => {
3188
3632
  } }
3189
3633
  } }
3190
3634
  }, async (ctx) => {
3191
- return consentEndpoint(ctx, opts);
3635
+ return consentEndpoint(ctx, opts, runOAuth2Authorize);
3192
3636
  }),
3193
3637
  oauth2Continue: createAuthEndpoint("/oauth2/continue", {
3194
3638
  method: "POST",
@@ -3215,7 +3659,7 @@ const oauthProvider = (options) => {
3215
3659
  } }
3216
3660
  } }
3217
3661
  }, async (ctx) => {
3218
- return continueEndpoint(ctx, opts);
3662
+ return continueEndpoint(ctx, runOAuth2Authorize);
3219
3663
  }),
3220
3664
  oauth2Token: createOAuthEndpoint("/oauth2/token", {
3221
3665
  method: "POST",
@@ -3233,13 +3677,16 @@ const oauthProvider = (options) => {
3233
3677
  code_verifier: z.string().optional(),
3234
3678
  redirect_uri: SafeUrlSchema.optional(),
3235
3679
  refresh_token: z.string().optional(),
3236
- resource: z.string().optional(),
3680
+ resource: z.union([ResourceUriSchema, z.array(ResourceUriSchema).min(1)]).optional(),
3237
3681
  scope: z.string().optional()
3238
3682
  }),
3239
- errorCodesByField: { grant_type: {
3240
- missing: "invalid_request",
3241
- invalid: "unsupported_grant_type"
3242
- } },
3683
+ errorCodesByField: {
3684
+ grant_type: {
3685
+ missing: "invalid_request",
3686
+ invalid: "unsupported_grant_type"
3687
+ },
3688
+ resource: { invalid: "invalid_target" }
3689
+ },
3243
3690
  metadata: {
3244
3691
  allowedMediaTypes: ["application/x-www-form-urlencoded"],
3245
3692
  openapi: {
@@ -3284,8 +3731,15 @@ const oauthProvider = (options) => {
3284
3731
  description: "Refresh token (for refresh_token grant)"
3285
3732
  },
3286
3733
  resource: {
3287
- type: "string",
3288
- description: "Requested token resource (ie audience) to obtain a JWT formatted access token"
3734
+ oneOf: [{
3735
+ type: "string",
3736
+ description: "Single resource (URL)"
3737
+ }, {
3738
+ type: "array",
3739
+ items: { type: "string" },
3740
+ description: "Multiple resources (URLs)"
3741
+ }],
3742
+ description: "Requested token resource(s) (ie audience) to obtain a JWT formatted access token"
3289
3743
  },
3290
3744
  scope: {
3291
3745
  type: "string",
@@ -3386,10 +3840,6 @@ const oauthProvider = (options) => {
3386
3840
  token_type_hint: {
3387
3841
  type: "string",
3388
3842
  description: "Hint about the token type. Recognized values: `access_token`, `refresh_token`."
3389
- },
3390
- resource: {
3391
- type: "string",
3392
- description: "Introspects a token for a specific resource."
3393
3843
  }
3394
3844
  },
3395
3845
  required: ["token"]
@@ -3537,7 +3987,7 @@ const oauthProvider = (options) => {
3537
3987
  return revokeEndpoint(ctx, opts);
3538
3988
  }),
3539
3989
  oauth2UserInfo: createAuthEndpoint("/oauth2/userinfo", {
3540
- method: "GET",
3990
+ method: ["GET", "POST"],
3541
3991
  metadata: { openapi: {
3542
3992
  description: "Get OpenID Connect user information (UserInfo endpoint)",
3543
3993
  security: [{ bearerAuth: [] }, { OAuth2: [
@@ -3671,13 +4121,15 @@ const oauthProvider = (options) => {
3671
4121
  software_version: z.string().optional(),
3672
4122
  software_statement: z.string().optional(),
3673
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(),
3674
4126
  token_endpoint_auth_method: z.enum([
3675
4127
  "none",
3676
4128
  "client_secret_basic",
3677
4129
  "client_secret_post",
3678
4130
  "private_key_jwt"
3679
4131
  ]).default("client_secret_basic").optional(),
3680
- jwks: z.union([z.array(z.record(z.string(), z.unknown())), z.object({ keys: z.array(z.record(z.string(), z.unknown())) })]).optional(),
4132
+ jwks: z.union([z.array(z.record(z.string(), z.unknown())).min(1), z.object({ keys: z.array(z.record(z.string(), z.unknown())).min(1) })]).optional(),
3681
4133
  jwks_uri: z.string().optional(),
3682
4134
  grant_types: z.array(z.enum([
3683
4135
  "authorization_code",
@@ -3783,6 +4235,15 @@ const oauthProvider = (options) => {
3783
4235
  },
3784
4236
  description: "List of allowed logout redirect uris"
3785
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
+ },
3786
4247
  token_endpoint_auth_method: {
3787
4248
  type: "string",
3788
4249
  description: "Requested authentication method for the token endpoint",
@@ -3891,6 +4352,19 @@ const oauthProvider = (options) => {
3891
4352
  //#endregion
3892
4353
  //#region src/authorize.ts
3893
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
+ /**
3894
4368
  * Formats an error url. Per OIDC Core 1.0 §5 / RFC 6749 §4.2.2.1, errors on
3895
4369
  * implicit and hybrid flows are delivered in the URL fragment, not the query.
3896
4370
  * Callers on the code flow (default) omit `mode` and get query delivery.
@@ -4037,6 +4511,7 @@ async function authorizeEndpoint(ctx, opts, settings) {
4037
4511
  error_description: "request not found",
4038
4512
  error: "invalid_request"
4039
4513
  });
4514
+ const request = ctx.request;
4040
4515
  let query = ctx.query;
4041
4516
  if (query.request_uri) {
4042
4517
  if (!opts.requestUriResolver) return handleRedirect(ctx, getErrorURL(ctx, "invalid_request_uri", "request_uri not supported"));
@@ -4051,6 +4526,14 @@ async function authorizeEndpoint(ctx, opts, settings) {
4051
4526
  if (urlClientId) query.client_id = urlClientId;
4052
4527
  }
4053
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;
4054
4537
  await oAuthState.set({ query: serializeAuthorizationQuery(query).toString() });
4055
4538
  if (!query.client_id) return handleRedirect(ctx, getErrorURL(ctx, "invalid_client", "client_id is required"));
4056
4539
  if (!query.response_type) return handleRedirect(ctx, getErrorURL(ctx, "invalid_request", "response_type is required"));
@@ -4061,6 +4544,7 @@ async function authorizeEndpoint(ctx, opts, settings) {
4061
4544
  const client = await getClient(ctx, opts, query.client_id);
4062
4545
  if (!client) return handleRedirect(ctx, getErrorURL(ctx, "invalid_client", "client_id is required"));
4063
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"));
4064
4548
  if (!findRegisteredRedirectUri(client.redirectUris, query.redirect_uri) || !query.redirect_uri) return handleRedirect(ctx, getErrorURL(ctx, "invalid_redirect", "invalid redirect uri"));
4065
4549
  let requestedScopes = query.scope?.split(" ").filter((s) => s);
4066
4550
  if (requestedScopes) {
@@ -4082,15 +4566,24 @@ async function authorizeEndpoint(ctx, opts, settings) {
4082
4566
  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)));
4083
4567
  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)));
4084
4568
  }
4569
+ const resource = query.resource;
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)));
4571
+ const requestedResources = toResourceList(resource) ?? [];
4085
4572
  const session = await getSessionFromCtx(ctx);
4086
- 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")) {
4087
4576
  if (promptNone) return redirectWithPromptNoneError(ctx, opts, query, "login_required", "authentication required");
4088
4577
  return redirectWithPromptCode(ctx, opts, promptSet?.has("create") ? "create" : "login");
4089
4578
  }
4579
+ if (hasSatisfiedMaxAge) {
4580
+ query = removeMaxAgeFromAuthorizationQuery(query);
4581
+ ctx.query = query;
4582
+ }
4090
4583
  if (settings?.isAuthorize && promptSet?.has("select_account")) return redirectWithPromptCode(ctx, opts, "select_account");
4091
4584
  if (settings?.isAuthorize && opts.selectAccount) {
4092
4585
  if (await opts.selectAccount.shouldRedirect({
4093
- headers: ctx.request.headers,
4586
+ headers: request.headers,
4094
4587
  user: session.user,
4095
4588
  session: session.session,
4096
4589
  scopes: requestedScopes
@@ -4101,7 +4594,7 @@ async function authorizeEndpoint(ctx, opts, settings) {
4101
4594
  }
4102
4595
  if (opts.signup?.shouldRedirect) {
4103
4596
  const signupRedirect = await opts.signup.shouldRedirect({
4104
- headers: ctx.request.headers,
4597
+ headers: request.headers,
4105
4598
  user: session.user,
4106
4599
  session: session.session,
4107
4600
  scopes: requestedScopes
@@ -4113,7 +4606,7 @@ async function authorizeEndpoint(ctx, opts, settings) {
4113
4606
  }
4114
4607
  if (!settings?.postLogin && opts.postLogin) {
4115
4608
  if (await opts.postLogin.shouldRedirect({
4116
- headers: ctx.request.headers,
4609
+ headers: request.headers,
4117
4610
  user: session.user,
4118
4611
  session: session.session,
4119
4612
  scopes: requestedScopes
@@ -4134,7 +4627,8 @@ async function authorizeEndpoint(ctx, opts, settings) {
4134
4627
  userId: session.user.id,
4135
4628
  sessionId: session.session.id,
4136
4629
  authTime: new Date(session.session.createdAt).getTime(),
4137
- referenceId
4630
+ referenceId,
4631
+ resource: requestedResources
4138
4632
  });
4139
4633
  const consent = await ctx.context.adapter.findOne({
4140
4634
  model: "oauthConsent",
@@ -4157,13 +4651,19 @@ async function authorizeEndpoint(ctx, opts, settings) {
4157
4651
  if (promptNone) return redirectWithPromptNoneError(ctx, opts, query, "consent_required", "End-User consent is required");
4158
4652
  return redirectWithPromptCode(ctx, opts, "consent", { sessionId: session.session.id });
4159
4653
  }
4654
+ const consentedResources = consent?.resources ?? [];
4655
+ if (requestedResources.some((requestedResource) => !consentedResources.includes(requestedResource))) {
4656
+ if (promptNone) return redirectWithPromptNoneError(ctx, opts, query, "consent_required", "End-User consent is required");
4657
+ return redirectWithPromptCode(ctx, opts, "consent", { sessionId: session.session.id });
4658
+ }
4160
4659
  return redirectWithAuthorizationCode(ctx, opts, {
4161
4660
  query,
4162
4661
  clientId: client.clientId,
4163
4662
  userId: session.user.id,
4164
4663
  sessionId: session.session.id,
4165
4664
  authTime: new Date(session.session.createdAt).getTime(),
4166
- referenceId
4665
+ referenceId,
4666
+ resource: requestedResources
4167
4667
  });
4168
4668
  }
4169
4669
  function serializeAuthorizationQuery(query) {
@@ -4189,7 +4689,8 @@ async function redirectWithAuthorizationCode(ctx, opts, verificationValue) {
4189
4689
  userId: verificationValue.userId,
4190
4690
  sessionId: verificationValue?.sessionId,
4191
4691
  referenceId: verificationValue.referenceId,
4192
- authTime: verificationValue.authTime
4692
+ authTime: verificationValue.authTime,
4693
+ resource: verificationValue.resource
4193
4694
  })
4194
4695
  };
4195
4696
  await ctx.context.internalAdapter.createVerificationValue({
@@ -4229,13 +4730,14 @@ async function signParams(ctx, opts, flags) {
4229
4730
  //#region src/metadata.ts
4230
4731
  function authServerMetadata(ctx, opts, overrides) {
4231
4732
  const baseURL = ctx.context.baseURL;
4733
+ const backchannelSupported = !overrides?.jwt_disabled;
4232
4734
  return {
4233
4735
  scopes_supported: overrides?.scopes_supported,
4234
4736
  issuer: validateIssuerUrl(opts?.jwt?.issuer ?? baseURL),
4235
4737
  authorization_endpoint: `${baseURL}/oauth2/authorize`,
4236
4738
  token_endpoint: `${baseURL}/oauth2/token`,
4237
4739
  jwks_uri: overrides?.jwt_disabled ? void 0 : opts?.jwks?.remoteUrl ?? `${baseURL}${opts?.jwks?.jwksPath ?? "/jwks"}`,
4238
- registration_endpoint: `${baseURL}/oauth2/register`,
4740
+ registration_endpoint: overrides?.dynamic_client_registration_supported ? `${baseURL}/oauth2/register` : void 0,
4239
4741
  introspection_endpoint: `${baseURL}/oauth2/introspect`,
4240
4742
  revocation_endpoint: `${baseURL}/oauth2/revoke`,
4241
4743
  response_types_supported: overrides?.grant_types_supported && !overrides.grant_types_supported.includes("authorization_code") ? [] : ["code"],
@@ -4251,21 +4753,23 @@ function authServerMetadata(ctx, opts, overrides) {
4251
4753
  "client_secret_post",
4252
4754
  "private_key_jwt"
4253
4755
  ],
4254
- token_endpoint_auth_signing_alg_values_supported: [...ASSERTION_SIGNING_ALGORITHMS],
4756
+ token_endpoint_auth_signing_alg_values_supported: [...PRIVATE_KEY_JWT_SIGNING_ALGORITHMS],
4255
4757
  introspection_endpoint_auth_methods_supported: [
4256
4758
  "client_secret_basic",
4257
4759
  "client_secret_post",
4258
4760
  "private_key_jwt"
4259
4761
  ],
4260
- introspection_endpoint_auth_signing_alg_values_supported: [...ASSERTION_SIGNING_ALGORITHMS],
4762
+ introspection_endpoint_auth_signing_alg_values_supported: [...PRIVATE_KEY_JWT_SIGNING_ALGORITHMS],
4261
4763
  revocation_endpoint_auth_methods_supported: [
4262
4764
  "client_secret_basic",
4263
4765
  "client_secret_post",
4264
4766
  "private_key_jwt"
4265
4767
  ],
4266
- revocation_endpoint_auth_signing_alg_values_supported: [...ASSERTION_SIGNING_ALGORITHMS],
4768
+ revocation_endpoint_auth_signing_alg_values_supported: [...PRIVATE_KEY_JWT_SIGNING_ALGORITHMS],
4267
4769
  code_challenge_methods_supported: ["S256"],
4268
- authorization_response_iss_parameter_supported: true
4770
+ authorization_response_iss_parameter_supported: true,
4771
+ backchannel_logout_supported: backchannelSupported,
4772
+ backchannel_logout_session_supported: backchannelSupported
4269
4773
  };
4270
4774
  }
4271
4775
  function oidcServerMetadata(ctx, opts) {
@@ -4274,6 +4778,7 @@ function oidcServerMetadata(ctx, opts) {
4274
4778
  return {
4275
4779
  ...authServerMetadata(ctx, jwtPluginOptions, {
4276
4780
  scopes_supported: opts.advertisedMetadata?.scopes_supported ?? opts.scopes,
4781
+ dynamic_client_registration_supported: opts.allowDynamicClientRegistration,
4277
4782
  public_client_supported: opts.allowUnauthenticatedClientRegistration || toClientDiscoveryArray(opts.clientDiscovery).length > 0,
4278
4783
  grant_types_supported: opts.grantTypes,
4279
4784
  jwt_disabled: opts.disableJwtPlugin