@better-auth/oauth-provider 1.7.0-beta.3 → 1.7.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{client-assertion-BYtMWGCE.mjs → client-assertion-DLMKVgoj.mjs} +4 -4
- package/dist/client-resource.d.mts +13 -6
- package/dist/client-resource.mjs +2 -2
- package/dist/client.d.mts +1 -1
- package/dist/client.mjs +1 -1
- package/dist/index.d.mts +7 -6
- package/dist/index.mjs +329 -145
- package/dist/{oauth-BxP4Iupj.d.mts → oauth-Vt3lTNHX.d.mts} +67 -20
- package/dist/{oauth-Ds-ejTJY.d.mts → oauth-q7dn10NU.d.mts} +36 -7
- package/dist/{utils-_Jr_enAe.mjs → utils-DKBWQ8fe.mjs} +55 -16
- package/dist/{version-CG1YnCiF.mjs → version-nFnRm-a3.mjs} +1 -1
- package/package.json +6 -6
package/dist/index.mjs
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { n as isPrivateHostname } from "./client-assertion-
|
|
1
|
+
import { n as isPrivateHostname } from "./client-assertion-DLMKVgoj.mjs";
|
|
2
2
|
import { n as mcpHandler } from "./mcp-CYnz-MXn.mjs";
|
|
3
|
-
import { C as validateClientCredentials, S as
|
|
4
|
-
import { t as PACKAGE_VERSION } from "./version-
|
|
3
|
+
import { C as toAudienceClaim, D as verifyOAuthQueryParams, E as validateClientCredentials, S as storeToken, T as toResourceList, _ as resolveSessionAuthTime, a as getClient, b as signedQueryIssuedAtParam, c as getSignedQueryIssuedAt, d as mergeDiscoveryMetadata, f as normalizeTimestampValue, g as removePromptFromQuery, h as postLoginClearedParam, i as extractClientCredentials, l as getStoredToken, m as parsePrompt, n as decryptStoredClientSecret, o as getJwtPlugin, p as parseClientMetadata, r as destructureCredentials, t as checkResource, u as isPKCERequired, v as resolveSubjectIdentifier, w as toClientDiscoveryArray, x as storeClientSecret, y as searchParamsToQuery } from "./utils-DKBWQ8fe.mjs";
|
|
4
|
+
import { t as PACKAGE_VERSION } from "./version-nFnRm-a3.mjs";
|
|
5
5
|
import { APIError, createAuthEndpoint, createAuthMiddleware, getOAuthState, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
|
|
6
6
|
import { generateCodeChallenge, getJwks, verifyJwsAccessToken } from "better-auth/oauth2";
|
|
7
7
|
import { APIError as APIError$1 } from "better-call";
|
|
8
|
-
import {
|
|
8
|
+
import { PRIVATE_KEY_JWT_SIGNING_ALGORITHMS } from "@better-auth/core/oauth2";
|
|
9
9
|
import { isBrowserFetchRequest } from "@better-auth/core/utils/fetch-metadata";
|
|
10
10
|
import { isLoopbackHost, isLoopbackIP } from "@better-auth/core/utils/host";
|
|
11
11
|
import { generateRandomString, makeSignature } from "better-auth/crypto";
|
|
@@ -17,6 +17,7 @@ import { mergeSchema } from "better-auth/db";
|
|
|
17
17
|
import * as z from "zod";
|
|
18
18
|
import { resolveSigningKey, signJWT, toExpJWT } from "better-auth/plugins";
|
|
19
19
|
import { SignJWT, base64url, compactVerify, createLocalJWKSet, decodeJwt, decodeProtectedHeader } from "jose";
|
|
20
|
+
import { SafeUrlSchema } from "@better-auth/core/utils/redirect-uri";
|
|
20
21
|
//#region src/consent.ts
|
|
21
22
|
async function consentEndpoint(ctx, opts) {
|
|
22
23
|
const oauthRequest = await oAuthState.get();
|
|
@@ -78,12 +79,14 @@ async function consentEndpoint(ctx, opts) {
|
|
|
78
79
|
]
|
|
79
80
|
});
|
|
80
81
|
const iat = Math.floor(Date.now() / 1e3);
|
|
82
|
+
const resource = query.getAll("resource");
|
|
81
83
|
const consent = {
|
|
82
84
|
clientId,
|
|
83
85
|
userId: session?.user.id,
|
|
84
86
|
scopes: requestedScopes ?? originalRequestedScopes,
|
|
85
87
|
createdAt: /* @__PURE__ */ new Date(iat * 1e3),
|
|
86
88
|
updatedAt: /* @__PURE__ */ new Date(iat * 1e3),
|
|
89
|
+
resources: resource.length ? resource : void 0,
|
|
87
90
|
referenceId
|
|
88
91
|
};
|
|
89
92
|
foundConsent?.id ? await ctx.context.adapter.update({
|
|
@@ -93,6 +96,7 @@ async function consentEndpoint(ctx, opts) {
|
|
|
93
96
|
value: foundConsent.id
|
|
94
97
|
}],
|
|
95
98
|
update: {
|
|
99
|
+
resources: consent.resources,
|
|
96
100
|
scopes: consent.scopes,
|
|
97
101
|
updatedAt: /* @__PURE__ */ new Date(iat * 1e3)
|
|
98
102
|
}
|
|
@@ -177,11 +181,6 @@ async function postLogin(ctx, opts) {
|
|
|
177
181
|
}
|
|
178
182
|
//#endregion
|
|
179
183
|
//#region src/types/zod.ts
|
|
180
|
-
const DANGEROUS_SCHEMES = [
|
|
181
|
-
"javascript:",
|
|
182
|
-
"data:",
|
|
183
|
-
"vbscript:"
|
|
184
|
-
];
|
|
185
184
|
/**
|
|
186
185
|
* Runtime schema for OAuthAuthorizationQuery.
|
|
187
186
|
* Uses passthrough to tolerate fields added by future extensions (PAR, FPA, etc.)
|
|
@@ -202,7 +201,8 @@ const oauthAuthorizationQuerySchema = z.object({
|
|
|
202
201
|
id_token_hint: z.string().optional(),
|
|
203
202
|
code_challenge: z.string().optional(),
|
|
204
203
|
code_challenge_method: z.literal("S256").optional(),
|
|
205
|
-
nonce: z.string().optional()
|
|
204
|
+
nonce: z.string().optional(),
|
|
205
|
+
resource: z.union([z.string(), z.array(z.string())]).optional()
|
|
206
206
|
}).passthrough();
|
|
207
207
|
/**
|
|
208
208
|
* Runtime schema for the authorization code verification value.
|
|
@@ -215,34 +215,40 @@ const verificationValueSchema = z.object({
|
|
|
215
215
|
sessionId: z.string(),
|
|
216
216
|
userId: z.string(),
|
|
217
217
|
referenceId: z.string().optional(),
|
|
218
|
-
authTime: z.number().optional()
|
|
218
|
+
authTime: z.number().optional(),
|
|
219
|
+
resource: z.array(z.string()).optional()
|
|
219
220
|
}).passthrough();
|
|
221
|
+
const DANGEROUS_SCHEMES = [
|
|
222
|
+
"javascript:",
|
|
223
|
+
"data:",
|
|
224
|
+
"vbscript:"
|
|
225
|
+
];
|
|
220
226
|
/**
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
227
|
+
* Validates an RFC 8707 resource indicator. The value must be an absolute URI
|
|
228
|
+
* with no fragment (RFC 8707 §2). Unlike a redirect URI it is not restricted to
|
|
229
|
+
* HTTPS, because a resource server identifier may use any absolute URI scheme;
|
|
230
|
+
* the configured `validAudiences` allowlist is the authoritative control over
|
|
231
|
+
* which resources a token may target.
|
|
225
232
|
*/
|
|
226
|
-
const
|
|
233
|
+
const ResourceUriSchema = z.string().superRefine((val, ctx) => {
|
|
227
234
|
if (!URL.canParse(val)) {
|
|
228
235
|
ctx.addIssue({
|
|
229
236
|
code: "custom",
|
|
230
|
-
message: "
|
|
237
|
+
message: "resource must be an absolute URI",
|
|
231
238
|
fatal: true
|
|
232
239
|
});
|
|
233
240
|
return z.NEVER;
|
|
234
241
|
}
|
|
235
|
-
|
|
236
|
-
if (DANGEROUS_SCHEMES.includes(u.protocol)) {
|
|
242
|
+
if (val.includes("#")) {
|
|
237
243
|
ctx.addIssue({
|
|
238
244
|
code: "custom",
|
|
239
|
-
message: "
|
|
245
|
+
message: "resource must not contain a fragment"
|
|
240
246
|
});
|
|
241
247
|
return;
|
|
242
248
|
}
|
|
243
|
-
if (
|
|
249
|
+
if (DANGEROUS_SCHEMES.includes(new URL(val).protocol)) ctx.addIssue({
|
|
244
250
|
code: "custom",
|
|
245
|
-
message: "
|
|
251
|
+
message: "resource cannot use javascript:, data:, or vbscript: scheme"
|
|
246
252
|
});
|
|
247
253
|
});
|
|
248
254
|
//#endregion
|
|
@@ -331,13 +337,13 @@ async function tokenEndpoint(ctx, opts) {
|
|
|
331
337
|
case "refresh_token": return handleRefreshTokenGrant(ctx, opts);
|
|
332
338
|
}
|
|
333
339
|
}
|
|
334
|
-
async function createJwtAccessToken(ctx, opts, user, client, audience, scopes, referenceId, overrides) {
|
|
340
|
+
async function createJwtAccessToken(ctx, opts, user, client, audience, scopes, resources, referenceId, overrides) {
|
|
335
341
|
const iat = overrides?.iat ?? Math.floor(Date.now() / 1e3);
|
|
336
342
|
const exp = overrides?.exp ?? iat + (opts.accessTokenExpiresIn ?? 3600);
|
|
337
343
|
const customClaims = opts.customAccessTokenClaims ? await opts.customAccessTokenClaims({
|
|
338
344
|
user,
|
|
339
345
|
scopes,
|
|
340
|
-
|
|
346
|
+
resources,
|
|
341
347
|
referenceId,
|
|
342
348
|
metadata: parseClientMetadata(client.metadata)
|
|
343
349
|
}) : {};
|
|
@@ -347,7 +353,7 @@ async function createJwtAccessToken(ctx, opts, user, client, audience, scopes, r
|
|
|
347
353
|
payload: {
|
|
348
354
|
...customClaims,
|
|
349
355
|
sub: user?.id,
|
|
350
|
-
aud:
|
|
356
|
+
aud: toAudienceClaim(audience),
|
|
351
357
|
azp: client.clientId,
|
|
352
358
|
scope: scopes.join(" "),
|
|
353
359
|
sid: overrides?.sid,
|
|
@@ -438,7 +444,7 @@ async function decodeRefreshToken(opts, token) {
|
|
|
438
444
|
});
|
|
439
445
|
return opts.formatRefreshToken?.decrypt ? opts.formatRefreshToken?.decrypt(token) : { token };
|
|
440
446
|
}
|
|
441
|
-
async function createOpaqueAccessToken(ctx, opts, user, client, scopes, payload, referenceId, refreshId) {
|
|
447
|
+
async function createOpaqueAccessToken(ctx, opts, user, client, scopes, payload, resources, referenceId, refreshId) {
|
|
442
448
|
const iat = payload.iat ?? Math.floor(Date.now() / 1e3);
|
|
443
449
|
const exp = payload?.exp ?? iat + (opts.accessTokenExpiresIn ?? 3600);
|
|
444
450
|
const token = opts.generateOpaqueAccessToken ? await opts.generateOpaqueAccessToken() : generateRandomString(32, "A-Z", "a-z");
|
|
@@ -450,6 +456,7 @@ async function createOpaqueAccessToken(ctx, opts, user, client, scopes, payload,
|
|
|
450
456
|
sessionId: payload?.sid,
|
|
451
457
|
userId: user?.id,
|
|
452
458
|
referenceId,
|
|
459
|
+
resources,
|
|
453
460
|
refreshId,
|
|
454
461
|
scopes,
|
|
455
462
|
createdAt: /* @__PURE__ */ new Date(iat * 1e3),
|
|
@@ -458,54 +465,100 @@ async function createOpaqueAccessToken(ctx, opts, user, client, scopes, payload,
|
|
|
458
465
|
});
|
|
459
466
|
return (opts.prefix?.opaqueAccessToken ?? "") + token;
|
|
460
467
|
}
|
|
461
|
-
|
|
468
|
+
/**
|
|
469
|
+
* Tear down the entire refresh-token family for a (client, user) pair, plus
|
|
470
|
+
* any access tokens that reference those refresh rows, per RFC 9700 §4.14.
|
|
471
|
+
* Access tokens are deleted first so the parent rows' foreign-key children
|
|
472
|
+
* do not block the refresh-row delete.
|
|
473
|
+
*
|
|
474
|
+
* TODO(invalidate-family-race): the two `deleteMany` calls are not atomic
|
|
475
|
+
* with respect to each other. Between them, a concurrent rotation in a
|
|
476
|
+
* different worker can `create` a fresh refresh row (and, immediately after,
|
|
477
|
+
* an access-token row referencing it) for the same (client, user) pair,
|
|
478
|
+
* leaving the family partially rebuilt and the new refresh row orphaned of
|
|
479
|
+
* any deletion. Closing this window requires the same transactional adapter
|
|
480
|
+
* contract tracked under FIXME(strict-family-invalidation) in
|
|
481
|
+
* `createRefreshToken`.
|
|
482
|
+
*
|
|
483
|
+
* @internal
|
|
484
|
+
*/
|
|
485
|
+
async function invalidateRefreshFamily(ctx, clientId, userId) {
|
|
486
|
+
const refreshTokens = await ctx.context.adapter.findMany({
|
|
487
|
+
model: "oauthRefreshToken",
|
|
488
|
+
where: [{
|
|
489
|
+
field: "clientId",
|
|
490
|
+
value: clientId
|
|
491
|
+
}, {
|
|
492
|
+
field: "userId",
|
|
493
|
+
value: userId
|
|
494
|
+
}]
|
|
495
|
+
});
|
|
496
|
+
if (refreshTokens.length) await ctx.context.adapter.deleteMany({
|
|
497
|
+
model: "oauthAccessToken",
|
|
498
|
+
where: [{
|
|
499
|
+
field: "refreshId",
|
|
500
|
+
operator: "in",
|
|
501
|
+
value: refreshTokens.map((r) => r.id)
|
|
502
|
+
}]
|
|
503
|
+
});
|
|
504
|
+
await ctx.context.adapter.deleteMany({
|
|
505
|
+
model: "oauthRefreshToken",
|
|
506
|
+
where: [{
|
|
507
|
+
field: "clientId",
|
|
508
|
+
value: clientId
|
|
509
|
+
}, {
|
|
510
|
+
field: "userId",
|
|
511
|
+
value: userId
|
|
512
|
+
}]
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
async function createRefreshToken(ctx, opts, user, referenceId, client, scopes, payload, originalRefresh, authTime, resources) {
|
|
462
516
|
const iat = payload.iat ?? Math.floor(Date.now() / 1e3);
|
|
463
517
|
const exp = payload?.exp ?? iat + (opts.refreshTokenExpiresIn ?? 2592e3);
|
|
464
518
|
const token = opts.generateRefreshToken ? await opts.generateRefreshToken() : generateRandomString(32, "A-Z", "a-z");
|
|
465
519
|
const sessionId = payload?.sid;
|
|
466
|
-
|
|
520
|
+
const newRow = {
|
|
521
|
+
token: await storeToken(opts.storeTokens, token, "refresh_token"),
|
|
522
|
+
clientId: client.clientId,
|
|
523
|
+
sessionId,
|
|
524
|
+
userId: user.id,
|
|
525
|
+
referenceId,
|
|
526
|
+
authTime,
|
|
527
|
+
scopes,
|
|
528
|
+
resources,
|
|
529
|
+
createdAt: /* @__PURE__ */ new Date(iat * 1e3),
|
|
530
|
+
expiresAt: /* @__PURE__ */ new Date(exp * 1e3)
|
|
531
|
+
};
|
|
532
|
+
if (!originalRefresh?.id) return {
|
|
533
|
+
id: (await ctx.context.adapter.create({
|
|
534
|
+
model: "oauthRefreshToken",
|
|
535
|
+
data: newRow
|
|
536
|
+
})).id,
|
|
537
|
+
token: await encodeRefreshToken(opts, token, sessionId)
|
|
538
|
+
};
|
|
539
|
+
if (!await ctx.context.adapter.update({
|
|
467
540
|
model: "oauthRefreshToken",
|
|
468
541
|
where: [{
|
|
469
542
|
field: "id",
|
|
470
543
|
value: originalRefresh.id
|
|
544
|
+
}, {
|
|
545
|
+
field: "revoked",
|
|
546
|
+
operator: "eq",
|
|
547
|
+
value: null
|
|
471
548
|
}],
|
|
472
549
|
update: { revoked: /* @__PURE__ */ new Date(iat * 1e3) }
|
|
550
|
+
})) throw new APIError("BAD_REQUEST", {
|
|
551
|
+
error_description: "invalid refresh token",
|
|
552
|
+
error: "invalid_grant"
|
|
473
553
|
});
|
|
474
554
|
return {
|
|
475
555
|
id: (await ctx.context.adapter.create({
|
|
476
556
|
model: "oauthRefreshToken",
|
|
477
|
-
data:
|
|
478
|
-
token: await storeToken(opts.storeTokens, token, "refresh_token"),
|
|
479
|
-
clientId: client.clientId,
|
|
480
|
-
sessionId,
|
|
481
|
-
userId: user.id,
|
|
482
|
-
referenceId,
|
|
483
|
-
authTime,
|
|
484
|
-
scopes,
|
|
485
|
-
createdAt: /* @__PURE__ */ new Date(iat * 1e3),
|
|
486
|
-
expiresAt: /* @__PURE__ */ new Date(exp * 1e3)
|
|
487
|
-
}
|
|
557
|
+
data: newRow
|
|
488
558
|
})).id,
|
|
489
559
|
token: await encodeRefreshToken(opts, token, sessionId)
|
|
490
560
|
};
|
|
491
561
|
}
|
|
492
|
-
/**
|
|
493
|
-
* Checks the resource parameter, if provided,
|
|
494
|
-
* and returns a valid audience based on the request
|
|
495
|
-
*/
|
|
496
|
-
async function checkResource(ctx, opts, scopes) {
|
|
497
|
-
const resource = ctx.body.resource;
|
|
498
|
-
const audience = typeof resource === "string" ? [resource] : resource ? [...resource] : void 0;
|
|
499
|
-
if (audience) {
|
|
500
|
-
if (scopes.includes("openid")) audience.push(`${ctx.context.baseURL}/oauth2/userinfo`);
|
|
501
|
-
const validAudiences = new Set([...opts.validAudiences ?? [ctx.context.baseURL], scopes?.includes("openid") ? `${ctx.context.baseURL}/oauth2/userinfo` : void 0].flat().filter((v) => v?.length));
|
|
502
|
-
for (const aud of audience) if (!validAudiences.has(aud)) throw new APIError("BAD_REQUEST", {
|
|
503
|
-
error_description: "requested resource invalid",
|
|
504
|
-
error: "invalid_request"
|
|
505
|
-
});
|
|
506
|
-
}
|
|
507
|
-
return audience?.length === 1 ? audience.at(0) : audience;
|
|
508
|
-
}
|
|
509
562
|
async function createUserTokens(ctx, opts, params) {
|
|
510
563
|
const { client, scopes, user, grantType, referenceId, sessionId, nonce, refreshToken: existingRefreshToken, authTime, verificationValue } = params;
|
|
511
564
|
const iat = Math.floor(Date.now() / 1e3);
|
|
@@ -513,7 +566,12 @@ async function createUserTokens(ctx, opts, params) {
|
|
|
513
566
|
const exp = opts.scopeExpirations ? scopes.map((sc) => opts.scopeExpirations?.[sc] ? toExpJWT(opts.scopeExpirations[sc], iat) : defaultExp).reduce((prev, curr) => {
|
|
514
567
|
return prev < curr ? prev : curr;
|
|
515
568
|
}, defaultExp) : defaultExp;
|
|
516
|
-
const
|
|
569
|
+
const resourceResult = await checkResource(ctx, opts, params?.resources, scopes);
|
|
570
|
+
if (!resourceResult.success) throw new APIError("BAD_REQUEST", {
|
|
571
|
+
error_description: "requested resource invalid",
|
|
572
|
+
error: "invalid_target"
|
|
573
|
+
});
|
|
574
|
+
const audience = resourceResult.audience;
|
|
517
575
|
const isRefreshToken = user && (existingRefreshToken?.scopes?.includes("offline_access") || scopes.includes("offline_access"));
|
|
518
576
|
const isJwtAccessToken = audience && !opts.disableJwtPlugin;
|
|
519
577
|
const isIdToken = user && scopes.includes("openid");
|
|
@@ -524,12 +582,13 @@ async function createUserTokens(ctx, opts, params) {
|
|
|
524
582
|
metadata: parseClientMetadata(client.metadata),
|
|
525
583
|
verificationValue
|
|
526
584
|
}) : void 0;
|
|
585
|
+
const refreshResources = params?.refreshToken?.resources ?? params?.originalResources ?? params?.resources;
|
|
527
586
|
const earlyRefreshToken = isRefreshToken && user && !isJwtAccessToken ? await createRefreshToken(ctx, opts, user, referenceId, client, scopes, {
|
|
528
587
|
iat,
|
|
529
588
|
exp: iat + (opts.refreshTokenExpiresIn ?? 2592e3),
|
|
530
589
|
sid: sessionId
|
|
531
|
-
}, existingRefreshToken, authTime) : void 0;
|
|
532
|
-
const [accessToken, refreshToken] = await Promise.all([isJwtAccessToken ? createJwtAccessToken(ctx, opts, user, client, audience, scopes, referenceId, {
|
|
590
|
+
}, existingRefreshToken, authTime, refreshResources) : void 0;
|
|
591
|
+
const [accessToken, refreshToken] = await Promise.all([isJwtAccessToken ? createJwtAccessToken(ctx, opts, user, client, audience, scopes, params?.resources, referenceId, {
|
|
533
592
|
iat,
|
|
534
593
|
exp,
|
|
535
594
|
sid: sessionId
|
|
@@ -537,11 +596,11 @@ async function createUserTokens(ctx, opts, params) {
|
|
|
537
596
|
iat,
|
|
538
597
|
exp,
|
|
539
598
|
sid: sessionId
|
|
540
|
-
}, referenceId, earlyRefreshToken?.id), earlyRefreshToken ? earlyRefreshToken : isRefreshToken && user ? createRefreshToken(ctx, opts, user, referenceId, client, scopes, {
|
|
599
|
+
}, params?.resources, referenceId, earlyRefreshToken?.id), earlyRefreshToken ? earlyRefreshToken : isRefreshToken && user ? createRefreshToken(ctx, opts, user, referenceId, client, scopes, {
|
|
541
600
|
iat,
|
|
542
601
|
exp: iat + (opts.refreshTokenExpiresIn ?? 2592e3),
|
|
543
602
|
sid: sessionId
|
|
544
|
-
}, existingRefreshToken, authTime) : void 0]);
|
|
603
|
+
}, existingRefreshToken, authTime, refreshResources) : void 0]);
|
|
545
604
|
const idToken = isIdToken ? await createIdToken(ctx, opts, user, client, scopes, nonce, sessionId, authTime, accessToken) : void 0;
|
|
546
605
|
return ctx.json({
|
|
547
606
|
...customFields,
|
|
@@ -558,16 +617,11 @@ async function createUserTokens(ctx, opts, params) {
|
|
|
558
617
|
} });
|
|
559
618
|
}
|
|
560
619
|
/** Checks verification value */
|
|
561
|
-
async function checkVerificationValue(ctx, opts, code, client_id, redirect_uri) {
|
|
562
|
-
const verification = await ctx.context.internalAdapter.
|
|
620
|
+
async function checkVerificationValue(ctx, opts, code, client_id, redirect_uri, resource) {
|
|
621
|
+
const verification = await ctx.context.internalAdapter.consumeVerificationValue(await storeToken(opts.storeTokens, code, "authorization_code"));
|
|
563
622
|
if (!verification) throw new APIError("UNAUTHORIZED", {
|
|
564
|
-
error_description: "
|
|
565
|
-
error: "
|
|
566
|
-
});
|
|
567
|
-
await ctx.context.internalAdapter.deleteVerificationByIdentifier(await storeToken(opts.storeTokens, code, "authorization_code"));
|
|
568
|
-
if (!verification.expiresAt || verification.expiresAt < /* @__PURE__ */ new Date()) throw new APIError("UNAUTHORIZED", {
|
|
569
|
-
error_description: "code expired",
|
|
570
|
-
error: "invalid_verification"
|
|
623
|
+
error_description: "invalid code",
|
|
624
|
+
error: "invalid_grant"
|
|
571
625
|
});
|
|
572
626
|
let rawValue;
|
|
573
627
|
try {
|
|
@@ -575,13 +629,13 @@ async function checkVerificationValue(ctx, opts, code, client_id, redirect_uri)
|
|
|
575
629
|
} catch {
|
|
576
630
|
throw new APIError("UNAUTHORIZED", {
|
|
577
631
|
error_description: "malformed verification value",
|
|
578
|
-
error: "
|
|
632
|
+
error: "invalid_grant"
|
|
579
633
|
});
|
|
580
634
|
}
|
|
581
635
|
const parsed = verificationValueSchema.safeParse(rawValue);
|
|
582
636
|
if (!parsed.success) throw new APIError("UNAUTHORIZED", {
|
|
583
637
|
error_description: "malformed verification value",
|
|
584
|
-
error: "
|
|
638
|
+
error: "invalid_grant"
|
|
585
639
|
});
|
|
586
640
|
const verificationValue = parsed.data;
|
|
587
641
|
if (verificationValue.query.client_id !== client_id) throw new APIError("UNAUTHORIZED", {
|
|
@@ -592,14 +646,29 @@ async function checkVerificationValue(ctx, opts, code, client_id, redirect_uri)
|
|
|
592
646
|
error_description: "redirect_uri mismatch",
|
|
593
647
|
error: "invalid_request"
|
|
594
648
|
});
|
|
595
|
-
|
|
649
|
+
const storedResources = toResourceList(verificationValue.resource) ?? toResourceList(verificationValue.query.resource);
|
|
650
|
+
const effectiveResources = resource ?? storedResources;
|
|
651
|
+
if (resource && storedResources) {
|
|
652
|
+
const requestedSet = new Set(resource);
|
|
653
|
+
const authorizedSet = new Set(storedResources);
|
|
654
|
+
for (const r of requestedSet) if (!authorizedSet.has(r)) throw new APIError("BAD_REQUEST", {
|
|
655
|
+
error_description: "requested resource not authorized",
|
|
656
|
+
error: "invalid_target"
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
return {
|
|
660
|
+
verificationValue,
|
|
661
|
+
effectiveResources,
|
|
662
|
+
authorizedResources: storedResources
|
|
663
|
+
};
|
|
596
664
|
}
|
|
597
665
|
/**
|
|
598
666
|
* Obtains new Session Jwt and Refresh Tokens using a code
|
|
599
667
|
*/
|
|
600
668
|
async function handleAuthorizationCodeGrant(ctx, opts) {
|
|
601
669
|
const { clientId: client_id, clientSecret: client_secret, preVerifiedClient } = destructureCredentials(await extractClientCredentials(ctx, opts, `${ctx.context.baseURL}/oauth2/token`));
|
|
602
|
-
const { code, code_verifier, redirect_uri } = ctx.body;
|
|
670
|
+
const { code, code_verifier, redirect_uri, resource } = ctx.body;
|
|
671
|
+
const resources = toResourceList(resource);
|
|
603
672
|
if (!client_id) throw new APIError("BAD_REQUEST", {
|
|
604
673
|
error_description: "client_id is required",
|
|
605
674
|
error: "invalid_request"
|
|
@@ -619,7 +688,7 @@ async function handleAuthorizationCodeGrant(ctx, opts) {
|
|
|
619
688
|
error: "invalid_request"
|
|
620
689
|
});
|
|
621
690
|
/** Get and check Verification Value */
|
|
622
|
-
const verificationValue = await checkVerificationValue(ctx, opts, code, client_id, redirect_uri);
|
|
691
|
+
const { verificationValue, effectiveResources, authorizedResources } = await checkVerificationValue(ctx, opts, code, client_id, redirect_uri, resources);
|
|
623
692
|
const scopes = verificationValue.query.scope?.split(" ");
|
|
624
693
|
if (!scopes) throw new APIError("INTERNAL_SERVER_ERROR", {
|
|
625
694
|
error_description: "verification scope unset",
|
|
@@ -684,7 +753,9 @@ async function handleAuthorizationCodeGrant(ctx, opts) {
|
|
|
684
753
|
sessionId: session.id,
|
|
685
754
|
nonce: verificationValue.query?.nonce,
|
|
686
755
|
authTime,
|
|
687
|
-
verificationValue
|
|
756
|
+
verificationValue,
|
|
757
|
+
resources: effectiveResources,
|
|
758
|
+
originalResources: authorizedResources
|
|
688
759
|
});
|
|
689
760
|
}
|
|
690
761
|
/**
|
|
@@ -695,7 +766,8 @@ async function handleAuthorizationCodeGrant(ctx, opts) {
|
|
|
695
766
|
*/
|
|
696
767
|
async function handleClientCredentialsGrant(ctx, opts) {
|
|
697
768
|
const { clientId: client_id, clientSecret: client_secret, preVerifiedClient } = destructureCredentials(await extractClientCredentials(ctx, opts, `${ctx.context.baseURL}/oauth2/token`));
|
|
698
|
-
const { scope } = ctx.body;
|
|
769
|
+
const { scope, resource } = ctx.body;
|
|
770
|
+
const resources = toResourceList(resource);
|
|
699
771
|
if (!client_id) throw new APIError("BAD_REQUEST", {
|
|
700
772
|
error_description: "Missing required client_id",
|
|
701
773
|
error: "invalid_grant"
|
|
@@ -726,7 +798,8 @@ async function handleClientCredentialsGrant(ctx, opts) {
|
|
|
726
798
|
return createUserTokens(ctx, opts, {
|
|
727
799
|
client,
|
|
728
800
|
scopes: requestedScopes,
|
|
729
|
-
grantType: "client_credentials"
|
|
801
|
+
grantType: "client_credentials",
|
|
802
|
+
resources
|
|
730
803
|
});
|
|
731
804
|
}
|
|
732
805
|
/**
|
|
@@ -737,7 +810,8 @@ async function handleClientCredentialsGrant(ctx, opts) {
|
|
|
737
810
|
*/
|
|
738
811
|
async function handleRefreshTokenGrant(ctx, opts) {
|
|
739
812
|
const { clientId: client_id, clientSecret: client_secret, preVerifiedClient } = destructureCredentials(await extractClientCredentials(ctx, opts, `${ctx.context.baseURL}/oauth2/token`));
|
|
740
|
-
const { refresh_token, scope } = ctx.body;
|
|
813
|
+
const { refresh_token, scope, resource } = ctx.body;
|
|
814
|
+
const resources = toResourceList(resource);
|
|
741
815
|
if (!client_id) throw new APIError("BAD_REQUEST", {
|
|
742
816
|
error_description: "Missing required client_id",
|
|
743
817
|
error: "invalid_grant"
|
|
@@ -767,21 +841,16 @@ async function handleRefreshTokenGrant(ctx, opts) {
|
|
|
767
841
|
error: "invalid_grant"
|
|
768
842
|
});
|
|
769
843
|
if (refreshToken.revoked) {
|
|
770
|
-
await ctx.
|
|
771
|
-
model: "oauthRefreshToken",
|
|
772
|
-
where: [{
|
|
773
|
-
field: "clientId",
|
|
774
|
-
value: client_id
|
|
775
|
-
}, {
|
|
776
|
-
field: "userId",
|
|
777
|
-
value: refreshToken.userId
|
|
778
|
-
}]
|
|
779
|
-
});
|
|
844
|
+
await invalidateRefreshFamily(ctx, client_id, refreshToken.userId);
|
|
780
845
|
throw new APIError("BAD_REQUEST", {
|
|
781
846
|
error_description: "invalid refresh token",
|
|
782
847
|
error: "invalid_grant"
|
|
783
848
|
});
|
|
784
849
|
}
|
|
850
|
+
if (resources && refreshToken.resources && !resources.every((v) => refreshToken.resources?.includes(v))) throw new APIError("BAD_REQUEST", {
|
|
851
|
+
error_description: "requested resource invalid",
|
|
852
|
+
error: "invalid_target"
|
|
853
|
+
});
|
|
785
854
|
const scopes = refreshToken?.scopes;
|
|
786
855
|
const requestedScopes = scope?.split(" ");
|
|
787
856
|
if (requestedScopes) {
|
|
@@ -806,6 +875,7 @@ async function handleRefreshTokenGrant(ctx, opts) {
|
|
|
806
875
|
referenceId: refreshToken.referenceId,
|
|
807
876
|
sessionId: refreshToken.sessionId,
|
|
808
877
|
refreshToken,
|
|
878
|
+
resources: resources ?? refreshToken.resources,
|
|
809
879
|
authTime
|
|
810
880
|
});
|
|
811
881
|
}
|
|
@@ -913,10 +983,17 @@ async function validateOpaqueAccessToken(ctx, opts, token, clientId) {
|
|
|
913
983
|
}
|
|
914
984
|
let user;
|
|
915
985
|
if (accessToken.userId) user = await ctx.context.internalAdapter.findUserById(accessToken?.userId);
|
|
986
|
+
const resources = Array.isArray(accessToken.resources) ? accessToken.resources : void 0;
|
|
987
|
+
const audience = resources ? [...resources] : void 0;
|
|
988
|
+
if (audience?.length && accessToken.scopes?.includes("openid")) {
|
|
989
|
+
const userInfoEndpoint = `${ctx.context.baseURL}/oauth2/userinfo`;
|
|
990
|
+
if (!audience.includes(userInfoEndpoint)) audience.push(userInfoEndpoint);
|
|
991
|
+
}
|
|
916
992
|
const customClaims = opts.customAccessTokenClaims ? await opts.customAccessTokenClaims({
|
|
917
993
|
user,
|
|
918
994
|
scopes: accessToken.scopes,
|
|
919
995
|
referenceId: accessToken?.referenceId,
|
|
996
|
+
resources,
|
|
920
997
|
metadata: parseClientMetadata(client?.metadata)
|
|
921
998
|
}) : {};
|
|
922
999
|
const jwtPluginOptions = (opts.disableJwtPlugin ? void 0 : getJwtPlugin(ctx.context))?.options;
|
|
@@ -924,6 +1001,7 @@ async function validateOpaqueAccessToken(ctx, opts, token, clientId) {
|
|
|
924
1001
|
...customClaims,
|
|
925
1002
|
active: true,
|
|
926
1003
|
iss: jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL,
|
|
1004
|
+
aud: toAudienceClaim(audience),
|
|
927
1005
|
client_id: accessToken.clientId,
|
|
928
1006
|
sub: user?.id,
|
|
929
1007
|
sid: sessionId,
|
|
@@ -1287,6 +1365,28 @@ const publicSessionMiddleware = (opts) => createAuthMiddleware(async (ctx) => {
|
|
|
1287
1365
|
if (!await verifyOAuthQueryParams(query, ctx.context.secret)) throw new APIError("UNAUTHORIZED", { error: "invalid_signature" });
|
|
1288
1366
|
});
|
|
1289
1367
|
//#endregion
|
|
1368
|
+
//#region src/oauthClient/privileges.ts
|
|
1369
|
+
/**
|
|
1370
|
+
* Authorizes a client action against the configured `clientPrivileges` hook.
|
|
1371
|
+
*
|
|
1372
|
+
* This is the single authorization helper for every OAuth client mutation. The
|
|
1373
|
+
* create path enforces it at the shared creation chokepoint so that no
|
|
1374
|
+
* registration route can reach client persistence without it.
|
|
1375
|
+
*
|
|
1376
|
+
* @throws APIError UNAUTHORIZED when there is no session or the hook denies the action.
|
|
1377
|
+
* @throws APIError BAD_REQUEST when the request carries no headers.
|
|
1378
|
+
*/
|
|
1379
|
+
async function assertClientPrivileges(ctx, session, opts, action) {
|
|
1380
|
+
if (!session) throw new APIError("UNAUTHORIZED");
|
|
1381
|
+
if (!ctx.headers) throw new APIError("BAD_REQUEST");
|
|
1382
|
+
if (opts.clientPrivileges && !await opts.clientPrivileges({
|
|
1383
|
+
headers: ctx.headers,
|
|
1384
|
+
action,
|
|
1385
|
+
session: session.session,
|
|
1386
|
+
user: session.user
|
|
1387
|
+
})) throw new APIError("UNAUTHORIZED");
|
|
1388
|
+
}
|
|
1389
|
+
//#endregion
|
|
1290
1390
|
//#region src/register.ts
|
|
1291
1391
|
/**
|
|
1292
1392
|
* Resolves the auth method and type for unauthenticated DCR.
|
|
@@ -1422,6 +1522,9 @@ async function checkOAuthClient(client, opts, settings) {
|
|
|
1422
1522
|
async function createOAuthClientEndpoint(ctx, opts, settings) {
|
|
1423
1523
|
const body = ctx.body;
|
|
1424
1524
|
const session = await getSessionFromCtx(ctx);
|
|
1525
|
+
if (settings.isRegister) {
|
|
1526
|
+
if (session) await assertClientPrivileges(ctx, session, opts, "create");
|
|
1527
|
+
} else await assertClientPrivileges(ctx, session, opts, "create");
|
|
1425
1528
|
const isPublic = body.token_endpoint_auth_method === "none";
|
|
1426
1529
|
const isPrivateKeyJwt = body.token_endpoint_auth_method === "private_key_jwt";
|
|
1427
1530
|
await checkOAuthClient(ctx.body, opts, {
|
|
@@ -1767,16 +1870,6 @@ async function rotateClientSecretEndpoint(ctx, opts) {
|
|
|
1767
1870
|
clientSecret: (opts.prefix?.clientSecret ?? "") + clientSecret
|
|
1768
1871
|
});
|
|
1769
1872
|
}
|
|
1770
|
-
async function assertClientPrivileges(ctx, session, opts, action) {
|
|
1771
|
-
if (!session) throw new APIError("UNAUTHORIZED");
|
|
1772
|
-
if (!ctx.headers) throw new APIError("BAD_REQUEST");
|
|
1773
|
-
if (opts.clientPrivileges && !await opts.clientPrivileges({
|
|
1774
|
-
headers: ctx.headers,
|
|
1775
|
-
action,
|
|
1776
|
-
session: session.session,
|
|
1777
|
-
user: session.user
|
|
1778
|
-
})) throw new APIError("UNAUTHORIZED");
|
|
1779
|
-
}
|
|
1780
1873
|
//#endregion
|
|
1781
1874
|
//#region src/oauthClient/index.ts
|
|
1782
1875
|
const adminCreateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/create-client", {
|
|
@@ -1800,7 +1893,7 @@ const adminCreateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/creat
|
|
|
1800
1893
|
"client_secret_post",
|
|
1801
1894
|
"private_key_jwt"
|
|
1802
1895
|
]).default("client_secret_basic").optional(),
|
|
1803
|
-
jwks: z.union([z.array(z.record(z.string(), z.unknown())), z.object({ keys: z.array(z.record(z.string(), z.unknown())) })]).optional(),
|
|
1896
|
+
jwks: z.union([z.array(z.record(z.string(), z.unknown())).min(1), z.object({ keys: z.array(z.record(z.string(), z.unknown())).min(1) })]).optional(),
|
|
1804
1897
|
jwks_uri: z.string().optional(),
|
|
1805
1898
|
grant_types: z.array(z.enum([
|
|
1806
1899
|
"authorization_code",
|
|
@@ -1962,7 +2055,6 @@ const adminCreateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/creat
|
|
|
1962
2055
|
}
|
|
1963
2056
|
}
|
|
1964
2057
|
}, async (ctx) => {
|
|
1965
|
-
await assertClientPrivileges(ctx, await getSessionFromCtx(ctx), opts, "create");
|
|
1966
2058
|
return createOAuthClientEndpoint(ctx, opts, { isRegister: false });
|
|
1967
2059
|
});
|
|
1968
2060
|
const createOAuthClient = (opts) => createAuthEndpoint("/oauth2/create-client", {
|
|
@@ -1987,7 +2079,7 @@ const createOAuthClient = (opts) => createAuthEndpoint("/oauth2/create-client",
|
|
|
1987
2079
|
"client_secret_post",
|
|
1988
2080
|
"private_key_jwt"
|
|
1989
2081
|
]).default("client_secret_basic").optional(),
|
|
1990
|
-
jwks: z.union([z.array(z.record(z.string(), z.unknown())), z.object({ keys: z.array(z.record(z.string(), z.unknown())) })]).optional(),
|
|
2082
|
+
jwks: z.union([z.array(z.record(z.string(), z.unknown())).min(1), z.object({ keys: z.array(z.record(z.string(), z.unknown())).min(1) })]).optional(),
|
|
1991
2083
|
jwks_uri: z.string().optional(),
|
|
1992
2084
|
grant_types: z.array(z.enum([
|
|
1993
2085
|
"authorization_code",
|
|
@@ -2135,7 +2227,6 @@ const createOAuthClient = (opts) => createAuthEndpoint("/oauth2/create-client",
|
|
|
2135
2227
|
} }
|
|
2136
2228
|
} }
|
|
2137
2229
|
}, async (ctx) => {
|
|
2138
|
-
await assertClientPrivileges(ctx, await getSessionFromCtx(ctx), opts, "create");
|
|
2139
2230
|
return createOAuthClientEndpoint(ctx, opts, { isRegister: false });
|
|
2140
2231
|
});
|
|
2141
2232
|
const getOAuthClient = (opts) => createAuthEndpoint("/oauth2/get-client", {
|
|
@@ -2339,12 +2430,12 @@ async function updateConsentEndpoint(ctx, opts) {
|
|
|
2339
2430
|
error_description: "no consent",
|
|
2340
2431
|
error: "not_found"
|
|
2341
2432
|
});
|
|
2433
|
+
if (consent.userId !== session.user.id) throw new APIError("UNAUTHORIZED");
|
|
2342
2434
|
const client = await getClient(ctx, opts, consent.clientId);
|
|
2343
|
-
if (!
|
|
2344
|
-
error_description: "
|
|
2435
|
+
if (!client) throw new APIError("NOT_FOUND", {
|
|
2436
|
+
error_description: "client not found",
|
|
2345
2437
|
error: "not_found"
|
|
2346
2438
|
});
|
|
2347
|
-
if (consent.userId !== session.user.id) throw new APIError("UNAUTHORIZED");
|
|
2348
2439
|
const allowedScopes = client?.scopes ?? opts.scopes ?? [];
|
|
2349
2440
|
const updates = ctx.body.update;
|
|
2350
2441
|
const scopes = updates.scopes;
|
|
@@ -2492,16 +2583,7 @@ async function revokeRefreshToken(ctx, opts, token, clientId) {
|
|
|
2492
2583
|
error: "invalid_request"
|
|
2493
2584
|
});
|
|
2494
2585
|
if (refreshToken.revoked) {
|
|
2495
|
-
await ctx.
|
|
2496
|
-
model: "oauthRefreshToken",
|
|
2497
|
-
where: [{
|
|
2498
|
-
field: "clientId",
|
|
2499
|
-
value: clientId
|
|
2500
|
-
}, {
|
|
2501
|
-
field: "userId",
|
|
2502
|
-
value: refreshToken.userId
|
|
2503
|
-
}]
|
|
2504
|
-
});
|
|
2586
|
+
await invalidateRefreshFamily(ctx, clientId, refreshToken.userId);
|
|
2505
2587
|
throw new APIError$1("BAD_REQUEST", {
|
|
2506
2588
|
error_description: "refresh token revoked",
|
|
2507
2589
|
error: "invalid_request"
|
|
@@ -2509,20 +2591,31 @@ async function revokeRefreshToken(ctx, opts, token, clientId) {
|
|
|
2509
2591
|
}
|
|
2510
2592
|
if (!refreshToken.clientId || refreshToken.clientId !== clientId) return null;
|
|
2511
2593
|
const iat = Math.floor(Date.now() / 1e3);
|
|
2512
|
-
await
|
|
2513
|
-
model: "oauthAccessToken",
|
|
2514
|
-
where: [{
|
|
2515
|
-
field: "refreshId",
|
|
2516
|
-
value: refreshToken.id
|
|
2517
|
-
}]
|
|
2518
|
-
}), ctx.context.adapter.update({
|
|
2594
|
+
if (!await ctx.context.adapter.update({
|
|
2519
2595
|
model: "oauthRefreshToken",
|
|
2520
2596
|
where: [{
|
|
2521
2597
|
field: "id",
|
|
2522
2598
|
value: refreshToken.id
|
|
2599
|
+
}, {
|
|
2600
|
+
field: "revoked",
|
|
2601
|
+
operator: "eq",
|
|
2602
|
+
value: null
|
|
2523
2603
|
}],
|
|
2524
2604
|
update: { revoked: /* @__PURE__ */ new Date(iat * 1e3) }
|
|
2525
|
-
})
|
|
2605
|
+
})) {
|
|
2606
|
+
await invalidateRefreshFamily(ctx, clientId, refreshToken.userId);
|
|
2607
|
+
throw new APIError$1("BAD_REQUEST", {
|
|
2608
|
+
error_description: "refresh token revoked",
|
|
2609
|
+
error: "invalid_request"
|
|
2610
|
+
});
|
|
2611
|
+
}
|
|
2612
|
+
await ctx.context.adapter.deleteMany({
|
|
2613
|
+
model: "oauthAccessToken",
|
|
2614
|
+
where: [{
|
|
2615
|
+
field: "refreshId",
|
|
2616
|
+
value: refreshToken.id
|
|
2617
|
+
}]
|
|
2618
|
+
});
|
|
2526
2619
|
}
|
|
2527
2620
|
/**
|
|
2528
2621
|
* We don't know the access token format so we try to validate it
|
|
@@ -2736,7 +2829,8 @@ const schema = {
|
|
|
2736
2829
|
oauthRefreshToken: { fields: {
|
|
2737
2830
|
token: {
|
|
2738
2831
|
type: "string",
|
|
2739
|
-
required: true
|
|
2832
|
+
required: true,
|
|
2833
|
+
unique: true
|
|
2740
2834
|
},
|
|
2741
2835
|
clientId: {
|
|
2742
2836
|
type: "string",
|
|
@@ -2770,6 +2864,10 @@ const schema = {
|
|
|
2770
2864
|
type: "string",
|
|
2771
2865
|
required: false
|
|
2772
2866
|
},
|
|
2867
|
+
resources: {
|
|
2868
|
+
type: "string[]",
|
|
2869
|
+
required: false
|
|
2870
|
+
},
|
|
2773
2871
|
expiresAt: { type: "date" },
|
|
2774
2872
|
createdAt: { type: "date" },
|
|
2775
2873
|
revoked: {
|
|
@@ -2824,6 +2922,10 @@ const schema = {
|
|
|
2824
2922
|
type: "string",
|
|
2825
2923
|
required: false
|
|
2826
2924
|
},
|
|
2925
|
+
resources: {
|
|
2926
|
+
type: "string[]",
|
|
2927
|
+
required: false
|
|
2928
|
+
},
|
|
2827
2929
|
refreshId: {
|
|
2828
2930
|
type: "string",
|
|
2829
2931
|
required: false,
|
|
@@ -2866,6 +2968,10 @@ const schema = {
|
|
|
2866
2968
|
type: "string",
|
|
2867
2969
|
required: false
|
|
2868
2970
|
},
|
|
2971
|
+
resources: {
|
|
2972
|
+
type: "string[]",
|
|
2973
|
+
required: false
|
|
2974
|
+
},
|
|
2869
2975
|
scopes: {
|
|
2870
2976
|
type: "string[]",
|
|
2871
2977
|
required: true
|
|
@@ -2943,10 +3049,55 @@ const oauthProvider = (options) => {
|
|
|
2943
3049
|
if (opts.grantTypes && opts.grantTypes.includes("refresh_token") && !opts.grantTypes.includes("authorization_code")) throw new BetterAuthError("refresh_token grant requires authorization_code grant");
|
|
2944
3050
|
if (opts.disableJwtPlugin && (opts.storeClientSecret === "hashed" || typeof opts.storeClientSecret === "object" && "hash" in opts.storeClientSecret)) throw new BetterAuthError("unable to store hashed secrets because id tokens will be signed with secret");
|
|
2945
3051
|
if (!opts.disableJwtPlugin && (opts.storeClientSecret === "encrypted" || typeof opts.storeClientSecret === "object" && ("encrypt" in opts.storeClientSecret || "decrypt" in opts.storeClientSecret))) throw new BetterAuthError("encryption method not recommended, please use 'hashed' or the 'hash' function");
|
|
3052
|
+
const handleIssuerMetadataRequest = async (request, ctx) => {
|
|
3053
|
+
const requestPathname = new URL(request.url).pathname;
|
|
3054
|
+
const requestPath = ctx.options.advanced?.skipTrailingSlashes ? requestPathname.replace(/\/+$/, "") || "/" : requestPathname;
|
|
3055
|
+
const issuer = opts.disableJwtPlugin ? ctx.baseURL : getJwtPlugin(ctx)?.options?.jwt?.issuer ?? ctx.baseURL;
|
|
3056
|
+
let issuerPath = "/";
|
|
3057
|
+
try {
|
|
3058
|
+
issuerPath = new URL(issuer).pathname.replace(/\/$/, "") || "";
|
|
3059
|
+
} catch {
|
|
3060
|
+
issuerPath = new URL(ctx.baseURL).pathname.replace(/\/$/, "") || "";
|
|
3061
|
+
}
|
|
3062
|
+
const endpointCtx = { context: ctx };
|
|
3063
|
+
const authServerMetadataPaths = new Set([`/.well-known/oauth-authorization-server${issuerPath}`, `${issuerPath}/.well-known/oauth-authorization-server`]);
|
|
3064
|
+
const openIdConfigPath = `${issuerPath}/.well-known/openid-configuration`;
|
|
3065
|
+
const isAuthServerMetadataRequest = authServerMetadataPaths.has(requestPath);
|
|
3066
|
+
const isOpenIdConfigRequest = opts.scopes?.includes("openid") && requestPath === openIdConfigPath;
|
|
3067
|
+
const createMetadataResponse = (metadata) => {
|
|
3068
|
+
const response = metadataResponse(metadata);
|
|
3069
|
+
if (request.method === "HEAD") return new Response(null, {
|
|
3070
|
+
status: response.status,
|
|
3071
|
+
headers: response.headers
|
|
3072
|
+
});
|
|
3073
|
+
return response;
|
|
3074
|
+
};
|
|
3075
|
+
if (isAuthServerMetadataRequest || isOpenIdConfigRequest) {
|
|
3076
|
+
if (request.method !== "GET" && request.method !== "HEAD") return { response: new Response(null, {
|
|
3077
|
+
status: 405,
|
|
3078
|
+
headers: { Allow: "GET, HEAD" }
|
|
3079
|
+
}) };
|
|
3080
|
+
}
|
|
3081
|
+
if (isAuthServerMetadataRequest) {
|
|
3082
|
+
if (opts.scopes?.includes("openid")) return { response: createMetadataResponse(oidcServerMetadata(endpointCtx, opts)) };
|
|
3083
|
+
return { response: createMetadataResponse({
|
|
3084
|
+
...authServerMetadata(endpointCtx, opts.disableJwtPlugin ? void 0 : getJwtPlugin(ctx)?.options, {
|
|
3085
|
+
scopes_supported: opts.advertisedMetadata?.scopes_supported ?? opts.scopes,
|
|
3086
|
+
dynamic_client_registration_supported: opts.allowDynamicClientRegistration,
|
|
3087
|
+
public_client_supported: opts.allowUnauthenticatedClientRegistration || toClientDiscoveryArray(opts.clientDiscovery).length > 0,
|
|
3088
|
+
grant_types_supported: opts.grantTypes,
|
|
3089
|
+
jwt_disabled: opts.disableJwtPlugin
|
|
3090
|
+
}),
|
|
3091
|
+
...mergeDiscoveryMetadata(opts.clientDiscovery)
|
|
3092
|
+
}) };
|
|
3093
|
+
}
|
|
3094
|
+
if (isOpenIdConfigRequest) return { response: createMetadataResponse(oidcServerMetadata(endpointCtx, opts)) };
|
|
3095
|
+
};
|
|
2946
3096
|
return {
|
|
2947
3097
|
id: "oauth-provider",
|
|
2948
3098
|
version: PACKAGE_VERSION,
|
|
2949
3099
|
options: opts,
|
|
3100
|
+
onRequest: handleIssuerMetadataRequest,
|
|
2950
3101
|
init: (ctx) => {
|
|
2951
3102
|
if (ctx.options.secondaryStorage && ctx.options.session?.storeSessionInDatabase !== true) throw new BetterAuthError("OAuth Provider requires `session.storeSessionInDatabase: true` when using secondaryStorage");
|
|
2952
3103
|
if (!opts.disableJwtPlugin) {
|
|
@@ -3021,6 +3172,7 @@ const oauthProvider = (options) => {
|
|
|
3021
3172
|
else return {
|
|
3022
3173
|
...authServerMetadata(ctx, opts.disableJwtPlugin ? void 0 : getJwtPlugin(ctx.context)?.options, {
|
|
3023
3174
|
scopes_supported: opts.advertisedMetadata?.scopes_supported ?? opts.scopes,
|
|
3175
|
+
dynamic_client_registration_supported: opts.allowDynamicClientRegistration,
|
|
3024
3176
|
public_client_supported: opts.allowUnauthenticatedClientRegistration || toClientDiscoveryArray(opts.clientDiscovery).length > 0,
|
|
3025
3177
|
grant_types_supported: opts.grantTypes,
|
|
3026
3178
|
jwt_disabled: opts.disableJwtPlugin
|
|
@@ -3047,6 +3199,7 @@ const oauthProvider = (options) => {
|
|
|
3047
3199
|
code_challenge: z.string().optional(),
|
|
3048
3200
|
code_challenge_method: z.string().pipe(z.enum(["S256"])).optional(),
|
|
3049
3201
|
nonce: z.string().optional(),
|
|
3202
|
+
resource: z.union([ResourceUriSchema, z.array(ResourceUriSchema).min(1)]).optional(),
|
|
3050
3203
|
prompt: z.string().pipe(z.enum([
|
|
3051
3204
|
"none",
|
|
3052
3205
|
"consent",
|
|
@@ -3058,7 +3211,10 @@ const oauthProvider = (options) => {
|
|
|
3058
3211
|
])).optional()
|
|
3059
3212
|
}),
|
|
3060
3213
|
redirectOnError: authorizeRedirectOnError(opts),
|
|
3061
|
-
errorCodesByField: {
|
|
3214
|
+
errorCodesByField: {
|
|
3215
|
+
response_type: { invalid: "unsupported_response_type" },
|
|
3216
|
+
resource: { invalid: "invalid_target" }
|
|
3217
|
+
},
|
|
3062
3218
|
metadata: { openapi: {
|
|
3063
3219
|
description: "Authorize an OAuth2 request",
|
|
3064
3220
|
parameters: [
|
|
@@ -3128,6 +3284,16 @@ const oauthProvider = (options) => {
|
|
|
3128
3284
|
schema: { type: "string" },
|
|
3129
3285
|
description: "OpenID Connect nonce"
|
|
3130
3286
|
},
|
|
3287
|
+
{
|
|
3288
|
+
name: "resource",
|
|
3289
|
+
in: "query",
|
|
3290
|
+
required: false,
|
|
3291
|
+
schema: {
|
|
3292
|
+
type: "array",
|
|
3293
|
+
items: { type: "string" }
|
|
3294
|
+
},
|
|
3295
|
+
description: "Requested token resource(s) (ie audience) to obtain a JWT formatted access token. May be supplied multiple times as repeated 'resource' query parameters (RFC 8707) or as an array of strings."
|
|
3296
|
+
},
|
|
3131
3297
|
{
|
|
3132
3298
|
name: "prompt",
|
|
3133
3299
|
in: "query",
|
|
@@ -3233,13 +3399,16 @@ const oauthProvider = (options) => {
|
|
|
3233
3399
|
code_verifier: z.string().optional(),
|
|
3234
3400
|
redirect_uri: SafeUrlSchema.optional(),
|
|
3235
3401
|
refresh_token: z.string().optional(),
|
|
3236
|
-
resource: z.
|
|
3402
|
+
resource: z.union([ResourceUriSchema, z.array(ResourceUriSchema).min(1)]).optional(),
|
|
3237
3403
|
scope: z.string().optional()
|
|
3238
3404
|
}),
|
|
3239
|
-
errorCodesByField: {
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3405
|
+
errorCodesByField: {
|
|
3406
|
+
grant_type: {
|
|
3407
|
+
missing: "invalid_request",
|
|
3408
|
+
invalid: "unsupported_grant_type"
|
|
3409
|
+
},
|
|
3410
|
+
resource: { invalid: "invalid_target" }
|
|
3411
|
+
},
|
|
3243
3412
|
metadata: {
|
|
3244
3413
|
allowedMediaTypes: ["application/x-www-form-urlencoded"],
|
|
3245
3414
|
openapi: {
|
|
@@ -3284,8 +3453,15 @@ const oauthProvider = (options) => {
|
|
|
3284
3453
|
description: "Refresh token (for refresh_token grant)"
|
|
3285
3454
|
},
|
|
3286
3455
|
resource: {
|
|
3287
|
-
|
|
3288
|
-
|
|
3456
|
+
oneOf: [{
|
|
3457
|
+
type: "string",
|
|
3458
|
+
description: "Single resource (URL)"
|
|
3459
|
+
}, {
|
|
3460
|
+
type: "array",
|
|
3461
|
+
items: { type: "string" },
|
|
3462
|
+
description: "Multiple resources (URLs)"
|
|
3463
|
+
}],
|
|
3464
|
+
description: "Requested token resource(s) (ie audience) to obtain a JWT formatted access token"
|
|
3289
3465
|
},
|
|
3290
3466
|
scope: {
|
|
3291
3467
|
type: "string",
|
|
@@ -3386,10 +3562,6 @@ const oauthProvider = (options) => {
|
|
|
3386
3562
|
token_type_hint: {
|
|
3387
3563
|
type: "string",
|
|
3388
3564
|
description: "Hint about the token type. Recognized values: `access_token`, `refresh_token`."
|
|
3389
|
-
},
|
|
3390
|
-
resource: {
|
|
3391
|
-
type: "string",
|
|
3392
|
-
description: "Introspects a token for a specific resource."
|
|
3393
3565
|
}
|
|
3394
3566
|
},
|
|
3395
3567
|
required: ["token"]
|
|
@@ -3677,7 +3849,7 @@ const oauthProvider = (options) => {
|
|
|
3677
3849
|
"client_secret_post",
|
|
3678
3850
|
"private_key_jwt"
|
|
3679
3851
|
]).default("client_secret_basic").optional(),
|
|
3680
|
-
jwks: z.union([z.array(z.record(z.string(), z.unknown())), z.object({ keys: z.array(z.record(z.string(), z.unknown())) })]).optional(),
|
|
3852
|
+
jwks: z.union([z.array(z.record(z.string(), z.unknown())).min(1), z.object({ keys: z.array(z.record(z.string(), z.unknown())).min(1) })]).optional(),
|
|
3681
3853
|
jwks_uri: z.string().optional(),
|
|
3682
3854
|
grant_types: z.array(z.enum([
|
|
3683
3855
|
"authorization_code",
|
|
@@ -4082,6 +4254,9 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
4082
4254
|
if (!query.code_challenge || !query.code_challenge_method) return handleRedirect(ctx, formatErrorURL(query.redirect_uri, "invalid_request", "code_challenge and code_challenge_method must both be provided", query.state, getIssuer(ctx, opts)));
|
|
4083
4255
|
if (!["S256"].includes(query.code_challenge_method)) return handleRedirect(ctx, formatErrorURL(query.redirect_uri, "invalid_request", "invalid code_challenge method, only S256 is supported", query.state, getIssuer(ctx, opts)));
|
|
4084
4256
|
}
|
|
4257
|
+
const resource = query.resource;
|
|
4258
|
+
if (!(await checkResource(ctx, opts, resource, requestedScopes)).success) return handleRedirect(ctx, formatErrorURL(query.redirect_uri, "invalid_target", "requested resource invalid", query.state, getIssuer(ctx, opts)));
|
|
4259
|
+
const requestedResources = toResourceList(resource) ?? [];
|
|
4085
4260
|
const session = await getSessionFromCtx(ctx);
|
|
4086
4261
|
if (!session || promptSet?.has("login") || promptSet?.has("create")) {
|
|
4087
4262
|
if (promptNone) return redirectWithPromptNoneError(ctx, opts, query, "login_required", "authentication required");
|
|
@@ -4134,7 +4309,8 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
4134
4309
|
userId: session.user.id,
|
|
4135
4310
|
sessionId: session.session.id,
|
|
4136
4311
|
authTime: new Date(session.session.createdAt).getTime(),
|
|
4137
|
-
referenceId
|
|
4312
|
+
referenceId,
|
|
4313
|
+
resource: requestedResources
|
|
4138
4314
|
});
|
|
4139
4315
|
const consent = await ctx.context.adapter.findOne({
|
|
4140
4316
|
model: "oauthConsent",
|
|
@@ -4157,13 +4333,19 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
4157
4333
|
if (promptNone) return redirectWithPromptNoneError(ctx, opts, query, "consent_required", "End-User consent is required");
|
|
4158
4334
|
return redirectWithPromptCode(ctx, opts, "consent", { sessionId: session.session.id });
|
|
4159
4335
|
}
|
|
4336
|
+
const consentedResources = consent?.resources ?? [];
|
|
4337
|
+
if (requestedResources.some((requestedResource) => !consentedResources.includes(requestedResource))) {
|
|
4338
|
+
if (promptNone) return redirectWithPromptNoneError(ctx, opts, query, "consent_required", "End-User consent is required");
|
|
4339
|
+
return redirectWithPromptCode(ctx, opts, "consent", { sessionId: session.session.id });
|
|
4340
|
+
}
|
|
4160
4341
|
return redirectWithAuthorizationCode(ctx, opts, {
|
|
4161
4342
|
query,
|
|
4162
4343
|
clientId: client.clientId,
|
|
4163
4344
|
userId: session.user.id,
|
|
4164
4345
|
sessionId: session.session.id,
|
|
4165
4346
|
authTime: new Date(session.session.createdAt).getTime(),
|
|
4166
|
-
referenceId
|
|
4347
|
+
referenceId,
|
|
4348
|
+
resource: requestedResources
|
|
4167
4349
|
});
|
|
4168
4350
|
}
|
|
4169
4351
|
function serializeAuthorizationQuery(query) {
|
|
@@ -4189,7 +4371,8 @@ async function redirectWithAuthorizationCode(ctx, opts, verificationValue) {
|
|
|
4189
4371
|
userId: verificationValue.userId,
|
|
4190
4372
|
sessionId: verificationValue?.sessionId,
|
|
4191
4373
|
referenceId: verificationValue.referenceId,
|
|
4192
|
-
authTime: verificationValue.authTime
|
|
4374
|
+
authTime: verificationValue.authTime,
|
|
4375
|
+
resource: verificationValue.resource
|
|
4193
4376
|
})
|
|
4194
4377
|
};
|
|
4195
4378
|
await ctx.context.internalAdapter.createVerificationValue({
|
|
@@ -4235,7 +4418,7 @@ function authServerMetadata(ctx, opts, overrides) {
|
|
|
4235
4418
|
authorization_endpoint: `${baseURL}/oauth2/authorize`,
|
|
4236
4419
|
token_endpoint: `${baseURL}/oauth2/token`,
|
|
4237
4420
|
jwks_uri: overrides?.jwt_disabled ? void 0 : opts?.jwks?.remoteUrl ?? `${baseURL}${opts?.jwks?.jwksPath ?? "/jwks"}`,
|
|
4238
|
-
registration_endpoint: `${baseURL}/oauth2/register
|
|
4421
|
+
registration_endpoint: overrides?.dynamic_client_registration_supported ? `${baseURL}/oauth2/register` : void 0,
|
|
4239
4422
|
introspection_endpoint: `${baseURL}/oauth2/introspect`,
|
|
4240
4423
|
revocation_endpoint: `${baseURL}/oauth2/revoke`,
|
|
4241
4424
|
response_types_supported: overrides?.grant_types_supported && !overrides.grant_types_supported.includes("authorization_code") ? [] : ["code"],
|
|
@@ -4251,19 +4434,19 @@ function authServerMetadata(ctx, opts, overrides) {
|
|
|
4251
4434
|
"client_secret_post",
|
|
4252
4435
|
"private_key_jwt"
|
|
4253
4436
|
],
|
|
4254
|
-
token_endpoint_auth_signing_alg_values_supported: [...
|
|
4437
|
+
token_endpoint_auth_signing_alg_values_supported: [...PRIVATE_KEY_JWT_SIGNING_ALGORITHMS],
|
|
4255
4438
|
introspection_endpoint_auth_methods_supported: [
|
|
4256
4439
|
"client_secret_basic",
|
|
4257
4440
|
"client_secret_post",
|
|
4258
4441
|
"private_key_jwt"
|
|
4259
4442
|
],
|
|
4260
|
-
introspection_endpoint_auth_signing_alg_values_supported: [...
|
|
4443
|
+
introspection_endpoint_auth_signing_alg_values_supported: [...PRIVATE_KEY_JWT_SIGNING_ALGORITHMS],
|
|
4261
4444
|
revocation_endpoint_auth_methods_supported: [
|
|
4262
4445
|
"client_secret_basic",
|
|
4263
4446
|
"client_secret_post",
|
|
4264
4447
|
"private_key_jwt"
|
|
4265
4448
|
],
|
|
4266
|
-
revocation_endpoint_auth_signing_alg_values_supported: [...
|
|
4449
|
+
revocation_endpoint_auth_signing_alg_values_supported: [...PRIVATE_KEY_JWT_SIGNING_ALGORITHMS],
|
|
4267
4450
|
code_challenge_methods_supported: ["S256"],
|
|
4268
4451
|
authorization_response_iss_parameter_supported: true
|
|
4269
4452
|
};
|
|
@@ -4274,6 +4457,7 @@ function oidcServerMetadata(ctx, opts) {
|
|
|
4274
4457
|
return {
|
|
4275
4458
|
...authServerMetadata(ctx, jwtPluginOptions, {
|
|
4276
4459
|
scopes_supported: opts.advertisedMetadata?.scopes_supported ?? opts.scopes,
|
|
4460
|
+
dynamic_client_registration_supported: opts.allowDynamicClientRegistration,
|
|
4277
4461
|
public_client_supported: opts.allowUnauthenticatedClientRegistration || toClientDiscoveryArray(opts.clientDiscovery).length > 0,
|
|
4278
4462
|
grant_types_supported: opts.grantTypes,
|
|
4279
4463
|
jwt_disabled: opts.disableJwtPlugin
|