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

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.
@@ -0,0 +1,2119 @@
1
+ import { t as __exportAll } from "./rolldown-runtime-wcPFST8Q.mjs";
2
+ import { A as collectExtensionUserInfoClaims, C as toAudienceClaim, F as getSupportedGrantTypes, I as hasUserInfoClaimExtension, N as getExtensionGrantHandler, O as collectExtensionAccessTokenClaims, S as storeToken, T as validateClientCredentials, a as getClient, c as getStoredToken, f as normalizeTimestampValue, i as extractClientCredentials, k as collectExtensionIdTokenClaims, l as isPKCERequired, m as parseClientMetadata, n as decryptStoredClientSecret, o as getJwtPlugin, r as destructureCredentials, t as clientAllowsGrant, v as resolveSessionAuthTime, w as toResourceList, y as resolveSubjectIdentifier } from "./utils-Baq6atYN.mjs";
3
+ import { APIError } from "better-auth/api";
4
+ import { generateRandomString } from "better-auth/crypto";
5
+ import { APIError as APIError$1 } from "better-call";
6
+ import { logger } from "@better-auth/core/env";
7
+ import * as z from "zod";
8
+ import { createDpopReplayStore, enforceDpopBinding, generateCodeChallenge, getConfirmationJkt, getDpopJktFromPayload, getJwks, isDpopBindingError, isDpopProofError, parseAccessTokenAuthorization, stripAccessTokenAuthorizationScheme, verifyDpopProof } from "better-auth/oauth2";
9
+ import { SignJWT, base64url, createLocalJWKSet, decodeProtectedHeader, jwtVerify } from "jose";
10
+ import { resolveSigningKey, signJWT, toExpJWT } from "better-auth/plugins";
11
+ import { SafeUrlSchema } from "@better-auth/core/utils/redirect-uri";
12
+ //#region src/claims.ts
13
+ /**
14
+ * Claim names the authorization server owns on a JWT access token, which no
15
+ * claim source (the `customAccessTokenClaims` plugin option, an extension
16
+ * contributor, or a resource row's `customClaims`) may override. The AS is the
17
+ * only source of truth for issuer identity, subject, audience, lifetime, scope,
18
+ * authentication context, the token's stable ID, and its sender-constraint
19
+ * (`cnf`).
20
+ *
21
+ * @see RFC 9068 §2.2 (registered access-token claims)
22
+ * @see RFC 7800 / RFC 9449 §6 (`cnf` confirmation — the token's bound key)
23
+ */
24
+ const RESERVED_ACCESS_TOKEN_CLAIMS = new Set([
25
+ "iss",
26
+ "sub",
27
+ "aud",
28
+ "exp",
29
+ "iat",
30
+ "jti",
31
+ "client_id",
32
+ "scope",
33
+ "auth_time",
34
+ "acr",
35
+ "amr",
36
+ "cnf"
37
+ ]);
38
+ /**
39
+ * Returns a copy of `claims` with reserved AS-owned names removed. Emits a
40
+ * `warn` naming the stripped keys when any were present (never silently
41
+ * dropped: surfacing the override attempt matters more than minimizing log
42
+ * noise). Stable iteration order (`Object.entries`) is preserved so token-debug
43
+ * logs stay reproducible across runs.
44
+ */
45
+ function stripReservedClaims(claims) {
46
+ if (!claims) return {};
47
+ const stripped = [];
48
+ const safe = {};
49
+ for (const [key, value] of Object.entries(claims)) {
50
+ if (RESERVED_ACCESS_TOKEN_CLAIMS.has(key)) {
51
+ stripped.push(key);
52
+ continue;
53
+ }
54
+ safe[key] = value;
55
+ }
56
+ if (stripped.length > 0) logger.warn(`oauth-provider: stripped reserved access-token claim name(s): ${stripped.join(", ")}. The AS owns these claim values.`);
57
+ return safe;
58
+ }
59
+ /**
60
+ * The single authority for the enriched (non-AS-owned) claim set an access
61
+ * token carries. Both the JWT mint and the opaque-token introspection
62
+ * re-derive path call this function, so the two formats cannot drift, and
63
+ * reserved RFC 9068 names are stripped unconditionally here so no caller can
64
+ * forget to.
65
+ *
66
+ * Precedence, lowest to highest: extension contributors < per-issuance
67
+ * `accessTokenClaims` < plugin `customAccessTokenClaims` < per-resource
68
+ * `customClaims`. Reserved names are removed from the merged result.
69
+ *
70
+ * Returns only the enriched claims; the caller stamps the AS-owned claims
71
+ * (`iss`/`sub`/`aud`/`exp`/`iat`/`jti`/`client_id`/`scope`/...) itself, so they
72
+ * always win.
73
+ */
74
+ async function resolveAccessTokenClaims(input) {
75
+ const { ctx, opts, user, client, scopes, resources, referenceId, metadata, grantType, sessionId, perRequestClaims, resourcePolicyClaims } = input;
76
+ const extensionClaims = await collectExtensionAccessTokenClaims(opts, {
77
+ ctx,
78
+ opts,
79
+ user,
80
+ client,
81
+ scopes,
82
+ grantType,
83
+ referenceId,
84
+ sessionId,
85
+ resources,
86
+ metadata
87
+ });
88
+ const pluginClaims = opts.customAccessTokenClaims ? await opts.customAccessTokenClaims({
89
+ user,
90
+ scopes,
91
+ resources,
92
+ referenceId,
93
+ metadata
94
+ }) : {};
95
+ return stripReservedClaims({
96
+ ...extensionClaims,
97
+ ...perRequestClaims ?? {},
98
+ ...pluginClaims,
99
+ ...resourcePolicyClaims
100
+ });
101
+ }
102
+ //#endregion
103
+ //#region src/resources.ts
104
+ /**
105
+ * Source-of-truth list of asymmetric JWS algorithms supported by the JWT
106
+ * plugin. Mirrors the {@link JWSAlgorithms} literal-union type from
107
+ * `packages/better-auth/src/plugins/jwt/types.ts`. Kept as a runtime const
108
+ * so the admin-CRUD zod schema AND the seed-config validator can reject
109
+ * bad values up-front rather than surfacing opaque jose errors at issuance.
110
+ *
111
+ * MUST stay in sync with `JWSAlgorithms`. The type-level guard immediately
112
+ * below fails the typecheck if the two drift.
113
+ */
114
+ const JWS_ALGORITHMS = [
115
+ "EdDSA",
116
+ "ES256",
117
+ "ES512",
118
+ "PS256",
119
+ "RS256"
120
+ ];
121
+ const JWS_ALGORITHM_SET = new Set(JWS_ALGORITHMS);
122
+ /**
123
+ * Upper bound on how many entries are accepted in a JWT's `aud` claim during
124
+ * introspection / revocation validation. Without a cap, a hostile or replayed
125
+ * token can supply hundreds of fake resource identifiers and amplify load on
126
+ * the resource table (one DB lookup per unique unrecognized entry). Real-world
127
+ * deployments use single-digit resource lists; 64 is a generous ceiling that
128
+ * stays well clear of any legitimate use and bounds the per-request DB fan-out.
129
+ *
130
+ * RFC 7519 §4.1.3 does not specify a maximum, so this is a defensive limit,
131
+ * not a spec one. Tokens that exceed it are treated as invalid (introspect →
132
+ * `active: false`, revoke → no-op) rather than triggering the lookup path.
133
+ *
134
+ * @internal
135
+ */
136
+ const MAX_AUD_VALUES = 64;
137
+ /**
138
+ * Builds a deterministic primary-key value for an `oauthClientResource`
139
+ * row. Used so the implicit PK uniqueness constraint enforces the composite
140
+ * `(clientId, resourceId)` uniqueness that Better Auth's schema layer
141
+ * cannot declare directly — making client-resource links idempotent across
142
+ * the admin link endpoint and Dynamic Client Registration.
143
+ *
144
+ * The `::` separator is collision-free: client_id is a URL-safe random
145
+ * string (no `::`) and resource identifier is an RFC 8707 absolute URI
146
+ * (a bare `::` would be a malformed IPv6 host the validator rejects).
147
+ *
148
+ * @see comment block in `schema.ts` on `oauthClientResource` for the full
149
+ * rationale.
150
+ * @internal
151
+ */
152
+ function buildClientResourceLinkId(clientId, resourceId) {
153
+ return `${clientId}::${resourceId}`;
154
+ }
155
+ /**
156
+ * Validates a resource `identifier` per RFC 8707 §2 (absolute URI, no
157
+ * fragment), honoring {@link OAuthOptions.identifierValidator} when provided.
158
+ *
159
+ * Returns a structured result so callers can decide whether to throw
160
+ * (admin CRUD / DCR) or warn-and-skip (config seed path).
161
+ *
162
+ * @internal
163
+ */
164
+ async function checkIdentifier(opts, identifier) {
165
+ const customValidator = opts.identifierValidator;
166
+ if (customValidator) {
167
+ if (!await customValidator(identifier)) return {
168
+ ok: false,
169
+ reason: `resource identifier ${identifier} failed validation`
170
+ };
171
+ return { ok: true };
172
+ }
173
+ let url;
174
+ try {
175
+ url = new URL(identifier);
176
+ } catch {
177
+ return {
178
+ ok: false,
179
+ reason: `resource identifier ${identifier} must be an absolute URI (RFC 8707 §2)`
180
+ };
181
+ }
182
+ if (url.hash) return {
183
+ ok: false,
184
+ reason: `resource identifier ${identifier} must not contain a URI fragment (RFC 8707 §2)`
185
+ };
186
+ return { ok: true };
187
+ }
188
+ /**
189
+ * Variant of {@link checkIdentifier} that throws `invalid_target` on failure.
190
+ * Used by admin CRUD endpoints where validation failure is a client error.
191
+ *
192
+ * @internal
193
+ */
194
+ async function assertIdentifierValid(opts, identifier) {
195
+ const result = await checkIdentifier(opts, identifier);
196
+ if (!result.ok) throw new APIError("BAD_REQUEST", {
197
+ error: "invalid_target",
198
+ error_description: result.reason
199
+ });
200
+ }
201
+ /**
202
+ * The OIDC userinfo endpoint's resource identifier. Always accepted as an
203
+ * implicit `aud` value when `openid` is in scope — not looked up against
204
+ * `oauthResource` rows.
205
+ */
206
+ const userInfoResource = (baseURL) => `${baseURL}/oauth2/userinfo`;
207
+ /**
208
+ * Re-parses a request's raw `application/x-www-form-urlencoded` body to
209
+ * recover every value of the repeated `resource` parameter (RFC 8707 §2).
210
+ *
211
+ * Workaround for better-call's form-body parser (better-call ≤1.3.5), which
212
+ * collapses repeated form keys with last-write-wins. A client sending
213
+ * `resource=https://a&resource=https://b` would otherwise arrive in the
214
+ * handler as `{ resource: "https://b" }`, silently narrowing the issued
215
+ * token's `aud` to a single resource.
216
+ *
217
+ * Returns the full ordered list when the body is form-encoded and contains
218
+ * any `resource` entries; `undefined` otherwise. Caller MUST enable
219
+ * `cloneRequest: true` on the endpoint — the body stream is read here a
220
+ * second time, and that only works when better-call cloned the request
221
+ * before its own parse.
222
+ *
223
+ * Note: the URL-encoded body path is the only one affected. The query-string
224
+ * path (e.g. `/oauth2/authorize?resource=…&resource=…`) is parsed by the
225
+ * router itself, which DOES array-promote duplicate keys correctly.
226
+ *
227
+ * @internal
228
+ */
229
+ async function extractRepeatedResourceFromForm(request) {
230
+ if (!(request.headers.get("content-type")?.toLowerCase() ?? "").includes("application/x-www-form-urlencoded")) return;
231
+ let text;
232
+ try {
233
+ text = await request.text();
234
+ } catch {
235
+ return;
236
+ }
237
+ if (!text) return void 0;
238
+ const values = new URLSearchParams(text).getAll("resource").filter((value) => value.length > 0);
239
+ return values.length > 0 ? values : void 0;
240
+ }
241
+ /**
242
+ * Normalizes a `resource` parameter into an array. Accepts the RFC 8707
243
+ * forms: a single string, a string[], or undefined.
244
+ *
245
+ * @internal
246
+ */
247
+ function normalizeResourceParam(resource) {
248
+ if (resource === void 0 || resource === null) return void 0;
249
+ if (typeof resource === "string") return [resource];
250
+ if (Array.isArray(resource)) {
251
+ const result = resource.filter((r) => typeof r === "string" && r.length > 0);
252
+ return result.length > 0 ? result : void 0;
253
+ }
254
+ }
255
+ /**
256
+ * Validates a JWT `aud` claim against the OAuth resource model.
257
+ *
258
+ * Every non-implicit audience value must resolve to an `oauthResource` row.
259
+ * Disabled rows still pass because disabling blocks new issuance; deleting the
260
+ * row is the operation that invalidates already-issued tokens.
261
+ *
262
+ * @internal
263
+ */
264
+ async function isAudienceClaimAllowed(ctx, opts, audienceClaim, implicitAudiences = []) {
265
+ if (audienceClaim === void 0) return true;
266
+ const audienceValues = Array.isArray(audienceClaim) ? audienceClaim : [audienceClaim];
267
+ if (audienceValues.length > MAX_AUD_VALUES) return false;
268
+ const implicitAudienceSet = new Set(implicitAudiences);
269
+ const resourcesToLookup = /* @__PURE__ */ new Set();
270
+ for (const audienceValue of audienceValues) {
271
+ if (implicitAudienceSet.has(audienceValue)) continue;
272
+ resourcesToLookup.add(audienceValue);
273
+ }
274
+ if (resourcesToLookup.size === 0) return true;
275
+ return (await Promise.all(Array.from(resourcesToLookup, (resource) => getResource(ctx, opts, resource)))).every(Boolean);
276
+ }
277
+ /**
278
+ * Resolves the resource policy for a request.
279
+ *
280
+ * Validation steps (RFC 8707 §3 + per-resource policy):
281
+ *
282
+ * 1. Resolve `resource` parameter to a list of identifiers.
283
+ * 2. For each identifier, look up the {@link OAuthResource} row.
284
+ * - Missing → `invalid_target`.
285
+ * - `disabled` → `invalid_target` (no new issuance).
286
+ * 3. If {@link OAuthOptions.enforcePerClientResources} resolves to true,
287
+ * confirm the client is linked to every requested resource via
288
+ * `oauthClientResource`. Unlinked → `invalid_target`.
289
+ * 4. Intersect `requestedScopes` with each resource's `allowedScopes`.
290
+ * Empty intersection → `invalid_scope`.
291
+ * 5. Pick the minimum `accessTokenTtl` across requested resources.
292
+ * 6. Pick signing config — only honored for single-resource requests
293
+ * (a JWT can only have one signature).
294
+ * 7. Merge per-resource `customClaims` (returned raw; reserved-claim
295
+ * stripping is owned by `resolveAccessTokenClaims`).
296
+ *
297
+ * When no `resource` param is present, returns an empty policy: no `aud`
298
+ * claim and no resource-specific overrides.
299
+ *
300
+ * @internal
301
+ */
302
+ async function resolveResourcePolicy(ctx, opts, params) {
303
+ const requestedResources = normalizeResourceParam(params.resource);
304
+ const includesOpenid = params.requestedScopes.includes("openid");
305
+ const userInfoResourceIdentifier = userInfoResource(ctx.context.baseURL ?? "");
306
+ if (!requestedResources) return {
307
+ audienceClaim: void 0,
308
+ accessTokenTtl: null,
309
+ refreshTokenTtl: null,
310
+ signingAlgorithm: null,
311
+ signingKeyId: null,
312
+ rawCustomClaims: {},
313
+ dpopBoundAccessTokensRequired: false,
314
+ effectiveScopes: [...params.requestedScopes]
315
+ };
316
+ const uniqueRequestedResources = [...new Set(requestedResources)];
317
+ const resolved = [];
318
+ for (const identifier of uniqueRequestedResources) {
319
+ if (identifier === userInfoResourceIdentifier) continue;
320
+ const row = await getResource(ctx, opts, identifier);
321
+ if (row) {
322
+ if (row.disabled) throw new APIError("BAD_REQUEST", {
323
+ error: "invalid_target",
324
+ error_description: `requested resource ${identifier} is disabled`
325
+ });
326
+ resolved.push(row);
327
+ continue;
328
+ }
329
+ throw new APIError("BAD_REQUEST", {
330
+ error: "invalid_target",
331
+ error_description: `requested resource ${identifier} is not configured`
332
+ });
333
+ }
334
+ const { value: enforcePerClient } = resolveEnforcePerClientResources(opts);
335
+ if (enforcePerClient && resolved.length > 0) await assertClientLinkedToResources(ctx, opts, params.clientId, resolved);
336
+ let effectiveScopes = [...params.requestedScopes];
337
+ for (const row of resolved) {
338
+ if (row.allowedScopes === null || row.allowedScopes === void 0) continue;
339
+ const allowed = new Set(row.allowedScopes);
340
+ const intersection = effectiveScopes.filter((s) => allowed.has(s));
341
+ if (intersection.length === 0) throw new APIError("BAD_REQUEST", {
342
+ error: "invalid_scope",
343
+ error_description: `none of the requested scopes are allowed for resource ${row.identifier}`
344
+ });
345
+ effectiveScopes = intersection;
346
+ }
347
+ let accessTokenTtl = null;
348
+ let refreshTokenTtl = null;
349
+ for (const row of resolved) {
350
+ if (row.accessTokenTtl != null) accessTokenTtl = accessTokenTtl === null ? row.accessTokenTtl : Math.min(accessTokenTtl, row.accessTokenTtl);
351
+ if (row.refreshTokenTtl != null) refreshTokenTtl = refreshTokenTtl === null ? row.refreshTokenTtl : Math.min(refreshTokenTtl, row.refreshTokenTtl);
352
+ }
353
+ const uniqueSigningAlgs = /* @__PURE__ */ new Set();
354
+ const uniqueSigningKids = /* @__PURE__ */ new Set();
355
+ for (const row of resolved) {
356
+ if (row.signingAlgorithm) uniqueSigningAlgs.add(row.signingAlgorithm);
357
+ if (row.signingKeyId) uniqueSigningKids.add(row.signingKeyId);
358
+ }
359
+ if (uniqueSigningAlgs.size > 1) throw new APIError("BAD_REQUEST", {
360
+ error: "invalid_request",
361
+ error_description: "multi-resource request has conflicting signingAlgorithm pins; a single JWS signature cannot satisfy multiple algorithms"
362
+ });
363
+ if (uniqueSigningKids.size > 1) throw new APIError("BAD_REQUEST", {
364
+ error: "invalid_request",
365
+ error_description: "multi-resource request has conflicting signingKeyId pins; a single JWS signature cannot satisfy multiple key ids"
366
+ });
367
+ const signingAlgorithm = uniqueSigningAlgs.values().next().value ?? null;
368
+ const signingKeyId = uniqueSigningKids.values().next().value ?? null;
369
+ const mergedClaims = {};
370
+ for (const row of resolved) if (row.customClaims && typeof row.customClaims === "object") Object.assign(mergedClaims, row.customClaims);
371
+ const dpopBoundAccessTokensRequired = resolved.some((row) => row.dpopBoundAccessTokensRequired === true);
372
+ const audienceIdentifiers = includesOpenid ? [...uniqueRequestedResources, userInfoResourceIdentifier] : uniqueRequestedResources;
373
+ const audClaim = [...new Set(audienceIdentifiers)];
374
+ return {
375
+ audienceClaim: audClaim.length === 1 ? audClaim[0] : audClaim,
376
+ accessTokenTtl,
377
+ refreshTokenTtl,
378
+ signingAlgorithm,
379
+ signingKeyId,
380
+ rawCustomClaims: mergedClaims,
381
+ dpopBoundAccessTokensRequired,
382
+ effectiveScopes
383
+ };
384
+ }
385
+ /**
386
+ * Throws `invalid_target` if the client isn't linked to every resource via
387
+ * the `oauthClientResource` join table. Issues one `findMany` per call.
388
+ *
389
+ * @internal
390
+ */
391
+ async function assertClientLinkedToResources(ctx, opts, clientId, resources) {
392
+ if (resources.length === 0) return;
393
+ const modelName = opts.schema?.oauthClientResource?.modelName ?? "oauthClientResource";
394
+ const links = await ctx.context.adapter.findMany({
395
+ model: modelName,
396
+ where: [{
397
+ field: "clientId",
398
+ value: clientId
399
+ }]
400
+ });
401
+ const linkedSet = new Set(links?.map((l) => l.resourceId) ?? []);
402
+ const unlinked = resources.filter((resource) => !linkedSet.has(resource.identifier));
403
+ if (unlinked.length > 0) throw new APIError("BAD_REQUEST", {
404
+ error: "invalid_target",
405
+ error_description: `client ${clientId} is not linked to resource(s) ${unlinked.map((resource) => resource.identifier).join(", ")}`
406
+ });
407
+ }
408
+ /**
409
+ * Returns `true` when `clientId` is linked, via the `oauthClientResource` join
410
+ * table, to at least one of `resourceIdentifiers`. Authorizes cross-client token
411
+ * introspection (RFC 7662 §2.1/§4): a resource server may introspect a token
412
+ * whose audience it serves, even when a different client issued the token.
413
+ *
414
+ * @internal
415
+ */
416
+ async function isClientLinkedToAnyResource(ctx, opts, clientId, resourceIdentifiers) {
417
+ if (resourceIdentifiers.length === 0) return false;
418
+ const modelName = opts.schema?.oauthClientResource?.modelName ?? "oauthClientResource";
419
+ return ((await ctx.context.adapter.findMany({
420
+ model: modelName,
421
+ where: [{
422
+ field: "clientId",
423
+ value: clientId
424
+ }, {
425
+ field: "resourceId",
426
+ operator: "in",
427
+ value: resourceIdentifiers
428
+ }],
429
+ limit: 1
430
+ }))?.length ?? 0) > 0;
431
+ }
432
+ /**
433
+ * Merges `customClaims` from the existing rows of `resourceIdentifiers` in
434
+ * order (later resources win on collision). Missing rows are skipped; disabled
435
+ * rows are included, since an already-issued token keeps its claims until it
436
+ * expires. Returned raw; `resolveAccessTokenClaims` strips reserved names.
437
+ *
438
+ * Unlike {@link resolveResourcePolicy}, this applies none of the new-issuance
439
+ * gates (disabled, scope allowlist, client linkage), so it is the right way to
440
+ * re-derive claims for an already-issued token at introspection.
441
+ *
442
+ * @internal
443
+ */
444
+ async function getResourceCustomClaims(ctx, opts, resourceIdentifiers) {
445
+ if (resourceIdentifiers.length === 0) return {};
446
+ const rows = await ctx.context.adapter.findMany({
447
+ model: opts.schema?.oauthResource?.modelName ?? "oauthResource",
448
+ where: [{
449
+ field: "identifier",
450
+ operator: "in",
451
+ value: resourceIdentifiers
452
+ }]
453
+ });
454
+ const rowByIdentifier = new Map(rows.map((row) => [row.identifier, row]));
455
+ const merged = {};
456
+ for (const identifier of resourceIdentifiers) {
457
+ const row = rowByIdentifier.get(identifier);
458
+ if (row?.customClaims && typeof row.customClaims === "object") Object.assign(merged, row.customClaims);
459
+ }
460
+ return merged;
461
+ }
462
+ /**
463
+ * Resolves the effective {@link OAuthOptions.enforcePerClientResources} value.
464
+ *
465
+ * - Explicit `true | false` always wins (`source: "explicit"`).
466
+ * - Otherwise resolve to `true` (`source: "default"`). RFC 8707 §3 per-client
467
+ * validation is the secure default.
468
+ *
469
+ * Deterministic and pure — safe to call on every validation pass.
470
+ */
471
+ function resolveEnforcePerClientResources(opts) {
472
+ if (opts.enforcePerClientResources !== void 0) return {
473
+ value: opts.enforcePerClientResources,
474
+ source: "explicit"
475
+ };
476
+ return {
477
+ value: true,
478
+ source: "default"
479
+ };
480
+ }
481
+ /**
482
+ * In-process cache of {@link OAuthResource} rows, keyed by `identifier`.
483
+ *
484
+ * Opt-in via {@link OAuthOptions.cachedResources} (same membership-Set pattern
485
+ * as `cachedTrustedClients`). Resources outside the set are looked up from the
486
+ * DB on every request — the safe default for deployments that edit rows
487
+ * through external tooling.
488
+ *
489
+ * Module-scoped so admin CRUD handlers can invalidate from anywhere via
490
+ * {@link invalidateResourceCache}.
491
+ */
492
+ const resourceCache = /* @__PURE__ */ new Map();
493
+ /**
494
+ * Removes an entry from the resource cache. Called by admin CRUD handlers
495
+ * after every write. Pass no argument to clear the entire cache.
496
+ *
497
+ * @internal
498
+ */
499
+ function invalidateResourceCache(identifier) {
500
+ if (identifier === void 0) {
501
+ resourceCache.clear();
502
+ return;
503
+ }
504
+ resourceCache.delete(identifier);
505
+ }
506
+ /**
507
+ * Looks up an {@link OAuthResource} by `identifier`, consulting the in-process
508
+ * cache when the identifier is in {@link OAuthOptions.cachedResources}.
509
+ *
510
+ * Triggers the lazy seed via {@link seedResourcesOnce} on first call — this
511
+ * is the safety net for deployments where migrations run after plugin init
512
+ * (so seeding at init couldn't see the table yet). The seed is idempotent
513
+ * and coalesced, so the cost is paid only once per process.
514
+ *
515
+ * Returns a defensive copy so callers can't mutate cached state.
516
+ *
517
+ * @internal
518
+ */
519
+ async function getResource(ctx, opts, identifier) {
520
+ await seedResourcesOnce(ctx.context, opts);
521
+ if (opts.cachedResources?.has(identifier)) {
522
+ const cached = resourceCache.get(identifier);
523
+ if (cached) return Object.assign({}, cached);
524
+ }
525
+ const dbResource = await ctx.context.adapter.findOne({
526
+ model: opts.schema?.oauthResource?.modelName ?? "oauthResource",
527
+ where: [{
528
+ field: "identifier",
529
+ value: identifier
530
+ }]
531
+ });
532
+ if (dbResource && opts.cachedResources?.has(identifier)) resourceCache.set(identifier, Object.assign({}, dbResource));
533
+ return dbResource;
534
+ }
535
+ /**
536
+ * Default `name` value when an input doesn't provide one. Mirrors what most
537
+ * admin UIs would show.
538
+ */
539
+ function defaultName(input) {
540
+ return input.name ?? input.identifier;
541
+ }
542
+ /**
543
+ * Coerces seed-config `resources` entries — string or object form — into a
544
+ * normalized list of {@link OAuthResourceInput}.
545
+ *
546
+ * @internal
547
+ */
548
+ function collectResourceInputs(opts) {
549
+ const inputs = [];
550
+ if (opts.resources?.length) for (const entry of opts.resources) inputs.push(typeof entry === "string" ? { identifier: entry } : entry);
551
+ return inputs;
552
+ }
553
+ /**
554
+ * Builds the row payload sent to the adapter for a seed insert. All policy
555
+ * columns default to `null` (= inherit plugin default at issuance time)
556
+ * when the input doesn't specify a value.
557
+ */
558
+ function buildSeedRow(input, now) {
559
+ return {
560
+ identifier: input.identifier,
561
+ name: defaultName(input),
562
+ accessTokenTtl: input.accessTokenTtl ?? null,
563
+ refreshTokenTtl: input.refreshTokenTtl ?? null,
564
+ signingAlgorithm: input.signingAlgorithm ?? null,
565
+ signingKeyId: input.signingKeyId ?? null,
566
+ allowedScopes: input.allowedScopes ?? null,
567
+ customClaims: input.customClaims ?? null,
568
+ dpopBoundAccessTokensRequired: input.dpopBoundAccessTokensRequired ?? false,
569
+ disabled: input.disabled ?? false,
570
+ policyVersion: 1,
571
+ metadata: input.metadata ?? null,
572
+ createdAt: now,
573
+ updatedAt: now
574
+ };
575
+ }
576
+ /**
577
+ * Builds the partial update payload when re-seeding an existing row.
578
+ *
579
+ * - `overwrite` mode replaces every policy column with the input value
580
+ * (omitted fields fall back to `null` to clear stale state).
581
+ * - `merge` mode updates only fields present in the input — admin edits to
582
+ * other fields are preserved.
583
+ */
584
+ function buildSeedUpdate(input, mode, now) {
585
+ if (mode === "overwrite") {
586
+ const { identifier: _identifier, createdAt: _createdAt, ...rest } = buildSeedRow(input, now);
587
+ return rest;
588
+ }
589
+ const update = { updatedAt: now };
590
+ if (input.name !== void 0) update.name = input.name;
591
+ if (input.accessTokenTtl !== void 0) update.accessTokenTtl = input.accessTokenTtl;
592
+ if (input.refreshTokenTtl !== void 0) update.refreshTokenTtl = input.refreshTokenTtl;
593
+ if (input.signingAlgorithm !== void 0) update.signingAlgorithm = input.signingAlgorithm;
594
+ if (input.signingKeyId !== void 0) update.signingKeyId = input.signingKeyId;
595
+ if (input.allowedScopes !== void 0) update.allowedScopes = input.allowedScopes;
596
+ if (input.customClaims !== void 0) update.customClaims = input.customClaims;
597
+ if (input.dpopBoundAccessTokensRequired !== void 0) update.dpopBoundAccessTokensRequired = input.dpopBoundAccessTokensRequired;
598
+ if (input.disabled !== void 0) update.disabled = input.disabled;
599
+ if (input.metadata !== void 0) update.metadata = input.metadata;
600
+ return update;
601
+ }
602
+ /**
603
+ * Pattern matched against adapter errors to detect the "table not yet
604
+ * created" case — i.e. migrations haven't been run. When matched, the
605
+ * caller treats the seed as deferred (will retry on first resource access).
606
+ *
607
+ * Covers SQLite ("no such table"), Postgres ("relation X does not exist"),
608
+ * and MySQL ("Table X does not exist" / contracted form).
609
+ */
610
+ const MISSING_TABLE_PATTERN = /no such table|relation.*does not exist|table.*does(?: not|n[''']?t) exist/i;
611
+ /**
612
+ * Per-adapter state for the lazy-seed path. A module-level boolean would let
613
+ * one Better Auth instance suppress seeding for every later instance in the same
614
+ * process. Endpoint `AuthContext` objects are not guaranteed to be stable across
615
+ * requests, so keying by the adapter keeps one seed state per backing store.
616
+ */
617
+ let seedStates = /* @__PURE__ */ new WeakMap();
618
+ function getSeedState(ctx) {
619
+ const key = ctx.adapter;
620
+ const existing = seedStates.get(key);
621
+ if (existing) return existing;
622
+ const created = {
623
+ completed: false,
624
+ promise: null
625
+ };
626
+ seedStates.set(key, created);
627
+ return created;
628
+ }
629
+ /**
630
+ * Seeds `oauthResource` rows from plugin config.
631
+ *
632
+ * Behavior is controlled by {@link OAuthOptions.resourceSeedMode}:
633
+ *
634
+ * - `"insertOnly"` (default, safe): inserts rows whose `identifier` is not
635
+ * already present. Existing rows are untouched — admin edits via CRUD
636
+ * are never reverted on restart.
637
+ * - `"merge"`: inserts missing rows; updates only specified fields for
638
+ * existing rows. Useful when config holds the "preferred defaults" but
639
+ * admins customize per-row.
640
+ * - `"overwrite"`: inserts missing rows; replaces every policy column on
641
+ * existing rows with the config value (omitted fields → null). Use only
642
+ * when config is the single source of truth.
643
+ *
644
+ * Race-safety: the `identifier` column carries a UNIQUE constraint, so two
645
+ * processes booting simultaneously can each attempt the insert — one wins,
646
+ * the other catches the constraint error and treats it as a no-op.
647
+ *
648
+ * Migration ordering: at plugin `init` time, tables may not exist yet
649
+ * (Better Auth's test harness, and many deployment setups, run migrations
650
+ * after auth construction). Seeding tolerates "no such table" errors and
651
+ * defers — the lazy {@link seedResourcesOnce} path picks up the work on
652
+ * the first resource access.
653
+ *
654
+ * Idempotent: safe to call multiple times.
655
+ *
656
+ * @internal
657
+ */
658
+ async function seedResources(ctx, opts) {
659
+ const inputs = collectResourceInputs(opts);
660
+ if (inputs.length === 0) return;
661
+ const mode = opts.resourceSeedMode ?? "insertOnly";
662
+ const modelName = opts.schema?.oauthResource?.modelName ?? "oauthResource";
663
+ for (const rawInput of inputs) {
664
+ const check = await checkIdentifier(opts, rawInput.identifier);
665
+ if (!check.ok) {
666
+ logger.warn(`oauth-provider: skipping resource seed for ${rawInput.identifier} — ${check.reason}`);
667
+ continue;
668
+ }
669
+ let input = rawInput;
670
+ if (input.signingAlgorithm != null && !JWS_ALGORITHM_SET.has(input.signingAlgorithm)) {
671
+ logger.warn(`oauth-provider: dropping unsupported signingAlgorithm "${input.signingAlgorithm}" for resource ${input.identifier} — must be one of ${JWS_ALGORITHMS.join(", ")}. Continuing without an algorithm override.`);
672
+ const { signingAlgorithm: _, ...rest } = input;
673
+ input = rest;
674
+ }
675
+ let existing;
676
+ try {
677
+ existing = await ctx.adapter.findOne({
678
+ model: modelName,
679
+ where: [{
680
+ field: "identifier",
681
+ value: input.identifier
682
+ }]
683
+ });
684
+ } catch (err) {
685
+ const message = err instanceof Error ? err.message : String(err);
686
+ if (MISSING_TABLE_PATTERN.test(message)) {
687
+ logger.debug("oauth-provider: oauthResource table not yet created; deferring resource seed to first access.");
688
+ return;
689
+ }
690
+ throw err;
691
+ }
692
+ const now = /* @__PURE__ */ new Date();
693
+ if (!existing) {
694
+ try {
695
+ await ctx.adapter.create({
696
+ model: modelName,
697
+ data: buildSeedRow(input, now)
698
+ });
699
+ } catch (err) {
700
+ const message = err instanceof Error ? err.message : String(err);
701
+ if (/unique|duplicate|UNIQUE/i.test(message)) {
702
+ logger.debug(`oauth-provider: resource ${input.identifier} already inserted by a concurrent process — skipping.`);
703
+ continue;
704
+ }
705
+ if (MISSING_TABLE_PATTERN.test(message)) {
706
+ logger.debug("oauth-provider: oauthResource table not yet created; deferring resource seed to first access.");
707
+ return;
708
+ }
709
+ throw err;
710
+ }
711
+ continue;
712
+ }
713
+ if (mode === "insertOnly") continue;
714
+ await ctx.adapter.update({
715
+ model: modelName,
716
+ where: [{
717
+ field: "identifier",
718
+ value: input.identifier
719
+ }],
720
+ update: buildSeedUpdate(input, mode, now)
721
+ });
722
+ }
723
+ }
724
+ /**
725
+ * Idempotent, coalesced wrapper around {@link seedResources} for the
726
+ * lazy-seed path. Safe to call from every resource lookup — the first call
727
+ * runs the seed, concurrent calls await the same promise, and subsequent
728
+ * calls become no-ops.
729
+ *
730
+ * The endpoint context shape (`{ context: AuthContext }`) is what
731
+ * `getResource` receives, so this is a convenience overload that unwraps
732
+ * for callers.
733
+ *
734
+ * @internal
735
+ */
736
+ async function seedResourcesOnce(ctx, opts) {
737
+ const state = getSeedState(ctx);
738
+ if (state.completed === true) return;
739
+ if (state.promise !== null) return state.promise;
740
+ state.promise = seedResources(ctx, opts).then(() => {
741
+ state.completed = true;
742
+ }).catch((err) => {
743
+ state.promise = null;
744
+ throw err;
745
+ });
746
+ return state.promise;
747
+ }
748
+ /**
749
+ * Logs the resolved value of {@link OAuthOptions.enforcePerClientResources}
750
+ * so deployment admins see which default applied at init. Separated from
751
+ * {@link resolveEnforcePerClientResources} so the latter stays pure and
752
+ * cheap to call from validation flow.
753
+ *
754
+ * @internal
755
+ */
756
+ function logEnforcePerClientResourcesResolution(opts) {
757
+ const resolved = resolveEnforcePerClientResources(opts);
758
+ logger.info(`oauth-provider: enforcePerClientResources resolved to ${resolved.value} (${resolved.source})`);
759
+ }
760
+ //#endregion
761
+ //#region src/dpop.ts
762
+ function getDpopProofJwt(ctx) {
763
+ return ctx.headers?.get("dpop") ?? void 0;
764
+ }
765
+ function getEndpointUrl(ctx, path) {
766
+ return ctx.request?.url ?? `${ctx.context.baseURL}${path}`;
767
+ }
768
+ //#endregion
769
+ //#region src/types/zod.ts
770
+ const DANGEROUS_SCHEMES = [
771
+ "javascript:",
772
+ "data:",
773
+ "vbscript:"
774
+ ];
775
+ /**
776
+ * Validates an RFC 8707 resource indicator. The value must be an absolute URI
777
+ * with no fragment (RFC 8707 §2). Unlike a redirect URI it is not restricted to
778
+ * HTTPS, because a resource server identifier may use any absolute URI scheme;
779
+ * configured OAuth resources are the authoritative control over
780
+ * which resources a token may target.
781
+ */
782
+ const ResourceUriSchema = z.string().superRefine((val, ctx) => {
783
+ if (!URL.canParse(val)) {
784
+ ctx.addIssue({
785
+ code: "custom",
786
+ message: "resource must be an absolute URI",
787
+ fatal: true
788
+ });
789
+ return z.NEVER;
790
+ }
791
+ if (val.includes("#")) {
792
+ ctx.addIssue({
793
+ code: "custom",
794
+ message: "resource must not contain a fragment"
795
+ });
796
+ return;
797
+ }
798
+ if (DANGEROUS_SCHEMES.includes(new URL(val).protocol)) ctx.addIssue({
799
+ code: "custom",
800
+ message: "resource cannot use javascript:, data:, or vbscript: scheme"
801
+ });
802
+ });
803
+ const authorizationPromptTokenSchema = z.enum([
804
+ "none",
805
+ "consent",
806
+ "login",
807
+ "create",
808
+ "select_account"
809
+ ]);
810
+ const authorizationPromptSchema = z.string().superRefine((value, ctx) => {
811
+ const promptTokens = value.split(" ").map((token) => token.trim()).filter(Boolean);
812
+ const promptSet = /* @__PURE__ */ new Set();
813
+ if (!promptTokens.length) {
814
+ ctx.addIssue({
815
+ code: "custom",
816
+ message: "prompt must include at least one value"
817
+ });
818
+ return;
819
+ }
820
+ for (const token of promptTokens) {
821
+ const result = authorizationPromptTokenSchema.safeParse(token);
822
+ if (!result.success) {
823
+ ctx.addIssue({
824
+ code: "custom",
825
+ message: `unsupported prompt value: ${token}`
826
+ });
827
+ continue;
828
+ }
829
+ promptSet.add(result.data);
830
+ }
831
+ if (promptSet.has("none") && promptSet.size > 1) ctx.addIssue({
832
+ code: "custom",
833
+ message: "prompt=none cannot be combined with other prompt values"
834
+ });
835
+ });
836
+ const maxAgeSchema = z.union([z.number(), z.string().trim().min(1)]).transform((value, ctx) => {
837
+ const maxAge = typeof value === "number" ? value : Number(value);
838
+ if (!Number.isInteger(maxAge) || maxAge < 0) {
839
+ ctx.addIssue({
840
+ code: "custom",
841
+ message: "max_age must be a non-negative integer"
842
+ });
843
+ return z.NEVER;
844
+ }
845
+ return maxAge;
846
+ });
847
+ const dpopJktSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/, "dpop_jkt must be a base64url-encoded SHA-256 JWK thumbprint");
848
+ /**
849
+ * Runtime schema for OAuthAuthorizationQuery.
850
+ * Uses passthrough to tolerate fields added by future extensions (PAR, FPA, etc.)
851
+ */
852
+ const authorizationQuerySchema = z.object({
853
+ response_type: z.string().pipe(z.enum(["code"])).optional(),
854
+ request_uri: z.string().optional(),
855
+ redirect_uri: SafeUrlSchema.optional(),
856
+ scope: z.string().optional(),
857
+ state: z.string().optional(),
858
+ client_id: z.string(),
859
+ prompt: authorizationPromptSchema.optional(),
860
+ display: z.string().optional(),
861
+ ui_locales: z.string().optional(),
862
+ max_age: maxAgeSchema.optional(),
863
+ acr_values: z.string().optional(),
864
+ login_hint: z.string().optional(),
865
+ id_token_hint: z.string().optional(),
866
+ code_challenge: z.string().optional(),
867
+ code_challenge_method: z.string().pipe(z.enum(["S256"])).optional(),
868
+ nonce: z.string().optional(),
869
+ dpop_jkt: dpopJktSchema.optional(),
870
+ resource: z.union([ResourceUriSchema, z.array(ResourceUriSchema).min(1)]).optional()
871
+ }).passthrough();
872
+ const storedAuthorizationQuerySchema = authorizationQuerySchema.extend({ redirect_uri: SafeUrlSchema });
873
+ /**
874
+ * Runtime schema for the authorization code verification value.
875
+ * Validates structure on deserialization from the JSON blob stored in the DB.
876
+ * Uses passthrough so future fields (e.g. from authorization challenge) don't break parsing.
877
+ */
878
+ const verificationValueSchema = z.object({
879
+ type: z.literal("authorization_code"),
880
+ query: storedAuthorizationQuerySchema,
881
+ sessionId: z.string(),
882
+ userId: z.string(),
883
+ referenceId: z.string().optional(),
884
+ authTime: z.number().optional(),
885
+ resource: z.array(z.string()).optional()
886
+ }).passthrough();
887
+ /**
888
+ * Request body accepted at `POST /oauth2/register` (RFC 7591 §2 client
889
+ * metadata). This is the single source of truth for the registration contract:
890
+ * the endpoint validates against it and {@link ClientRegistrationRequest} is
891
+ * inferred from it, so the type a `validateInitialAccessToken` callback receives
892
+ * always matches what is actually validated. `grant_types` and
893
+ * `token_endpoint_auth_method` are open strings because extensions can register
894
+ * custom values. Server-assigned fields (`client_id`, `client_secret`, the
895
+ * issued/expiry timestamps) and internal state (`disabled`, `reference_id`) are
896
+ * never part of a registration request.
897
+ *
898
+ * @see https://datatracker.ietf.org/doc/html/rfc7591#section-2
899
+ */
900
+ const clientRegistrationRequestSchema = z.object({
901
+ redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
902
+ scope: z.string().optional(),
903
+ client_name: z.string().optional(),
904
+ client_uri: z.string().optional(),
905
+ logo_uri: z.string().optional(),
906
+ contacts: z.array(z.string().min(1)).min(1).optional(),
907
+ tos_uri: z.string().optional(),
908
+ policy_uri: z.string().optional(),
909
+ software_id: z.string().optional(),
910
+ software_version: z.string().optional(),
911
+ software_statement: z.string().optional(),
912
+ post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
913
+ backchannel_logout_uri: SafeUrlSchema.optional(),
914
+ backchannel_logout_session_required: z.boolean().optional(),
915
+ token_endpoint_auth_method: z.string().trim().min(1).optional(),
916
+ 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(),
917
+ jwks_uri: z.string().optional(),
918
+ grant_types: z.array(z.string().trim().min(1)).min(1).optional(),
919
+ response_types: z.array(z.enum(["code"])).optional(),
920
+ type: z.enum([
921
+ "web",
922
+ "native",
923
+ "user-agent-based"
924
+ ]).optional(),
925
+ subject_type: z.enum(["public", "pairwise"]).optional(),
926
+ dpop_bound_access_tokens: z.boolean().optional(),
927
+ resources: z.array(ResourceUriSchema).optional(),
928
+ skip_consent: z.never({ error: "skip_consent cannot be set during dynamic client registration" }).optional()
929
+ });
930
+ //#endregion
931
+ //#region src/userinfo.ts
932
+ /**
933
+ * Provides shared /userinfo and id_token claims functionality
934
+ *
935
+ * @see https://openid.net/specs/openid-connect-core-1_0.html#NormalClaims
936
+ */
937
+ function userNormalClaims(user, scopes) {
938
+ const name = user.name.split(" ").filter((v) => v !== "");
939
+ const profile = {
940
+ name: user.name ?? void 0,
941
+ picture: user.image ?? void 0,
942
+ given_name: name.length > 1 ? name.slice(0, -1).join(" ") : void 0,
943
+ family_name: name.length > 1 ? name.at(-1) : void 0
944
+ };
945
+ const email = {
946
+ email: user.email ?? void 0,
947
+ email_verified: user.emailVerified ?? false
948
+ };
949
+ return {
950
+ sub: user.id ?? void 0,
951
+ ...scopes.includes("profile") ? profile : {},
952
+ ...scopes.includes("email") ? email : {}
953
+ };
954
+ }
955
+ /**
956
+ * Returns the defined-valued entries of `claims`, dropping any key already
957
+ * present in `base` when given.
958
+ *
959
+ * This is the two-tier claim authority shared by the /userinfo response and the
960
+ * ID token:
961
+ * - Called WITH `base` (the provider's own claims): the additive rule for
962
+ * third-party extension claims. A contributor may add new keys but never
963
+ * replace a claim the provider already owns.
964
+ * - Called WITHOUT `base`: the deliberate first-party override path for the
965
+ * operator's own `customUserInfoClaims` / `customIdTokenClaims`, which is
966
+ * trusted to override identity claims (for example a formatted `name`). The
967
+ * caller re-pins `sub` afterwards, so subject integrity holds either way
968
+ * (OIDC Core §5.3.2: UserInfo `sub` MUST match the ID Token `sub`).
969
+ */
970
+ function pickClaims(claims, base) {
971
+ const next = {};
972
+ for (const [key, value] of Object.entries(claims ?? {})) {
973
+ if (value === void 0) continue;
974
+ if (base && key in base) continue;
975
+ next[key] = value;
976
+ }
977
+ return next;
978
+ }
979
+ /**
980
+ * Handles the /oauth2/userinfo endpoint
981
+ */
982
+ async function userInfoEndpoint(ctx, opts) {
983
+ const authorization = ctx.headers?.get("authorization");
984
+ const accessTokenAuthorization = parseAccessTokenAuthorization(authorization);
985
+ if (!accessTokenAuthorization?.token) throw new APIError("UNAUTHORIZED", {
986
+ error_description: "authorization header not found",
987
+ error: "invalid_request"
988
+ });
989
+ const jwt = await requireActiveAccessToken(ctx, opts, accessTokenAuthorization.token);
990
+ if (getDpopJktFromPayload(jwt) && !ctx.request) throw new APIError("UNAUTHORIZED", {
991
+ error_description: "DPoP-bound access token requires an HTTP request context",
992
+ error: "invalid_token"
993
+ });
994
+ try {
995
+ await enforceDpopBinding({
996
+ payload: jwt,
997
+ authorization: accessTokenAuthorization,
998
+ proofJwt: getDpopProofJwt(ctx),
999
+ method: ctx.request?.method ?? "GET",
1000
+ url: getEndpointUrl(ctx, "/oauth2/userinfo"),
1001
+ proofMaxAgeSeconds: opts.dpop?.proofMaxAgeSeconds,
1002
+ signingAlgorithms: opts.dpop?.signingAlgorithms,
1003
+ replayStore: createDpopReplayStore(ctx.context.internalAdapter)
1004
+ });
1005
+ } catch (error) {
1006
+ if (isDpopBindingError(error)) throw new APIError("UNAUTHORIZED", {
1007
+ error_description: error.message,
1008
+ error: error.code
1009
+ });
1010
+ throw error;
1011
+ }
1012
+ const scopes = jwt.scope?.split(" ");
1013
+ if (!scopes?.includes("openid")) throw new APIError("BAD_REQUEST", {
1014
+ error_description: "Missing required scope",
1015
+ error: "invalid_scope"
1016
+ });
1017
+ if (!jwt.sub) throw new APIError("BAD_REQUEST", {
1018
+ error_description: "user not found",
1019
+ error: "invalid_request"
1020
+ });
1021
+ const user = await ctx.context.internalAdapter.findUserById(jwt.sub);
1022
+ if (!user) throw new APIError("BAD_REQUEST", {
1023
+ error_description: "user not found",
1024
+ error: "invalid_request"
1025
+ });
1026
+ const baseUserClaims = userNormalClaims(user, scopes ?? []);
1027
+ const clientId = jwt.client_id ?? jwt.azp;
1028
+ const client = clientId && (opts.pairwiseSecret || hasUserInfoClaimExtension(opts)) ? await getClient(ctx, opts, clientId) : void 0;
1029
+ if (opts.pairwiseSecret && client) baseUserClaims.sub = await resolveSubjectIdentifier(user.id, client, opts);
1030
+ const extensionUserClaims = scopes?.length ? await collectExtensionUserInfoClaims(opts, {
1031
+ ctx,
1032
+ opts,
1033
+ user,
1034
+ scopes,
1035
+ jwt,
1036
+ client: client ?? void 0
1037
+ }) : {};
1038
+ const additionalInfoUserClaims = opts.customUserInfoClaims && scopes?.length ? await opts.customUserInfoClaims({
1039
+ user,
1040
+ scopes,
1041
+ jwt
1042
+ }) : {};
1043
+ return {
1044
+ ...baseUserClaims,
1045
+ ...pickClaims(extensionUserClaims, baseUserClaims),
1046
+ ...pickClaims(additionalInfoUserClaims),
1047
+ sub: baseUserClaims.sub
1048
+ };
1049
+ }
1050
+ //#endregion
1051
+ //#region src/token.ts
1052
+ /**
1053
+ * Token presentation scheme implied by a confirmation: a DPoP key thumbprint
1054
+ * (`jkt`) yields `"DPoP"`; any other confirmation (including mTLS `x5t#S256`)
1055
+ * keeps `"Bearer"`, since that constraint lives at the TLS layer.
1056
+ */
1057
+ function confirmationTokenType(confirmation) {
1058
+ return getConfirmationJkt(confirmation) ? "DPoP" : "Bearer";
1059
+ }
1060
+ const JWT_ACCESS_TOKEN_TYPE = "at+jwt";
1061
+ /**
1062
+ * Handles the /oauth2/token endpoint by delegating
1063
+ * the grant types
1064
+ */
1065
+ async function tokenEndpoint(ctx, opts) {
1066
+ const grantType = ctx.body.grant_type;
1067
+ if (!getSupportedGrantTypes(opts).includes(grantType)) throw new APIError("BAD_REQUEST", {
1068
+ error_description: `unsupported grant_type ${grantType}`,
1069
+ error: "unsupported_grant_type"
1070
+ });
1071
+ switch (grantType) {
1072
+ case "authorization_code": return handleAuthorizationCodeGrant(ctx, opts);
1073
+ case "client_credentials": return handleClientCredentialsGrant(ctx, opts);
1074
+ case "refresh_token": return handleRefreshTokenGrant(ctx, opts);
1075
+ default: {
1076
+ const handler = getExtensionGrantHandler(opts, grantType);
1077
+ if (handler) return handler({
1078
+ ctx,
1079
+ opts,
1080
+ grantType,
1081
+ provider: getOAuthProviderApi(ctx, opts, grantType)
1082
+ });
1083
+ throw new APIError("BAD_REQUEST", {
1084
+ error_description: `unsupported grant_type ${grantType}`,
1085
+ error: "unsupported_grant_type"
1086
+ });
1087
+ }
1088
+ }
1089
+ }
1090
+ /**
1091
+ * Returns the OAuth Provider's server-side capability surface bound to `ctx`.
1092
+ * The token endpoint passes one (pre-bound to the dispatched grant) to each
1093
+ * extension grant handler; a companion plugin's own endpoint calls this directly
1094
+ * with its grant type. `grantType` is bound here, not per issuance, so a handler
1095
+ * cannot mislabel the grant; omit it for capabilities that do not issue tokens
1096
+ * (`getClient`, `validateAccessToken`, `requireActiveAccessToken`), and
1097
+ * `issueTokens` then throws.
1098
+ */
1099
+ function getOAuthProviderApi(ctx, opts, grantType) {
1100
+ return {
1101
+ getClient: (clientId) => getClient(ctx, opts, clientId),
1102
+ authenticateClient: async (request) => {
1103
+ const { clientId, clientSecret, preVerified, authMethod, confirmation } = destructureCredentials(await extractClientCredentials(ctx, opts, `${ctx.context.baseURL}${ctx.path ?? "/oauth2/token"}`));
1104
+ if (!clientId) throw new APIError("BAD_REQUEST", {
1105
+ error_description: "Missing required client_id",
1106
+ error: "invalid_grant"
1107
+ });
1108
+ if (request?.requireCredentials !== false && !clientSecret && !preVerified) throw new APIError("BAD_REQUEST", {
1109
+ error_description: "Missing required client credentials",
1110
+ error: "invalid_grant"
1111
+ });
1112
+ return {
1113
+ clientId,
1114
+ client: await validateClientCredentials(ctx, opts, clientId, clientSecret, request?.scopes, preVerified, grantType, authMethod),
1115
+ method: authMethod,
1116
+ confirmation
1117
+ };
1118
+ },
1119
+ issueTokens: (params) => {
1120
+ if (!grantType) throw new APIError("INTERNAL_SERVER_ERROR", {
1121
+ error_description: "issueTokens requires a grant type; pass it to getOAuthProviderApi(ctx, opts, grantType).",
1122
+ error: "server_error"
1123
+ });
1124
+ return createUserTokens(ctx, opts, {
1125
+ ...params,
1126
+ grantType
1127
+ });
1128
+ },
1129
+ hashToken: (token, type) => storeToken(opts.storeTokens, token, type),
1130
+ validateAccessToken: async (token, clientId) => {
1131
+ const { validateAccessToken } = await Promise.resolve().then(() => introspect_exports);
1132
+ return validateAccessToken(ctx, opts, token, clientId);
1133
+ },
1134
+ requireActiveAccessToken: async (token, clientId) => {
1135
+ const { requireActiveAccessToken } = await Promise.resolve().then(() => introspect_exports);
1136
+ return requireActiveAccessToken(ctx, opts, token, clientId);
1137
+ }
1138
+ };
1139
+ }
1140
+ async function createJwtAccessToken(ctx, opts, user, client, audienceClaim, scopes, overrides) {
1141
+ const iat = overrides?.iat ?? Math.floor(Date.now() / 1e3);
1142
+ const exp = overrides?.exp ?? iat + (opts.accessTokenExpiresIn ?? 3600);
1143
+ const jwtPluginOptions = getJwtPlugin(ctx.context).options;
1144
+ const subject = user?.id ?? client.clientId;
1145
+ return signJWT(ctx, {
1146
+ options: jwtPluginOptions,
1147
+ header: { typ: JWT_ACCESS_TOKEN_TYPE },
1148
+ signingKeyId: overrides?.signingKeyId ?? void 0,
1149
+ signingAlgorithm: overrides?.signingAlgorithm ?? void 0,
1150
+ payload: {
1151
+ ...overrides?.accessTokenClaims ?? {},
1152
+ sub: subject,
1153
+ aud: toAudienceClaim(audienceClaim),
1154
+ client_id: client.clientId,
1155
+ azp: client.clientId,
1156
+ scope: scopes.join(" "),
1157
+ sid: overrides?.sid,
1158
+ iss: jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL,
1159
+ iat,
1160
+ exp,
1161
+ jti: generateRandomString(32),
1162
+ ...overrides?.confirmation ? { cnf: overrides.confirmation } : {}
1163
+ }
1164
+ });
1165
+ }
1166
+ /**
1167
+ * Computes an OIDC hash (at_hash, c_hash) per OIDC Core §3.1.3.6.
1168
+ * Hashes the token, takes the left half, and base64url-encodes it.
1169
+ */
1170
+ async function computeOidcHash(token, signingAlg) {
1171
+ let hashAlg;
1172
+ if (signingAlg === "EdDSA") hashAlg = "SHA-512";
1173
+ else if (signingAlg.endsWith("384")) hashAlg = "SHA-384";
1174
+ else if (signingAlg.endsWith("512")) hashAlg = "SHA-512";
1175
+ else hashAlg = "SHA-256";
1176
+ const digest = new Uint8Array(await crypto.subtle.digest(hashAlg, new TextEncoder().encode(token)));
1177
+ return base64url.encode(digest.slice(0, digest.length / 2));
1178
+ }
1179
+ /**
1180
+ * Creates a user id token in code_authorization with scope of 'openid'
1181
+ * and hybrid/implicit (not yet implemented) flows
1182
+ */
1183
+ async function createIdToken(ctx, opts, user, client, scopes, nonce, sessionId, authTime, accessToken, extraClaims) {
1184
+ const iat = Math.floor(Date.now() / 1e3);
1185
+ const exp = iat + (opts.idTokenExpiresIn ?? 36e3);
1186
+ const userClaims = userNormalClaims(user, scopes);
1187
+ const resolvedSub = await resolveSubjectIdentifier(user.id, client, opts);
1188
+ const authTimeSec = authTime != null ? Math.floor(authTime.getTime() / 1e3) : void 0;
1189
+ const acr = "urn:mace:incommon:iap:bronze";
1190
+ const customClaims = opts.customIdTokenClaims ? await opts.customIdTokenClaims({
1191
+ user,
1192
+ scopes,
1193
+ metadata: parseClientMetadata(client.metadata)
1194
+ }) : {};
1195
+ const jwtPluginOptions = opts.disableJwtPlugin ? void 0 : getJwtPlugin(ctx.context).options;
1196
+ const resolvedKey = !opts.disableJwtPlugin && !jwtPluginOptions?.jwt?.sign ? await resolveSigningKey(ctx, jwtPluginOptions) : void 0;
1197
+ const signingAlg = opts.disableJwtPlugin ? "HS256" : resolvedKey?.alg ?? jwtPluginOptions?.jwks?.keyPairConfig?.alg;
1198
+ const atHash = accessToken ? await computeOidcHash(accessToken, signingAlg) : void 0;
1199
+ const emitSid = Boolean(client.enableEndSession || client.backchannelLogoutUri);
1200
+ const payload = {
1201
+ ...userClaims,
1202
+ auth_time: authTimeSec,
1203
+ acr,
1204
+ ...customClaims,
1205
+ at_hash: atHash,
1206
+ iss: jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL,
1207
+ sub: resolvedSub,
1208
+ aud: client.clientId,
1209
+ nonce,
1210
+ iat,
1211
+ exp,
1212
+ sid: emitSid ? sessionId : void 0
1213
+ };
1214
+ Object.assign(payload, pickClaims(extraClaims, payload));
1215
+ if (opts.disableJwtPlugin && !client.clientSecret) return;
1216
+ 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, {
1217
+ options: jwtPluginOptions,
1218
+ payload,
1219
+ resolvedKey: resolvedKey ?? void 0
1220
+ });
1221
+ if (idToken && atHash && jwtPluginOptions?.jwt?.sign) {
1222
+ const header = decodeProtectedHeader(idToken);
1223
+ if (header.alg !== signingAlg) throw new APIError("INTERNAL_SERVER_ERROR", {
1224
+ error_description: `ID token signed with "${header.alg}" but at_hash was computed for "${signingAlg}". Ensure jwt.sign uses the algorithm declared in keyPairConfig.alg.`,
1225
+ error: "server_error"
1226
+ });
1227
+ }
1228
+ return idToken;
1229
+ }
1230
+ /**
1231
+ * Encodes a refresh token for a client
1232
+ */
1233
+ async function encodeRefreshToken(opts, token, sessionId) {
1234
+ return (opts.prefix?.refreshToken ?? "") + (opts.formatRefreshToken?.encrypt ? opts.formatRefreshToken.encrypt(token, sessionId) : token);
1235
+ }
1236
+ /**
1237
+ * Decodes a refresh token for a client
1238
+ *
1239
+ * @internal
1240
+ */
1241
+ async function decodeRefreshToken(opts, token) {
1242
+ if (opts.prefix?.refreshToken) if (token.startsWith(opts.prefix.refreshToken)) token = token.replace(opts.prefix.refreshToken, "");
1243
+ else throw new APIError("BAD_REQUEST", {
1244
+ error_description: "refresh token not found",
1245
+ error: "invalid_token"
1246
+ });
1247
+ return opts.formatRefreshToken?.decrypt ? opts.formatRefreshToken?.decrypt(token) : { token };
1248
+ }
1249
+ async function createOpaqueAccessToken(ctx, opts, user, client, scopes, payload, resources, referenceId, refreshId, confirmation) {
1250
+ const iat = payload.iat ?? Math.floor(Date.now() / 1e3);
1251
+ const exp = payload?.exp ?? iat + (opts.accessTokenExpiresIn ?? 3600);
1252
+ const token = opts.generateOpaqueAccessToken ? await opts.generateOpaqueAccessToken() : generateRandomString(32, "A-Z", "a-z");
1253
+ await ctx.context.adapter.create({
1254
+ model: "oauthAccessToken",
1255
+ data: {
1256
+ token: await storeToken(opts.storeTokens, token, "access_token"),
1257
+ clientId: client.clientId,
1258
+ sessionId: payload?.sid,
1259
+ userId: user?.id,
1260
+ referenceId,
1261
+ resources,
1262
+ refreshId,
1263
+ confirmation,
1264
+ scopes,
1265
+ createdAt: /* @__PURE__ */ new Date(iat * 1e3),
1266
+ expiresAt: /* @__PURE__ */ new Date(exp * 1e3)
1267
+ }
1268
+ });
1269
+ return (opts.prefix?.opaqueAccessToken ?? "") + token;
1270
+ }
1271
+ /**
1272
+ * Tear down the entire refresh-token family for a (client, user) pair, plus
1273
+ * any access tokens that reference those refresh rows, per RFC 9700 §4.14.
1274
+ * Access tokens are deleted first so the parent rows' foreign-key children
1275
+ * do not block the refresh-row delete.
1276
+ *
1277
+ * TODO(invalidate-family-race): the two `deleteMany` calls are not atomic
1278
+ * with respect to each other. Between them, a concurrent rotation in a
1279
+ * different worker can `create` a fresh refresh row (and, immediately after,
1280
+ * an access-token row referencing it) for the same (client, user) pair,
1281
+ * leaving the family partially rebuilt and the new refresh row orphaned of
1282
+ * any deletion. Closing this window requires the same transactional adapter
1283
+ * contract tracked under FIXME(strict-family-invalidation) in
1284
+ * `createRefreshToken`.
1285
+ *
1286
+ * @internal
1287
+ */
1288
+ async function invalidateRefreshFamily(ctx, clientId, userId) {
1289
+ const refreshTokens = await ctx.context.adapter.findMany({
1290
+ model: "oauthRefreshToken",
1291
+ where: [{
1292
+ field: "clientId",
1293
+ value: clientId
1294
+ }, {
1295
+ field: "userId",
1296
+ value: userId
1297
+ }]
1298
+ });
1299
+ if (refreshTokens.length) await ctx.context.adapter.deleteMany({
1300
+ model: "oauthAccessToken",
1301
+ where: [{
1302
+ field: "refreshId",
1303
+ operator: "in",
1304
+ value: refreshTokens.map((r) => r.id)
1305
+ }]
1306
+ });
1307
+ await ctx.context.adapter.deleteMany({
1308
+ model: "oauthRefreshToken",
1309
+ where: [{
1310
+ field: "clientId",
1311
+ value: clientId
1312
+ }, {
1313
+ field: "userId",
1314
+ value: userId
1315
+ }]
1316
+ });
1317
+ }
1318
+ async function createRefreshToken(ctx, opts, user, referenceId, client, scopes, payload, originalRefresh, authTime, resources, confirmation) {
1319
+ const iat = payload.iat ?? Math.floor(Date.now() / 1e3);
1320
+ const exp = payload?.exp ?? iat + (opts.refreshTokenExpiresIn ?? 2592e3);
1321
+ const token = opts.generateRefreshToken ? await opts.generateRefreshToken() : generateRandomString(32, "A-Z", "a-z");
1322
+ const sessionId = payload?.sid;
1323
+ const newRow = {
1324
+ token: await storeToken(opts.storeTokens, token, "refresh_token"),
1325
+ clientId: client.clientId,
1326
+ sessionId,
1327
+ userId: user.id,
1328
+ referenceId,
1329
+ authTime,
1330
+ confirmation,
1331
+ scopes,
1332
+ resources,
1333
+ createdAt: /* @__PURE__ */ new Date(iat * 1e3),
1334
+ expiresAt: /* @__PURE__ */ new Date(exp * 1e3)
1335
+ };
1336
+ if (!originalRefresh?.id) return {
1337
+ id: (await ctx.context.adapter.create({
1338
+ model: "oauthRefreshToken",
1339
+ data: newRow
1340
+ })).id,
1341
+ token: await encodeRefreshToken(opts, token, sessionId)
1342
+ };
1343
+ if (!await ctx.context.adapter.incrementOne({
1344
+ model: "oauthRefreshToken",
1345
+ where: [{
1346
+ field: "id",
1347
+ value: originalRefresh.id
1348
+ }, {
1349
+ field: "revoked",
1350
+ operator: "eq",
1351
+ value: null
1352
+ }],
1353
+ increment: {},
1354
+ set: { revoked: /* @__PURE__ */ new Date(iat * 1e3) }
1355
+ })) throw new APIError("BAD_REQUEST", {
1356
+ error_description: "invalid refresh token",
1357
+ error: "invalid_grant"
1358
+ });
1359
+ return {
1360
+ id: (await ctx.context.adapter.create({
1361
+ model: "oauthRefreshToken",
1362
+ data: newRow
1363
+ })).id,
1364
+ token: await encodeRefreshToken(opts, token, sessionId)
1365
+ };
1366
+ }
1367
+ async function resolveResourceGrantIssuance(ctx, opts, params) {
1368
+ const resourcePolicy = await resolveResourcePolicy(ctx, opts, {
1369
+ resource: params.resources,
1370
+ clientId: params.clientId,
1371
+ requestedScopes: params.requestedScopes
1372
+ });
1373
+ const resourceExpiresAtSeconds = resourcePolicy.accessTokenTtl !== null ? params.iat + resourcePolicy.accessTokenTtl : params.scopeExpiresAtSeconds;
1374
+ const refreshTokenDefaultTtl = opts.refreshTokenExpiresIn ?? 2592e3;
1375
+ const refreshTokenTtl = resourcePolicy.refreshTokenTtl !== null ? Math.min(resourcePolicy.refreshTokenTtl, refreshTokenDefaultTtl) : refreshTokenDefaultTtl;
1376
+ return {
1377
+ audienceClaim: resourcePolicy.audienceClaim,
1378
+ effectiveScopes: resourcePolicy.effectiveScopes,
1379
+ accessTokenExpiresAtSeconds: Math.min(params.scopeExpiresAtSeconds, resourceExpiresAtSeconds),
1380
+ refreshTokenExpiresAtSeconds: params.iat + refreshTokenTtl,
1381
+ refreshResources: params.refreshToken?.resources ?? params.originalResources ?? params.resources,
1382
+ signingAlgorithm: resourcePolicy.signingAlgorithm,
1383
+ signingKeyId: resourcePolicy.signingKeyId,
1384
+ resourceCustomClaims: resourcePolicy.rawCustomClaims,
1385
+ dpopBoundAccessTokensRequired: resourcePolicy.dpopBoundAccessTokensRequired
1386
+ };
1387
+ }
1388
+ function throwInvalidDpopProof(errorDescription) {
1389
+ throw new APIError("BAD_REQUEST", {
1390
+ error: "invalid_dpop_proof",
1391
+ error_description: errorDescription
1392
+ });
1393
+ }
1394
+ function clientRequiresDpopBoundAccessTokens(client) {
1395
+ const metadata = parseClientMetadata(client.metadata) ?? {};
1396
+ return client.dpopBoundAccessTokens === true || metadata.dpop_bound_access_tokens === true;
1397
+ }
1398
+ async function resolveDpopTokenBinding(ctx, opts, params) {
1399
+ const authCodeDpopJkt = params.verificationValue?.query.dpop_jkt;
1400
+ const refreshJkt = getConfirmationJkt(params.refreshToken?.confirmation);
1401
+ const expectedJkt = refreshJkt ?? authCodeDpopJkt;
1402
+ const dpopProofJwt = getDpopProofJwt(ctx);
1403
+ const dpopRequired = clientRequiresDpopBoundAccessTokens(params.client) || params.grantIssuance.dpopBoundAccessTokensRequired || !!authCodeDpopJkt || !!refreshJkt;
1404
+ if (!dpopProofJwt) {
1405
+ if (dpopRequired) throwInvalidDpopProof("DPoP proof header is required");
1406
+ return;
1407
+ }
1408
+ try {
1409
+ return { jkt: (await verifyDpopProof({
1410
+ proofJwt: dpopProofJwt,
1411
+ method: "POST",
1412
+ url: getEndpointUrl(ctx, "/oauth2/token"),
1413
+ expectedJkt,
1414
+ proofMaxAgeSeconds: opts.dpop?.proofMaxAgeSeconds,
1415
+ signingAlgorithms: opts.dpop?.signingAlgorithms,
1416
+ replayStore: createDpopReplayStore(ctx.context.internalAdapter)
1417
+ })).jkt };
1418
+ } catch (error) {
1419
+ if (isDpopProofError(error)) throwInvalidDpopProof(error.message);
1420
+ throw error;
1421
+ }
1422
+ }
1423
+ async function createUserTokens(ctx, opts, params) {
1424
+ const { client, scopes, user, grantType, referenceId, sessionId, nonce, refreshToken: existingRefreshToken, authTime, verificationValue } = params;
1425
+ const iat = Math.floor(Date.now() / 1e3);
1426
+ const defaultExp = iat + (user ? opts.accessTokenExpiresIn ?? 3600 : opts.m2mAccessTokenExpiresIn ?? 3600);
1427
+ const scopeExp = opts.scopeExpirations ? scopes.map((sc) => opts.scopeExpirations?.[sc] ? toExpJWT(opts.scopeExpirations[sc], iat) : defaultExp).reduce((prev, curr) => {
1428
+ return prev < curr ? prev : curr;
1429
+ }, defaultExp) : defaultExp;
1430
+ const grantIssuance = await resolveResourceGrantIssuance(ctx, opts, {
1431
+ clientId: client.clientId,
1432
+ requestedScopes: scopes,
1433
+ resources: params.resources,
1434
+ originalResources: params.originalResources,
1435
+ refreshToken: params.refreshToken,
1436
+ iat,
1437
+ scopeExpiresAtSeconds: scopeExp
1438
+ });
1439
+ const audienceClaim = grantIssuance.audienceClaim;
1440
+ const effectiveScopes = grantIssuance.effectiveScopes;
1441
+ const exp = grantIssuance.accessTokenExpiresAtSeconds;
1442
+ const refreshTokenExp = grantIssuance.refreshTokenExpiresAtSeconds;
1443
+ const isRefreshToken = user && clientAllowsGrant(client, "refresh_token") && (existingRefreshToken?.scopes?.includes("offline_access") || scopes.includes("offline_access"));
1444
+ const isJwtAccessToken = audienceClaim && !opts.disableJwtPlugin;
1445
+ const isIdToken = user && effectiveScopes.includes("openid");
1446
+ const metadata = parseClientMetadata(client.metadata);
1447
+ const additionalIdTokenClaims = isIdToken && user ? {
1448
+ ...await collectExtensionIdTokenClaims(opts, {
1449
+ ctx,
1450
+ opts,
1451
+ user,
1452
+ client,
1453
+ scopes: effectiveScopes,
1454
+ grantType,
1455
+ referenceId,
1456
+ sessionId,
1457
+ resources: params.resources,
1458
+ metadata
1459
+ }),
1460
+ ...params.idTokenClaims ?? {}
1461
+ } : void 0;
1462
+ const customFields = opts.customTokenResponseFields ? await opts.customTokenResponseFields({
1463
+ grantType,
1464
+ user,
1465
+ scopes: effectiveScopes,
1466
+ metadata,
1467
+ verificationValue
1468
+ }) : void 0;
1469
+ const refreshResources = grantIssuance.refreshResources;
1470
+ const confirmation = params.confirmation ?? await resolveDpopTokenBinding(ctx, opts, {
1471
+ client,
1472
+ grantIssuance,
1473
+ verificationValue,
1474
+ refreshToken: existingRefreshToken
1475
+ });
1476
+ const earlyRefreshToken = isRefreshToken && user && !isJwtAccessToken ? await createRefreshToken(ctx, opts, user, referenceId, client, effectiveScopes, {
1477
+ iat,
1478
+ exp: refreshTokenExp,
1479
+ sid: sessionId
1480
+ }, existingRefreshToken, authTime, refreshResources, confirmation) : void 0;
1481
+ const accessTokenClaims = isJwtAccessToken ? await resolveAccessTokenClaims({
1482
+ ctx,
1483
+ opts,
1484
+ user,
1485
+ client,
1486
+ scopes: effectiveScopes,
1487
+ grantType,
1488
+ sessionId,
1489
+ resources: params.resources,
1490
+ referenceId,
1491
+ metadata,
1492
+ perRequestClaims: params.accessTokenClaims,
1493
+ resourcePolicyClaims: grantIssuance.resourceCustomClaims
1494
+ }) : void 0;
1495
+ const [accessToken, refreshToken] = await Promise.all([isJwtAccessToken ? createJwtAccessToken(ctx, opts, user, client, audienceClaim, effectiveScopes, {
1496
+ iat,
1497
+ exp,
1498
+ sid: sessionId,
1499
+ signingAlgorithm: grantIssuance.signingAlgorithm,
1500
+ signingKeyId: grantIssuance.signingKeyId,
1501
+ accessTokenClaims,
1502
+ confirmation
1503
+ }) : createOpaqueAccessToken(ctx, opts, user, client, effectiveScopes, {
1504
+ iat,
1505
+ exp,
1506
+ sid: sessionId
1507
+ }, params?.resources, referenceId, earlyRefreshToken?.id, confirmation), earlyRefreshToken ? earlyRefreshToken : isRefreshToken && user ? createRefreshToken(ctx, opts, user, referenceId, client, effectiveScopes, {
1508
+ iat,
1509
+ exp: refreshTokenExp,
1510
+ sid: sessionId
1511
+ }, existingRefreshToken, authTime, refreshResources, confirmation) : void 0]);
1512
+ const idToken = isIdToken ? await createIdToken(ctx, opts, user, client, effectiveScopes, nonce, sessionId, authTime, accessToken, additionalIdTokenClaims) : void 0;
1513
+ return ctx.json({
1514
+ ...customFields,
1515
+ ...params.tokenResponse ?? {},
1516
+ access_token: accessToken,
1517
+ expires_in: exp - iat,
1518
+ expires_at: exp,
1519
+ token_type: confirmationTokenType(confirmation),
1520
+ refresh_token: refreshToken?.token,
1521
+ scope: effectiveScopes.join(" "),
1522
+ id_token: idToken
1523
+ });
1524
+ }
1525
+ /** Checks verification value */
1526
+ async function checkVerificationValue(ctx, opts, code, client_id, redirect_uri, resource) {
1527
+ const verification = await ctx.context.internalAdapter.consumeVerificationValue(await storeToken(opts.storeTokens, code, "authorization_code"));
1528
+ if (!verification) throw new APIError("UNAUTHORIZED", {
1529
+ error_description: "invalid code",
1530
+ error: "invalid_grant"
1531
+ });
1532
+ let rawValue;
1533
+ try {
1534
+ rawValue = JSON.parse(verification.value);
1535
+ } catch {
1536
+ throw new APIError("UNAUTHORIZED", {
1537
+ error_description: "malformed verification value",
1538
+ error: "invalid_grant"
1539
+ });
1540
+ }
1541
+ const parsed = verificationValueSchema.safeParse(rawValue);
1542
+ if (!parsed.success) throw new APIError("UNAUTHORIZED", {
1543
+ error_description: "malformed verification value",
1544
+ error: "invalid_grant"
1545
+ });
1546
+ const verificationValue = parsed.data;
1547
+ if (verificationValue.query.client_id !== client_id) throw new APIError("UNAUTHORIZED", {
1548
+ error_description: "invalid client_id",
1549
+ error: "invalid_client"
1550
+ });
1551
+ if (verificationValue.query?.redirect_uri && verificationValue.query?.redirect_uri !== redirect_uri) throw new APIError("BAD_REQUEST", {
1552
+ error_description: "redirect_uri mismatch",
1553
+ error: "invalid_request"
1554
+ });
1555
+ const storedResources = toResourceList(verificationValue.resource) ?? toResourceList(verificationValue.query.resource);
1556
+ const effectiveResources = resource ?? storedResources;
1557
+ if (resource && storedResources) {
1558
+ const requestedSet = new Set(resource);
1559
+ const authorizedSet = new Set(storedResources);
1560
+ for (const r of requestedSet) if (!authorizedSet.has(r)) throw new APIError("BAD_REQUEST", {
1561
+ error_description: "requested resource not authorized",
1562
+ error: "invalid_target"
1563
+ });
1564
+ }
1565
+ return {
1566
+ verificationValue,
1567
+ effectiveResources,
1568
+ authorizedResources: storedResources
1569
+ };
1570
+ }
1571
+ /**
1572
+ * Obtains new Session Jwt and Refresh Tokens using a code
1573
+ */
1574
+ async function handleAuthorizationCodeGrant(ctx, opts) {
1575
+ const { clientId: client_id, clientSecret: client_secret, preVerified, authMethod } = destructureCredentials(await extractClientCredentials(ctx, opts, `${ctx.context.baseURL}/oauth2/token`));
1576
+ const { code, code_verifier, redirect_uri, resource } = ctx.body;
1577
+ const resources = toResourceList(resource);
1578
+ if (!client_id) throw new APIError("BAD_REQUEST", {
1579
+ error_description: "client_id is required",
1580
+ error: "invalid_request"
1581
+ });
1582
+ if (!code) throw new APIError("BAD_REQUEST", {
1583
+ error_description: "code is required",
1584
+ error: "invalid_request"
1585
+ });
1586
+ if (!redirect_uri) throw new APIError("BAD_REQUEST", {
1587
+ error_description: "redirect_uri is required",
1588
+ error: "invalid_request"
1589
+ });
1590
+ const isAuthCodeWithSecret = client_id && client_secret;
1591
+ const isAuthCodeWithPkce = client_id && code && code_verifier;
1592
+ if (!isAuthCodeWithSecret && !isAuthCodeWithPkce && !preVerified) throw new APIError("BAD_REQUEST", {
1593
+ error_description: "Either code_verifier or client_secret is required",
1594
+ error: "invalid_request"
1595
+ });
1596
+ /** Get and check Verification Value */
1597
+ const { verificationValue, effectiveResources, authorizedResources } = await checkVerificationValue(ctx, opts, code, client_id, redirect_uri, resources);
1598
+ const scopes = verificationValue.query.scope?.split(" ");
1599
+ if (!scopes) throw new APIError("INTERNAL_SERVER_ERROR", {
1600
+ error_description: "verification scope unset",
1601
+ error: "invalid_scope"
1602
+ });
1603
+ /** Verify Client */
1604
+ const client = await validateClientCredentials(ctx, opts, client_id, client_secret, scopes, preVerified, "authorization_code", authMethod);
1605
+ if (isPKCERequired(client, (verificationValue.query?.scope)?.split(" ") || [])) {
1606
+ if (!isAuthCodeWithPkce) throw new APIError("BAD_REQUEST", {
1607
+ error_description: "PKCE is required for this client",
1608
+ error: "invalid_request"
1609
+ });
1610
+ } else if (!(isAuthCodeWithPkce || isAuthCodeWithSecret || preVerified)) throw new APIError("BAD_REQUEST", {
1611
+ error_description: "Either PKCE (code_verifier) or client authentication (client_secret or client_assertion) is required",
1612
+ error: "invalid_request"
1613
+ });
1614
+ /** Check PKCE challenge if verifier is provided */
1615
+ const pkceUsedInAuth = !!verificationValue.query?.code_challenge;
1616
+ const pkceUsedInToken = !!code_verifier;
1617
+ if (pkceUsedInAuth || pkceUsedInToken) {
1618
+ if (pkceUsedInAuth && !pkceUsedInToken) throw new APIError("UNAUTHORIZED", {
1619
+ error_description: "code_verifier required because PKCE was used in authorization",
1620
+ error: "invalid_request"
1621
+ });
1622
+ if (!pkceUsedInAuth && pkceUsedInToken) throw new APIError("UNAUTHORIZED", {
1623
+ error_description: "code_verifier provided but PKCE was not used in authorization",
1624
+ error: "invalid_request"
1625
+ });
1626
+ if ((verificationValue.query?.code_challenge_method === "S256" ? await generateCodeChallenge(code_verifier) : void 0) !== verificationValue.query?.code_challenge) throw new APIError("UNAUTHORIZED", {
1627
+ error_description: "code verification failed",
1628
+ error: "invalid_request"
1629
+ });
1630
+ }
1631
+ /** Get user */
1632
+ if (!verificationValue.userId) throw new APIError("BAD_REQUEST", {
1633
+ error_description: "missing user, user may have been deleted",
1634
+ error: "invalid_user"
1635
+ });
1636
+ const user = await ctx.context.internalAdapter.findUserById(verificationValue.userId);
1637
+ if (!user) throw new APIError("BAD_REQUEST", {
1638
+ error_description: "missing user, user may have been deleted",
1639
+ error: "invalid_user"
1640
+ });
1641
+ const session = await ctx.context.adapter.findOne({
1642
+ model: "session",
1643
+ where: [{
1644
+ field: "id",
1645
+ value: verificationValue.sessionId
1646
+ }]
1647
+ });
1648
+ if (!session || session.expiresAt < /* @__PURE__ */ new Date()) throw new APIError("BAD_REQUEST", {
1649
+ error_description: "session no longer exists",
1650
+ error: "invalid_request"
1651
+ });
1652
+ const authTime = verificationValue.authTime != null ? normalizeTimestampValue(verificationValue.authTime) : resolveSessionAuthTime(session);
1653
+ return createUserTokens(ctx, opts, {
1654
+ client,
1655
+ scopes: verificationValue.query.scope?.split(" ") ?? [],
1656
+ user,
1657
+ grantType: "authorization_code",
1658
+ referenceId: verificationValue.referenceId,
1659
+ sessionId: session.id,
1660
+ nonce: verificationValue.query?.nonce,
1661
+ authTime,
1662
+ verificationValue,
1663
+ resources: effectiveResources,
1664
+ originalResources: authorizedResources
1665
+ });
1666
+ }
1667
+ /**
1668
+ * Grant that allows direct access to an API using the application's credentials
1669
+ * This grant is for M2M so the concept of a user id does not exist on the token.
1670
+ *
1671
+ * MUST follow https://datatracker.ietf.org/doc/html/rfc6749#section-4.4
1672
+ */
1673
+ async function handleClientCredentialsGrant(ctx, opts) {
1674
+ const { clientId: client_id, clientSecret: client_secret, preVerified, authMethod } = destructureCredentials(await extractClientCredentials(ctx, opts, `${ctx.context.baseURL}/oauth2/token`));
1675
+ const { scope, resource } = ctx.body;
1676
+ const resources = toResourceList(resource);
1677
+ if (!client_id) throw new APIError("BAD_REQUEST", {
1678
+ error_description: "Missing required client_id",
1679
+ error: "invalid_grant"
1680
+ });
1681
+ if (!client_secret && !preVerified) throw new APIError("BAD_REQUEST", {
1682
+ error_description: "Missing a required client_secret",
1683
+ error: "invalid_grant"
1684
+ });
1685
+ const client = await validateClientCredentials(ctx, opts, client_id, client_secret, void 0, preVerified, "client_credentials", authMethod);
1686
+ let requestedScopes = scope?.split(" ");
1687
+ if (requestedScopes) {
1688
+ const validScopes = new Set(client.scopes ?? opts.scopes);
1689
+ const oidcScopes = new Set([
1690
+ "openid",
1691
+ "profile",
1692
+ "email",
1693
+ "offline_access"
1694
+ ]);
1695
+ const invalidScopes = requestedScopes.filter((scope) => {
1696
+ return !validScopes?.has(scope) || oidcScopes.has(scope);
1697
+ });
1698
+ if (invalidScopes.length) throw new APIError("BAD_REQUEST", {
1699
+ error_description: `The following scopes are invalid: ${invalidScopes.join(", ")}`,
1700
+ error: "invalid_scope"
1701
+ });
1702
+ }
1703
+ if (!requestedScopes) requestedScopes = client.scopes ?? opts.clientCredentialGrantDefaultScopes ?? opts.scopes ?? [];
1704
+ return createUserTokens(ctx, opts, {
1705
+ client,
1706
+ scopes: requestedScopes,
1707
+ grantType: "client_credentials",
1708
+ resources
1709
+ });
1710
+ }
1711
+ /**
1712
+ * Obtains new Session Jwt and Refresh Tokens using a refresh token
1713
+ *
1714
+ * Refresh tokens will only allow the same or lesser scopes as the initial authorize request.
1715
+ * To add scopes, you must restart the authorize process again.
1716
+ */
1717
+ async function handleRefreshTokenGrant(ctx, opts) {
1718
+ const { clientId: client_id, clientSecret: client_secret, preVerified, authMethod } = destructureCredentials(await extractClientCredentials(ctx, opts, `${ctx.context.baseURL}/oauth2/token`));
1719
+ const { refresh_token, scope, resource } = ctx.body;
1720
+ const resources = toResourceList(resource);
1721
+ if (!client_id) throw new APIError("BAD_REQUEST", {
1722
+ error_description: "Missing required client_id",
1723
+ error: "invalid_grant"
1724
+ });
1725
+ if (!refresh_token) throw new APIError("BAD_REQUEST", {
1726
+ error_description: "Missing a required refresh_token for refresh_token grant",
1727
+ error: "invalid_grant"
1728
+ });
1729
+ const decodedRefresh = await decodeRefreshToken(opts, refresh_token);
1730
+ const refreshToken = await ctx.context.adapter.findOne({
1731
+ model: "oauthRefreshToken",
1732
+ where: [{
1733
+ field: "token",
1734
+ value: await getStoredToken(opts.storeTokens, decodedRefresh.token, "refresh_token")
1735
+ }]
1736
+ });
1737
+ if (!refreshToken) throw new APIError("BAD_REQUEST", {
1738
+ error_description: "session not found",
1739
+ error: "invalid_grant"
1740
+ });
1741
+ if (refreshToken.clientId !== client_id) throw new APIError("BAD_REQUEST", {
1742
+ error_description: "invalid client_id",
1743
+ error: "invalid_client"
1744
+ });
1745
+ if (refreshToken.expiresAt < /* @__PURE__ */ new Date()) throw new APIError("BAD_REQUEST", {
1746
+ error_description: "invalid refresh token",
1747
+ error: "invalid_grant"
1748
+ });
1749
+ if (refreshToken.revoked) {
1750
+ await invalidateRefreshFamily(ctx, client_id, refreshToken.userId);
1751
+ throw new APIError("BAD_REQUEST", {
1752
+ error_description: "invalid refresh token",
1753
+ error: "invalid_grant"
1754
+ });
1755
+ }
1756
+ if (resources && refreshToken.resources && !resources.every((v) => refreshToken.resources?.includes(v))) throw new APIError("BAD_REQUEST", {
1757
+ error_description: "requested resource invalid",
1758
+ error: "invalid_target"
1759
+ });
1760
+ const scopes = refreshToken?.scopes;
1761
+ const requestedScopes = scope?.split(" ");
1762
+ if (requestedScopes) {
1763
+ const validScopes = new Set(scopes);
1764
+ for (const requestedScope of requestedScopes) if (!validScopes.has(requestedScope)) throw new APIError("BAD_REQUEST", {
1765
+ error_description: `unable to issue scope ${requestedScope}`,
1766
+ error: "invalid_scope"
1767
+ });
1768
+ }
1769
+ const client = await validateClientCredentials(ctx, opts, client_id, client_secret, requestedScopes ?? scopes, preVerified, "refresh_token", authMethod);
1770
+ const user = await ctx.context.internalAdapter.findUserById(refreshToken.userId);
1771
+ if (!user) throw new APIError("BAD_REQUEST", {
1772
+ error_description: "user not found",
1773
+ error: "invalid_request"
1774
+ });
1775
+ const authTime = refreshToken.authTime != null ? normalizeTimestampValue(refreshToken.authTime) : void 0;
1776
+ return createUserTokens(ctx, opts, {
1777
+ client,
1778
+ scopes: requestedScopes ?? scopes,
1779
+ user,
1780
+ grantType: "refresh_token",
1781
+ referenceId: refreshToken.referenceId,
1782
+ sessionId: refreshToken.sessionId,
1783
+ refreshToken,
1784
+ resources: resources ?? refreshToken.resources,
1785
+ authTime
1786
+ });
1787
+ }
1788
+ //#endregion
1789
+ //#region src/introspect.ts
1790
+ var introspect_exports = /* @__PURE__ */ __exportAll({
1791
+ introspectEndpoint: () => introspectEndpoint,
1792
+ requireActiveAccessToken: () => requireActiveAccessToken,
1793
+ validateAccessToken: () => validateAccessToken
1794
+ });
1795
+ const INVALID_ACCESS_TOKEN_ERROR_DESCRIPTION = "Invalid access token";
1796
+ const INVALID_ACCESS_TOKEN_WWW_AUTHENTICATE = `Bearer error="invalid_token", error_description="${INVALID_ACCESS_TOKEN_ERROR_DESCRIPTION}"`;
1797
+ /**
1798
+ * IMPORTANT NOTES:
1799
+ * Introspection follows RFC7662
1800
+ * https://datatracker.ietf.org/doc/html/rfc7662
1801
+ * - APIError: Continue catches (returnable to client)
1802
+ * - Error: Should immediately stop catches (internal error)
1803
+ */
1804
+ /**
1805
+ * Resource identifiers in a token's audience, excluding the implicit
1806
+ * `/oauth2/userinfo` audience (which is not a configured resource server).
1807
+ */
1808
+ function audienceResourceIdentifiers(aud, userInfoAud) {
1809
+ if (aud == null) return [];
1810
+ return (Array.isArray(aud) ? aud : [aud]).filter((value) => typeof value === "string" && value !== userInfoAud);
1811
+ }
1812
+ /**
1813
+ * Authorizes an introspection request (RFC 7662 §2.1/§4). The caller is already
1814
+ * authenticated as a registered client; it may introspect the token when it
1815
+ * issued the token, or when it is a resource server linked to one of the token's
1816
+ * audience resources. Tokens with no resource audience stay issuer-only. An
1817
+ * unauthorized caller receives `{active:false}` (indistinguishable from an
1818
+ * expired or unknown token), preserving the §4 anti-scanning property.
1819
+ */
1820
+ async function isIntrospectionAuthorized(ctx, opts, params) {
1821
+ const { introspectingClientId, issuerClientId, audienceResources } = params;
1822
+ if (!introspectingClientId) return true;
1823
+ if (introspectingClientId === issuerClientId) return true;
1824
+ if (audienceResources.length === 0) return false;
1825
+ return isClientLinkedToAnyResource(ctx, opts, introspectingClientId, audienceResources);
1826
+ }
1827
+ /**
1828
+ * Validates a JWT access token against the configured JWKs.
1829
+ *
1830
+ * @returns RFC7662 introspection format
1831
+ */
1832
+ async function validateJwtAccessToken(ctx, opts, token, clientId) {
1833
+ const jwtPlugin = opts.disableJwtPlugin ? void 0 : getJwtPlugin(ctx.context);
1834
+ const jwtPluginOptions = jwtPlugin?.options;
1835
+ const userInfoAud = `${ctx.context.baseURL ?? ""}/oauth2/userinfo`;
1836
+ const expectedIssuer = jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL;
1837
+ let jwtPayload;
1838
+ try {
1839
+ jwtPayload = (await jwtVerify(token, createLocalJWKSet(await getJwks(token, {
1840
+ jwksFetch: jwtPluginOptions?.jwks?.remoteUrl ? jwtPluginOptions.jwks.remoteUrl : async () => {
1841
+ return (await jwtPlugin?.endpoints.getJwks(ctx))?.response;
1842
+ },
1843
+ jwksCacheKey: jwtPlugin
1844
+ })), { issuer: expectedIssuer })).payload;
1845
+ } catch (error) {
1846
+ if (error instanceof Error) {
1847
+ if (error.name === "TypeError" || error.name === "JWSInvalid") throw new APIError$1("BAD_REQUEST", {
1848
+ error_description: "invalid JWT signature",
1849
+ error: "invalid_request"
1850
+ });
1851
+ else if (error.name === "JWTExpired") return { active: false };
1852
+ else if (error.name === "JWTInvalid") return { active: false };
1853
+ throw error;
1854
+ }
1855
+ throw new Error(error);
1856
+ }
1857
+ const rawAud = jwtPayload.aud;
1858
+ if (!await isAudienceClaimAllowed(ctx, opts, rawAud, [userInfoAud])) return { active: false };
1859
+ if (!jwtPayload.azp) return { active: false };
1860
+ const client = await getClient(ctx, opts, jwtPayload.azp);
1861
+ if (!client || client?.disabled) return { active: false };
1862
+ if (!await isIntrospectionAuthorized(ctx, opts, {
1863
+ introspectingClientId: clientId,
1864
+ issuerClientId: jwtPayload.azp,
1865
+ audienceResources: audienceResourceIdentifiers(rawAud, userInfoAud)
1866
+ })) return { active: false };
1867
+ const sessionId = jwtPayload.sid;
1868
+ if (sessionId) {
1869
+ const session = await ctx.context.adapter.findOne({
1870
+ model: "session",
1871
+ where: [{
1872
+ field: "id",
1873
+ value: sessionId
1874
+ }]
1875
+ });
1876
+ if (!session || session.expiresAt < /* @__PURE__ */ new Date()) return { active: false };
1877
+ }
1878
+ jwtPayload.client_id = jwtPayload.azp;
1879
+ jwtPayload.active = true;
1880
+ jwtPayload.token_type = confirmationTokenType(jwtPayload.cnf);
1881
+ return jwtPayload;
1882
+ }
1883
+ /**
1884
+ * Searches for an opaque access token in the database and validates it
1885
+ *
1886
+ * @returns RFC7662 introspection format
1887
+ */
1888
+ async function validateOpaqueAccessToken(ctx, opts, token, clientId) {
1889
+ let tokenValue = token;
1890
+ if (opts.prefix?.opaqueAccessToken) if (tokenValue.startsWith(opts.prefix.opaqueAccessToken)) tokenValue = tokenValue.replace(opts.prefix.opaqueAccessToken, "");
1891
+ else throw new APIError$1("BAD_REQUEST", {
1892
+ error_description: "opaque access token not found",
1893
+ error: "invalid_request"
1894
+ });
1895
+ const accessToken = await ctx.context.adapter.findOne({
1896
+ model: "oauthAccessToken",
1897
+ where: [{
1898
+ field: "token",
1899
+ value: await getStoredToken(opts.storeTokens, tokenValue, "access_token")
1900
+ }]
1901
+ });
1902
+ if (!accessToken) throw new APIError$1("BAD_REQUEST", {
1903
+ error_description: "opaque access token not found",
1904
+ error: "invalid_token"
1905
+ });
1906
+ if (!accessToken.expiresAt || accessToken.expiresAt < /* @__PURE__ */ new Date()) return { active: false };
1907
+ if (accessToken.revoked) return { active: false };
1908
+ const resources = Array.isArray(accessToken.resources) ? accessToken.resources : void 0;
1909
+ let client;
1910
+ if (accessToken.clientId) {
1911
+ client = await getClient(ctx, opts, accessToken.clientId);
1912
+ if (!client || client?.disabled) return { active: false };
1913
+ if (!await isIntrospectionAuthorized(ctx, opts, {
1914
+ introspectingClientId: clientId,
1915
+ issuerClientId: accessToken.clientId,
1916
+ audienceResources: resources ?? []
1917
+ })) return { active: false };
1918
+ }
1919
+ const sessionId = accessToken.sessionId ?? void 0;
1920
+ if (sessionId) {
1921
+ const session = await ctx.context.adapter.findOne({
1922
+ model: "session",
1923
+ where: [{
1924
+ field: "id",
1925
+ value: sessionId
1926
+ }]
1927
+ });
1928
+ if (!session || session.expiresAt < /* @__PURE__ */ new Date()) return { active: false };
1929
+ }
1930
+ let user;
1931
+ if (accessToken.userId) user = await ctx.context.internalAdapter.findUserById(accessToken?.userId);
1932
+ const userInfoEndpoint = `${ctx.context.baseURL}/oauth2/userinfo`;
1933
+ if (resources?.length && !await isAudienceClaimAllowed(ctx, opts, resources, [userInfoEndpoint])) return { active: false };
1934
+ const audienceClaim = resources ? [...resources] : void 0;
1935
+ if (audienceClaim?.length && accessToken.scopes?.includes("openid")) {
1936
+ if (!audienceClaim.includes(userInfoEndpoint)) audienceClaim.push(userInfoEndpoint);
1937
+ }
1938
+ const resourcePolicyClaims = resources?.length ? await getResourceCustomClaims(ctx, opts, resources) : {};
1939
+ const accessTokenClaims = client ? await resolveAccessTokenClaims({
1940
+ ctx,
1941
+ opts,
1942
+ user,
1943
+ client,
1944
+ scopes: accessToken.scopes ?? [],
1945
+ grantType: void 0,
1946
+ sessionId: void 0,
1947
+ resources,
1948
+ referenceId: accessToken.referenceId,
1949
+ metadata: parseClientMetadata(client.metadata),
1950
+ perRequestClaims: void 0,
1951
+ resourcePolicyClaims
1952
+ }) : {};
1953
+ const jwtPluginOptions = (opts.disableJwtPlugin ? void 0 : getJwtPlugin(ctx.context))?.options;
1954
+ return {
1955
+ ...accessTokenClaims,
1956
+ active: true,
1957
+ iss: jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL,
1958
+ aud: toAudienceClaim(audienceClaim),
1959
+ client_id: accessToken.clientId,
1960
+ azp: accessToken.clientId,
1961
+ sub: user?.id,
1962
+ sid: sessionId,
1963
+ exp: Math.floor(new Date(accessToken.expiresAt).getTime() / 1e3),
1964
+ iat: Math.floor(new Date(accessToken.createdAt).getTime() / 1e3),
1965
+ scope: accessToken.scopes?.join(" "),
1966
+ token_type: confirmationTokenType(accessToken.confirmation),
1967
+ ...accessToken.confirmation ? { cnf: accessToken.confirmation } : {}
1968
+ };
1969
+ }
1970
+ /**
1971
+ * Validates a refresh token in the session store.
1972
+ *
1973
+ * @returns payload in RFC7662 introspection format
1974
+ */
1975
+ async function validateRefreshToken(ctx, opts, token, clientId) {
1976
+ const refreshToken = await ctx.context.adapter.findOne({
1977
+ model: "oauthRefreshToken",
1978
+ where: [{
1979
+ field: "token",
1980
+ value: await getStoredToken(opts.storeTokens, token, "refresh_token")
1981
+ }]
1982
+ });
1983
+ if (!refreshToken) throw new APIError$1("BAD_REQUEST", {
1984
+ error_description: "token not found",
1985
+ error: "invalid_token"
1986
+ });
1987
+ if (!refreshToken.clientId || refreshToken.clientId !== clientId) return { active: false };
1988
+ if (!refreshToken.expiresAt || refreshToken.expiresAt < /* @__PURE__ */ new Date()) return { active: false };
1989
+ if (refreshToken.revoked) return { active: false };
1990
+ let sessionId = refreshToken.sessionId ?? void 0;
1991
+ if (sessionId) {
1992
+ const session = await ctx.context.adapter.findOne({
1993
+ model: "session",
1994
+ where: [{
1995
+ field: "id",
1996
+ value: sessionId
1997
+ }]
1998
+ });
1999
+ if (!session || session.expiresAt < /* @__PURE__ */ new Date()) sessionId = void 0;
2000
+ }
2001
+ let user = void 0;
2002
+ if (refreshToken.userId) user = await ctx.context.internalAdapter.findUserById(refreshToken?.userId) ?? void 0;
2003
+ return {
2004
+ active: true,
2005
+ client_id: clientId,
2006
+ iss: ((opts.disableJwtPlugin ? void 0 : getJwtPlugin(ctx.context))?.options)?.jwt?.issuer ?? ctx.context.baseURL,
2007
+ sub: user?.id,
2008
+ sid: sessionId,
2009
+ exp: Math.floor(new Date(refreshToken.expiresAt).getTime() / 1e3),
2010
+ iat: Math.floor(new Date(refreshToken.createdAt).getTime() / 1e3),
2011
+ scope: refreshToken.scopes?.join(" "),
2012
+ token_type: confirmationTokenType(refreshToken.confirmation),
2013
+ ...refreshToken.confirmation ? { cnf: refreshToken.confirmation } : {}
2014
+ };
2015
+ }
2016
+ function createInvalidAccessTokenError() {
2017
+ return new APIError$1("UNAUTHORIZED", {
2018
+ error_description: INVALID_ACCESS_TOKEN_ERROR_DESCRIPTION,
2019
+ error: "invalid_token"
2020
+ }, { "WWW-Authenticate": INVALID_ACCESS_TOKEN_WWW_AUTHENTICATE });
2021
+ }
2022
+ function isInactiveTokenError(error) {
2023
+ return error.status === "BAD_REQUEST" || error.body?.error === "invalid_token";
2024
+ }
2025
+ /**
2026
+ * We don't know the access token format so we try to validate it
2027
+ * as a JWT first, then as an opaque token.
2028
+ *
2029
+ * @returns RFC7662 introspection format
2030
+ *
2031
+ * @internal
2032
+ */
2033
+ async function validateAccessToken(ctx, opts, token, clientId) {
2034
+ try {
2035
+ return await validateJwtAccessToken(ctx, opts, token, clientId);
2036
+ } catch (err) {
2037
+ if (err instanceof APIError$1) {} else if (err instanceof Error) throw err;
2038
+ else throw new Error(err);
2039
+ }
2040
+ try {
2041
+ return await validateOpaqueAccessToken(ctx, opts, token, clientId);
2042
+ } catch (err) {
2043
+ if (err instanceof APIError$1) {} else if (err instanceof Error) throw err;
2044
+ else throw new Error("Unknown error validating access token");
2045
+ }
2046
+ throw createInvalidAccessTokenError();
2047
+ }
2048
+ async function requireActiveAccessToken(ctx, opts, token, clientId) {
2049
+ const payload = await validateAccessToken(ctx, opts, token, clientId);
2050
+ if (payload.active) return payload;
2051
+ throw createInvalidAccessTokenError();
2052
+ }
2053
+ /**
2054
+ * Resolves pairwise sub on an introspection payload.
2055
+ * Applied at the presentation layer so internal validation functions
2056
+ * keep real user.id (needed for user lookup in /userinfo).
2057
+ */
2058
+ async function resolveIntrospectionSub(ctx, opts, payload, introspectingClient) {
2059
+ if (!payload.active || !payload.sub) return payload;
2060
+ const issuerClientId = payload.client_id ?? payload.azp;
2061
+ if (!issuerClientId) return payload;
2062
+ const issuingClient = issuerClientId === introspectingClient.clientId ? introspectingClient : await getClient(ctx, opts, issuerClientId);
2063
+ if (!issuingClient) return payload;
2064
+ const resolvedSub = await resolveSubjectIdentifier(payload.sub, issuingClient, opts);
2065
+ return {
2066
+ ...payload,
2067
+ sub: resolvedSub
2068
+ };
2069
+ }
2070
+ async function introspectEndpoint(ctx, opts) {
2071
+ let { token, token_type_hint } = ctx.body;
2072
+ if (token_type_hint !== "access_token" && token_type_hint !== "refresh_token") token_type_hint = void 0;
2073
+ const { clientId: client_id, clientSecret: client_secret, preVerified, authMethod } = destructureCredentials(await extractClientCredentials(ctx, opts, `${ctx.context.baseURL}/oauth2/introspect`));
2074
+ if (!client_id || !client_secret && !preVerified) throw new APIError$1("UNAUTHORIZED", {
2075
+ error_description: "missing required credentials",
2076
+ error: "invalid_client"
2077
+ });
2078
+ if (token && typeof token === "string") token = stripAccessTokenAuthorizationScheme(token);
2079
+ if (!token?.length) throw new APIError$1("BAD_REQUEST", {
2080
+ error_description: "missing a required token for introspection",
2081
+ error: "invalid_request"
2082
+ });
2083
+ const client = await validateClientCredentials(ctx, opts, client_id, client_secret, void 0, preVerified, void 0, authMethod);
2084
+ try {
2085
+ if (token_type_hint === void 0 || token_type_hint === "access_token") try {
2086
+ return resolveIntrospectionSub(ctx, opts, await validateAccessToken(ctx, opts, token, client.clientId), client);
2087
+ } catch (error) {
2088
+ if (error instanceof APIError$1) {
2089
+ if (token_type_hint === "access_token") throw error;
2090
+ } else if (error instanceof Error) throw error;
2091
+ else throw new Error(error);
2092
+ }
2093
+ if (token_type_hint === void 0 || token_type_hint === "refresh_token") try {
2094
+ return resolveIntrospectionSub(ctx, opts, await validateRefreshToken(ctx, opts, (await decodeRefreshToken(opts, token)).token, client.clientId), client);
2095
+ } catch (error) {
2096
+ if (error instanceof APIError$1) {
2097
+ if (token_type_hint === "refresh_token") throw error;
2098
+ } else if (error instanceof Error) throw error;
2099
+ else throw new Error(error);
2100
+ }
2101
+ throw new APIError$1("BAD_REQUEST", {
2102
+ error_description: "token not found",
2103
+ error: "invalid_request"
2104
+ });
2105
+ } catch (error) {
2106
+ if (error instanceof APIError$1) {
2107
+ if (isInactiveTokenError(error)) return { active: false };
2108
+ throw error;
2109
+ } else if (error instanceof Error) {
2110
+ logger.error("Introspection error:", error.message, error.stack);
2111
+ throw new APIError$1("INTERNAL_SERVER_ERROR");
2112
+ } else {
2113
+ logger.error("Introspection error:", error);
2114
+ throw new APIError$1("INTERNAL_SERVER_ERROR");
2115
+ }
2116
+ }
2117
+ }
2118
+ //#endregion
2119
+ export { invalidateResourceCache as _, invalidateRefreshFamily as a, resolveResourcePolicy as b, ResourceUriSchema as c, clientRegistrationRequestSchema as d, JWS_ALGORITHMS as f, getResource as g, extractRepeatedResourceFromForm as h, getOAuthProviderApi as i, SafeUrlSchema as l, buildClientResourceLinkId as m, introspect_exports as n, tokenEndpoint as o, assertIdentifierValid as p, decodeRefreshToken as r, userInfoEndpoint as s, introspectEndpoint as t, authorizationQuerySchema as u, isAudienceClaimAllowed as v, seedResources as x, logEnforcePerClientResourcesResolution as y };