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

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-CderPEmR.mjs";
1
+ import { n as isPrivateHostname } from "./client-assertion-DLMKVgoj.mjs";
2
2
  import { n as mcpHandler } from "./mcp-CYnz-MXn.mjs";
3
- import { _ as storeClientSecret, a as getClient, b as validateClientCredentials, c as getStoredToken, d as normalizeTimestampValue, f as parseClientMetadata, g as searchParamsToQuery, h as resolveSubjectIdentifier, i as extractClientCredentials, l as isPKCERequired, m as resolveSessionAuthTime, n as deleteFromPrompt, o as getJwtPlugin, p as parsePrompt, r as destructureCredentials, t as decryptStoredClientSecret, u as mergeDiscoveryMetadata, v as storeToken, x as verifyOAuthQueryParams, y as toClientDiscoveryArray } from "./utils-Cx_XnD9i.mjs";
4
- import { t as PACKAGE_VERSION } from "./version-CZxZ64qJ.mjs";
3
+ import { C as toAudienceClaim, D as verifyOAuthQueryParams, E as validateClientCredentials, S as storeToken, T as toResourceList, _ as resolveSessionAuthTime, a as getClient, b as signedQueryIssuedAtParam, c as getSignedQueryIssuedAt, d as mergeDiscoveryMetadata, f as normalizeTimestampValue, g as removePromptFromQuery, h as postLoginClearedParam, i as extractClientCredentials, l as getStoredToken, m as parsePrompt, n as decryptStoredClientSecret, o as getJwtPlugin, p as parseClientMetadata, r as destructureCredentials, t as checkResource, u as isPKCERequired, v as resolveSubjectIdentifier, w as toClientDiscoveryArray, x as storeClientSecret, y as searchParamsToQuery } from "./utils-DKBWQ8fe.mjs";
4
+ import { t as PACKAGE_VERSION } from "./version-nFnRm-a3.mjs";
5
5
  import { APIError, createAuthEndpoint, createAuthMiddleware, 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,9 +17,11 @@ 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
22
  async function consentEndpoint(ctx, opts) {
22
- const _query = (await oAuthState.get())?.query;
23
+ const oauthRequest = await oAuthState.get();
24
+ const _query = oauthRequest?.query;
23
25
  if (!_query) throw new APIError("BAD_REQUEST", {
24
26
  error_description: "missing oauth query",
25
27
  error: "invalid_request"
@@ -43,6 +45,17 @@ async function consentEndpoint(ctx, opts) {
43
45
  url: formatErrorURL(query.get("redirect_uri") ?? "", "access_denied", "User denied access", query.get("state") ?? void 0, getIssuer(ctx, opts))
44
46
  };
45
47
  const session = await getSessionFromCtx(ctx);
48
+ const hasLoginPrompt = parsePrompt(query.get("prompt") ?? "").has("login");
49
+ const hasSatisfiedLoginPrompt = hasLoginPrompt && sessionSatisfiesLoginPrompt(session?.session.createdAt, oauthRequest?.signedQueryIssuedAt);
50
+ if (hasLoginPrompt && !hasSatisfiedLoginPrompt) {
51
+ ctx?.headers?.set("accept", "application/json");
52
+ ctx.query = searchParamsToQuery(query);
53
+ const { url } = await authorizeEndpoint(ctx, opts);
54
+ return {
55
+ redirect: true,
56
+ url
57
+ };
58
+ }
46
59
  const referenceId = await opts.postLogin?.consentReferenceId?.({
47
60
  user: session?.user,
48
61
  session: session?.session,
@@ -66,12 +79,14 @@ async function consentEndpoint(ctx, opts) {
66
79
  ]
67
80
  });
68
81
  const iat = Math.floor(Date.now() / 1e3);
82
+ const resource = query.getAll("resource");
69
83
  const consent = {
70
84
  clientId,
71
85
  userId: session?.user.id,
72
86
  scopes: requestedScopes ?? originalRequestedScopes,
73
87
  createdAt: /* @__PURE__ */ new Date(iat * 1e3),
74
88
  updatedAt: /* @__PURE__ */ new Date(iat * 1e3),
89
+ resources: resource.length ? resource : void 0,
75
90
  referenceId
76
91
  };
77
92
  foundConsent?.id ? await ctx.context.adapter.update({
@@ -81,6 +96,7 @@ async function consentEndpoint(ctx, opts) {
81
96
  value: foundConsent.id
82
97
  }],
83
98
  update: {
99
+ resources: consent.resources,
84
100
  scopes: consent.scopes,
85
101
  updatedAt: /* @__PURE__ */ new Date(iat * 1e3)
86
102
  }
@@ -93,14 +109,21 @@ async function consentEndpoint(ctx, opts) {
93
109
  });
94
110
  if (requestedScopes) query.set("scope", consent.scopes.join(" "));
95
111
  ctx?.headers?.set("accept", "application/json");
96
- ctx.query = deleteFromPrompt(query, "consent");
97
- ctx.context.postLogin = true;
98
- const { url } = await authorizeEndpoint(ctx, opts);
112
+ let authorizationQuery = removePromptFromQuery(query, "consent");
113
+ if (hasSatisfiedLoginPrompt) authorizationQuery = removePromptFromQuery(authorizationQuery, "login");
114
+ ctx.query = searchParamsToQuery(authorizationQuery);
115
+ const { url } = await authorizeEndpoint(ctx, opts, { postLogin: oauthRequest?.postLoginClearedForSession !== void 0 && oauthRequest.postLoginClearedForSession === session?.session.id });
99
116
  return {
100
117
  redirect: true,
101
118
  url
102
119
  };
103
120
  }
121
+ function sessionSatisfiesLoginPrompt(sessionCreatedAt, signedQueryIssuedAt) {
122
+ if (!signedQueryIssuedAt) return false;
123
+ const normalized = normalizeTimestampValue(sessionCreatedAt);
124
+ if (!normalized) return false;
125
+ return normalized.getTime() >= signedQueryIssuedAt.getTime();
126
+ }
104
127
  //#endregion
105
128
  //#region src/continue.ts
106
129
  async function continueEndpoint(ctx, opts) {
@@ -119,7 +142,7 @@ async function selected(ctx, opts) {
119
142
  error: "invalid_request"
120
143
  });
121
144
  ctx.headers?.set("accept", "application/json");
122
- ctx.query = deleteFromPrompt(new URLSearchParams(_query), "select_account");
145
+ ctx.query = searchParamsToQuery(removePromptFromQuery(new URLSearchParams(_query), "select_account"));
123
146
  const { url } = await authorizeEndpoint(ctx, opts);
124
147
  return {
125
148
  redirect: true,
@@ -134,7 +157,7 @@ async function created(ctx, opts) {
134
157
  });
135
158
  const query = new URLSearchParams(_query);
136
159
  ctx.headers?.set("accept", "application/json");
137
- ctx.query = deleteFromPrompt(query, "create");
160
+ ctx.query = searchParamsToQuery(removePromptFromQuery(query, "create"));
138
161
  const { url } = await authorizeEndpoint(ctx, opts);
139
162
  return {
140
163
  redirect: true,
@@ -158,11 +181,6 @@ async function postLogin(ctx, opts) {
158
181
  }
159
182
  //#endregion
160
183
  //#region src/types/zod.ts
161
- const DANGEROUS_SCHEMES = [
162
- "javascript:",
163
- "data:",
164
- "vbscript:"
165
- ];
166
184
  /**
167
185
  * Runtime schema for OAuthAuthorizationQuery.
168
186
  * Uses passthrough to tolerate fields added by future extensions (PAR, FPA, etc.)
@@ -183,7 +201,8 @@ const oauthAuthorizationQuerySchema = z.object({
183
201
  id_token_hint: z.string().optional(),
184
202
  code_challenge: z.string().optional(),
185
203
  code_challenge_method: z.literal("S256").optional(),
186
- nonce: z.string().optional()
204
+ nonce: z.string().optional(),
205
+ resource: z.union([z.string(), z.array(z.string())]).optional()
187
206
  }).passthrough();
188
207
  /**
189
208
  * Runtime schema for the authorization code verification value.
@@ -196,34 +215,40 @@ const verificationValueSchema = z.object({
196
215
  sessionId: z.string(),
197
216
  userId: z.string(),
198
217
  referenceId: z.string().optional(),
199
- authTime: z.number().optional()
218
+ authTime: z.number().optional(),
219
+ resource: z.array(z.string()).optional()
200
220
  }).passthrough();
221
+ const DANGEROUS_SCHEMES = [
222
+ "javascript:",
223
+ "data:",
224
+ "vbscript:"
225
+ ];
201
226
  /**
202
- * Reusable URL validation for OAuth redirect URIs.
203
- * - Blocks dangerous schemes (javascript:, data:, vbscript:)
204
- * - For http/https: requires HTTPS (HTTP allowed only for loopback hosts: 127.0.0.0/8, [::1], *.localhost per RFC 6761)
205
- * - Allows custom schemes for mobile apps (e.g., myapp://callback)
227
+ * Validates an RFC 8707 resource indicator. The value must be an absolute URI
228
+ * with no fragment (RFC 8707 §2). Unlike a redirect URI it is not restricted to
229
+ * HTTPS, because a resource server identifier may use any absolute URI scheme;
230
+ * the configured `validAudiences` allowlist is the authoritative control over
231
+ * which resources a token may target.
206
232
  */
207
- const SafeUrlSchema = z.url().superRefine((val, ctx) => {
233
+ const ResourceUriSchema = z.string().superRefine((val, ctx) => {
208
234
  if (!URL.canParse(val)) {
209
235
  ctx.addIssue({
210
236
  code: "custom",
211
- message: "URL must be parseable",
237
+ message: "resource must be an absolute URI",
212
238
  fatal: true
213
239
  });
214
240
  return z.NEVER;
215
241
  }
216
- const u = new URL(val);
217
- if (DANGEROUS_SCHEMES.includes(u.protocol)) {
242
+ if (val.includes("#")) {
218
243
  ctx.addIssue({
219
244
  code: "custom",
220
- message: "URL cannot use javascript:, data:, or vbscript: scheme"
245
+ message: "resource must not contain a fragment"
221
246
  });
222
247
  return;
223
248
  }
224
- if (u.protocol === "http:" && !isLoopbackHost(u.host)) ctx.addIssue({
249
+ if (DANGEROUS_SCHEMES.includes(new URL(val).protocol)) ctx.addIssue({
225
250
  code: "custom",
226
- message: "Redirect URI must use HTTPS (HTTP allowed only for loopback hosts)"
251
+ message: "resource cannot use javascript:, data:, or vbscript: scheme"
227
252
  });
228
253
  });
229
254
  //#endregion
@@ -312,13 +337,13 @@ async function tokenEndpoint(ctx, opts) {
312
337
  case "refresh_token": return handleRefreshTokenGrant(ctx, opts);
313
338
  }
314
339
  }
315
- async function createJwtAccessToken(ctx, opts, user, client, audience, scopes, referenceId, overrides) {
340
+ async function createJwtAccessToken(ctx, opts, user, client, audience, scopes, resources, referenceId, overrides) {
316
341
  const iat = overrides?.iat ?? Math.floor(Date.now() / 1e3);
317
342
  const exp = overrides?.exp ?? iat + (opts.accessTokenExpiresIn ?? 3600);
318
343
  const customClaims = opts.customAccessTokenClaims ? await opts.customAccessTokenClaims({
319
344
  user,
320
345
  scopes,
321
- resource: ctx.body.resource,
346
+ resources,
322
347
  referenceId,
323
348
  metadata: parseClientMetadata(client.metadata)
324
349
  }) : {};
@@ -328,7 +353,7 @@ async function createJwtAccessToken(ctx, opts, user, client, audience, scopes, r
328
353
  payload: {
329
354
  ...customClaims,
330
355
  sub: user?.id,
331
- aud: typeof audience === "string" ? audience : audience?.length === 1 ? audience.at(0) : audience,
356
+ aud: toAudienceClaim(audience),
332
357
  azp: client.clientId,
333
358
  scope: scopes.join(" "),
334
359
  sid: overrides?.sid,
@@ -419,7 +444,7 @@ async function decodeRefreshToken(opts, token) {
419
444
  });
420
445
  return opts.formatRefreshToken?.decrypt ? opts.formatRefreshToken?.decrypt(token) : { token };
421
446
  }
422
- async function createOpaqueAccessToken(ctx, opts, user, client, scopes, payload, referenceId, refreshId) {
447
+ async function createOpaqueAccessToken(ctx, opts, user, client, scopes, payload, resources, referenceId, refreshId) {
423
448
  const iat = payload.iat ?? Math.floor(Date.now() / 1e3);
424
449
  const exp = payload?.exp ?? iat + (opts.accessTokenExpiresIn ?? 3600);
425
450
  const token = opts.generateOpaqueAccessToken ? await opts.generateOpaqueAccessToken() : generateRandomString(32, "A-Z", "a-z");
@@ -431,6 +456,7 @@ async function createOpaqueAccessToken(ctx, opts, user, client, scopes, payload,
431
456
  sessionId: payload?.sid,
432
457
  userId: user?.id,
433
458
  referenceId,
459
+ resources,
434
460
  refreshId,
435
461
  scopes,
436
462
  createdAt: /* @__PURE__ */ new Date(iat * 1e3),
@@ -439,54 +465,100 @@ async function createOpaqueAccessToken(ctx, opts, user, client, scopes, payload,
439
465
  });
440
466
  return (opts.prefix?.opaqueAccessToken ?? "") + token;
441
467
  }
442
- async function createRefreshToken(ctx, opts, user, referenceId, client, scopes, payload, originalRefresh, authTime) {
468
+ /**
469
+ * Tear down the entire refresh-token family for a (client, user) pair, plus
470
+ * any access tokens that reference those refresh rows, per RFC 9700 §4.14.
471
+ * Access tokens are deleted first so the parent rows' foreign-key children
472
+ * do not block the refresh-row delete.
473
+ *
474
+ * TODO(invalidate-family-race): the two `deleteMany` calls are not atomic
475
+ * with respect to each other. Between them, a concurrent rotation in a
476
+ * different worker can `create` a fresh refresh row (and, immediately after,
477
+ * an access-token row referencing it) for the same (client, user) pair,
478
+ * leaving the family partially rebuilt and the new refresh row orphaned of
479
+ * any deletion. Closing this window requires the same transactional adapter
480
+ * contract tracked under FIXME(strict-family-invalidation) in
481
+ * `createRefreshToken`.
482
+ *
483
+ * @internal
484
+ */
485
+ async function invalidateRefreshFamily(ctx, clientId, userId) {
486
+ const refreshTokens = await ctx.context.adapter.findMany({
487
+ model: "oauthRefreshToken",
488
+ where: [{
489
+ field: "clientId",
490
+ value: clientId
491
+ }, {
492
+ field: "userId",
493
+ value: userId
494
+ }]
495
+ });
496
+ if (refreshTokens.length) await ctx.context.adapter.deleteMany({
497
+ model: "oauthAccessToken",
498
+ where: [{
499
+ field: "refreshId",
500
+ operator: "in",
501
+ value: refreshTokens.map((r) => r.id)
502
+ }]
503
+ });
504
+ await ctx.context.adapter.deleteMany({
505
+ model: "oauthRefreshToken",
506
+ where: [{
507
+ field: "clientId",
508
+ value: clientId
509
+ }, {
510
+ field: "userId",
511
+ value: userId
512
+ }]
513
+ });
514
+ }
515
+ async function createRefreshToken(ctx, opts, user, referenceId, client, scopes, payload, originalRefresh, authTime, resources) {
443
516
  const iat = payload.iat ?? Math.floor(Date.now() / 1e3);
444
517
  const exp = payload?.exp ?? iat + (opts.refreshTokenExpiresIn ?? 2592e3);
445
518
  const token = opts.generateRefreshToken ? await opts.generateRefreshToken() : generateRandomString(32, "A-Z", "a-z");
446
519
  const sessionId = payload?.sid;
447
- if (originalRefresh?.id) await ctx.context.adapter.update({
520
+ const newRow = {
521
+ token: await storeToken(opts.storeTokens, token, "refresh_token"),
522
+ clientId: client.clientId,
523
+ sessionId,
524
+ userId: user.id,
525
+ referenceId,
526
+ authTime,
527
+ scopes,
528
+ resources,
529
+ createdAt: /* @__PURE__ */ new Date(iat * 1e3),
530
+ expiresAt: /* @__PURE__ */ new Date(exp * 1e3)
531
+ };
532
+ if (!originalRefresh?.id) return {
533
+ id: (await ctx.context.adapter.create({
534
+ model: "oauthRefreshToken",
535
+ data: newRow
536
+ })).id,
537
+ token: await encodeRefreshToken(opts, token, sessionId)
538
+ };
539
+ if (!await ctx.context.adapter.update({
448
540
  model: "oauthRefreshToken",
449
541
  where: [{
450
542
  field: "id",
451
543
  value: originalRefresh.id
544
+ }, {
545
+ field: "revoked",
546
+ operator: "eq",
547
+ value: null
452
548
  }],
453
549
  update: { revoked: /* @__PURE__ */ new Date(iat * 1e3) }
550
+ })) throw new APIError("BAD_REQUEST", {
551
+ error_description: "invalid refresh token",
552
+ error: "invalid_grant"
454
553
  });
455
554
  return {
456
555
  id: (await ctx.context.adapter.create({
457
556
  model: "oauthRefreshToken",
458
- data: {
459
- token: await storeToken(opts.storeTokens, token, "refresh_token"),
460
- clientId: client.clientId,
461
- sessionId,
462
- userId: user.id,
463
- referenceId,
464
- authTime,
465
- scopes,
466
- createdAt: /* @__PURE__ */ new Date(iat * 1e3),
467
- expiresAt: /* @__PURE__ */ new Date(exp * 1e3)
468
- }
557
+ data: newRow
469
558
  })).id,
470
559
  token: await encodeRefreshToken(opts, token, sessionId)
471
560
  };
472
561
  }
473
- /**
474
- * Checks the resource parameter, if provided,
475
- * and returns a valid audience based on the request
476
- */
477
- async function checkResource(ctx, opts, scopes) {
478
- const resource = ctx.body.resource;
479
- const audience = typeof resource === "string" ? [resource] : resource ? [...resource] : void 0;
480
- if (audience) {
481
- if (scopes.includes("openid")) audience.push(`${ctx.context.baseURL}/oauth2/userinfo`);
482
- const validAudiences = new Set([...opts.validAudiences ?? [ctx.context.baseURL], scopes?.includes("openid") ? `${ctx.context.baseURL}/oauth2/userinfo` : void 0].flat().filter((v) => v?.length));
483
- for (const aud of audience) if (!validAudiences.has(aud)) throw new APIError("BAD_REQUEST", {
484
- error_description: "requested resource invalid",
485
- error: "invalid_request"
486
- });
487
- }
488
- return audience?.length === 1 ? audience.at(0) : audience;
489
- }
490
562
  async function createUserTokens(ctx, opts, params) {
491
563
  const { client, scopes, user, grantType, referenceId, sessionId, nonce, refreshToken: existingRefreshToken, authTime, verificationValue } = params;
492
564
  const iat = Math.floor(Date.now() / 1e3);
@@ -494,7 +566,12 @@ async function createUserTokens(ctx, opts, params) {
494
566
  const exp = opts.scopeExpirations ? scopes.map((sc) => opts.scopeExpirations?.[sc] ? toExpJWT(opts.scopeExpirations[sc], iat) : defaultExp).reduce((prev, curr) => {
495
567
  return prev < curr ? prev : curr;
496
568
  }, defaultExp) : defaultExp;
497
- const audience = await checkResource(ctx, opts, scopes);
569
+ const resourceResult = await checkResource(ctx, opts, params?.resources, scopes);
570
+ if (!resourceResult.success) throw new APIError("BAD_REQUEST", {
571
+ error_description: "requested resource invalid",
572
+ error: "invalid_target"
573
+ });
574
+ const audience = resourceResult.audience;
498
575
  const isRefreshToken = user && (existingRefreshToken?.scopes?.includes("offline_access") || scopes.includes("offline_access"));
499
576
  const isJwtAccessToken = audience && !opts.disableJwtPlugin;
500
577
  const isIdToken = user && scopes.includes("openid");
@@ -505,12 +582,13 @@ async function createUserTokens(ctx, opts, params) {
505
582
  metadata: parseClientMetadata(client.metadata),
506
583
  verificationValue
507
584
  }) : void 0;
585
+ const refreshResources = params?.refreshToken?.resources ?? params?.originalResources ?? params?.resources;
508
586
  const earlyRefreshToken = isRefreshToken && user && !isJwtAccessToken ? await createRefreshToken(ctx, opts, user, referenceId, client, scopes, {
509
587
  iat,
510
588
  exp: iat + (opts.refreshTokenExpiresIn ?? 2592e3),
511
589
  sid: sessionId
512
- }, existingRefreshToken, authTime) : void 0;
513
- const [accessToken, refreshToken] = await Promise.all([isJwtAccessToken ? createJwtAccessToken(ctx, opts, user, client, audience, scopes, referenceId, {
590
+ }, existingRefreshToken, authTime, refreshResources) : void 0;
591
+ const [accessToken, refreshToken] = await Promise.all([isJwtAccessToken ? createJwtAccessToken(ctx, opts, user, client, audience, scopes, params?.resources, referenceId, {
514
592
  iat,
515
593
  exp,
516
594
  sid: sessionId
@@ -518,11 +596,11 @@ async function createUserTokens(ctx, opts, params) {
518
596
  iat,
519
597
  exp,
520
598
  sid: sessionId
521
- }, referenceId, earlyRefreshToken?.id), earlyRefreshToken ? earlyRefreshToken : isRefreshToken && user ? createRefreshToken(ctx, opts, user, referenceId, client, scopes, {
599
+ }, params?.resources, referenceId, earlyRefreshToken?.id), earlyRefreshToken ? earlyRefreshToken : isRefreshToken && user ? createRefreshToken(ctx, opts, user, referenceId, client, scopes, {
522
600
  iat,
523
601
  exp: iat + (opts.refreshTokenExpiresIn ?? 2592e3),
524
602
  sid: sessionId
525
- }, existingRefreshToken, authTime) : void 0]);
603
+ }, existingRefreshToken, authTime, refreshResources) : void 0]);
526
604
  const idToken = isIdToken ? await createIdToken(ctx, opts, user, client, scopes, nonce, sessionId, authTime, accessToken) : void 0;
527
605
  return ctx.json({
528
606
  ...customFields,
@@ -539,16 +617,11 @@ async function createUserTokens(ctx, opts, params) {
539
617
  } });
540
618
  }
541
619
  /** Checks verification value */
542
- async function checkVerificationValue(ctx, opts, code, client_id, redirect_uri) {
543
- const verification = await ctx.context.internalAdapter.findVerificationValue(await storeToken(opts.storeTokens, code, "authorization_code"));
620
+ async function checkVerificationValue(ctx, opts, code, client_id, redirect_uri, resource) {
621
+ const verification = await ctx.context.internalAdapter.consumeVerificationValue(await storeToken(opts.storeTokens, code, "authorization_code"));
544
622
  if (!verification) throw new APIError("UNAUTHORIZED", {
545
- error_description: "Invalid code",
546
- error: "invalid_verification"
547
- });
548
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(await storeToken(opts.storeTokens, code, "authorization_code"));
549
- if (!verification.expiresAt || verification.expiresAt < /* @__PURE__ */ new Date()) throw new APIError("UNAUTHORIZED", {
550
- error_description: "code expired",
551
- error: "invalid_verification"
623
+ error_description: "invalid code",
624
+ error: "invalid_grant"
552
625
  });
553
626
  let rawValue;
554
627
  try {
@@ -556,13 +629,13 @@ async function checkVerificationValue(ctx, opts, code, client_id, redirect_uri)
556
629
  } catch {
557
630
  throw new APIError("UNAUTHORIZED", {
558
631
  error_description: "malformed verification value",
559
- error: "invalid_verification"
632
+ error: "invalid_grant"
560
633
  });
561
634
  }
562
635
  const parsed = verificationValueSchema.safeParse(rawValue);
563
636
  if (!parsed.success) throw new APIError("UNAUTHORIZED", {
564
637
  error_description: "malformed verification value",
565
- error: "invalid_verification"
638
+ error: "invalid_grant"
566
639
  });
567
640
  const verificationValue = parsed.data;
568
641
  if (verificationValue.query.client_id !== client_id) throw new APIError("UNAUTHORIZED", {
@@ -573,14 +646,29 @@ async function checkVerificationValue(ctx, opts, code, client_id, redirect_uri)
573
646
  error_description: "redirect_uri mismatch",
574
647
  error: "invalid_request"
575
648
  });
576
- return verificationValue;
649
+ const storedResources = toResourceList(verificationValue.resource) ?? toResourceList(verificationValue.query.resource);
650
+ const effectiveResources = resource ?? storedResources;
651
+ if (resource && storedResources) {
652
+ const requestedSet = new Set(resource);
653
+ const authorizedSet = new Set(storedResources);
654
+ for (const r of requestedSet) if (!authorizedSet.has(r)) throw new APIError("BAD_REQUEST", {
655
+ error_description: "requested resource not authorized",
656
+ error: "invalid_target"
657
+ });
658
+ }
659
+ return {
660
+ verificationValue,
661
+ effectiveResources,
662
+ authorizedResources: storedResources
663
+ };
577
664
  }
578
665
  /**
579
666
  * Obtains new Session Jwt and Refresh Tokens using a code
580
667
  */
581
668
  async function handleAuthorizationCodeGrant(ctx, opts) {
582
669
  const { clientId: client_id, clientSecret: client_secret, preVerifiedClient } = destructureCredentials(await extractClientCredentials(ctx, opts, `${ctx.context.baseURL}/oauth2/token`));
583
- const { code, code_verifier, redirect_uri } = ctx.body;
670
+ const { code, code_verifier, redirect_uri, resource } = ctx.body;
671
+ const resources = toResourceList(resource);
584
672
  if (!client_id) throw new APIError("BAD_REQUEST", {
585
673
  error_description: "client_id is required",
586
674
  error: "invalid_request"
@@ -600,7 +688,7 @@ async function handleAuthorizationCodeGrant(ctx, opts) {
600
688
  error: "invalid_request"
601
689
  });
602
690
  /** Get and check Verification Value */
603
- const verificationValue = await checkVerificationValue(ctx, opts, code, client_id, redirect_uri);
691
+ const { verificationValue, effectiveResources, authorizedResources } = await checkVerificationValue(ctx, opts, code, client_id, redirect_uri, resources);
604
692
  const scopes = verificationValue.query.scope?.split(" ");
605
693
  if (!scopes) throw new APIError("INTERNAL_SERVER_ERROR", {
606
694
  error_description: "verification scope unset",
@@ -665,7 +753,9 @@ async function handleAuthorizationCodeGrant(ctx, opts) {
665
753
  sessionId: session.id,
666
754
  nonce: verificationValue.query?.nonce,
667
755
  authTime,
668
- verificationValue
756
+ verificationValue,
757
+ resources: effectiveResources,
758
+ originalResources: authorizedResources
669
759
  });
670
760
  }
671
761
  /**
@@ -676,7 +766,8 @@ async function handleAuthorizationCodeGrant(ctx, opts) {
676
766
  */
677
767
  async function handleClientCredentialsGrant(ctx, opts) {
678
768
  const { clientId: client_id, clientSecret: client_secret, preVerifiedClient } = destructureCredentials(await extractClientCredentials(ctx, opts, `${ctx.context.baseURL}/oauth2/token`));
679
- const { scope } = ctx.body;
769
+ const { scope, resource } = ctx.body;
770
+ const resources = toResourceList(resource);
680
771
  if (!client_id) throw new APIError("BAD_REQUEST", {
681
772
  error_description: "Missing required client_id",
682
773
  error: "invalid_grant"
@@ -707,7 +798,8 @@ async function handleClientCredentialsGrant(ctx, opts) {
707
798
  return createUserTokens(ctx, opts, {
708
799
  client,
709
800
  scopes: requestedScopes,
710
- grantType: "client_credentials"
801
+ grantType: "client_credentials",
802
+ resources
711
803
  });
712
804
  }
713
805
  /**
@@ -718,7 +810,8 @@ async function handleClientCredentialsGrant(ctx, opts) {
718
810
  */
719
811
  async function handleRefreshTokenGrant(ctx, opts) {
720
812
  const { clientId: client_id, clientSecret: client_secret, preVerifiedClient } = destructureCredentials(await extractClientCredentials(ctx, opts, `${ctx.context.baseURL}/oauth2/token`));
721
- const { refresh_token, scope } = ctx.body;
813
+ const { refresh_token, scope, resource } = ctx.body;
814
+ const resources = toResourceList(resource);
722
815
  if (!client_id) throw new APIError("BAD_REQUEST", {
723
816
  error_description: "Missing required client_id",
724
817
  error: "invalid_grant"
@@ -748,21 +841,16 @@ async function handleRefreshTokenGrant(ctx, opts) {
748
841
  error: "invalid_grant"
749
842
  });
750
843
  if (refreshToken.revoked) {
751
- await ctx.context.adapter.deleteMany({
752
- model: "oauthRefreshToken",
753
- where: [{
754
- field: "clientId",
755
- value: client_id
756
- }, {
757
- field: "userId",
758
- value: refreshToken.userId
759
- }]
760
- });
844
+ await invalidateRefreshFamily(ctx, client_id, refreshToken.userId);
761
845
  throw new APIError("BAD_REQUEST", {
762
846
  error_description: "invalid refresh token",
763
847
  error: "invalid_grant"
764
848
  });
765
849
  }
850
+ if (resources && refreshToken.resources && !resources.every((v) => refreshToken.resources?.includes(v))) throw new APIError("BAD_REQUEST", {
851
+ error_description: "requested resource invalid",
852
+ error: "invalid_target"
853
+ });
766
854
  const scopes = refreshToken?.scopes;
767
855
  const requestedScopes = scope?.split(" ");
768
856
  if (requestedScopes) {
@@ -787,6 +875,7 @@ async function handleRefreshTokenGrant(ctx, opts) {
787
875
  referenceId: refreshToken.referenceId,
788
876
  sessionId: refreshToken.sessionId,
789
877
  refreshToken,
878
+ resources: resources ?? refreshToken.resources,
790
879
  authTime
791
880
  });
792
881
  }
@@ -894,10 +983,17 @@ async function validateOpaqueAccessToken(ctx, opts, token, clientId) {
894
983
  }
895
984
  let user;
896
985
  if (accessToken.userId) user = await ctx.context.internalAdapter.findUserById(accessToken?.userId);
986
+ const resources = Array.isArray(accessToken.resources) ? accessToken.resources : void 0;
987
+ const audience = resources ? [...resources] : void 0;
988
+ if (audience?.length && accessToken.scopes?.includes("openid")) {
989
+ const userInfoEndpoint = `${ctx.context.baseURL}/oauth2/userinfo`;
990
+ if (!audience.includes(userInfoEndpoint)) audience.push(userInfoEndpoint);
991
+ }
897
992
  const customClaims = opts.customAccessTokenClaims ? await opts.customAccessTokenClaims({
898
993
  user,
899
994
  scopes: accessToken.scopes,
900
995
  referenceId: accessToken?.referenceId,
996
+ resources,
901
997
  metadata: parseClientMetadata(client?.metadata)
902
998
  }) : {};
903
999
  const jwtPluginOptions = (opts.disableJwtPlugin ? void 0 : getJwtPlugin(ctx.context))?.options;
@@ -905,6 +1001,7 @@ async function validateOpaqueAccessToken(ctx, opts, token, clientId) {
905
1001
  ...customClaims,
906
1002
  active: true,
907
1003
  iss: jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL,
1004
+ aud: toAudienceClaim(audience),
908
1005
  client_id: accessToken.clientId,
909
1006
  sub: user?.id,
910
1007
  sid: sessionId,
@@ -939,7 +1036,7 @@ async function validateRefreshToken(ctx, opts, token, clientId) {
939
1036
  model: "session",
940
1037
  where: [{
941
1038
  field: "id",
942
- value: refreshToken.sessionId
1039
+ value: sessionId
943
1040
  }]
944
1041
  });
945
1042
  if (!session || session.expiresAt < /* @__PURE__ */ new Date()) sessionId = void 0;
@@ -1268,6 +1365,28 @@ const publicSessionMiddleware = (opts) => createAuthMiddleware(async (ctx) => {
1268
1365
  if (!await verifyOAuthQueryParams(query, ctx.context.secret)) throw new APIError("UNAUTHORIZED", { error: "invalid_signature" });
1269
1366
  });
1270
1367
  //#endregion
1368
+ //#region src/oauthClient/privileges.ts
1369
+ /**
1370
+ * Authorizes a client action against the configured `clientPrivileges` hook.
1371
+ *
1372
+ * This is the single authorization helper for every OAuth client mutation. The
1373
+ * create path enforces it at the shared creation chokepoint so that no
1374
+ * registration route can reach client persistence without it.
1375
+ *
1376
+ * @throws APIError UNAUTHORIZED when there is no session or the hook denies the action.
1377
+ * @throws APIError BAD_REQUEST when the request carries no headers.
1378
+ */
1379
+ async function assertClientPrivileges(ctx, session, opts, action) {
1380
+ if (!session) throw new APIError("UNAUTHORIZED");
1381
+ if (!ctx.headers) throw new APIError("BAD_REQUEST");
1382
+ if (opts.clientPrivileges && !await opts.clientPrivileges({
1383
+ headers: ctx.headers,
1384
+ action,
1385
+ session: session.session,
1386
+ user: session.user
1387
+ })) throw new APIError("UNAUTHORIZED");
1388
+ }
1389
+ //#endregion
1271
1390
  //#region src/register.ts
1272
1391
  /**
1273
1392
  * Resolves the auth method and type for unauthenticated DCR.
@@ -1403,6 +1522,9 @@ async function checkOAuthClient(client, opts, settings) {
1403
1522
  async function createOAuthClientEndpoint(ctx, opts, settings) {
1404
1523
  const body = ctx.body;
1405
1524
  const session = await getSessionFromCtx(ctx);
1525
+ if (settings.isRegister) {
1526
+ if (session) await assertClientPrivileges(ctx, session, opts, "create");
1527
+ } else await assertClientPrivileges(ctx, session, opts, "create");
1406
1528
  const isPublic = body.token_endpoint_auth_method === "none";
1407
1529
  const isPrivateKeyJwt = body.token_endpoint_auth_method === "private_key_jwt";
1408
1530
  await checkOAuthClient(ctx.body, opts, {
@@ -1748,16 +1870,6 @@ async function rotateClientSecretEndpoint(ctx, opts) {
1748
1870
  clientSecret: (opts.prefix?.clientSecret ?? "") + clientSecret
1749
1871
  });
1750
1872
  }
1751
- async function assertClientPrivileges(ctx, session, opts, action) {
1752
- if (!session) throw new APIError("UNAUTHORIZED");
1753
- if (!ctx.headers) throw new APIError("BAD_REQUEST");
1754
- if (opts.clientPrivileges && !await opts.clientPrivileges({
1755
- headers: ctx.headers,
1756
- action,
1757
- session: session.session,
1758
- user: session.user
1759
- })) throw new APIError("UNAUTHORIZED");
1760
- }
1761
1873
  //#endregion
1762
1874
  //#region src/oauthClient/index.ts
1763
1875
  const adminCreateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/create-client", {
@@ -1781,7 +1893,7 @@ const adminCreateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/creat
1781
1893
  "client_secret_post",
1782
1894
  "private_key_jwt"
1783
1895
  ]).default("client_secret_basic").optional(),
1784
- jwks: z.union([z.array(z.record(z.string(), z.unknown())), z.object({ keys: z.array(z.record(z.string(), z.unknown())) })]).optional(),
1896
+ 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(),
1785
1897
  jwks_uri: z.string().optional(),
1786
1898
  grant_types: z.array(z.enum([
1787
1899
  "authorization_code",
@@ -1943,7 +2055,6 @@ const adminCreateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/creat
1943
2055
  }
1944
2056
  }
1945
2057
  }, async (ctx) => {
1946
- await assertClientPrivileges(ctx, await getSessionFromCtx(ctx), opts, "create");
1947
2058
  return createOAuthClientEndpoint(ctx, opts, { isRegister: false });
1948
2059
  });
1949
2060
  const createOAuthClient = (opts) => createAuthEndpoint("/oauth2/create-client", {
@@ -1968,7 +2079,7 @@ const createOAuthClient = (opts) => createAuthEndpoint("/oauth2/create-client",
1968
2079
  "client_secret_post",
1969
2080
  "private_key_jwt"
1970
2081
  ]).default("client_secret_basic").optional(),
1971
- jwks: z.union([z.array(z.record(z.string(), z.unknown())), z.object({ keys: z.array(z.record(z.string(), z.unknown())) })]).optional(),
2082
+ 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(),
1972
2083
  jwks_uri: z.string().optional(),
1973
2084
  grant_types: z.array(z.enum([
1974
2085
  "authorization_code",
@@ -2116,7 +2227,6 @@ const createOAuthClient = (opts) => createAuthEndpoint("/oauth2/create-client",
2116
2227
  } }
2117
2228
  } }
2118
2229
  }, async (ctx) => {
2119
- await assertClientPrivileges(ctx, await getSessionFromCtx(ctx), opts, "create");
2120
2230
  return createOAuthClientEndpoint(ctx, opts, { isRegister: false });
2121
2231
  });
2122
2232
  const getOAuthClient = (opts) => createAuthEndpoint("/oauth2/get-client", {
@@ -2320,12 +2430,12 @@ async function updateConsentEndpoint(ctx, opts) {
2320
2430
  error_description: "no consent",
2321
2431
  error: "not_found"
2322
2432
  });
2433
+ if (consent.userId !== session.user.id) throw new APIError("UNAUTHORIZED");
2323
2434
  const client = await getClient(ctx, opts, consent.clientId);
2324
- if (!consent) throw new APIError("NOT_FOUND", {
2325
- error_description: "no consent",
2435
+ if (!client) throw new APIError("NOT_FOUND", {
2436
+ error_description: "client not found",
2326
2437
  error: "not_found"
2327
2438
  });
2328
- if (consent.userId !== session.user.id) throw new APIError("UNAUTHORIZED");
2329
2439
  const allowedScopes = client?.scopes ?? opts.scopes ?? [];
2330
2440
  const updates = ctx.body.update;
2331
2441
  const scopes = updates.scopes;
@@ -2473,16 +2583,7 @@ async function revokeRefreshToken(ctx, opts, token, clientId) {
2473
2583
  error: "invalid_request"
2474
2584
  });
2475
2585
  if (refreshToken.revoked) {
2476
- await ctx.context.adapter.deleteMany({
2477
- model: "oauthRefreshToken",
2478
- where: [{
2479
- field: "clientId",
2480
- value: clientId
2481
- }, {
2482
- field: "userId",
2483
- value: refreshToken.userId
2484
- }]
2485
- });
2586
+ await invalidateRefreshFamily(ctx, clientId, refreshToken.userId);
2486
2587
  throw new APIError$1("BAD_REQUEST", {
2487
2588
  error_description: "refresh token revoked",
2488
2589
  error: "invalid_request"
@@ -2490,20 +2591,31 @@ async function revokeRefreshToken(ctx, opts, token, clientId) {
2490
2591
  }
2491
2592
  if (!refreshToken.clientId || refreshToken.clientId !== clientId) return null;
2492
2593
  const iat = Math.floor(Date.now() / 1e3);
2493
- await Promise.allSettled([ctx.context.adapter.deleteMany({
2494
- model: "oauthAccessToken",
2495
- where: [{
2496
- field: "refreshId",
2497
- value: refreshToken.id
2498
- }]
2499
- }), ctx.context.adapter.update({
2594
+ if (!await ctx.context.adapter.update({
2500
2595
  model: "oauthRefreshToken",
2501
2596
  where: [{
2502
2597
  field: "id",
2503
2598
  value: refreshToken.id
2599
+ }, {
2600
+ field: "revoked",
2601
+ operator: "eq",
2602
+ value: null
2504
2603
  }],
2505
2604
  update: { revoked: /* @__PURE__ */ new Date(iat * 1e3) }
2506
- })]);
2605
+ })) {
2606
+ await invalidateRefreshFamily(ctx, clientId, refreshToken.userId);
2607
+ throw new APIError$1("BAD_REQUEST", {
2608
+ error_description: "refresh token revoked",
2609
+ error: "invalid_request"
2610
+ });
2611
+ }
2612
+ await ctx.context.adapter.deleteMany({
2613
+ model: "oauthAccessToken",
2614
+ where: [{
2615
+ field: "refreshId",
2616
+ value: refreshToken.id
2617
+ }]
2618
+ });
2507
2619
  }
2508
2620
  /**
2509
2621
  * We don't know the access token format so we try to validate it
@@ -2617,7 +2729,8 @@ const schema = {
2617
2729
  references: {
2618
2730
  model: "user",
2619
2731
  field: "id"
2620
- }
2732
+ },
2733
+ index: true
2621
2734
  },
2622
2735
  createdAt: {
2623
2736
  type: "date",
@@ -2716,7 +2829,8 @@ const schema = {
2716
2829
  oauthRefreshToken: { fields: {
2717
2830
  token: {
2718
2831
  type: "string",
2719
- required: true
2832
+ required: true,
2833
+ unique: true
2720
2834
  },
2721
2835
  clientId: {
2722
2836
  type: "string",
@@ -2724,7 +2838,8 @@ const schema = {
2724
2838
  references: {
2725
2839
  model: "oauthClient",
2726
2840
  field: "clientId"
2727
- }
2841
+ },
2842
+ index: true
2728
2843
  },
2729
2844
  sessionId: {
2730
2845
  type: "string",
@@ -2733,7 +2848,8 @@ const schema = {
2733
2848
  model: "session",
2734
2849
  field: "id",
2735
2850
  onDelete: "set null"
2736
- }
2851
+ },
2852
+ index: true
2737
2853
  },
2738
2854
  userId: {
2739
2855
  type: "string",
@@ -2741,12 +2857,17 @@ const schema = {
2741
2857
  references: {
2742
2858
  model: "user",
2743
2859
  field: "id"
2744
- }
2860
+ },
2861
+ index: true
2745
2862
  },
2746
2863
  referenceId: {
2747
2864
  type: "string",
2748
2865
  required: false
2749
2866
  },
2867
+ resources: {
2868
+ type: "string[]",
2869
+ required: false
2870
+ },
2750
2871
  expiresAt: { type: "date" },
2751
2872
  createdAt: { type: "date" },
2752
2873
  revoked: {
@@ -2775,7 +2896,8 @@ const schema = {
2775
2896
  references: {
2776
2897
  model: "oauthClient",
2777
2898
  field: "clientId"
2778
- }
2899
+ },
2900
+ index: true
2779
2901
  },
2780
2902
  sessionId: {
2781
2903
  type: "string",
@@ -2784,7 +2906,8 @@ const schema = {
2784
2906
  model: "session",
2785
2907
  field: "id",
2786
2908
  onDelete: "set null"
2787
- }
2909
+ },
2910
+ index: true
2788
2911
  },
2789
2912
  userId: {
2790
2913
  type: "string",
@@ -2792,19 +2915,25 @@ const schema = {
2792
2915
  references: {
2793
2916
  model: "user",
2794
2917
  field: "id"
2795
- }
2918
+ },
2919
+ index: true
2796
2920
  },
2797
2921
  referenceId: {
2798
2922
  type: "string",
2799
2923
  required: false
2800
2924
  },
2925
+ resources: {
2926
+ type: "string[]",
2927
+ required: false
2928
+ },
2801
2929
  refreshId: {
2802
2930
  type: "string",
2803
2931
  required: false,
2804
2932
  references: {
2805
2933
  model: "oauthRefreshToken",
2806
2934
  field: "id"
2807
- }
2935
+ },
2936
+ index: true
2808
2937
  },
2809
2938
  expiresAt: { type: "date" },
2810
2939
  createdAt: { type: "date" },
@@ -2823,7 +2952,8 @@ const schema = {
2823
2952
  references: {
2824
2953
  model: "oauthClient",
2825
2954
  field: "clientId"
2826
- }
2955
+ },
2956
+ index: true
2827
2957
  },
2828
2958
  userId: {
2829
2959
  type: "string",
@@ -2831,12 +2961,17 @@ const schema = {
2831
2961
  references: {
2832
2962
  model: "user",
2833
2963
  field: "id"
2834
- }
2964
+ },
2965
+ index: true
2835
2966
  },
2836
2967
  referenceId: {
2837
2968
  type: "string",
2838
2969
  required: false
2839
2970
  },
2971
+ resources: {
2972
+ type: "string[]",
2973
+ required: false
2974
+ },
2840
2975
  scopes: {
2841
2976
  type: "string[]",
2842
2977
  required: true
@@ -2914,10 +3049,55 @@ const oauthProvider = (options) => {
2914
3049
  if (opts.grantTypes && opts.grantTypes.includes("refresh_token") && !opts.grantTypes.includes("authorization_code")) throw new BetterAuthError("refresh_token grant requires authorization_code grant");
2915
3050
  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");
2916
3051
  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");
3052
+ const handleIssuerMetadataRequest = async (request, ctx) => {
3053
+ const requestPathname = new URL(request.url).pathname;
3054
+ const requestPath = ctx.options.advanced?.skipTrailingSlashes ? requestPathname.replace(/\/+$/, "") || "/" : requestPathname;
3055
+ const issuer = opts.disableJwtPlugin ? ctx.baseURL : getJwtPlugin(ctx)?.options?.jwt?.issuer ?? ctx.baseURL;
3056
+ let issuerPath = "/";
3057
+ try {
3058
+ issuerPath = new URL(issuer).pathname.replace(/\/$/, "") || "";
3059
+ } catch {
3060
+ issuerPath = new URL(ctx.baseURL).pathname.replace(/\/$/, "") || "";
3061
+ }
3062
+ const endpointCtx = { context: ctx };
3063
+ const authServerMetadataPaths = new Set([`/.well-known/oauth-authorization-server${issuerPath}`, `${issuerPath}/.well-known/oauth-authorization-server`]);
3064
+ const openIdConfigPath = `${issuerPath}/.well-known/openid-configuration`;
3065
+ const isAuthServerMetadataRequest = authServerMetadataPaths.has(requestPath);
3066
+ const isOpenIdConfigRequest = opts.scopes?.includes("openid") && requestPath === openIdConfigPath;
3067
+ const createMetadataResponse = (metadata) => {
3068
+ const response = metadataResponse(metadata);
3069
+ if (request.method === "HEAD") return new Response(null, {
3070
+ status: response.status,
3071
+ headers: response.headers
3072
+ });
3073
+ return response;
3074
+ };
3075
+ if (isAuthServerMetadataRequest || isOpenIdConfigRequest) {
3076
+ if (request.method !== "GET" && request.method !== "HEAD") return { response: new Response(null, {
3077
+ status: 405,
3078
+ headers: { Allow: "GET, HEAD" }
3079
+ }) };
3080
+ }
3081
+ if (isAuthServerMetadataRequest) {
3082
+ if (opts.scopes?.includes("openid")) return { response: createMetadataResponse(oidcServerMetadata(endpointCtx, opts)) };
3083
+ return { response: createMetadataResponse({
3084
+ ...authServerMetadata(endpointCtx, opts.disableJwtPlugin ? void 0 : getJwtPlugin(ctx)?.options, {
3085
+ scopes_supported: opts.advertisedMetadata?.scopes_supported ?? opts.scopes,
3086
+ dynamic_client_registration_supported: opts.allowDynamicClientRegistration,
3087
+ public_client_supported: opts.allowUnauthenticatedClientRegistration || toClientDiscoveryArray(opts.clientDiscovery).length > 0,
3088
+ grant_types_supported: opts.grantTypes,
3089
+ jwt_disabled: opts.disableJwtPlugin
3090
+ }),
3091
+ ...mergeDiscoveryMetadata(opts.clientDiscovery)
3092
+ }) };
3093
+ }
3094
+ if (isOpenIdConfigRequest) return { response: createMetadataResponse(oidcServerMetadata(endpointCtx, opts)) };
3095
+ };
2917
3096
  return {
2918
3097
  id: "oauth-provider",
2919
3098
  version: PACKAGE_VERSION,
2920
3099
  options: opts,
3100
+ onRequest: handleIssuerMetadataRequest,
2921
3101
  init: (ctx) => {
2922
3102
  if (ctx.options.secondaryStorage && ctx.options.session?.storeSessionInDatabase !== true) throw new BetterAuthError("OAuth Provider requires `session.storeSessionInDatabase: true` when using secondaryStorage");
2923
3103
  if (!opts.disableJwtPlugin) {
@@ -2943,10 +3123,18 @@ const oauthProvider = (options) => {
2943
3123
  handler: createAuthMiddleware(async (ctx) => {
2944
3124
  const query = ctx.body.oauth_query;
2945
3125
  if (!await verifyOAuthQueryParams(query, ctx.context.secret)) throw new APIError("BAD_REQUEST", { error: "invalid_signature" });
3126
+ const signedQueryIssuedAt = getSignedQueryIssuedAt(query);
2946
3127
  const queryParams = new URLSearchParams(query);
3128
+ const postLoginClearedForSession = queryParams.get("ba_pl") ?? void 0;
2947
3129
  queryParams.delete("sig");
2948
3130
  queryParams.delete("exp");
2949
- await oAuthState.set({ query: queryParams.toString() });
3131
+ queryParams.delete(signedQueryIssuedAtParam);
3132
+ queryParams.delete(postLoginClearedParam);
3133
+ await oAuthState.set({
3134
+ query: queryParams.toString(),
3135
+ signedQueryIssuedAt: signedQueryIssuedAt ?? void 0,
3136
+ postLoginClearedForSession
3137
+ });
2950
3138
  if (ctx.path === "/sign-in/social") {
2951
3139
  if (ctx.body.additionalData?.query) return;
2952
3140
  if (!ctx.body.additionalData) ctx.body.additionalData = {};
@@ -2970,7 +3158,7 @@ const oauthProvider = (options) => {
2970
3158
  const secFetchMode = ctx.request?.headers?.get("sec-fetch-mode")?.toLowerCase();
2971
3159
  const acceptHeader = ctx.request?.headers?.get("accept")?.toLowerCase() ?? "";
2972
3160
  if (!(secFetchMode === "navigate" || !secFetchMode && (acceptHeader.includes("text/html") || acceptHeader.includes("application/xhtml+xml")))) ctx.headers?.set("accept", "application/json");
2973
- ctx.query = deleteFromPrompt(query, "login");
3161
+ ctx.query = searchParamsToQuery(removePromptFromQuery(query, "login"));
2974
3162
  return await authorizeEndpoint(ctx, opts);
2975
3163
  })
2976
3164
  }]
@@ -2984,6 +3172,7 @@ const oauthProvider = (options) => {
2984
3172
  else return {
2985
3173
  ...authServerMetadata(ctx, opts.disableJwtPlugin ? void 0 : getJwtPlugin(ctx.context)?.options, {
2986
3174
  scopes_supported: opts.advertisedMetadata?.scopes_supported ?? opts.scopes,
3175
+ dynamic_client_registration_supported: opts.allowDynamicClientRegistration,
2987
3176
  public_client_supported: opts.allowUnauthenticatedClientRegistration || toClientDiscoveryArray(opts.clientDiscovery).length > 0,
2988
3177
  grant_types_supported: opts.grantTypes,
2989
3178
  jwt_disabled: opts.disableJwtPlugin
@@ -3010,6 +3199,7 @@ const oauthProvider = (options) => {
3010
3199
  code_challenge: z.string().optional(),
3011
3200
  code_challenge_method: z.string().pipe(z.enum(["S256"])).optional(),
3012
3201
  nonce: z.string().optional(),
3202
+ resource: z.union([ResourceUriSchema, z.array(ResourceUriSchema).min(1)]).optional(),
3013
3203
  prompt: z.string().pipe(z.enum([
3014
3204
  "none",
3015
3205
  "consent",
@@ -3021,7 +3211,10 @@ const oauthProvider = (options) => {
3021
3211
  ])).optional()
3022
3212
  }),
3023
3213
  redirectOnError: authorizeRedirectOnError(opts),
3024
- errorCodesByField: { response_type: { invalid: "unsupported_response_type" } },
3214
+ errorCodesByField: {
3215
+ response_type: { invalid: "unsupported_response_type" },
3216
+ resource: { invalid: "invalid_target" }
3217
+ },
3025
3218
  metadata: { openapi: {
3026
3219
  description: "Authorize an OAuth2 request",
3027
3220
  parameters: [
@@ -3091,6 +3284,16 @@ const oauthProvider = (options) => {
3091
3284
  schema: { type: "string" },
3092
3285
  description: "OpenID Connect nonce"
3093
3286
  },
3287
+ {
3288
+ name: "resource",
3289
+ in: "query",
3290
+ required: false,
3291
+ schema: {
3292
+ type: "array",
3293
+ items: { type: "string" }
3294
+ },
3295
+ description: "Requested token resource(s) (ie audience) to obtain a JWT formatted access token. May be supplied multiple times as repeated 'resource' query parameters (RFC 8707) or as an array of strings."
3296
+ },
3094
3297
  {
3095
3298
  name: "prompt",
3096
3299
  in: "query",
@@ -3196,13 +3399,16 @@ const oauthProvider = (options) => {
3196
3399
  code_verifier: z.string().optional(),
3197
3400
  redirect_uri: SafeUrlSchema.optional(),
3198
3401
  refresh_token: z.string().optional(),
3199
- resource: z.string().optional(),
3402
+ resource: z.union([ResourceUriSchema, z.array(ResourceUriSchema).min(1)]).optional(),
3200
3403
  scope: z.string().optional()
3201
3404
  }),
3202
- errorCodesByField: { grant_type: {
3203
- missing: "invalid_request",
3204
- invalid: "unsupported_grant_type"
3205
- } },
3405
+ errorCodesByField: {
3406
+ grant_type: {
3407
+ missing: "invalid_request",
3408
+ invalid: "unsupported_grant_type"
3409
+ },
3410
+ resource: { invalid: "invalid_target" }
3411
+ },
3206
3412
  metadata: {
3207
3413
  allowedMediaTypes: ["application/x-www-form-urlencoded"],
3208
3414
  openapi: {
@@ -3247,8 +3453,15 @@ const oauthProvider = (options) => {
3247
3453
  description: "Refresh token (for refresh_token grant)"
3248
3454
  },
3249
3455
  resource: {
3250
- type: "string",
3251
- description: "Requested token resource (ie audience) to obtain a JWT formatted access token"
3456
+ oneOf: [{
3457
+ type: "string",
3458
+ description: "Single resource (URL)"
3459
+ }, {
3460
+ type: "array",
3461
+ items: { type: "string" },
3462
+ description: "Multiple resources (URLs)"
3463
+ }],
3464
+ description: "Requested token resource(s) (ie audience) to obtain a JWT formatted access token"
3252
3465
  },
3253
3466
  scope: {
3254
3467
  type: "string",
@@ -3349,10 +3562,6 @@ const oauthProvider = (options) => {
3349
3562
  token_type_hint: {
3350
3563
  type: "string",
3351
3564
  description: "Hint about the token type. Recognized values: `access_token`, `refresh_token`."
3352
- },
3353
- resource: {
3354
- type: "string",
3355
- description: "Introspects a token for a specific resource."
3356
3565
  }
3357
3566
  },
3358
3567
  required: ["token"]
@@ -3640,7 +3849,7 @@ const oauthProvider = (options) => {
3640
3849
  "client_secret_post",
3641
3850
  "private_key_jwt"
3642
3851
  ]).default("client_secret_basic").optional(),
3643
- jwks: z.union([z.array(z.record(z.string(), z.unknown())), z.object({ keys: z.array(z.record(z.string(), z.unknown())) })]).optional(),
3852
+ 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(),
3644
3853
  jwks_uri: z.string().optional(),
3645
3854
  grant_types: z.array(z.enum([
3646
3855
  "authorization_code",
@@ -4045,6 +4254,9 @@ async function authorizeEndpoint(ctx, opts, settings) {
4045
4254
  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)));
4046
4255
  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)));
4047
4256
  }
4257
+ const resource = query.resource;
4258
+ if (!(await checkResource(ctx, opts, resource, requestedScopes)).success) return handleRedirect(ctx, formatErrorURL(query.redirect_uri, "invalid_target", "requested resource invalid", query.state, getIssuer(ctx, opts)));
4259
+ const requestedResources = toResourceList(resource) ?? [];
4048
4260
  const session = await getSessionFromCtx(ctx);
4049
4261
  if (!session || promptSet?.has("login") || promptSet?.has("create")) {
4050
4262
  if (promptNone) return redirectWithPromptNoneError(ctx, opts, query, "login_required", "authentication required");
@@ -4071,7 +4283,7 @@ async function authorizeEndpoint(ctx, opts, settings) {
4071
4283
  });
4072
4284
  if (signupRedirect) {
4073
4285
  if (promptNone) return redirectWithPromptNoneError(ctx, opts, query, "interaction_required", "End-User interaction is required");
4074
- return redirectWithPromptCode(ctx, opts, "create", typeof signupRedirect === "string" ? signupRedirect : void 0);
4286
+ return redirectWithPromptCode(ctx, opts, "create", { page: typeof signupRedirect === "string" ? signupRedirect : void 0 });
4075
4287
  }
4076
4288
  }
4077
4289
  if (!settings?.postLogin && opts.postLogin) {
@@ -4085,7 +4297,7 @@ async function authorizeEndpoint(ctx, opts, settings) {
4085
4297
  return redirectWithPromptCode(ctx, opts, "post_login");
4086
4298
  }
4087
4299
  }
4088
- if (promptSet?.has("consent")) return redirectWithPromptCode(ctx, opts, "consent");
4300
+ if (promptSet?.has("consent")) return redirectWithPromptCode(ctx, opts, "consent", { sessionId: session.session.id });
4089
4301
  const referenceId = await opts.postLogin?.consentReferenceId?.({
4090
4302
  user: session.user,
4091
4303
  session: session.session,
@@ -4097,7 +4309,8 @@ async function authorizeEndpoint(ctx, opts, settings) {
4097
4309
  userId: session.user.id,
4098
4310
  sessionId: session.session.id,
4099
4311
  authTime: new Date(session.session.createdAt).getTime(),
4100
- referenceId
4312
+ referenceId,
4313
+ resource: requestedResources
4101
4314
  });
4102
4315
  const consent = await ctx.context.adapter.findOne({
4103
4316
  model: "oauthConsent",
@@ -4118,7 +4331,12 @@ async function authorizeEndpoint(ctx, opts, settings) {
4118
4331
  });
4119
4332
  if (!consent || !requestedScopes.every((val) => consent.scopes.includes(val))) {
4120
4333
  if (promptNone) return redirectWithPromptNoneError(ctx, opts, query, "consent_required", "End-User consent is required");
4121
- return redirectWithPromptCode(ctx, opts, "consent");
4334
+ return redirectWithPromptCode(ctx, opts, "consent", { sessionId: session.session.id });
4335
+ }
4336
+ const consentedResources = consent?.resources ?? [];
4337
+ if (requestedResources.some((requestedResource) => !consentedResources.includes(requestedResource))) {
4338
+ if (promptNone) return redirectWithPromptNoneError(ctx, opts, query, "consent_required", "End-User consent is required");
4339
+ return redirectWithPromptCode(ctx, opts, "consent", { sessionId: session.session.id });
4122
4340
  }
4123
4341
  return redirectWithAuthorizationCode(ctx, opts, {
4124
4342
  query,
@@ -4126,7 +4344,8 @@ async function authorizeEndpoint(ctx, opts, settings) {
4126
4344
  userId: session.user.id,
4127
4345
  sessionId: session.session.id,
4128
4346
  authTime: new Date(session.session.createdAt).getTime(),
4129
- referenceId
4347
+ referenceId,
4348
+ resource: requestedResources
4130
4349
  });
4131
4350
  }
4132
4351
  function serializeAuthorizationQuery(query) {
@@ -4152,7 +4371,8 @@ async function redirectWithAuthorizationCode(ctx, opts, verificationValue) {
4152
4371
  userId: verificationValue.userId,
4153
4372
  sessionId: verificationValue?.sessionId,
4154
4373
  referenceId: verificationValue.referenceId,
4155
- authTime: verificationValue.authTime
4374
+ authTime: verificationValue.authTime,
4375
+ resource: verificationValue.resource
4156
4376
  })
4157
4377
  };
4158
4378
  await ctx.context.internalAdapter.createVerificationValue({
@@ -4165,8 +4385,8 @@ async function redirectWithAuthorizationCode(ctx, opts, verificationValue) {
4165
4385
  redirectUriWithCode.searchParams.set("iss", getIssuer(ctx, opts));
4166
4386
  return handleRedirect(ctx, redirectUriWithCode.toString());
4167
4387
  }
4168
- async function redirectWithPromptCode(ctx, opts, type, page) {
4169
- const queryParams = await signParams(ctx, opts);
4388
+ async function redirectWithPromptCode(ctx, opts, type, options) {
4389
+ const queryParams = await signParams(ctx, opts, { postLoginClearedForSession: type === "consent" && opts.postLogin ? options?.sessionId : void 0 });
4170
4390
  let path = opts.loginPage;
4171
4391
  if (type === "select_account") path = opts.selectAccount?.page ?? opts.loginPage;
4172
4392
  else if (type === "post_login") {
@@ -4174,12 +4394,16 @@ async function redirectWithPromptCode(ctx, opts, type, page) {
4174
4394
  path = opts.postLogin?.page;
4175
4395
  } else if (type === "consent") path = opts.consentPage;
4176
4396
  else if (type === "create") path = opts.signup?.page ?? opts.loginPage;
4177
- return handleRedirect(ctx, `${page ?? path}?${queryParams}`);
4397
+ return handleRedirect(ctx, `${options?.page ?? path}?${queryParams}`);
4178
4398
  }
4179
- async function signParams(ctx, opts) {
4180
- const exp = Math.floor(Date.now() / 1e3) + (opts.codeExpiresIn ?? 600);
4399
+ async function signParams(ctx, opts, flags) {
4400
+ const issuedAt = Date.now();
4401
+ const exp = Math.floor(issuedAt / 1e3) + (opts.codeExpiresIn ?? 600);
4181
4402
  const params = serializeAuthorizationQuery(ctx.query);
4182
4403
  params.set("exp", String(exp));
4404
+ params.set(signedQueryIssuedAtParam, String(issuedAt));
4405
+ params.delete(postLoginClearedParam);
4406
+ if (flags?.postLoginClearedForSession) params.set(postLoginClearedParam, flags.postLoginClearedForSession);
4183
4407
  const signature = await makeSignature(params.toString(), ctx.context.secret);
4184
4408
  params.append("sig", signature);
4185
4409
  return params.toString();
@@ -4194,7 +4418,7 @@ function authServerMetadata(ctx, opts, overrides) {
4194
4418
  authorization_endpoint: `${baseURL}/oauth2/authorize`,
4195
4419
  token_endpoint: `${baseURL}/oauth2/token`,
4196
4420
  jwks_uri: overrides?.jwt_disabled ? void 0 : opts?.jwks?.remoteUrl ?? `${baseURL}${opts?.jwks?.jwksPath ?? "/jwks"}`,
4197
- registration_endpoint: `${baseURL}/oauth2/register`,
4421
+ registration_endpoint: overrides?.dynamic_client_registration_supported ? `${baseURL}/oauth2/register` : void 0,
4198
4422
  introspection_endpoint: `${baseURL}/oauth2/introspect`,
4199
4423
  revocation_endpoint: `${baseURL}/oauth2/revoke`,
4200
4424
  response_types_supported: overrides?.grant_types_supported && !overrides.grant_types_supported.includes("authorization_code") ? [] : ["code"],
@@ -4210,19 +4434,19 @@ function authServerMetadata(ctx, opts, overrides) {
4210
4434
  "client_secret_post",
4211
4435
  "private_key_jwt"
4212
4436
  ],
4213
- token_endpoint_auth_signing_alg_values_supported: [...ASSERTION_SIGNING_ALGORITHMS],
4437
+ token_endpoint_auth_signing_alg_values_supported: [...PRIVATE_KEY_JWT_SIGNING_ALGORITHMS],
4214
4438
  introspection_endpoint_auth_methods_supported: [
4215
4439
  "client_secret_basic",
4216
4440
  "client_secret_post",
4217
4441
  "private_key_jwt"
4218
4442
  ],
4219
- introspection_endpoint_auth_signing_alg_values_supported: [...ASSERTION_SIGNING_ALGORITHMS],
4443
+ introspection_endpoint_auth_signing_alg_values_supported: [...PRIVATE_KEY_JWT_SIGNING_ALGORITHMS],
4220
4444
  revocation_endpoint_auth_methods_supported: [
4221
4445
  "client_secret_basic",
4222
4446
  "client_secret_post",
4223
4447
  "private_key_jwt"
4224
4448
  ],
4225
- revocation_endpoint_auth_signing_alg_values_supported: [...ASSERTION_SIGNING_ALGORITHMS],
4449
+ revocation_endpoint_auth_signing_alg_values_supported: [...PRIVATE_KEY_JWT_SIGNING_ALGORITHMS],
4226
4450
  code_challenge_methods_supported: ["S256"],
4227
4451
  authorization_response_iss_parameter_supported: true
4228
4452
  };
@@ -4233,6 +4457,7 @@ function oidcServerMetadata(ctx, opts) {
4233
4457
  return {
4234
4458
  ...authServerMetadata(ctx, jwtPluginOptions, {
4235
4459
  scopes_supported: opts.advertisedMetadata?.scopes_supported ?? opts.scopes,
4460
+ dynamic_client_registration_supported: opts.allowDynamicClientRegistration,
4236
4461
  public_client_supported: opts.allowUnauthenticatedClientRegistration || toClientDiscoveryArray(opts.clientDiscovery).length > 0,
4237
4462
  grant_types_supported: opts.grantTypes,
4238
4463
  jwt_disabled: opts.disableJwtPlugin