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