@better-auth/oauth-provider 1.7.0-beta.3 → 1.7.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{client-assertion-BYtMWGCE.mjs → client-assertion-DmT1B6_6.mjs} +42 -51
- package/dist/client-resource.d.mts +27 -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 +9 -6
- package/dist/index.mjs +893 -388
- package/dist/{oauth-Ds-ejTJY.d.mts → oauth-BXrYl5x6.d.mts} +129 -7
- package/dist/{oauth-BxP4Iupj.d.mts → oauth-DU6NeviY.d.mts} +171 -52
- package/dist/{utils-_Jr_enAe.mjs → utils-D2dLqo7f.mjs} +86 -17
- package/dist/{version-CG1YnCiF.mjs → version-B1ZiRmxj.mjs} +1 -1
- package/package.json +8 -8
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-DmT1B6_6.mjs";
|
|
2
2
|
import { n as mcpHandler } from "./mcp-CYnz-MXn.mjs";
|
|
3
|
-
import { C as
|
|
4
|
-
import { t as PACKAGE_VERSION } from "./version-
|
|
5
|
-
import { APIError, createAuthEndpoint, createAuthMiddleware, getOAuthState, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
|
|
3
|
+
import { A as verifyOAuthQueryParams, C as signedQueryIssuedAtParam, D as toClientDiscoveryArray, E as toAudienceClaim, O as toResourceList, S as searchParamsToQuery, T as storeToken, _ as postLoginClearedParam, a as extractClientCredentials, b as resolveSessionAuthTime, d as isPKCERequired, f as isSessionFreshForSignedQuery, g as parsePrompt, h as parseClientMetadata, i as destructureCredentials, k as validateClientCredentials, l as getSignedQueryIssuedAt, m as normalizeTimestampValue, n as clientAllowsGrant, o as getClient, p as mergeDiscoveryMetadata, r as decryptStoredClientSecret, s as getJwtPlugin, t as checkResource, u as getStoredToken, v as removeMaxAgeFromQuery, w as storeClientSecret, x as resolveSubjectIdentifier, y as removePromptFromQuery } from "./utils-D2dLqo7f.mjs";
|
|
4
|
+
import { t as PACKAGE_VERSION } from "./version-B1ZiRmxj.mjs";
|
|
5
|
+
import { APIError, addOAuthServerContext, createAuthEndpoint, createAuthMiddleware, dispatchAuthEndpoint, getOAuthState, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
|
|
6
6
|
import { generateCodeChallenge, getJwks, verifyJwsAccessToken } from "better-auth/oauth2";
|
|
7
7
|
import { APIError as APIError$1 } from "better-call";
|
|
8
|
-
import {
|
|
8
|
+
import { PRIVATE_KEY_JWT_SIGNING_ALGORITHMS } from "@better-auth/core/oauth2";
|
|
9
9
|
import { isBrowserFetchRequest } from "@better-auth/core/utils/fetch-metadata";
|
|
10
10
|
import { isLoopbackHost, isLoopbackIP } from "@better-auth/core/utils/host";
|
|
11
11
|
import { generateRandomString, makeSignature } from "better-auth/crypto";
|
|
@@ -17,8 +17,9 @@ import { mergeSchema } from "better-auth/db";
|
|
|
17
17
|
import * as z from "zod";
|
|
18
18
|
import { resolveSigningKey, signJWT, toExpJWT } from "better-auth/plugins";
|
|
19
19
|
import { SignJWT, base64url, compactVerify, createLocalJWKSet, decodeJwt, decodeProtectedHeader } from "jose";
|
|
20
|
+
import { SafeUrlSchema } from "@better-auth/core/utils/redirect-uri";
|
|
20
21
|
//#region src/consent.ts
|
|
21
|
-
async function consentEndpoint(ctx, opts) {
|
|
22
|
+
async function consentEndpoint(ctx, opts, authorize) {
|
|
22
23
|
const oauthRequest = await oAuthState.get();
|
|
23
24
|
const _query = oauthRequest?.query;
|
|
24
25
|
if (!_query) throw new APIError("BAD_REQUEST", {
|
|
@@ -45,15 +46,11 @@ async function consentEndpoint(ctx, opts) {
|
|
|
45
46
|
};
|
|
46
47
|
const session = await getSessionFromCtx(ctx);
|
|
47
48
|
const hasLoginPrompt = parsePrompt(query.get("prompt") ?? "").has("login");
|
|
48
|
-
const hasSatisfiedLoginPrompt = hasLoginPrompt &&
|
|
49
|
+
const hasSatisfiedLoginPrompt = hasLoginPrompt && isSessionFreshForSignedQuery(session?.session.createdAt, oauthRequest?.signedQueryIssuedAt);
|
|
49
50
|
if (hasLoginPrompt && !hasSatisfiedLoginPrompt) {
|
|
50
51
|
ctx?.headers?.set("accept", "application/json");
|
|
51
52
|
ctx.query = searchParamsToQuery(query);
|
|
52
|
-
|
|
53
|
-
return {
|
|
54
|
-
redirect: true,
|
|
55
|
-
url
|
|
56
|
-
};
|
|
53
|
+
return await authorize(ctx);
|
|
57
54
|
}
|
|
58
55
|
const referenceId = await opts.postLogin?.consentReferenceId?.({
|
|
59
56
|
user: session?.user,
|
|
@@ -78,12 +75,14 @@ async function consentEndpoint(ctx, opts) {
|
|
|
78
75
|
]
|
|
79
76
|
});
|
|
80
77
|
const iat = Math.floor(Date.now() / 1e3);
|
|
78
|
+
const resource = query.getAll("resource");
|
|
81
79
|
const consent = {
|
|
82
80
|
clientId,
|
|
83
81
|
userId: session?.user.id,
|
|
84
82
|
scopes: requestedScopes ?? originalRequestedScopes,
|
|
85
83
|
createdAt: /* @__PURE__ */ new Date(iat * 1e3),
|
|
86
84
|
updatedAt: /* @__PURE__ */ new Date(iat * 1e3),
|
|
85
|
+
resources: resource.length ? resource : void 0,
|
|
87
86
|
referenceId
|
|
88
87
|
};
|
|
89
88
|
foundConsent?.id ? await ctx.context.adapter.update({
|
|
@@ -93,6 +92,7 @@ async function consentEndpoint(ctx, opts) {
|
|
|
93
92
|
value: foundConsent.id
|
|
94
93
|
}],
|
|
95
94
|
update: {
|
|
95
|
+
resources: consent.resources,
|
|
96
96
|
scopes: consent.scopes,
|
|
97
97
|
updatedAt: /* @__PURE__ */ new Date(iat * 1e3)
|
|
98
98
|
}
|
|
@@ -106,32 +106,25 @@ async function consentEndpoint(ctx, opts) {
|
|
|
106
106
|
if (requestedScopes) query.set("scope", consent.scopes.join(" "));
|
|
107
107
|
ctx?.headers?.set("accept", "application/json");
|
|
108
108
|
let authorizationQuery = removePromptFromQuery(query, "consent");
|
|
109
|
-
if (hasSatisfiedLoginPrompt)
|
|
109
|
+
if (hasSatisfiedLoginPrompt) {
|
|
110
|
+
authorizationQuery = removePromptFromQuery(authorizationQuery, "login");
|
|
111
|
+
authorizationQuery = removeMaxAgeFromQuery(authorizationQuery);
|
|
112
|
+
}
|
|
110
113
|
ctx.query = searchParamsToQuery(authorizationQuery);
|
|
111
|
-
|
|
112
|
-
return {
|
|
113
|
-
redirect: true,
|
|
114
|
-
url
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
function sessionSatisfiesLoginPrompt(sessionCreatedAt, signedQueryIssuedAt) {
|
|
118
|
-
if (!signedQueryIssuedAt) return false;
|
|
119
|
-
const normalized = normalizeTimestampValue(sessionCreatedAt);
|
|
120
|
-
if (!normalized) return false;
|
|
121
|
-
return normalized.getTime() >= signedQueryIssuedAt.getTime();
|
|
114
|
+
return await authorize(ctx, { postLogin: oauthRequest?.postLoginClearedForSession !== void 0 && oauthRequest.postLoginClearedForSession === session?.session.id });
|
|
122
115
|
}
|
|
123
116
|
//#endregion
|
|
124
117
|
//#region src/continue.ts
|
|
125
|
-
async function continueEndpoint(ctx,
|
|
126
|
-
if (ctx.body.selected === true) return await selected(ctx,
|
|
127
|
-
else if (ctx.body.created === true) return await created(ctx,
|
|
128
|
-
else if (ctx.body.postLogin === true) return await postLogin(ctx,
|
|
118
|
+
async function continueEndpoint(ctx, authorize) {
|
|
119
|
+
if (ctx.body.selected === true) return await selected(ctx, authorize);
|
|
120
|
+
else if (ctx.body.created === true) return await created(ctx, authorize);
|
|
121
|
+
else if (ctx.body.postLogin === true) return await postLogin(ctx, authorize);
|
|
129
122
|
else throw new APIError("BAD_REQUEST", {
|
|
130
123
|
error_description: "Missing parameters",
|
|
131
124
|
error: "invalid_request"
|
|
132
125
|
});
|
|
133
126
|
}
|
|
134
|
-
async function selected(ctx,
|
|
127
|
+
async function selected(ctx, authorize) {
|
|
135
128
|
const _query = (await oAuthState.get())?.query;
|
|
136
129
|
if (!_query) throw new APIError("BAD_REQUEST", {
|
|
137
130
|
error_description: "missing oauth query",
|
|
@@ -139,13 +132,9 @@ async function selected(ctx, opts) {
|
|
|
139
132
|
});
|
|
140
133
|
ctx.headers?.set("accept", "application/json");
|
|
141
134
|
ctx.query = searchParamsToQuery(removePromptFromQuery(new URLSearchParams(_query), "select_account"));
|
|
142
|
-
|
|
143
|
-
return {
|
|
144
|
-
redirect: true,
|
|
145
|
-
url
|
|
146
|
-
};
|
|
135
|
+
return await authorize(ctx);
|
|
147
136
|
}
|
|
148
|
-
async function created(ctx,
|
|
137
|
+
async function created(ctx, authorize) {
|
|
149
138
|
const _query = (await oAuthState.get())?.query;
|
|
150
139
|
if (!_query) throw new APIError("BAD_REQUEST", {
|
|
151
140
|
error_description: "missing oauth query",
|
|
@@ -154,14 +143,11 @@ async function created(ctx, opts) {
|
|
|
154
143
|
const query = new URLSearchParams(_query);
|
|
155
144
|
ctx.headers?.set("accept", "application/json");
|
|
156
145
|
ctx.query = searchParamsToQuery(removePromptFromQuery(query, "create"));
|
|
157
|
-
|
|
158
|
-
return {
|
|
159
|
-
redirect: true,
|
|
160
|
-
url
|
|
161
|
-
};
|
|
146
|
+
return await authorize(ctx);
|
|
162
147
|
}
|
|
163
|
-
async function postLogin(ctx,
|
|
164
|
-
const
|
|
148
|
+
async function postLogin(ctx, authorize) {
|
|
149
|
+
const state = await oAuthState.get();
|
|
150
|
+
const _query = state?.query;
|
|
165
151
|
if (!_query) throw new APIError("BAD_REQUEST", {
|
|
166
152
|
error_description: "missing oauth query",
|
|
167
153
|
error: "invalid_request"
|
|
@@ -169,11 +155,8 @@ async function postLogin(ctx, opts) {
|
|
|
169
155
|
const query = new URLSearchParams(_query);
|
|
170
156
|
ctx.headers?.set("accept", "application/json");
|
|
171
157
|
ctx.query = searchParamsToQuery(query);
|
|
172
|
-
const
|
|
173
|
-
return {
|
|
174
|
-
redirect: true,
|
|
175
|
-
url
|
|
176
|
-
};
|
|
158
|
+
const session = await getSessionFromCtx(ctx);
|
|
159
|
+
return await authorize(ctx, { postLogin: state?.postLoginClearedForSession !== void 0 && state.postLoginClearedForSession === session?.session.id });
|
|
177
160
|
}
|
|
178
161
|
//#endregion
|
|
179
162
|
//#region src/types/zod.ts
|
|
@@ -183,27 +166,101 @@ const DANGEROUS_SCHEMES = [
|
|
|
183
166
|
"vbscript:"
|
|
184
167
|
];
|
|
185
168
|
/**
|
|
169
|
+
* Validates an RFC 8707 resource indicator. The value must be an absolute URI
|
|
170
|
+
* with no fragment (RFC 8707 §2). Unlike a redirect URI it is not restricted to
|
|
171
|
+
* HTTPS, because a resource server identifier may use any absolute URI scheme;
|
|
172
|
+
* the configured `validAudiences` allowlist is the authoritative control over
|
|
173
|
+
* which resources a token may target.
|
|
174
|
+
*/
|
|
175
|
+
const ResourceUriSchema = z.string().superRefine((val, ctx) => {
|
|
176
|
+
if (!URL.canParse(val)) {
|
|
177
|
+
ctx.addIssue({
|
|
178
|
+
code: "custom",
|
|
179
|
+
message: "resource must be an absolute URI",
|
|
180
|
+
fatal: true
|
|
181
|
+
});
|
|
182
|
+
return z.NEVER;
|
|
183
|
+
}
|
|
184
|
+
if (val.includes("#")) {
|
|
185
|
+
ctx.addIssue({
|
|
186
|
+
code: "custom",
|
|
187
|
+
message: "resource must not contain a fragment"
|
|
188
|
+
});
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (DANGEROUS_SCHEMES.includes(new URL(val).protocol)) ctx.addIssue({
|
|
192
|
+
code: "custom",
|
|
193
|
+
message: "resource cannot use javascript:, data:, or vbscript: scheme"
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
const authorizationPromptTokenSchema = z.enum([
|
|
197
|
+
"none",
|
|
198
|
+
"consent",
|
|
199
|
+
"login",
|
|
200
|
+
"create",
|
|
201
|
+
"select_account"
|
|
202
|
+
]);
|
|
203
|
+
const authorizationPromptSchema = z.string().superRefine((value, ctx) => {
|
|
204
|
+
const promptTokens = value.split(" ").map((token) => token.trim()).filter(Boolean);
|
|
205
|
+
const promptSet = /* @__PURE__ */ new Set();
|
|
206
|
+
if (!promptTokens.length) {
|
|
207
|
+
ctx.addIssue({
|
|
208
|
+
code: "custom",
|
|
209
|
+
message: "prompt must include at least one value"
|
|
210
|
+
});
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
for (const token of promptTokens) {
|
|
214
|
+
const result = authorizationPromptTokenSchema.safeParse(token);
|
|
215
|
+
if (!result.success) {
|
|
216
|
+
ctx.addIssue({
|
|
217
|
+
code: "custom",
|
|
218
|
+
message: `unsupported prompt value: ${token}`
|
|
219
|
+
});
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
promptSet.add(result.data);
|
|
223
|
+
}
|
|
224
|
+
if (promptSet.has("none") && promptSet.size > 1) ctx.addIssue({
|
|
225
|
+
code: "custom",
|
|
226
|
+
message: "prompt=none cannot be combined with other prompt values"
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
const maxAgeSchema = z.union([z.number(), z.string().trim().min(1)]).transform((value, ctx) => {
|
|
230
|
+
const maxAge = typeof value === "number" ? value : Number(value);
|
|
231
|
+
if (!Number.isInteger(maxAge) || maxAge < 0) {
|
|
232
|
+
ctx.addIssue({
|
|
233
|
+
code: "custom",
|
|
234
|
+
message: "max_age must be a non-negative integer"
|
|
235
|
+
});
|
|
236
|
+
return z.NEVER;
|
|
237
|
+
}
|
|
238
|
+
return maxAge;
|
|
239
|
+
});
|
|
240
|
+
/**
|
|
186
241
|
* Runtime schema for OAuthAuthorizationQuery.
|
|
187
242
|
* Uses passthrough to tolerate fields added by future extensions (PAR, FPA, etc.)
|
|
188
243
|
*/
|
|
189
|
-
const
|
|
190
|
-
response_type: z.
|
|
244
|
+
const authorizationQuerySchema = z.object({
|
|
245
|
+
response_type: z.string().pipe(z.enum(["code"])).optional(),
|
|
191
246
|
request_uri: z.string().optional(),
|
|
192
|
-
redirect_uri:
|
|
247
|
+
redirect_uri: SafeUrlSchema.optional(),
|
|
193
248
|
scope: z.string().optional(),
|
|
194
249
|
state: z.string().optional(),
|
|
195
250
|
client_id: z.string(),
|
|
196
|
-
prompt:
|
|
251
|
+
prompt: authorizationPromptSchema.optional(),
|
|
197
252
|
display: z.string().optional(),
|
|
198
253
|
ui_locales: z.string().optional(),
|
|
199
|
-
max_age:
|
|
254
|
+
max_age: maxAgeSchema.optional(),
|
|
200
255
|
acr_values: z.string().optional(),
|
|
201
256
|
login_hint: z.string().optional(),
|
|
202
257
|
id_token_hint: z.string().optional(),
|
|
203
258
|
code_challenge: z.string().optional(),
|
|
204
|
-
code_challenge_method: z.
|
|
205
|
-
nonce: z.string().optional()
|
|
259
|
+
code_challenge_method: z.string().pipe(z.enum(["S256"])).optional(),
|
|
260
|
+
nonce: z.string().optional(),
|
|
261
|
+
resource: z.union([ResourceUriSchema, z.array(ResourceUriSchema).min(1)]).optional()
|
|
206
262
|
}).passthrough();
|
|
263
|
+
const storedAuthorizationQuerySchema = authorizationQuerySchema.extend({ redirect_uri: SafeUrlSchema });
|
|
207
264
|
/**
|
|
208
265
|
* Runtime schema for the authorization code verification value.
|
|
209
266
|
* Validates structure on deserialization from the JSON blob stored in the DB.
|
|
@@ -211,40 +268,13 @@ const oauthAuthorizationQuerySchema = z.object({
|
|
|
211
268
|
*/
|
|
212
269
|
const verificationValueSchema = z.object({
|
|
213
270
|
type: z.literal("authorization_code"),
|
|
214
|
-
query:
|
|
271
|
+
query: storedAuthorizationQuerySchema,
|
|
215
272
|
sessionId: z.string(),
|
|
216
273
|
userId: z.string(),
|
|
217
274
|
referenceId: z.string().optional(),
|
|
218
|
-
authTime: z.number().optional()
|
|
275
|
+
authTime: z.number().optional(),
|
|
276
|
+
resource: z.array(z.string()).optional()
|
|
219
277
|
}).passthrough();
|
|
220
|
-
/**
|
|
221
|
-
* Reusable URL validation for OAuth redirect URIs.
|
|
222
|
-
* - Blocks dangerous schemes (javascript:, data:, vbscript:)
|
|
223
|
-
* - For http/https: requires HTTPS (HTTP allowed only for loopback hosts: 127.0.0.0/8, [::1], *.localhost per RFC 6761)
|
|
224
|
-
* - Allows custom schemes for mobile apps (e.g., myapp://callback)
|
|
225
|
-
*/
|
|
226
|
-
const SafeUrlSchema = z.url().superRefine((val, ctx) => {
|
|
227
|
-
if (!URL.canParse(val)) {
|
|
228
|
-
ctx.addIssue({
|
|
229
|
-
code: "custom",
|
|
230
|
-
message: "URL must be parseable",
|
|
231
|
-
fatal: true
|
|
232
|
-
});
|
|
233
|
-
return z.NEVER;
|
|
234
|
-
}
|
|
235
|
-
const u = new URL(val);
|
|
236
|
-
if (DANGEROUS_SCHEMES.includes(u.protocol)) {
|
|
237
|
-
ctx.addIssue({
|
|
238
|
-
code: "custom",
|
|
239
|
-
message: "URL cannot use javascript:, data:, or vbscript: scheme"
|
|
240
|
-
});
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
if (u.protocol === "http:" && !isLoopbackHost(u.host)) ctx.addIssue({
|
|
244
|
-
code: "custom",
|
|
245
|
-
message: "Redirect URI must use HTTPS (HTTP allowed only for loopback hosts)"
|
|
246
|
-
});
|
|
247
|
-
});
|
|
248
278
|
//#endregion
|
|
249
279
|
//#region src/userinfo.ts
|
|
250
280
|
/**
|
|
@@ -281,6 +311,10 @@ async function userInfoEndpoint(ctx, opts) {
|
|
|
281
311
|
error: "invalid_request"
|
|
282
312
|
});
|
|
283
313
|
const jwt = await validateAccessToken(ctx, opts, token);
|
|
314
|
+
if (!jwt.active) throw new APIError("UNAUTHORIZED", {
|
|
315
|
+
error_description: "the access token is invalid or has been revoked",
|
|
316
|
+
error: "invalid_token"
|
|
317
|
+
});
|
|
284
318
|
const scopes = jwt.scope?.split(" ");
|
|
285
319
|
if (!scopes?.includes("openid")) throw new APIError("BAD_REQUEST", {
|
|
286
320
|
error_description: "Missing required scope",
|
|
@@ -331,13 +365,13 @@ async function tokenEndpoint(ctx, opts) {
|
|
|
331
365
|
case "refresh_token": return handleRefreshTokenGrant(ctx, opts);
|
|
332
366
|
}
|
|
333
367
|
}
|
|
334
|
-
async function createJwtAccessToken(ctx, opts, user, client, audience, scopes, referenceId, overrides) {
|
|
368
|
+
async function createJwtAccessToken(ctx, opts, user, client, audience, scopes, resources, referenceId, overrides) {
|
|
335
369
|
const iat = overrides?.iat ?? Math.floor(Date.now() / 1e3);
|
|
336
370
|
const exp = overrides?.exp ?? iat + (opts.accessTokenExpiresIn ?? 3600);
|
|
337
371
|
const customClaims = opts.customAccessTokenClaims ? await opts.customAccessTokenClaims({
|
|
338
372
|
user,
|
|
339
373
|
scopes,
|
|
340
|
-
|
|
374
|
+
resources,
|
|
341
375
|
referenceId,
|
|
342
376
|
metadata: parseClientMetadata(client.metadata)
|
|
343
377
|
}) : {};
|
|
@@ -347,7 +381,7 @@ async function createJwtAccessToken(ctx, opts, user, client, audience, scopes, r
|
|
|
347
381
|
payload: {
|
|
348
382
|
...customClaims,
|
|
349
383
|
sub: user?.id,
|
|
350
|
-
aud:
|
|
384
|
+
aud: toAudienceClaim(audience),
|
|
351
385
|
azp: client.clientId,
|
|
352
386
|
scope: scopes.join(" "),
|
|
353
387
|
sid: overrides?.sid,
|
|
@@ -390,6 +424,7 @@ async function createIdToken(ctx, opts, user, client, scopes, nonce, sessionId,
|
|
|
390
424
|
const resolvedKey = !opts.disableJwtPlugin && !jwtPluginOptions?.jwt?.sign ? await resolveSigningKey(ctx, jwtPluginOptions) : void 0;
|
|
391
425
|
const signingAlg = opts.disableJwtPlugin ? "HS256" : resolvedKey?.alg ?? jwtPluginOptions?.jwks?.keyPairConfig?.alg;
|
|
392
426
|
const atHash = accessToken ? await computeOidcHash(accessToken, signingAlg) : void 0;
|
|
427
|
+
const emitSid = Boolean(client.enableEndSession || client.backchannelLogoutUri);
|
|
393
428
|
const payload = {
|
|
394
429
|
...userClaims,
|
|
395
430
|
auth_time: authTimeSec,
|
|
@@ -402,7 +437,7 @@ async function createIdToken(ctx, opts, user, client, scopes, nonce, sessionId,
|
|
|
402
437
|
nonce,
|
|
403
438
|
iat,
|
|
404
439
|
exp,
|
|
405
|
-
sid:
|
|
440
|
+
sid: emitSid ? sessionId : void 0
|
|
406
441
|
};
|
|
407
442
|
if (opts.disableJwtPlugin && !client.clientSecret) return;
|
|
408
443
|
const idToken = opts.disableJwtPlugin ? await new SignJWT(payload).setProtectedHeader({ alg: "HS256" }).sign(new TextEncoder().encode(await decryptStoredClientSecret(ctx, opts.storeClientSecret, client.clientSecret))) : await signJWT(ctx, {
|
|
@@ -438,7 +473,7 @@ async function decodeRefreshToken(opts, token) {
|
|
|
438
473
|
});
|
|
439
474
|
return opts.formatRefreshToken?.decrypt ? opts.formatRefreshToken?.decrypt(token) : { token };
|
|
440
475
|
}
|
|
441
|
-
async function createOpaqueAccessToken(ctx, opts, user, client, scopes, payload, referenceId, refreshId) {
|
|
476
|
+
async function createOpaqueAccessToken(ctx, opts, user, client, scopes, payload, resources, referenceId, refreshId) {
|
|
442
477
|
const iat = payload.iat ?? Math.floor(Date.now() / 1e3);
|
|
443
478
|
const exp = payload?.exp ?? iat + (opts.accessTokenExpiresIn ?? 3600);
|
|
444
479
|
const token = opts.generateOpaqueAccessToken ? await opts.generateOpaqueAccessToken() : generateRandomString(32, "A-Z", "a-z");
|
|
@@ -450,6 +485,7 @@ async function createOpaqueAccessToken(ctx, opts, user, client, scopes, payload,
|
|
|
450
485
|
sessionId: payload?.sid,
|
|
451
486
|
userId: user?.id,
|
|
452
487
|
referenceId,
|
|
488
|
+
resources,
|
|
453
489
|
refreshId,
|
|
454
490
|
scopes,
|
|
455
491
|
createdAt: /* @__PURE__ */ new Date(iat * 1e3),
|
|
@@ -458,54 +494,100 @@ async function createOpaqueAccessToken(ctx, opts, user, client, scopes, payload,
|
|
|
458
494
|
});
|
|
459
495
|
return (opts.prefix?.opaqueAccessToken ?? "") + token;
|
|
460
496
|
}
|
|
461
|
-
|
|
497
|
+
/**
|
|
498
|
+
* Tear down the entire refresh-token family for a (client, user) pair, plus
|
|
499
|
+
* any access tokens that reference those refresh rows, per RFC 9700 §4.14.
|
|
500
|
+
* Access tokens are deleted first so the parent rows' foreign-key children
|
|
501
|
+
* do not block the refresh-row delete.
|
|
502
|
+
*
|
|
503
|
+
* TODO(invalidate-family-race): the two `deleteMany` calls are not atomic
|
|
504
|
+
* with respect to each other. Between them, a concurrent rotation in a
|
|
505
|
+
* different worker can `create` a fresh refresh row (and, immediately after,
|
|
506
|
+
* an access-token row referencing it) for the same (client, user) pair,
|
|
507
|
+
* leaving the family partially rebuilt and the new refresh row orphaned of
|
|
508
|
+
* any deletion. Closing this window requires the same transactional adapter
|
|
509
|
+
* contract tracked under FIXME(strict-family-invalidation) in
|
|
510
|
+
* `createRefreshToken`.
|
|
511
|
+
*
|
|
512
|
+
* @internal
|
|
513
|
+
*/
|
|
514
|
+
async function invalidateRefreshFamily(ctx, clientId, userId) {
|
|
515
|
+
const refreshTokens = await ctx.context.adapter.findMany({
|
|
516
|
+
model: "oauthRefreshToken",
|
|
517
|
+
where: [{
|
|
518
|
+
field: "clientId",
|
|
519
|
+
value: clientId
|
|
520
|
+
}, {
|
|
521
|
+
field: "userId",
|
|
522
|
+
value: userId
|
|
523
|
+
}]
|
|
524
|
+
});
|
|
525
|
+
if (refreshTokens.length) await ctx.context.adapter.deleteMany({
|
|
526
|
+
model: "oauthAccessToken",
|
|
527
|
+
where: [{
|
|
528
|
+
field: "refreshId",
|
|
529
|
+
operator: "in",
|
|
530
|
+
value: refreshTokens.map((r) => r.id)
|
|
531
|
+
}]
|
|
532
|
+
});
|
|
533
|
+
await ctx.context.adapter.deleteMany({
|
|
534
|
+
model: "oauthRefreshToken",
|
|
535
|
+
where: [{
|
|
536
|
+
field: "clientId",
|
|
537
|
+
value: clientId
|
|
538
|
+
}, {
|
|
539
|
+
field: "userId",
|
|
540
|
+
value: userId
|
|
541
|
+
}]
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
async function createRefreshToken(ctx, opts, user, referenceId, client, scopes, payload, originalRefresh, authTime, resources) {
|
|
462
545
|
const iat = payload.iat ?? Math.floor(Date.now() / 1e3);
|
|
463
546
|
const exp = payload?.exp ?? iat + (opts.refreshTokenExpiresIn ?? 2592e3);
|
|
464
547
|
const token = opts.generateRefreshToken ? await opts.generateRefreshToken() : generateRandomString(32, "A-Z", "a-z");
|
|
465
548
|
const sessionId = payload?.sid;
|
|
466
|
-
|
|
549
|
+
const newRow = {
|
|
550
|
+
token: await storeToken(opts.storeTokens, token, "refresh_token"),
|
|
551
|
+
clientId: client.clientId,
|
|
552
|
+
sessionId,
|
|
553
|
+
userId: user.id,
|
|
554
|
+
referenceId,
|
|
555
|
+
authTime,
|
|
556
|
+
scopes,
|
|
557
|
+
resources,
|
|
558
|
+
createdAt: /* @__PURE__ */ new Date(iat * 1e3),
|
|
559
|
+
expiresAt: /* @__PURE__ */ new Date(exp * 1e3)
|
|
560
|
+
};
|
|
561
|
+
if (!originalRefresh?.id) return {
|
|
562
|
+
id: (await ctx.context.adapter.create({
|
|
563
|
+
model: "oauthRefreshToken",
|
|
564
|
+
data: newRow
|
|
565
|
+
})).id,
|
|
566
|
+
token: await encodeRefreshToken(opts, token, sessionId)
|
|
567
|
+
};
|
|
568
|
+
if (!await ctx.context.adapter.update({
|
|
467
569
|
model: "oauthRefreshToken",
|
|
468
570
|
where: [{
|
|
469
571
|
field: "id",
|
|
470
572
|
value: originalRefresh.id
|
|
573
|
+
}, {
|
|
574
|
+
field: "revoked",
|
|
575
|
+
operator: "eq",
|
|
576
|
+
value: null
|
|
471
577
|
}],
|
|
472
578
|
update: { revoked: /* @__PURE__ */ new Date(iat * 1e3) }
|
|
579
|
+
})) throw new APIError("BAD_REQUEST", {
|
|
580
|
+
error_description: "invalid refresh token",
|
|
581
|
+
error: "invalid_grant"
|
|
473
582
|
});
|
|
474
583
|
return {
|
|
475
584
|
id: (await ctx.context.adapter.create({
|
|
476
585
|
model: "oauthRefreshToken",
|
|
477
|
-
data:
|
|
478
|
-
token: await storeToken(opts.storeTokens, token, "refresh_token"),
|
|
479
|
-
clientId: client.clientId,
|
|
480
|
-
sessionId,
|
|
481
|
-
userId: user.id,
|
|
482
|
-
referenceId,
|
|
483
|
-
authTime,
|
|
484
|
-
scopes,
|
|
485
|
-
createdAt: /* @__PURE__ */ new Date(iat * 1e3),
|
|
486
|
-
expiresAt: /* @__PURE__ */ new Date(exp * 1e3)
|
|
487
|
-
}
|
|
586
|
+
data: newRow
|
|
488
587
|
})).id,
|
|
489
588
|
token: await encodeRefreshToken(opts, token, sessionId)
|
|
490
589
|
};
|
|
491
590
|
}
|
|
492
|
-
/**
|
|
493
|
-
* Checks the resource parameter, if provided,
|
|
494
|
-
* and returns a valid audience based on the request
|
|
495
|
-
*/
|
|
496
|
-
async function checkResource(ctx, opts, scopes) {
|
|
497
|
-
const resource = ctx.body.resource;
|
|
498
|
-
const audience = typeof resource === "string" ? [resource] : resource ? [...resource] : void 0;
|
|
499
|
-
if (audience) {
|
|
500
|
-
if (scopes.includes("openid")) audience.push(`${ctx.context.baseURL}/oauth2/userinfo`);
|
|
501
|
-
const validAudiences = new Set([...opts.validAudiences ?? [ctx.context.baseURL], scopes?.includes("openid") ? `${ctx.context.baseURL}/oauth2/userinfo` : void 0].flat().filter((v) => v?.length));
|
|
502
|
-
for (const aud of audience) if (!validAudiences.has(aud)) throw new APIError("BAD_REQUEST", {
|
|
503
|
-
error_description: "requested resource invalid",
|
|
504
|
-
error: "invalid_request"
|
|
505
|
-
});
|
|
506
|
-
}
|
|
507
|
-
return audience?.length === 1 ? audience.at(0) : audience;
|
|
508
|
-
}
|
|
509
591
|
async function createUserTokens(ctx, opts, params) {
|
|
510
592
|
const { client, scopes, user, grantType, referenceId, sessionId, nonce, refreshToken: existingRefreshToken, authTime, verificationValue } = params;
|
|
511
593
|
const iat = Math.floor(Date.now() / 1e3);
|
|
@@ -513,8 +595,13 @@ async function createUserTokens(ctx, opts, params) {
|
|
|
513
595
|
const exp = opts.scopeExpirations ? scopes.map((sc) => opts.scopeExpirations?.[sc] ? toExpJWT(opts.scopeExpirations[sc], iat) : defaultExp).reduce((prev, curr) => {
|
|
514
596
|
return prev < curr ? prev : curr;
|
|
515
597
|
}, defaultExp) : defaultExp;
|
|
516
|
-
const
|
|
517
|
-
|
|
598
|
+
const resourceResult = await checkResource(ctx, opts, params?.resources, scopes);
|
|
599
|
+
if (!resourceResult.success) throw new APIError("BAD_REQUEST", {
|
|
600
|
+
error_description: "requested resource invalid",
|
|
601
|
+
error: "invalid_target"
|
|
602
|
+
});
|
|
603
|
+
const audience = resourceResult.audience;
|
|
604
|
+
const isRefreshToken = user && clientAllowsGrant(client, "refresh_token") && (existingRefreshToken?.scopes?.includes("offline_access") || scopes.includes("offline_access"));
|
|
518
605
|
const isJwtAccessToken = audience && !opts.disableJwtPlugin;
|
|
519
606
|
const isIdToken = user && scopes.includes("openid");
|
|
520
607
|
const customFields = opts.customTokenResponseFields ? await opts.customTokenResponseFields({
|
|
@@ -524,12 +611,13 @@ async function createUserTokens(ctx, opts, params) {
|
|
|
524
611
|
metadata: parseClientMetadata(client.metadata),
|
|
525
612
|
verificationValue
|
|
526
613
|
}) : void 0;
|
|
614
|
+
const refreshResources = params?.refreshToken?.resources ?? params?.originalResources ?? params?.resources;
|
|
527
615
|
const earlyRefreshToken = isRefreshToken && user && !isJwtAccessToken ? await createRefreshToken(ctx, opts, user, referenceId, client, scopes, {
|
|
528
616
|
iat,
|
|
529
617
|
exp: iat + (opts.refreshTokenExpiresIn ?? 2592e3),
|
|
530
618
|
sid: sessionId
|
|
531
|
-
}, existingRefreshToken, authTime) : void 0;
|
|
532
|
-
const [accessToken, refreshToken] = await Promise.all([isJwtAccessToken ? createJwtAccessToken(ctx, opts, user, client, audience, scopes, referenceId, {
|
|
619
|
+
}, existingRefreshToken, authTime, refreshResources) : void 0;
|
|
620
|
+
const [accessToken, refreshToken] = await Promise.all([isJwtAccessToken ? createJwtAccessToken(ctx, opts, user, client, audience, scopes, params?.resources, referenceId, {
|
|
533
621
|
iat,
|
|
534
622
|
exp,
|
|
535
623
|
sid: sessionId
|
|
@@ -537,11 +625,11 @@ async function createUserTokens(ctx, opts, params) {
|
|
|
537
625
|
iat,
|
|
538
626
|
exp,
|
|
539
627
|
sid: sessionId
|
|
540
|
-
}, referenceId, earlyRefreshToken?.id), earlyRefreshToken ? earlyRefreshToken : isRefreshToken && user ? createRefreshToken(ctx, opts, user, referenceId, client, scopes, {
|
|
628
|
+
}, params?.resources, referenceId, earlyRefreshToken?.id), earlyRefreshToken ? earlyRefreshToken : isRefreshToken && user ? createRefreshToken(ctx, opts, user, referenceId, client, scopes, {
|
|
541
629
|
iat,
|
|
542
630
|
exp: iat + (opts.refreshTokenExpiresIn ?? 2592e3),
|
|
543
631
|
sid: sessionId
|
|
544
|
-
}, existingRefreshToken, authTime) : void 0]);
|
|
632
|
+
}, existingRefreshToken, authTime, refreshResources) : void 0]);
|
|
545
633
|
const idToken = isIdToken ? await createIdToken(ctx, opts, user, client, scopes, nonce, sessionId, authTime, accessToken) : void 0;
|
|
546
634
|
return ctx.json({
|
|
547
635
|
...customFields,
|
|
@@ -558,16 +646,11 @@ async function createUserTokens(ctx, opts, params) {
|
|
|
558
646
|
} });
|
|
559
647
|
}
|
|
560
648
|
/** Checks verification value */
|
|
561
|
-
async function checkVerificationValue(ctx, opts, code, client_id, redirect_uri) {
|
|
562
|
-
const verification = await ctx.context.internalAdapter.
|
|
649
|
+
async function checkVerificationValue(ctx, opts, code, client_id, redirect_uri, resource) {
|
|
650
|
+
const verification = await ctx.context.internalAdapter.consumeVerificationValue(await storeToken(opts.storeTokens, code, "authorization_code"));
|
|
563
651
|
if (!verification) throw new APIError("UNAUTHORIZED", {
|
|
564
|
-
error_description: "
|
|
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"
|
|
652
|
+
error_description: "invalid code",
|
|
653
|
+
error: "invalid_grant"
|
|
571
654
|
});
|
|
572
655
|
let rawValue;
|
|
573
656
|
try {
|
|
@@ -575,13 +658,13 @@ async function checkVerificationValue(ctx, opts, code, client_id, redirect_uri)
|
|
|
575
658
|
} catch {
|
|
576
659
|
throw new APIError("UNAUTHORIZED", {
|
|
577
660
|
error_description: "malformed verification value",
|
|
578
|
-
error: "
|
|
661
|
+
error: "invalid_grant"
|
|
579
662
|
});
|
|
580
663
|
}
|
|
581
664
|
const parsed = verificationValueSchema.safeParse(rawValue);
|
|
582
665
|
if (!parsed.success) throw new APIError("UNAUTHORIZED", {
|
|
583
666
|
error_description: "malformed verification value",
|
|
584
|
-
error: "
|
|
667
|
+
error: "invalid_grant"
|
|
585
668
|
});
|
|
586
669
|
const verificationValue = parsed.data;
|
|
587
670
|
if (verificationValue.query.client_id !== client_id) throw new APIError("UNAUTHORIZED", {
|
|
@@ -592,14 +675,29 @@ async function checkVerificationValue(ctx, opts, code, client_id, redirect_uri)
|
|
|
592
675
|
error_description: "redirect_uri mismatch",
|
|
593
676
|
error: "invalid_request"
|
|
594
677
|
});
|
|
595
|
-
|
|
678
|
+
const storedResources = toResourceList(verificationValue.resource) ?? toResourceList(verificationValue.query.resource);
|
|
679
|
+
const effectiveResources = resource ?? storedResources;
|
|
680
|
+
if (resource && storedResources) {
|
|
681
|
+
const requestedSet = new Set(resource);
|
|
682
|
+
const authorizedSet = new Set(storedResources);
|
|
683
|
+
for (const r of requestedSet) if (!authorizedSet.has(r)) throw new APIError("BAD_REQUEST", {
|
|
684
|
+
error_description: "requested resource not authorized",
|
|
685
|
+
error: "invalid_target"
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
return {
|
|
689
|
+
verificationValue,
|
|
690
|
+
effectiveResources,
|
|
691
|
+
authorizedResources: storedResources
|
|
692
|
+
};
|
|
596
693
|
}
|
|
597
694
|
/**
|
|
598
695
|
* Obtains new Session Jwt and Refresh Tokens using a code
|
|
599
696
|
*/
|
|
600
697
|
async function handleAuthorizationCodeGrant(ctx, opts) {
|
|
601
698
|
const { clientId: client_id, clientSecret: client_secret, preVerifiedClient } = destructureCredentials(await extractClientCredentials(ctx, opts, `${ctx.context.baseURL}/oauth2/token`));
|
|
602
|
-
const { code, code_verifier, redirect_uri } = ctx.body;
|
|
699
|
+
const { code, code_verifier, redirect_uri, resource } = ctx.body;
|
|
700
|
+
const resources = toResourceList(resource);
|
|
603
701
|
if (!client_id) throw new APIError("BAD_REQUEST", {
|
|
604
702
|
error_description: "client_id is required",
|
|
605
703
|
error: "invalid_request"
|
|
@@ -619,14 +717,14 @@ async function handleAuthorizationCodeGrant(ctx, opts) {
|
|
|
619
717
|
error: "invalid_request"
|
|
620
718
|
});
|
|
621
719
|
/** Get and check Verification Value */
|
|
622
|
-
const verificationValue = await checkVerificationValue(ctx, opts, code, client_id, redirect_uri);
|
|
720
|
+
const { verificationValue, effectiveResources, authorizedResources } = await checkVerificationValue(ctx, opts, code, client_id, redirect_uri, resources);
|
|
623
721
|
const scopes = verificationValue.query.scope?.split(" ");
|
|
624
722
|
if (!scopes) throw new APIError("INTERNAL_SERVER_ERROR", {
|
|
625
723
|
error_description: "verification scope unset",
|
|
626
724
|
error: "invalid_scope"
|
|
627
725
|
});
|
|
628
726
|
/** Verify Client */
|
|
629
|
-
const client = await validateClientCredentials(ctx, opts, client_id, client_secret, scopes, preVerifiedClient);
|
|
727
|
+
const client = await validateClientCredentials(ctx, opts, client_id, client_secret, scopes, preVerifiedClient, "authorization_code");
|
|
630
728
|
if (isPKCERequired(client, (verificationValue.query?.scope)?.split(" ") || [])) {
|
|
631
729
|
if (!isAuthCodeWithPkce) throw new APIError("BAD_REQUEST", {
|
|
632
730
|
error_description: "PKCE is required for this client",
|
|
@@ -684,7 +782,9 @@ async function handleAuthorizationCodeGrant(ctx, opts) {
|
|
|
684
782
|
sessionId: session.id,
|
|
685
783
|
nonce: verificationValue.query?.nonce,
|
|
686
784
|
authTime,
|
|
687
|
-
verificationValue
|
|
785
|
+
verificationValue,
|
|
786
|
+
resources: effectiveResources,
|
|
787
|
+
originalResources: authorizedResources
|
|
688
788
|
});
|
|
689
789
|
}
|
|
690
790
|
/**
|
|
@@ -695,7 +795,8 @@ async function handleAuthorizationCodeGrant(ctx, opts) {
|
|
|
695
795
|
*/
|
|
696
796
|
async function handleClientCredentialsGrant(ctx, opts) {
|
|
697
797
|
const { clientId: client_id, clientSecret: client_secret, preVerifiedClient } = destructureCredentials(await extractClientCredentials(ctx, opts, `${ctx.context.baseURL}/oauth2/token`));
|
|
698
|
-
const { scope } = ctx.body;
|
|
798
|
+
const { scope, resource } = ctx.body;
|
|
799
|
+
const resources = toResourceList(resource);
|
|
699
800
|
if (!client_id) throw new APIError("BAD_REQUEST", {
|
|
700
801
|
error_description: "Missing required client_id",
|
|
701
802
|
error: "invalid_grant"
|
|
@@ -704,7 +805,7 @@ async function handleClientCredentialsGrant(ctx, opts) {
|
|
|
704
805
|
error_description: "Missing a required client_secret",
|
|
705
806
|
error: "invalid_grant"
|
|
706
807
|
});
|
|
707
|
-
const client = await validateClientCredentials(ctx, opts, client_id, client_secret, void 0, preVerifiedClient);
|
|
808
|
+
const client = await validateClientCredentials(ctx, opts, client_id, client_secret, void 0, preVerifiedClient, "client_credentials");
|
|
708
809
|
let requestedScopes = scope?.split(" ");
|
|
709
810
|
if (requestedScopes) {
|
|
710
811
|
const validScopes = new Set(client.scopes ?? opts.scopes);
|
|
@@ -726,7 +827,8 @@ async function handleClientCredentialsGrant(ctx, opts) {
|
|
|
726
827
|
return createUserTokens(ctx, opts, {
|
|
727
828
|
client,
|
|
728
829
|
scopes: requestedScopes,
|
|
729
|
-
grantType: "client_credentials"
|
|
830
|
+
grantType: "client_credentials",
|
|
831
|
+
resources
|
|
730
832
|
});
|
|
731
833
|
}
|
|
732
834
|
/**
|
|
@@ -737,7 +839,8 @@ async function handleClientCredentialsGrant(ctx, opts) {
|
|
|
737
839
|
*/
|
|
738
840
|
async function handleRefreshTokenGrant(ctx, opts) {
|
|
739
841
|
const { clientId: client_id, clientSecret: client_secret, preVerifiedClient } = destructureCredentials(await extractClientCredentials(ctx, opts, `${ctx.context.baseURL}/oauth2/token`));
|
|
740
|
-
const { refresh_token, scope } = ctx.body;
|
|
842
|
+
const { refresh_token, scope, resource } = ctx.body;
|
|
843
|
+
const resources = toResourceList(resource);
|
|
741
844
|
if (!client_id) throw new APIError("BAD_REQUEST", {
|
|
742
845
|
error_description: "Missing required client_id",
|
|
743
846
|
error: "invalid_grant"
|
|
@@ -767,21 +870,16 @@ async function handleRefreshTokenGrant(ctx, opts) {
|
|
|
767
870
|
error: "invalid_grant"
|
|
768
871
|
});
|
|
769
872
|
if (refreshToken.revoked) {
|
|
770
|
-
await ctx.
|
|
771
|
-
model: "oauthRefreshToken",
|
|
772
|
-
where: [{
|
|
773
|
-
field: "clientId",
|
|
774
|
-
value: client_id
|
|
775
|
-
}, {
|
|
776
|
-
field: "userId",
|
|
777
|
-
value: refreshToken.userId
|
|
778
|
-
}]
|
|
779
|
-
});
|
|
873
|
+
await invalidateRefreshFamily(ctx, client_id, refreshToken.userId);
|
|
780
874
|
throw new APIError("BAD_REQUEST", {
|
|
781
875
|
error_description: "invalid refresh token",
|
|
782
876
|
error: "invalid_grant"
|
|
783
877
|
});
|
|
784
878
|
}
|
|
879
|
+
if (resources && refreshToken.resources && !resources.every((v) => refreshToken.resources?.includes(v))) throw new APIError("BAD_REQUEST", {
|
|
880
|
+
error_description: "requested resource invalid",
|
|
881
|
+
error: "invalid_target"
|
|
882
|
+
});
|
|
785
883
|
const scopes = refreshToken?.scopes;
|
|
786
884
|
const requestedScopes = scope?.split(" ");
|
|
787
885
|
if (requestedScopes) {
|
|
@@ -791,7 +889,7 @@ async function handleRefreshTokenGrant(ctx, opts) {
|
|
|
791
889
|
error: "invalid_scope"
|
|
792
890
|
});
|
|
793
891
|
}
|
|
794
|
-
const client = await validateClientCredentials(ctx, opts, client_id, client_secret, requestedScopes ?? scopes, preVerifiedClient);
|
|
892
|
+
const client = await validateClientCredentials(ctx, opts, client_id, client_secret, requestedScopes ?? scopes, preVerifiedClient, "refresh_token");
|
|
795
893
|
const user = await ctx.context.internalAdapter.findUserById(refreshToken.userId);
|
|
796
894
|
if (!user) throw new APIError("BAD_REQUEST", {
|
|
797
895
|
error_description: "user not found",
|
|
@@ -806,6 +904,7 @@ async function handleRefreshTokenGrant(ctx, opts) {
|
|
|
806
904
|
referenceId: refreshToken.referenceId,
|
|
807
905
|
sessionId: refreshToken.sessionId,
|
|
808
906
|
refreshToken,
|
|
907
|
+
resources: resources ?? refreshToken.resources,
|
|
809
908
|
authTime
|
|
810
909
|
});
|
|
811
910
|
}
|
|
@@ -849,12 +948,10 @@ async function validateJwtAccessToken(ctx, opts, token, clientId) {
|
|
|
849
948
|
}
|
|
850
949
|
throw new Error(error);
|
|
851
950
|
}
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
if (clientId && jwtPayload.azp !== clientId) return { active: false };
|
|
857
|
-
}
|
|
951
|
+
if (!jwtPayload.azp) return { active: false };
|
|
952
|
+
const client = await getClient(ctx, opts, jwtPayload.azp);
|
|
953
|
+
if (!client || client?.disabled) return { active: false };
|
|
954
|
+
if (clientId && jwtPayload.azp !== clientId) return { active: false };
|
|
858
955
|
const sessionId = jwtPayload.sid;
|
|
859
956
|
if (sessionId) {
|
|
860
957
|
const session = await ctx.context.adapter.findOne({
|
|
@@ -864,9 +961,9 @@ async function validateJwtAccessToken(ctx, opts, token, clientId) {
|
|
|
864
961
|
value: sessionId
|
|
865
962
|
}]
|
|
866
963
|
});
|
|
867
|
-
if (!session || session.expiresAt < /* @__PURE__ */ new Date())
|
|
964
|
+
if (!session || session.expiresAt < /* @__PURE__ */ new Date()) return { active: false };
|
|
868
965
|
}
|
|
869
|
-
|
|
966
|
+
jwtPayload.client_id = jwtPayload.azp;
|
|
870
967
|
jwtPayload.active = true;
|
|
871
968
|
return jwtPayload;
|
|
872
969
|
}
|
|
@@ -894,13 +991,14 @@ async function validateOpaqueAccessToken(ctx, opts, token, clientId) {
|
|
|
894
991
|
error: "invalid_token"
|
|
895
992
|
});
|
|
896
993
|
if (!accessToken.expiresAt || accessToken.expiresAt < /* @__PURE__ */ new Date()) return { active: false };
|
|
994
|
+
if (accessToken.revoked) return { active: false };
|
|
897
995
|
let client;
|
|
898
996
|
if (accessToken.clientId) {
|
|
899
997
|
client = await getClient(ctx, opts, accessToken.clientId);
|
|
900
998
|
if (!client || client?.disabled) return { active: false };
|
|
901
999
|
if (clientId && accessToken.clientId !== clientId) return { active: false };
|
|
902
1000
|
}
|
|
903
|
-
|
|
1001
|
+
const sessionId = accessToken.sessionId ?? void 0;
|
|
904
1002
|
if (sessionId) {
|
|
905
1003
|
const session = await ctx.context.adapter.findOne({
|
|
906
1004
|
model: "session",
|
|
@@ -909,14 +1007,21 @@ async function validateOpaqueAccessToken(ctx, opts, token, clientId) {
|
|
|
909
1007
|
value: sessionId
|
|
910
1008
|
}]
|
|
911
1009
|
});
|
|
912
|
-
if (!session || session.expiresAt < /* @__PURE__ */ new Date())
|
|
1010
|
+
if (!session || session.expiresAt < /* @__PURE__ */ new Date()) return { active: false };
|
|
913
1011
|
}
|
|
914
1012
|
let user;
|
|
915
1013
|
if (accessToken.userId) user = await ctx.context.internalAdapter.findUserById(accessToken?.userId);
|
|
1014
|
+
const resources = Array.isArray(accessToken.resources) ? accessToken.resources : void 0;
|
|
1015
|
+
const audience = resources ? [...resources] : void 0;
|
|
1016
|
+
if (audience?.length && accessToken.scopes?.includes("openid")) {
|
|
1017
|
+
const userInfoEndpoint = `${ctx.context.baseURL}/oauth2/userinfo`;
|
|
1018
|
+
if (!audience.includes(userInfoEndpoint)) audience.push(userInfoEndpoint);
|
|
1019
|
+
}
|
|
916
1020
|
const customClaims = opts.customAccessTokenClaims ? await opts.customAccessTokenClaims({
|
|
917
1021
|
user,
|
|
918
1022
|
scopes: accessToken.scopes,
|
|
919
1023
|
referenceId: accessToken?.referenceId,
|
|
1024
|
+
resources,
|
|
920
1025
|
metadata: parseClientMetadata(client?.metadata)
|
|
921
1026
|
}) : {};
|
|
922
1027
|
const jwtPluginOptions = (opts.disableJwtPlugin ? void 0 : getJwtPlugin(ctx.context))?.options;
|
|
@@ -924,6 +1029,7 @@ async function validateOpaqueAccessToken(ctx, opts, token, clientId) {
|
|
|
924
1029
|
...customClaims,
|
|
925
1030
|
active: true,
|
|
926
1031
|
iss: jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL,
|
|
1032
|
+
aud: toAudienceClaim(audience),
|
|
927
1033
|
client_id: accessToken.clientId,
|
|
928
1034
|
sub: user?.id,
|
|
929
1035
|
sid: sessionId,
|
|
@@ -1067,9 +1173,173 @@ async function introspectEndpoint(ctx, opts) {
|
|
|
1067
1173
|
}
|
|
1068
1174
|
//#endregion
|
|
1069
1175
|
//#region src/logout.ts
|
|
1176
|
+
const BACKCHANNEL_LOGOUT_EVENT_URI = "http://schemas.openid.net/event/backchannel-logout";
|
|
1177
|
+
const LOGOUT_TOKEN_JWT_TYP = "logout+jwt";
|
|
1178
|
+
const LOGOUT_TOKEN_LIFETIME_SECONDS = 120;
|
|
1179
|
+
const BACKCHANNEL_DISPATCH_TIMEOUT_MS = 5e3;
|
|
1070
1180
|
/**
|
|
1071
|
-
*
|
|
1072
|
-
*
|
|
1181
|
+
* Signs a Back-Channel Logout Token per OIDC Back-Channel Logout 1.0 §2.4.
|
|
1182
|
+
*
|
|
1183
|
+
* The token reuses the ID Token signing key so any RP that validates ID Tokens
|
|
1184
|
+
* from this OP can validate Logout Tokens without extra configuration. The
|
|
1185
|
+
* caller resolves that key once and passes it in so a fan-out to many RPs does
|
|
1186
|
+
* not re-read it per target.
|
|
1187
|
+
*
|
|
1188
|
+
* §2.4 mandates `iss`, `aud`, `iat`, `exp`, `jti`, `events`, and at least one
|
|
1189
|
+
* of `sub` / `sid` (we send both). A `nonce` claim MUST NOT be present, and
|
|
1190
|
+
* `alg: none` is forbidden (§2.6).
|
|
1191
|
+
*/
|
|
1192
|
+
async function signLogoutToken(ctx, options, resolvedKey, claims) {
|
|
1193
|
+
return signJWT(ctx, {
|
|
1194
|
+
options,
|
|
1195
|
+
payload: {
|
|
1196
|
+
...claims,
|
|
1197
|
+
events: { [BACKCHANNEL_LOGOUT_EVENT_URI]: {} }
|
|
1198
|
+
},
|
|
1199
|
+
header: { typ: LOGOUT_TOKEN_JWT_TYP },
|
|
1200
|
+
resolvedKey: resolvedKey ?? void 0
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* Synchronous phase: enumerate tokens for the session being terminated, revoke
|
|
1205
|
+
* them, and return a plan for the asynchronous delivery phase. Runs inline in
|
|
1206
|
+
* the `session.delete.before` hook so the DB state is consistent before the
|
|
1207
|
+
* session row disappears.
|
|
1208
|
+
*
|
|
1209
|
+
* Revocation is the stored backstop, not the primary enforcement: introspection
|
|
1210
|
+
* and `/userinfo` already treat a token whose session has ended as inactive
|
|
1211
|
+
* (see `validateOpaqueAccessToken` / `validateJwtAccessToken`), so a missed
|
|
1212
|
+
* `revoked` write cannot keep a session-bound token alive on its own. Access
|
|
1213
|
+
* tokens bound to the session are revoked as OP hardening. Refresh tokens
|
|
1214
|
+
* follow OIDC Back-Channel Logout 1.0 §2.7: those without `offline_access` are
|
|
1215
|
+
* revoked; `offline_access` refresh tokens survive so long-lived API access can
|
|
1216
|
+
* outlive the browser session.
|
|
1217
|
+
*
|
|
1218
|
+
* Revocation runs regardless of the JWT plugin (refresh-token revocation has no
|
|
1219
|
+
* dependency on signing). Only the Logout Token delivery plan needs the JWT
|
|
1220
|
+
* plugin, so when it is disabled we still revoke but never build a plan.
|
|
1221
|
+
*
|
|
1222
|
+
* Returns `null` when there is nothing to do, so the caller can skip the
|
|
1223
|
+
* background handoff entirely.
|
|
1224
|
+
*/
|
|
1225
|
+
async function revokeAndPlanBackchannelLogout(ctx, opts, input) {
|
|
1226
|
+
const { sessionId, userId } = input;
|
|
1227
|
+
if (!userId) return null;
|
|
1228
|
+
const logger = ctx.context.logger;
|
|
1229
|
+
try {
|
|
1230
|
+
const where = [{
|
|
1231
|
+
field: "sessionId",
|
|
1232
|
+
value: sessionId
|
|
1233
|
+
}];
|
|
1234
|
+
const [accessTokens, refreshTokens] = await Promise.all([ctx.context.adapter.findMany({
|
|
1235
|
+
model: "oauthAccessToken",
|
|
1236
|
+
where
|
|
1237
|
+
}), ctx.context.adapter.findMany({
|
|
1238
|
+
model: "oauthRefreshToken",
|
|
1239
|
+
where
|
|
1240
|
+
})]);
|
|
1241
|
+
const affectedClientIds = /* @__PURE__ */ new Set();
|
|
1242
|
+
for (const t of accessTokens) affectedClientIds.add(t.clientId);
|
|
1243
|
+
for (const t of refreshTokens) affectedClientIds.add(t.clientId);
|
|
1244
|
+
if (affectedClientIds.size === 0) return null;
|
|
1245
|
+
const clients = await ctx.context.adapter.findMany({
|
|
1246
|
+
model: "oauthClient",
|
|
1247
|
+
where: [{
|
|
1248
|
+
field: "clientId",
|
|
1249
|
+
operator: "in",
|
|
1250
|
+
value: Array.from(affectedClientIds)
|
|
1251
|
+
}]
|
|
1252
|
+
});
|
|
1253
|
+
const revokedAt = /* @__PURE__ */ new Date();
|
|
1254
|
+
const accessToRevokeIds = accessTokens.filter((t) => !t.revoked).map((t) => t.id);
|
|
1255
|
+
const refreshToRevokeIds = refreshTokens.filter((t) => !t.revoked && !t.scopes?.includes("offline_access")).map((t) => t.id);
|
|
1256
|
+
const revocations = await Promise.allSettled([accessToRevokeIds.length > 0 ? ctx.context.adapter.updateMany({
|
|
1257
|
+
model: "oauthAccessToken",
|
|
1258
|
+
where: [{
|
|
1259
|
+
field: "id",
|
|
1260
|
+
operator: "in",
|
|
1261
|
+
value: accessToRevokeIds
|
|
1262
|
+
}],
|
|
1263
|
+
update: { revoked: revokedAt }
|
|
1264
|
+
}) : Promise.resolve(), refreshToRevokeIds.length > 0 ? ctx.context.adapter.updateMany({
|
|
1265
|
+
model: "oauthRefreshToken",
|
|
1266
|
+
where: [{
|
|
1267
|
+
field: "id",
|
|
1268
|
+
operator: "in",
|
|
1269
|
+
value: refreshToRevokeIds
|
|
1270
|
+
}],
|
|
1271
|
+
update: { revoked: revokedAt }
|
|
1272
|
+
}) : Promise.resolve()]);
|
|
1273
|
+
for (const result of revocations) if (result.status === "rejected") logger.error("back-channel logout: token revocation update failed", result.reason);
|
|
1274
|
+
const eligibleClients = opts.disableJwtPlugin ? [] : clients.filter((c) => Boolean(c.backchannelLogoutUri) && !c.disabled);
|
|
1275
|
+
if (eligibleClients.length === 0) return null;
|
|
1276
|
+
return {
|
|
1277
|
+
sessionId,
|
|
1278
|
+
targets: await Promise.all(eligibleClients.map(async (client) => ({
|
|
1279
|
+
client,
|
|
1280
|
+
sub: await resolveSubjectIdentifier(userId, client, opts)
|
|
1281
|
+
})))
|
|
1282
|
+
};
|
|
1283
|
+
} catch (error) {
|
|
1284
|
+
logger.error("back-channel logout revocation failed", error);
|
|
1285
|
+
return null;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* Asynchronous phase: sign one Logout Token per target client and POST it to
|
|
1290
|
+
* the registered `backchannel_logout_uri`. The caller hands this to
|
|
1291
|
+
* `runInBackgroundOrAwait`, so when a background handler is configured (Vercel
|
|
1292
|
+
* `waitUntil`, Cloudflare `ctx.waitUntil`) it runs after the response; without
|
|
1293
|
+
* one it completes inline so delivery is not lost on request teardown.
|
|
1294
|
+
*
|
|
1295
|
+
* Spec §2.5: "the OP SHOULD NOT retransmit", so each RP gets a single attempt
|
|
1296
|
+
* within `BACKCHANNEL_DISPATCH_TIMEOUT_MS`. Every per-client failure (fetch
|
|
1297
|
+
* error, non-2xx response, signing error, subject resolution error) is
|
|
1298
|
+
* logged; none of them can reject the outer promise.
|
|
1299
|
+
*/
|
|
1300
|
+
async function deliverBackchannelLogoutTokens(ctx, plan) {
|
|
1301
|
+
const logger = ctx.context.logger;
|
|
1302
|
+
const jwtPluginOptions = getJwtPlugin(ctx.context)?.options;
|
|
1303
|
+
const iss = jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL;
|
|
1304
|
+
const iat = Math.floor(Date.now() / 1e3);
|
|
1305
|
+
const exp = iat + LOGOUT_TOKEN_LIFETIME_SECONDS;
|
|
1306
|
+
const resolvedKey = jwtPluginOptions?.jwt?.sign ? null : await resolveSigningKey(ctx, jwtPluginOptions);
|
|
1307
|
+
await Promise.allSettled(plan.targets.map(async ({ client, sub }) => {
|
|
1308
|
+
try {
|
|
1309
|
+
const jti = generateRandomString(32, "a-z", "A-Z", "0-9");
|
|
1310
|
+
const token = await signLogoutToken(ctx, jwtPluginOptions, resolvedKey, {
|
|
1311
|
+
iss,
|
|
1312
|
+
aud: client.clientId,
|
|
1313
|
+
sub,
|
|
1314
|
+
sid: plan.sessionId,
|
|
1315
|
+
iat,
|
|
1316
|
+
exp,
|
|
1317
|
+
jti
|
|
1318
|
+
});
|
|
1319
|
+
const response = await fetch(client.backchannelLogoutUri, {
|
|
1320
|
+
method: "POST",
|
|
1321
|
+
headers: {
|
|
1322
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
1323
|
+
Accept: "application/json"
|
|
1324
|
+
},
|
|
1325
|
+
body: new URLSearchParams({ logout_token: token }),
|
|
1326
|
+
signal: AbortSignal.timeout(BACKCHANNEL_DISPATCH_TIMEOUT_MS),
|
|
1327
|
+
redirect: "error"
|
|
1328
|
+
});
|
|
1329
|
+
if (response.status !== 200 && response.status !== 204) logger.warn(`back-channel logout to client ${client.clientId} returned ${response.status}`);
|
|
1330
|
+
} catch (error) {
|
|
1331
|
+
logger.warn(`back-channel logout to client ${client.clientId} failed`, error);
|
|
1332
|
+
}
|
|
1333
|
+
}));
|
|
1334
|
+
}
|
|
1335
|
+
/**
|
|
1336
|
+
* RP-Initiated Logout (OIDC RP-Initiated Logout 1.0). The RP presents a signed
|
|
1337
|
+
* `id_token_hint`; after verification, the OP terminates the matching session
|
|
1338
|
+
* and optionally redirects to `post_logout_redirect_uri`.
|
|
1339
|
+
*
|
|
1340
|
+
* Session termination goes through `internalAdapter.deleteSession`, which fires
|
|
1341
|
+
* `session.delete.before` so the hook drives revocation and back-channel
|
|
1342
|
+
* notifications to every RP with tokens on the session.
|
|
1073
1343
|
*
|
|
1074
1344
|
* @see https://openid.net/specs/openid-connect-rpinitiated-1_0.html
|
|
1075
1345
|
*/
|
|
@@ -1117,12 +1387,10 @@ async function rpInitiatedLogoutEndpoint(ctx, opts) {
|
|
|
1117
1387
|
});
|
|
1118
1388
|
const secret = await decryptStoredClientSecret(ctx, opts.storeClientSecret, clientSecret);
|
|
1119
1389
|
const { payload } = await compactVerify(id_token_hint, new TextEncoder().encode(secret));
|
|
1120
|
-
|
|
1121
|
-
idTokenPayload = JSON.parse(idToken);
|
|
1390
|
+
idTokenPayload = JSON.parse(new TextDecoder().decode(payload));
|
|
1122
1391
|
} else {
|
|
1123
1392
|
const { payload } = await compactVerify(id_token_hint, createLocalJWKSet(await getJwks(id_token_hint, { jwksFetch: jwksUrl })));
|
|
1124
|
-
|
|
1125
|
-
idTokenPayload = JSON.parse(idToken);
|
|
1393
|
+
idTokenPayload = JSON.parse(new TextDecoder().decode(payload));
|
|
1126
1394
|
}
|
|
1127
1395
|
if (!idTokenPayload) throw new APIError$1("INTERNAL_SERVER_ERROR", {
|
|
1128
1396
|
error_description: "missing payload",
|
|
@@ -1154,18 +1422,13 @@ async function rpInitiatedLogoutEndpoint(ctx, opts) {
|
|
|
1154
1422
|
value: sessionId
|
|
1155
1423
|
}]
|
|
1156
1424
|
});
|
|
1157
|
-
session?.token
|
|
1425
|
+
if (session?.token) await ctx.context.internalAdapter.deleteSession(session.token);
|
|
1426
|
+
else if (session) await ctx.context.adapter.delete({
|
|
1158
1427
|
model: "session",
|
|
1159
1428
|
where: [{
|
|
1160
1429
|
field: "id",
|
|
1161
1430
|
value: session.id
|
|
1162
1431
|
}]
|
|
1163
|
-
}) : await ctx.context.adapter.delete({
|
|
1164
|
-
model: "session",
|
|
1165
|
-
where: [{
|
|
1166
|
-
field: "id",
|
|
1167
|
-
value: sessionId
|
|
1168
|
-
}]
|
|
1169
1432
|
});
|
|
1170
1433
|
} catch {}
|
|
1171
1434
|
if (post_logout_redirect_uri) {
|
|
@@ -1287,6 +1550,28 @@ const publicSessionMiddleware = (opts) => createAuthMiddleware(async (ctx) => {
|
|
|
1287
1550
|
if (!await verifyOAuthQueryParams(query, ctx.context.secret)) throw new APIError("UNAUTHORIZED", { error: "invalid_signature" });
|
|
1288
1551
|
});
|
|
1289
1552
|
//#endregion
|
|
1553
|
+
//#region src/oauthClient/privileges.ts
|
|
1554
|
+
/**
|
|
1555
|
+
* Authorizes a client action against the configured `clientPrivileges` hook.
|
|
1556
|
+
*
|
|
1557
|
+
* This is the single authorization helper for every OAuth client mutation. The
|
|
1558
|
+
* create path enforces it at the shared creation chokepoint so that no
|
|
1559
|
+
* registration route can reach client persistence without it.
|
|
1560
|
+
*
|
|
1561
|
+
* @throws APIError UNAUTHORIZED when there is no session or the hook denies the action.
|
|
1562
|
+
* @throws APIError BAD_REQUEST when the request carries no headers.
|
|
1563
|
+
*/
|
|
1564
|
+
async function assertClientPrivileges(ctx, session, opts, action) {
|
|
1565
|
+
if (!session) throw new APIError("UNAUTHORIZED");
|
|
1566
|
+
if (!ctx.headers) throw new APIError("BAD_REQUEST");
|
|
1567
|
+
if (opts.clientPrivileges && !await opts.clientPrivileges({
|
|
1568
|
+
headers: ctx.headers,
|
|
1569
|
+
action,
|
|
1570
|
+
session: session.session,
|
|
1571
|
+
user: session.user
|
|
1572
|
+
})) throw new APIError("UNAUTHORIZED");
|
|
1573
|
+
}
|
|
1574
|
+
//#endregion
|
|
1290
1575
|
//#region src/register.ts
|
|
1291
1576
|
/**
|
|
1292
1577
|
* Resolves the auth method and type for unauthenticated DCR.
|
|
@@ -1418,10 +1703,45 @@ async function checkOAuthClient(client, opts, settings) {
|
|
|
1418
1703
|
error: "invalid_client_metadata",
|
|
1419
1704
|
error_description: "jwks and jwks_uri are only allowed with private_key_jwt authentication"
|
|
1420
1705
|
});
|
|
1706
|
+
if (client.backchannel_logout_uri !== void 0) {
|
|
1707
|
+
if (opts.disableJwtPlugin) throw new APIError("BAD_REQUEST", {
|
|
1708
|
+
error: "invalid_client_metadata",
|
|
1709
|
+
error_description: "backchannel_logout_uri requires the jwt plugin (disableJwtPlugin must be false)"
|
|
1710
|
+
});
|
|
1711
|
+
let url;
|
|
1712
|
+
try {
|
|
1713
|
+
url = new URL(client.backchannel_logout_uri);
|
|
1714
|
+
} catch {
|
|
1715
|
+
throw new APIError("BAD_REQUEST", {
|
|
1716
|
+
error: "invalid_client_metadata",
|
|
1717
|
+
error_description: "backchannel_logout_uri must be an absolute URL"
|
|
1718
|
+
});
|
|
1719
|
+
}
|
|
1720
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") throw new APIError("BAD_REQUEST", {
|
|
1721
|
+
error: "invalid_client_metadata",
|
|
1722
|
+
error_description: "backchannel_logout_uri must use http or https"
|
|
1723
|
+
});
|
|
1724
|
+
if (client.backchannel_logout_uri.includes("#")) throw new APIError("BAD_REQUEST", {
|
|
1725
|
+
error: "invalid_client_metadata",
|
|
1726
|
+
error_description: "backchannel_logout_uri must not include a fragment component"
|
|
1727
|
+
});
|
|
1728
|
+
const loopback = isLoopbackHost(url.hostname);
|
|
1729
|
+
if (!isPublic && url.protocol !== "https:" && !loopback) throw new APIError("BAD_REQUEST", {
|
|
1730
|
+
error: "invalid_client_metadata",
|
|
1731
|
+
error_description: "backchannel_logout_uri must use https for confidential clients"
|
|
1732
|
+
});
|
|
1733
|
+
if (isPrivateHostname(url.hostname) && !loopback) throw new APIError("BAD_REQUEST", {
|
|
1734
|
+
error: "invalid_client_metadata",
|
|
1735
|
+
error_description: "backchannel_logout_uri must not point to a private or reserved address"
|
|
1736
|
+
});
|
|
1737
|
+
}
|
|
1421
1738
|
}
|
|
1422
1739
|
async function createOAuthClientEndpoint(ctx, opts, settings) {
|
|
1423
1740
|
const body = ctx.body;
|
|
1424
1741
|
const session = await getSessionFromCtx(ctx);
|
|
1742
|
+
if (settings.isRegister) {
|
|
1743
|
+
if (session) await assertClientPrivileges(ctx, session, opts, "create");
|
|
1744
|
+
} else await assertClientPrivileges(ctx, session, opts, "create");
|
|
1425
1745
|
const isPublic = body.token_endpoint_auth_method === "none";
|
|
1426
1746
|
const isPrivateKeyJwt = body.token_endpoint_auth_method === "private_key_jwt";
|
|
1427
1747
|
await checkOAuthClient(ctx.body, opts, {
|
|
@@ -1473,7 +1793,7 @@ async function createOAuthClientEndpoint(ctx, opts, settings) {
|
|
|
1473
1793
|
* @returns
|
|
1474
1794
|
*/
|
|
1475
1795
|
function oauthToSchema(input) {
|
|
1476
|
-
const { client_id: clientId, client_secret: clientSecret, client_secret_expires_at: _expiresAt, scope: _scope, user_id: userId, client_id_issued_at: _createdAt, client_name: name, client_uri: uri, logo_uri: icon, contacts, tos_uri: tos, policy_uri: policy, jwks: inputJwks, jwks_uri: jwksUri, software_id: softwareId, software_version: softwareVersion, software_statement: softwareStatement, redirect_uris: redirectUris, post_logout_redirect_uris: postLogoutRedirectUris, token_endpoint_auth_method: tokenEndpointAuthMethod, grant_types: grantTypes, response_types: responseTypes, public: _public, type, disabled, skip_consent: skipConsent, enable_end_session: enableEndSession, require_pkce: requirePKCE, subject_type: subjectType, reference_id: referenceId, metadata: inputMetadata, ...rest } = input;
|
|
1796
|
+
const { client_id: clientId, client_secret: clientSecret, client_secret_expires_at: _expiresAt, scope: _scope, user_id: userId, client_id_issued_at: _createdAt, client_name: name, client_uri: uri, logo_uri: icon, contacts, tos_uri: tos, policy_uri: policy, jwks: inputJwks, jwks_uri: jwksUri, software_id: softwareId, software_version: softwareVersion, software_statement: softwareStatement, redirect_uris: redirectUris, post_logout_redirect_uris: postLogoutRedirectUris, backchannel_logout_uri: backchannelLogoutUri, backchannel_logout_session_required: backchannelLogoutSessionRequired, token_endpoint_auth_method: tokenEndpointAuthMethod, grant_types: grantTypes, response_types: responseTypes, public: _public, type, disabled, skip_consent: skipConsent, enable_end_session: enableEndSession, require_pkce: requirePKCE, subject_type: subjectType, reference_id: referenceId, metadata: inputMetadata, ...rest } = input;
|
|
1477
1797
|
const expiresAt = _expiresAt ? /* @__PURE__ */ new Date(_expiresAt * 1e3) : void 0;
|
|
1478
1798
|
const createdAt = _createdAt ? /* @__PURE__ */ new Date(_createdAt * 1e3) : void 0;
|
|
1479
1799
|
const scopes = _scope?.split(" ");
|
|
@@ -1501,6 +1821,8 @@ function oauthToSchema(input) {
|
|
|
1501
1821
|
softwareStatement,
|
|
1502
1822
|
redirectUris,
|
|
1503
1823
|
postLogoutRedirectUris,
|
|
1824
|
+
backchannelLogoutUri,
|
|
1825
|
+
backchannelLogoutSessionRequired,
|
|
1504
1826
|
tokenEndpointAuthMethod,
|
|
1505
1827
|
grantTypes,
|
|
1506
1828
|
responseTypes,
|
|
@@ -1523,7 +1845,7 @@ function oauthToSchema(input) {
|
|
|
1523
1845
|
* @returns
|
|
1524
1846
|
*/
|
|
1525
1847
|
function schemaToOAuth(input) {
|
|
1526
|
-
const { clientId, clientSecret, disabled, scopes, userId, createdAt, updatedAt: _updatedAt, expiresAt, name, uri, icon, contacts, tos, policy, softwareId, softwareVersion, softwareStatement, redirectUris, postLogoutRedirectUris, tokenEndpointAuthMethod, grantTypes, responseTypes, public: _public, type, jwks, jwksUri, skipConsent, enableEndSession, requirePKCE, subjectType, referenceId, metadata } = input;
|
|
1848
|
+
const { clientId, clientSecret, disabled, scopes, userId, createdAt, updatedAt: _updatedAt, expiresAt, name, uri, icon, contacts, tos, policy, softwareId, softwareVersion, softwareStatement, redirectUris, postLogoutRedirectUris, backchannelLogoutUri, backchannelLogoutSessionRequired, tokenEndpointAuthMethod, grantTypes, responseTypes, public: _public, type, jwks, jwksUri, skipConsent, enableEndSession, requirePKCE, subjectType, referenceId, metadata } = input;
|
|
1527
1849
|
const _expiresAt = expiresAt ? Math.round(new Date(expiresAt).getTime() / 1e3) : void 0;
|
|
1528
1850
|
const _createdAt = createdAt ? Math.round(new Date(createdAt).getTime() / 1e3) : void 0;
|
|
1529
1851
|
const _scopes = scopes?.join(" ");
|
|
@@ -1548,6 +1870,8 @@ function schemaToOAuth(input) {
|
|
|
1548
1870
|
software_statement: softwareStatement ?? void 0,
|
|
1549
1871
|
redirect_uris: redirectUris ?? [],
|
|
1550
1872
|
post_logout_redirect_uris: postLogoutRedirectUris ?? void 0,
|
|
1873
|
+
backchannel_logout_uri: backchannelLogoutUri ?? void 0,
|
|
1874
|
+
backchannel_logout_session_required: backchannelLogoutSessionRequired ?? void 0,
|
|
1551
1875
|
token_endpoint_auth_method: tokenEndpointAuthMethod ?? void 0,
|
|
1552
1876
|
grant_types: grantTypes ?? void 0,
|
|
1553
1877
|
response_types: responseTypes ?? void 0,
|
|
@@ -1767,16 +2091,6 @@ async function rotateClientSecretEndpoint(ctx, opts) {
|
|
|
1767
2091
|
clientSecret: (opts.prefix?.clientSecret ?? "") + clientSecret
|
|
1768
2092
|
});
|
|
1769
2093
|
}
|
|
1770
|
-
async function assertClientPrivileges(ctx, session, opts, action) {
|
|
1771
|
-
if (!session) throw new APIError("UNAUTHORIZED");
|
|
1772
|
-
if (!ctx.headers) throw new APIError("BAD_REQUEST");
|
|
1773
|
-
if (opts.clientPrivileges && !await opts.clientPrivileges({
|
|
1774
|
-
headers: ctx.headers,
|
|
1775
|
-
action,
|
|
1776
|
-
session: session.session,
|
|
1777
|
-
user: session.user
|
|
1778
|
-
})) throw new APIError("UNAUTHORIZED");
|
|
1779
|
-
}
|
|
1780
2094
|
//#endregion
|
|
1781
2095
|
//#region src/oauthClient/index.ts
|
|
1782
2096
|
const adminCreateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/create-client", {
|
|
@@ -1794,13 +2108,15 @@ const adminCreateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/creat
|
|
|
1794
2108
|
software_version: z.string().optional(),
|
|
1795
2109
|
software_statement: z.string().optional(),
|
|
1796
2110
|
post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
|
|
2111
|
+
backchannel_logout_uri: SafeUrlSchema.optional(),
|
|
2112
|
+
backchannel_logout_session_required: z.boolean().optional(),
|
|
1797
2113
|
token_endpoint_auth_method: z.enum([
|
|
1798
2114
|
"none",
|
|
1799
2115
|
"client_secret_basic",
|
|
1800
2116
|
"client_secret_post",
|
|
1801
2117
|
"private_key_jwt"
|
|
1802
2118
|
]).default("client_secret_basic").optional(),
|
|
1803
|
-
jwks: z.union([z.array(z.record(z.string(), z.unknown())), z.object({ keys: z.array(z.record(z.string(), z.unknown())) })]).optional(),
|
|
2119
|
+
jwks: z.union([z.array(z.record(z.string(), z.unknown())).min(1), z.object({ keys: z.array(z.record(z.string(), z.unknown())).min(1) })]).optional(),
|
|
1804
2120
|
jwks_uri: z.string().optional(),
|
|
1805
2121
|
grant_types: z.array(z.enum([
|
|
1806
2122
|
"authorization_code",
|
|
@@ -1962,7 +2278,6 @@ const adminCreateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/creat
|
|
|
1962
2278
|
}
|
|
1963
2279
|
}
|
|
1964
2280
|
}, async (ctx) => {
|
|
1965
|
-
await assertClientPrivileges(ctx, await getSessionFromCtx(ctx), opts, "create");
|
|
1966
2281
|
return createOAuthClientEndpoint(ctx, opts, { isRegister: false });
|
|
1967
2282
|
});
|
|
1968
2283
|
const createOAuthClient = (opts) => createAuthEndpoint("/oauth2/create-client", {
|
|
@@ -1981,13 +2296,15 @@ const createOAuthClient = (opts) => createAuthEndpoint("/oauth2/create-client",
|
|
|
1981
2296
|
software_version: z.string().optional(),
|
|
1982
2297
|
software_statement: z.string().optional(),
|
|
1983
2298
|
post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
|
|
2299
|
+
backchannel_logout_uri: SafeUrlSchema.optional(),
|
|
2300
|
+
backchannel_logout_session_required: z.boolean().optional(),
|
|
1984
2301
|
token_endpoint_auth_method: z.enum([
|
|
1985
2302
|
"none",
|
|
1986
2303
|
"client_secret_basic",
|
|
1987
2304
|
"client_secret_post",
|
|
1988
2305
|
"private_key_jwt"
|
|
1989
2306
|
]).default("client_secret_basic").optional(),
|
|
1990
|
-
jwks: z.union([z.array(z.record(z.string(), z.unknown())), z.object({ keys: z.array(z.record(z.string(), z.unknown())) })]).optional(),
|
|
2307
|
+
jwks: z.union([z.array(z.record(z.string(), z.unknown())).min(1), z.object({ keys: z.array(z.record(z.string(), z.unknown())).min(1) })]).optional(),
|
|
1991
2308
|
jwks_uri: z.string().optional(),
|
|
1992
2309
|
grant_types: z.array(z.enum([
|
|
1993
2310
|
"authorization_code",
|
|
@@ -2135,7 +2452,6 @@ const createOAuthClient = (opts) => createAuthEndpoint("/oauth2/create-client",
|
|
|
2135
2452
|
} }
|
|
2136
2453
|
} }
|
|
2137
2454
|
}, async (ctx) => {
|
|
2138
|
-
await assertClientPrivileges(ctx, await getSessionFromCtx(ctx), opts, "create");
|
|
2139
2455
|
return createOAuthClientEndpoint(ctx, opts, { isRegister: false });
|
|
2140
2456
|
});
|
|
2141
2457
|
const getOAuthClient = (opts) => createAuthEndpoint("/oauth2/get-client", {
|
|
@@ -2191,6 +2507,8 @@ const adminUpdateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/updat
|
|
|
2191
2507
|
software_version: z.string().optional(),
|
|
2192
2508
|
software_statement: z.string().optional(),
|
|
2193
2509
|
post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
|
|
2510
|
+
backchannel_logout_uri: SafeUrlSchema.optional(),
|
|
2511
|
+
backchannel_logout_session_required: z.boolean().optional(),
|
|
2194
2512
|
grant_types: z.array(z.enum([
|
|
2195
2513
|
"authorization_code",
|
|
2196
2514
|
"client_credentials",
|
|
@@ -2233,6 +2551,8 @@ const updateOAuthClient = (opts) => createAuthEndpoint("/oauth2/update-client",
|
|
|
2233
2551
|
software_version: z.string().optional(),
|
|
2234
2552
|
software_statement: z.string().optional(),
|
|
2235
2553
|
post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
|
|
2554
|
+
backchannel_logout_uri: SafeUrlSchema.optional(),
|
|
2555
|
+
backchannel_logout_session_required: z.boolean().optional(),
|
|
2236
2556
|
grant_types: z.array(z.enum([
|
|
2237
2557
|
"authorization_code",
|
|
2238
2558
|
"client_credentials",
|
|
@@ -2339,12 +2659,12 @@ async function updateConsentEndpoint(ctx, opts) {
|
|
|
2339
2659
|
error_description: "no consent",
|
|
2340
2660
|
error: "not_found"
|
|
2341
2661
|
});
|
|
2662
|
+
if (consent.userId !== session.user.id) throw new APIError("UNAUTHORIZED");
|
|
2342
2663
|
const client = await getClient(ctx, opts, consent.clientId);
|
|
2343
|
-
if (!
|
|
2344
|
-
error_description: "
|
|
2664
|
+
if (!client) throw new APIError("NOT_FOUND", {
|
|
2665
|
+
error_description: "client not found",
|
|
2345
2666
|
error: "not_found"
|
|
2346
2667
|
});
|
|
2347
|
-
if (consent.userId !== session.user.id) throw new APIError("UNAUTHORIZED");
|
|
2348
2668
|
const allowedScopes = client?.scopes ?? opts.scopes ?? [];
|
|
2349
2669
|
const updates = ctx.body.update;
|
|
2350
2670
|
const scopes = updates.scopes;
|
|
@@ -2412,7 +2732,14 @@ const deleteOAuthConsent = (opts) => createAuthEndpoint("/oauth2/delete-consent"
|
|
|
2412
2732
|
*/
|
|
2413
2733
|
/**
|
|
2414
2734
|
* Revokes a JWT access token against the configured JWKs.
|
|
2415
|
-
*
|
|
2735
|
+
*
|
|
2736
|
+
* A JWT access token is self-contained and never stored, so there is nothing to
|
|
2737
|
+
* delete. Once the token is confirmed to be a valid JWT for this server, the
|
|
2738
|
+
* endpoint reports `unsupported_token_type` (RFC 7009 §2.2.1) instead of a
|
|
2739
|
+
* silent success, so callers can tell that no server-side revocation happened.
|
|
2740
|
+
* An expired or wrong-audience JWT is already inactive and still resolves as a
|
|
2741
|
+
* successful no-op. Session-bound tokens (carrying `sid`) are cut off early by
|
|
2742
|
+
* the session-liveness check in introspection and userinfo.
|
|
2416
2743
|
*/
|
|
2417
2744
|
async function revokeJwtAccessToken(ctx, opts, token) {
|
|
2418
2745
|
const jwtPlugin = opts.disableJwtPlugin ? void 0 : getJwtPlugin(ctx.context);
|
|
@@ -2439,6 +2766,10 @@ async function revokeJwtAccessToken(ctx, opts, token) {
|
|
|
2439
2766
|
}
|
|
2440
2767
|
throw new Error(error);
|
|
2441
2768
|
}
|
|
2769
|
+
throw new APIError$1("BAD_REQUEST", {
|
|
2770
|
+
error_description: "JWT access tokens are self-contained and cannot be revoked server-side",
|
|
2771
|
+
error: "unsupported_token_type"
|
|
2772
|
+
});
|
|
2442
2773
|
}
|
|
2443
2774
|
/**
|
|
2444
2775
|
* Searches for an opaque access token in the database and validates it
|
|
@@ -2492,16 +2823,7 @@ async function revokeRefreshToken(ctx, opts, token, clientId) {
|
|
|
2492
2823
|
error: "invalid_request"
|
|
2493
2824
|
});
|
|
2494
2825
|
if (refreshToken.revoked) {
|
|
2495
|
-
await ctx.
|
|
2496
|
-
model: "oauthRefreshToken",
|
|
2497
|
-
where: [{
|
|
2498
|
-
field: "clientId",
|
|
2499
|
-
value: clientId
|
|
2500
|
-
}, {
|
|
2501
|
-
field: "userId",
|
|
2502
|
-
value: refreshToken.userId
|
|
2503
|
-
}]
|
|
2504
|
-
});
|
|
2826
|
+
await invalidateRefreshFamily(ctx, clientId, refreshToken.userId);
|
|
2505
2827
|
throw new APIError$1("BAD_REQUEST", {
|
|
2506
2828
|
error_description: "refresh token revoked",
|
|
2507
2829
|
error: "invalid_request"
|
|
@@ -2509,20 +2831,31 @@ async function revokeRefreshToken(ctx, opts, token, clientId) {
|
|
|
2509
2831
|
}
|
|
2510
2832
|
if (!refreshToken.clientId || refreshToken.clientId !== clientId) return null;
|
|
2511
2833
|
const iat = Math.floor(Date.now() / 1e3);
|
|
2512
|
-
await
|
|
2513
|
-
model: "oauthAccessToken",
|
|
2514
|
-
where: [{
|
|
2515
|
-
field: "refreshId",
|
|
2516
|
-
value: refreshToken.id
|
|
2517
|
-
}]
|
|
2518
|
-
}), ctx.context.adapter.update({
|
|
2834
|
+
if (!await ctx.context.adapter.update({
|
|
2519
2835
|
model: "oauthRefreshToken",
|
|
2520
2836
|
where: [{
|
|
2521
2837
|
field: "id",
|
|
2522
2838
|
value: refreshToken.id
|
|
2839
|
+
}, {
|
|
2840
|
+
field: "revoked",
|
|
2841
|
+
operator: "eq",
|
|
2842
|
+
value: null
|
|
2523
2843
|
}],
|
|
2524
2844
|
update: { revoked: /* @__PURE__ */ new Date(iat * 1e3) }
|
|
2525
|
-
})
|
|
2845
|
+
})) {
|
|
2846
|
+
await invalidateRefreshFamily(ctx, clientId, refreshToken.userId);
|
|
2847
|
+
throw new APIError$1("BAD_REQUEST", {
|
|
2848
|
+
error_description: "refresh token revoked",
|
|
2849
|
+
error: "invalid_request"
|
|
2850
|
+
});
|
|
2851
|
+
}
|
|
2852
|
+
await ctx.context.adapter.deleteMany({
|
|
2853
|
+
model: "oauthAccessToken",
|
|
2854
|
+
where: [{
|
|
2855
|
+
field: "refreshId",
|
|
2856
|
+
value: refreshToken.id
|
|
2857
|
+
}]
|
|
2858
|
+
});
|
|
2526
2859
|
}
|
|
2527
2860
|
/**
|
|
2528
2861
|
* We don't know the access token format so we try to validate it
|
|
@@ -2532,7 +2865,9 @@ async function revokeAccessToken(ctx, opts, clientId, token) {
|
|
|
2532
2865
|
try {
|
|
2533
2866
|
return await revokeJwtAccessToken(ctx, opts, token);
|
|
2534
2867
|
} catch (err) {
|
|
2535
|
-
if (err instanceof APIError$1) {
|
|
2868
|
+
if (err instanceof APIError$1) {
|
|
2869
|
+
if (err.body?.error === "unsupported_token_type") throw err;
|
|
2870
|
+
} else if (err instanceof Error) throw err;
|
|
2536
2871
|
else throw new Error(err);
|
|
2537
2872
|
}
|
|
2538
2873
|
try {
|
|
@@ -2565,7 +2900,7 @@ async function revokeEndpoint(ctx, opts) {
|
|
|
2565
2900
|
return await revokeAccessToken(ctx, opts, client.clientId, token);
|
|
2566
2901
|
} catch (error) {
|
|
2567
2902
|
if (error instanceof APIError$1) {
|
|
2568
|
-
if (token_type_hint === "access_token") throw error;
|
|
2903
|
+
if (token_type_hint === "access_token" || error.body?.error === "unsupported_token_type") throw error;
|
|
2569
2904
|
} else if (error instanceof Error) throw error;
|
|
2570
2905
|
else throw new Error(error);
|
|
2571
2906
|
}
|
|
@@ -2583,6 +2918,7 @@ async function revokeEndpoint(ctx, opts) {
|
|
|
2583
2918
|
});
|
|
2584
2919
|
} catch (error) {
|
|
2585
2920
|
if (error instanceof APIError$1) {
|
|
2921
|
+
if (error.body?.error === "unsupported_token_type") throw error;
|
|
2586
2922
|
if (error.name === "BAD_REQUEST") return null;
|
|
2587
2923
|
throw error;
|
|
2588
2924
|
} else if (error instanceof Error) {
|
|
@@ -2691,6 +3027,14 @@ const schema = {
|
|
|
2691
3027
|
type: "string[]",
|
|
2692
3028
|
required: false
|
|
2693
3029
|
},
|
|
3030
|
+
backchannelLogoutUri: {
|
|
3031
|
+
type: "string",
|
|
3032
|
+
required: false
|
|
3033
|
+
},
|
|
3034
|
+
backchannelLogoutSessionRequired: {
|
|
3035
|
+
type: "boolean",
|
|
3036
|
+
required: false
|
|
3037
|
+
},
|
|
2694
3038
|
tokenEndpointAuthMethod: {
|
|
2695
3039
|
type: "string",
|
|
2696
3040
|
required: false
|
|
@@ -2736,7 +3080,8 @@ const schema = {
|
|
|
2736
3080
|
oauthRefreshToken: { fields: {
|
|
2737
3081
|
token: {
|
|
2738
3082
|
type: "string",
|
|
2739
|
-
required: true
|
|
3083
|
+
required: true,
|
|
3084
|
+
unique: true
|
|
2740
3085
|
},
|
|
2741
3086
|
clientId: {
|
|
2742
3087
|
type: "string",
|
|
@@ -2770,6 +3115,10 @@ const schema = {
|
|
|
2770
3115
|
type: "string",
|
|
2771
3116
|
required: false
|
|
2772
3117
|
},
|
|
3118
|
+
resources: {
|
|
3119
|
+
type: "string[]",
|
|
3120
|
+
required: false
|
|
3121
|
+
},
|
|
2773
3122
|
expiresAt: { type: "date" },
|
|
2774
3123
|
createdAt: { type: "date" },
|
|
2775
3124
|
revoked: {
|
|
@@ -2824,6 +3173,10 @@ const schema = {
|
|
|
2824
3173
|
type: "string",
|
|
2825
3174
|
required: false
|
|
2826
3175
|
},
|
|
3176
|
+
resources: {
|
|
3177
|
+
type: "string[]",
|
|
3178
|
+
required: false
|
|
3179
|
+
},
|
|
2827
3180
|
refreshId: {
|
|
2828
3181
|
type: "string",
|
|
2829
3182
|
required: false,
|
|
@@ -2835,6 +3188,10 @@ const schema = {
|
|
|
2835
3188
|
},
|
|
2836
3189
|
expiresAt: { type: "date" },
|
|
2837
3190
|
createdAt: { type: "date" },
|
|
3191
|
+
revoked: {
|
|
3192
|
+
type: "date",
|
|
3193
|
+
required: false
|
|
3194
|
+
},
|
|
2838
3195
|
scopes: {
|
|
2839
3196
|
type: "string[]",
|
|
2840
3197
|
required: true
|
|
@@ -2866,6 +3223,10 @@ const schema = {
|
|
|
2866
3223
|
type: "string",
|
|
2867
3224
|
required: false
|
|
2868
3225
|
},
|
|
3226
|
+
resources: {
|
|
3227
|
+
type: "string[]",
|
|
3228
|
+
required: false
|
|
3229
|
+
},
|
|
2869
3230
|
scopes: {
|
|
2870
3231
|
type: "string[]",
|
|
2871
3232
|
required: true
|
|
@@ -2873,12 +3234,25 @@ const schema = {
|
|
|
2873
3234
|
createdAt: { type: "date" },
|
|
2874
3235
|
updatedAt: { type: "date" }
|
|
2875
3236
|
}
|
|
3237
|
+
},
|
|
3238
|
+
oauthClientAssertion: {
|
|
3239
|
+
modelName: "oauthClientAssertion",
|
|
3240
|
+
fields: { expiresAt: {
|
|
3241
|
+
type: "date",
|
|
3242
|
+
required: true
|
|
3243
|
+
} }
|
|
2876
3244
|
}
|
|
2877
3245
|
};
|
|
2878
3246
|
//#endregion
|
|
2879
3247
|
//#region src/oauth.ts
|
|
2880
3248
|
const oAuthState = defineRequestState(() => null);
|
|
2881
3249
|
const getOAuthProviderState = oAuthState.get;
|
|
3250
|
+
const signedQueryIssuedAtMsKey = "signedQueryIssuedAtMs";
|
|
3251
|
+
function getServerContextSignedQueryIssuedAt(value) {
|
|
3252
|
+
const issuedAtMs = typeof value === "number" ? value : typeof value === "string" ? Number(value) : void 0;
|
|
3253
|
+
if (!issuedAtMs || !Number.isFinite(issuedAtMs) || issuedAtMs <= 0) return;
|
|
3254
|
+
return new Date(issuedAtMs);
|
|
3255
|
+
}
|
|
2882
3256
|
/**
|
|
2883
3257
|
* oAuth 2.1 provider plugin for Better Auth.
|
|
2884
3258
|
*
|
|
@@ -2943,10 +3317,195 @@ const oauthProvider = (options) => {
|
|
|
2943
3317
|
if (opts.grantTypes && opts.grantTypes.includes("refresh_token") && !opts.grantTypes.includes("authorization_code")) throw new BetterAuthError("refresh_token grant requires authorization_code grant");
|
|
2944
3318
|
if (opts.disableJwtPlugin && (opts.storeClientSecret === "hashed" || typeof opts.storeClientSecret === "object" && "hash" in opts.storeClientSecret)) throw new BetterAuthError("unable to store hashed secrets because id tokens will be signed with secret");
|
|
2945
3319
|
if (!opts.disableJwtPlugin && (opts.storeClientSecret === "encrypted" || typeof opts.storeClientSecret === "object" && ("encrypt" in opts.storeClientSecret || "decrypt" in opts.storeClientSecret))) throw new BetterAuthError("encryption method not recommended, please use 'hashed' or the 'hash' function");
|
|
3320
|
+
const handleIssuerMetadataRequest = async (request, ctx) => {
|
|
3321
|
+
const requestPathname = new URL(request.url).pathname;
|
|
3322
|
+
const requestPath = ctx.options.advanced?.skipTrailingSlashes ? requestPathname.replace(/\/+$/, "") || "/" : requestPathname;
|
|
3323
|
+
const issuer = opts.disableJwtPlugin ? ctx.baseURL : getJwtPlugin(ctx)?.options?.jwt?.issuer ?? ctx.baseURL;
|
|
3324
|
+
let issuerPath = "/";
|
|
3325
|
+
try {
|
|
3326
|
+
issuerPath = new URL(issuer).pathname.replace(/\/$/, "") || "";
|
|
3327
|
+
} catch {
|
|
3328
|
+
issuerPath = new URL(ctx.baseURL).pathname.replace(/\/$/, "") || "";
|
|
3329
|
+
}
|
|
3330
|
+
const endpointCtx = { context: ctx };
|
|
3331
|
+
const authServerMetadataPaths = new Set([`/.well-known/oauth-authorization-server${issuerPath}`, `${issuerPath}/.well-known/oauth-authorization-server`]);
|
|
3332
|
+
const openIdConfigPath = `${issuerPath}/.well-known/openid-configuration`;
|
|
3333
|
+
const isAuthServerMetadataRequest = authServerMetadataPaths.has(requestPath);
|
|
3334
|
+
const isOpenIdConfigRequest = opts.scopes?.includes("openid") && requestPath === openIdConfigPath;
|
|
3335
|
+
const createMetadataResponse = (metadata) => {
|
|
3336
|
+
const response = metadataResponse(metadata);
|
|
3337
|
+
if (request.method === "HEAD") return new Response(null, {
|
|
3338
|
+
status: response.status,
|
|
3339
|
+
headers: response.headers
|
|
3340
|
+
});
|
|
3341
|
+
return response;
|
|
3342
|
+
};
|
|
3343
|
+
if (isAuthServerMetadataRequest || isOpenIdConfigRequest) {
|
|
3344
|
+
if (request.method !== "GET" && request.method !== "HEAD") return { response: new Response(null, {
|
|
3345
|
+
status: 405,
|
|
3346
|
+
headers: { Allow: "GET, HEAD" }
|
|
3347
|
+
}) };
|
|
3348
|
+
}
|
|
3349
|
+
if (isAuthServerMetadataRequest) {
|
|
3350
|
+
if (opts.scopes?.includes("openid")) return { response: createMetadataResponse(oidcServerMetadata(endpointCtx, opts)) };
|
|
3351
|
+
return { response: createMetadataResponse({
|
|
3352
|
+
...authServerMetadata(endpointCtx, opts.disableJwtPlugin ? void 0 : getJwtPlugin(ctx)?.options, {
|
|
3353
|
+
scopes_supported: opts.advertisedMetadata?.scopes_supported ?? opts.scopes,
|
|
3354
|
+
dynamic_client_registration_supported: opts.allowDynamicClientRegistration,
|
|
3355
|
+
public_client_supported: opts.allowUnauthenticatedClientRegistration || toClientDiscoveryArray(opts.clientDiscovery).length > 0,
|
|
3356
|
+
grant_types_supported: opts.grantTypes,
|
|
3357
|
+
jwt_disabled: opts.disableJwtPlugin
|
|
3358
|
+
}),
|
|
3359
|
+
...mergeDiscoveryMetadata(opts.clientDiscovery)
|
|
3360
|
+
}) };
|
|
3361
|
+
}
|
|
3362
|
+
if (isOpenIdConfigRequest) return { response: createMetadataResponse(oidcServerMetadata(endpointCtx, opts)) };
|
|
3363
|
+
};
|
|
3364
|
+
const oauth2AuthorizeEndpoint = createOAuthEndpoint("/oauth2/authorize", {
|
|
3365
|
+
method: "GET",
|
|
3366
|
+
query: authorizationQuerySchema,
|
|
3367
|
+
redirectOnError: authorizeRedirectOnError(opts),
|
|
3368
|
+
errorCodesByField: {
|
|
3369
|
+
response_type: { invalid: "unsupported_response_type" },
|
|
3370
|
+
resource: { invalid: "invalid_target" }
|
|
3371
|
+
},
|
|
3372
|
+
metadata: { openapi: {
|
|
3373
|
+
description: "Authorize an OAuth2 request",
|
|
3374
|
+
parameters: [
|
|
3375
|
+
{
|
|
3376
|
+
name: "response_type",
|
|
3377
|
+
in: "query",
|
|
3378
|
+
required: false,
|
|
3379
|
+
schema: { type: "string" },
|
|
3380
|
+
description: "OAuth2 response type (e.g., 'code')"
|
|
3381
|
+
},
|
|
3382
|
+
{
|
|
3383
|
+
name: "client_id",
|
|
3384
|
+
in: "query",
|
|
3385
|
+
required: true,
|
|
3386
|
+
schema: { type: "string" },
|
|
3387
|
+
description: "OAuth2 client ID"
|
|
3388
|
+
},
|
|
3389
|
+
{
|
|
3390
|
+
name: "redirect_uri",
|
|
3391
|
+
in: "query",
|
|
3392
|
+
required: false,
|
|
3393
|
+
schema: {
|
|
3394
|
+
type: "string",
|
|
3395
|
+
format: "uri"
|
|
3396
|
+
},
|
|
3397
|
+
description: "OAuth2 redirect URI"
|
|
3398
|
+
},
|
|
3399
|
+
{
|
|
3400
|
+
name: "scope",
|
|
3401
|
+
in: "query",
|
|
3402
|
+
required: false,
|
|
3403
|
+
schema: { type: "string" },
|
|
3404
|
+
description: "OAuth2 scopes (space-separated)"
|
|
3405
|
+
},
|
|
3406
|
+
{
|
|
3407
|
+
name: "state",
|
|
3408
|
+
in: "query",
|
|
3409
|
+
required: false,
|
|
3410
|
+
schema: { type: "string" },
|
|
3411
|
+
description: "OAuth2 state parameter"
|
|
3412
|
+
},
|
|
3413
|
+
{
|
|
3414
|
+
name: "request_uri",
|
|
3415
|
+
in: "query",
|
|
3416
|
+
required: false,
|
|
3417
|
+
schema: { type: "string" },
|
|
3418
|
+
description: "Pushed Authorization Request URI referencing stored parameters"
|
|
3419
|
+
},
|
|
3420
|
+
{
|
|
3421
|
+
name: "code_challenge",
|
|
3422
|
+
in: "query",
|
|
3423
|
+
required: false,
|
|
3424
|
+
schema: { type: "string" },
|
|
3425
|
+
description: "PKCE code challenge"
|
|
3426
|
+
},
|
|
3427
|
+
{
|
|
3428
|
+
name: "code_challenge_method",
|
|
3429
|
+
in: "query",
|
|
3430
|
+
required: false,
|
|
3431
|
+
schema: { type: "string" },
|
|
3432
|
+
description: "PKCE code challenge method"
|
|
3433
|
+
},
|
|
3434
|
+
{
|
|
3435
|
+
name: "nonce",
|
|
3436
|
+
in: "query",
|
|
3437
|
+
required: false,
|
|
3438
|
+
schema: { type: "string" },
|
|
3439
|
+
description: "OpenID Connect nonce"
|
|
3440
|
+
},
|
|
3441
|
+
{
|
|
3442
|
+
name: "max_age",
|
|
3443
|
+
in: "query",
|
|
3444
|
+
required: false,
|
|
3445
|
+
schema: {
|
|
3446
|
+
type: "integer",
|
|
3447
|
+
minimum: 0
|
|
3448
|
+
},
|
|
3449
|
+
description: "Maximum authentication age in seconds; forces re-authentication when exceeded"
|
|
3450
|
+
},
|
|
3451
|
+
{
|
|
3452
|
+
name: "resource",
|
|
3453
|
+
in: "query",
|
|
3454
|
+
required: false,
|
|
3455
|
+
schema: {
|
|
3456
|
+
type: "array",
|
|
3457
|
+
items: { type: "string" }
|
|
3458
|
+
},
|
|
3459
|
+
description: "Requested token resource(s) (ie audience) to obtain a JWT formatted access token. May be supplied multiple times as repeated 'resource' query parameters (RFC 8707) or as an array of strings."
|
|
3460
|
+
},
|
|
3461
|
+
{
|
|
3462
|
+
name: "prompt",
|
|
3463
|
+
in: "query",
|
|
3464
|
+
required: false,
|
|
3465
|
+
schema: { type: "string" },
|
|
3466
|
+
description: "OAuth2 prompt parameter"
|
|
3467
|
+
}
|
|
3468
|
+
],
|
|
3469
|
+
responses: {
|
|
3470
|
+
"302": {
|
|
3471
|
+
description: "Redirect to client with code or error",
|
|
3472
|
+
headers: { Location: {
|
|
3473
|
+
description: "Redirect URI with code or error",
|
|
3474
|
+
schema: {
|
|
3475
|
+
type: "string",
|
|
3476
|
+
format: "uri"
|
|
3477
|
+
}
|
|
3478
|
+
} }
|
|
3479
|
+
},
|
|
3480
|
+
"400": {
|
|
3481
|
+
description: "Invalid request",
|
|
3482
|
+
content: { "application/json": { schema: {
|
|
3483
|
+
type: "object",
|
|
3484
|
+
properties: {
|
|
3485
|
+
error: { type: "string" },
|
|
3486
|
+
error_description: { type: "string" },
|
|
3487
|
+
state: { type: "string" }
|
|
3488
|
+
},
|
|
3489
|
+
required: ["error"]
|
|
3490
|
+
} } }
|
|
3491
|
+
}
|
|
3492
|
+
}
|
|
3493
|
+
} }
|
|
3494
|
+
}, async (ctx) => {
|
|
3495
|
+
return authorizeEndpoint(ctx, opts, ctx.authorizeSettings ?? { isAuthorize: true });
|
|
3496
|
+
});
|
|
3497
|
+
const runOAuth2Authorize = (ctx, settings) => dispatchAuthEndpoint(oauth2AuthorizeEndpoint, {
|
|
3498
|
+
...ctx,
|
|
3499
|
+
asResponse: false,
|
|
3500
|
+
returnHeaders: false,
|
|
3501
|
+
returnStatus: false,
|
|
3502
|
+
authorizeSettings: settings ?? {}
|
|
3503
|
+
});
|
|
2946
3504
|
return {
|
|
2947
3505
|
id: "oauth-provider",
|
|
2948
3506
|
version: PACKAGE_VERSION,
|
|
2949
3507
|
options: opts,
|
|
3508
|
+
onRequest: handleIssuerMetadataRequest,
|
|
2950
3509
|
init: (ctx) => {
|
|
2951
3510
|
if (ctx.options.secondaryStorage && ctx.options.session?.storeSessionInDatabase !== true) throw new BetterAuthError("OAuth Provider requires `session.storeSessionInDatabase: true` when using secondaryStorage");
|
|
2952
3511
|
if (!opts.disableJwtPlugin) {
|
|
@@ -2957,12 +3516,20 @@ const oauthProvider = (options) => {
|
|
|
2957
3516
|
try {
|
|
2958
3517
|
issuerPath = new URL(issuer).pathname;
|
|
2959
3518
|
} catch (error) {
|
|
2960
|
-
if (isDynamicBaseURLInit
|
|
2961
|
-
throw error;
|
|
3519
|
+
if (!isDynamicBaseURLInit || issuer !== "") throw error;
|
|
2962
3520
|
}
|
|
2963
|
-
if (!opts.silenceWarnings?.oauthAuthServerConfig && !(ctx.options.basePath === "/" && issuerPath === "/")) logger.warn(`Please ensure '/.well-known/oauth-authorization-server${issuerPath === "/" ? "" : issuerPath}' exists. Upon completion, clear with silenceWarnings.oauthAuthServerConfig.`);
|
|
2964
|
-
if (!opts.silenceWarnings?.openidConfig && ctx.options.basePath !== issuerPath && opts.scopes?.includes("openid")) logger.warn(`Please ensure '${issuerPath}${issuerPath.endsWith("/") ? "" : "/"}.well-known/openid-configuration' exists. Upon completion, clear with silenceWarnings.openidConfig.`);
|
|
3521
|
+
if (issuerPath !== void 0 && !opts.silenceWarnings?.oauthAuthServerConfig && !(ctx.options.basePath === "/" && issuerPath === "/")) logger.warn(`Please ensure '/.well-known/oauth-authorization-server${issuerPath === "/" ? "" : issuerPath}' exists. Upon completion, clear with silenceWarnings.oauthAuthServerConfig.`);
|
|
3522
|
+
if (issuerPath !== void 0 && !opts.silenceWarnings?.openidConfig && ctx.options.basePath !== issuerPath && opts.scopes?.includes("openid")) logger.warn(`Please ensure '${issuerPath}${issuerPath.endsWith("/") ? "" : "/"}.well-known/openid-configuration' exists. Upon completion, clear with silenceWarnings.openidConfig.`);
|
|
2965
3523
|
}
|
|
3524
|
+
return { options: { databaseHooks: { session: { delete: { async before(session, hookCtx) {
|
|
3525
|
+
if (!hookCtx) return;
|
|
3526
|
+
const plan = await revokeAndPlanBackchannelLogout(hookCtx, opts, {
|
|
3527
|
+
sessionId: session.id,
|
|
3528
|
+
userId: session.userId
|
|
3529
|
+
});
|
|
3530
|
+
if (!plan) return;
|
|
3531
|
+
await hookCtx.context.runInBackgroundOrAwait(deliverBackchannelLogoutTokens(hookCtx, plan));
|
|
3532
|
+
} } } } } };
|
|
2966
3533
|
},
|
|
2967
3534
|
hooks: {
|
|
2968
3535
|
before: [{
|
|
@@ -2984,11 +3551,10 @@ const oauthProvider = (options) => {
|
|
|
2984
3551
|
signedQueryIssuedAt: signedQueryIssuedAt ?? void 0,
|
|
2985
3552
|
postLoginClearedForSession
|
|
2986
3553
|
});
|
|
2987
|
-
if (ctx.path === "/sign-in/social") {
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
}
|
|
3554
|
+
if (ctx.path === "/sign-in/social") await addOAuthServerContext({
|
|
3555
|
+
query: queryParams.toString(),
|
|
3556
|
+
...signedQueryIssuedAt ? { [signedQueryIssuedAtMsKey]: signedQueryIssuedAt.getTime() } : {}
|
|
3557
|
+
});
|
|
2992
3558
|
})
|
|
2993
3559
|
}],
|
|
2994
3560
|
after: [{
|
|
@@ -2998,7 +3564,9 @@ const oauthProvider = (options) => {
|
|
|
2998
3564
|
handler: createAuthMiddleware(async (ctx) => {
|
|
2999
3565
|
const sessionToken = parseSetCookieHeader(ctx.context.responseHeaders?.get("set-cookie") || "").get(ctx.context.authCookies.sessionToken.name)?.value.split(".")[0];
|
|
3000
3566
|
if (!sessionToken) return;
|
|
3001
|
-
const
|
|
3567
|
+
const oauthRequest = await oAuthState.get();
|
|
3568
|
+
const serverContext = (await getOAuthState())?.serverContext;
|
|
3569
|
+
const _query = oauthRequest?.query ?? serverContext?.query;
|
|
3002
3570
|
if (!_query) return;
|
|
3003
3571
|
const query = new URLSearchParams(_query);
|
|
3004
3572
|
const session = await ctx.context.internalAdapter.findSession(sessionToken);
|
|
@@ -3007,8 +3575,11 @@ const oauthProvider = (options) => {
|
|
|
3007
3575
|
const secFetchMode = ctx.request?.headers?.get("sec-fetch-mode")?.toLowerCase();
|
|
3008
3576
|
const acceptHeader = ctx.request?.headers?.get("accept")?.toLowerCase() ?? "";
|
|
3009
3577
|
if (!(secFetchMode === "navigate" || !secFetchMode && (acceptHeader.includes("text/html") || acceptHeader.includes("application/xhtml+xml")))) ctx.headers?.set("accept", "application/json");
|
|
3010
|
-
|
|
3011
|
-
|
|
3578
|
+
const signedQueryIssuedAt = oauthRequest?.signedQueryIssuedAt ?? getServerContextSignedQueryIssuedAt(serverContext?.[signedQueryIssuedAtMsKey]);
|
|
3579
|
+
let authorizationQuery = removePromptFromQuery(query, "login");
|
|
3580
|
+
if (isSessionFreshForSignedQuery(session.session.createdAt, signedQueryIssuedAt)) authorizationQuery = removeMaxAgeFromQuery(authorizationQuery);
|
|
3581
|
+
ctx.query = searchParamsToQuery(authorizationQuery);
|
|
3582
|
+
return await runOAuth2Authorize(ctx);
|
|
3012
3583
|
})
|
|
3013
3584
|
}]
|
|
3014
3585
|
},
|
|
@@ -3021,6 +3592,7 @@ const oauthProvider = (options) => {
|
|
|
3021
3592
|
else return {
|
|
3022
3593
|
...authServerMetadata(ctx, opts.disableJwtPlugin ? void 0 : getJwtPlugin(ctx.context)?.options, {
|
|
3023
3594
|
scopes_supported: opts.advertisedMetadata?.scopes_supported ?? opts.scopes,
|
|
3595
|
+
dynamic_client_registration_supported: opts.allowDynamicClientRegistration,
|
|
3024
3596
|
public_client_supported: opts.allowUnauthenticatedClientRegistration || toClientDiscoveryArray(opts.clientDiscovery).length > 0,
|
|
3025
3597
|
grant_types_supported: opts.grantTypes,
|
|
3026
3598
|
jwt_disabled: opts.disableJwtPlugin
|
|
@@ -3035,135 +3607,7 @@ const oauthProvider = (options) => {
|
|
|
3035
3607
|
if (opts.scopes && !opts.scopes.includes("openid")) throw new APIError("NOT_FOUND");
|
|
3036
3608
|
return oidcServerMetadata(ctx, opts);
|
|
3037
3609
|
}),
|
|
3038
|
-
oauth2Authorize:
|
|
3039
|
-
method: "GET",
|
|
3040
|
-
query: z.object({
|
|
3041
|
-
response_type: z.string().pipe(z.enum(["code"])).optional(),
|
|
3042
|
-
client_id: z.string(),
|
|
3043
|
-
redirect_uri: SafeUrlSchema.optional(),
|
|
3044
|
-
scope: z.string().optional(),
|
|
3045
|
-
state: z.string().optional(),
|
|
3046
|
-
request_uri: z.string().optional(),
|
|
3047
|
-
code_challenge: z.string().optional(),
|
|
3048
|
-
code_challenge_method: z.string().pipe(z.enum(["S256"])).optional(),
|
|
3049
|
-
nonce: z.string().optional(),
|
|
3050
|
-
prompt: z.string().pipe(z.enum([
|
|
3051
|
-
"none",
|
|
3052
|
-
"consent",
|
|
3053
|
-
"login",
|
|
3054
|
-
"create",
|
|
3055
|
-
"select_account",
|
|
3056
|
-
"login consent",
|
|
3057
|
-
"select_account consent"
|
|
3058
|
-
])).optional()
|
|
3059
|
-
}),
|
|
3060
|
-
redirectOnError: authorizeRedirectOnError(opts),
|
|
3061
|
-
errorCodesByField: { response_type: { invalid: "unsupported_response_type" } },
|
|
3062
|
-
metadata: { openapi: {
|
|
3063
|
-
description: "Authorize an OAuth2 request",
|
|
3064
|
-
parameters: [
|
|
3065
|
-
{
|
|
3066
|
-
name: "response_type",
|
|
3067
|
-
in: "query",
|
|
3068
|
-
required: false,
|
|
3069
|
-
schema: { type: "string" },
|
|
3070
|
-
description: "OAuth2 response type (e.g., 'code')"
|
|
3071
|
-
},
|
|
3072
|
-
{
|
|
3073
|
-
name: "client_id",
|
|
3074
|
-
in: "query",
|
|
3075
|
-
required: true,
|
|
3076
|
-
schema: { type: "string" },
|
|
3077
|
-
description: "OAuth2 client ID"
|
|
3078
|
-
},
|
|
3079
|
-
{
|
|
3080
|
-
name: "redirect_uri",
|
|
3081
|
-
in: "query",
|
|
3082
|
-
required: false,
|
|
3083
|
-
schema: {
|
|
3084
|
-
type: "string",
|
|
3085
|
-
format: "uri"
|
|
3086
|
-
},
|
|
3087
|
-
description: "OAuth2 redirect URI"
|
|
3088
|
-
},
|
|
3089
|
-
{
|
|
3090
|
-
name: "scope",
|
|
3091
|
-
in: "query",
|
|
3092
|
-
required: false,
|
|
3093
|
-
schema: { type: "string" },
|
|
3094
|
-
description: "OAuth2 scopes (space-separated)"
|
|
3095
|
-
},
|
|
3096
|
-
{
|
|
3097
|
-
name: "state",
|
|
3098
|
-
in: "query",
|
|
3099
|
-
required: false,
|
|
3100
|
-
schema: { type: "string" },
|
|
3101
|
-
description: "OAuth2 state parameter"
|
|
3102
|
-
},
|
|
3103
|
-
{
|
|
3104
|
-
name: "request_uri",
|
|
3105
|
-
in: "query",
|
|
3106
|
-
required: false,
|
|
3107
|
-
schema: { type: "string" },
|
|
3108
|
-
description: "Pushed Authorization Request URI referencing stored parameters"
|
|
3109
|
-
},
|
|
3110
|
-
{
|
|
3111
|
-
name: "code_challenge",
|
|
3112
|
-
in: "query",
|
|
3113
|
-
required: false,
|
|
3114
|
-
schema: { type: "string" },
|
|
3115
|
-
description: "PKCE code challenge"
|
|
3116
|
-
},
|
|
3117
|
-
{
|
|
3118
|
-
name: "code_challenge_method",
|
|
3119
|
-
in: "query",
|
|
3120
|
-
required: false,
|
|
3121
|
-
schema: { type: "string" },
|
|
3122
|
-
description: "PKCE code challenge method"
|
|
3123
|
-
},
|
|
3124
|
-
{
|
|
3125
|
-
name: "nonce",
|
|
3126
|
-
in: "query",
|
|
3127
|
-
required: false,
|
|
3128
|
-
schema: { type: "string" },
|
|
3129
|
-
description: "OpenID Connect nonce"
|
|
3130
|
-
},
|
|
3131
|
-
{
|
|
3132
|
-
name: "prompt",
|
|
3133
|
-
in: "query",
|
|
3134
|
-
required: false,
|
|
3135
|
-
schema: { type: "string" },
|
|
3136
|
-
description: "OAuth2 prompt parameter"
|
|
3137
|
-
}
|
|
3138
|
-
],
|
|
3139
|
-
responses: {
|
|
3140
|
-
"302": {
|
|
3141
|
-
description: "Redirect to client with code or error",
|
|
3142
|
-
headers: { Location: {
|
|
3143
|
-
description: "Redirect URI with code or error",
|
|
3144
|
-
schema: {
|
|
3145
|
-
type: "string",
|
|
3146
|
-
format: "uri"
|
|
3147
|
-
}
|
|
3148
|
-
} }
|
|
3149
|
-
},
|
|
3150
|
-
"400": {
|
|
3151
|
-
description: "Invalid request",
|
|
3152
|
-
content: { "application/json": { schema: {
|
|
3153
|
-
type: "object",
|
|
3154
|
-
properties: {
|
|
3155
|
-
error: { type: "string" },
|
|
3156
|
-
error_description: { type: "string" },
|
|
3157
|
-
state: { type: "string" }
|
|
3158
|
-
},
|
|
3159
|
-
required: ["error"]
|
|
3160
|
-
} } }
|
|
3161
|
-
}
|
|
3162
|
-
}
|
|
3163
|
-
} }
|
|
3164
|
-
}, async (ctx) => {
|
|
3165
|
-
return authorizeEndpoint(ctx, opts, { isAuthorize: true });
|
|
3166
|
-
}),
|
|
3610
|
+
oauth2Authorize: oauth2AuthorizeEndpoint,
|
|
3167
3611
|
oauth2Consent: createAuthEndpoint("/oauth2/consent", {
|
|
3168
3612
|
method: "POST",
|
|
3169
3613
|
body: z.object({
|
|
@@ -3188,7 +3632,7 @@ const oauthProvider = (options) => {
|
|
|
3188
3632
|
} }
|
|
3189
3633
|
} }
|
|
3190
3634
|
}, async (ctx) => {
|
|
3191
|
-
return consentEndpoint(ctx, opts);
|
|
3635
|
+
return consentEndpoint(ctx, opts, runOAuth2Authorize);
|
|
3192
3636
|
}),
|
|
3193
3637
|
oauth2Continue: createAuthEndpoint("/oauth2/continue", {
|
|
3194
3638
|
method: "POST",
|
|
@@ -3215,7 +3659,7 @@ const oauthProvider = (options) => {
|
|
|
3215
3659
|
} }
|
|
3216
3660
|
} }
|
|
3217
3661
|
}, async (ctx) => {
|
|
3218
|
-
return continueEndpoint(ctx,
|
|
3662
|
+
return continueEndpoint(ctx, runOAuth2Authorize);
|
|
3219
3663
|
}),
|
|
3220
3664
|
oauth2Token: createOAuthEndpoint("/oauth2/token", {
|
|
3221
3665
|
method: "POST",
|
|
@@ -3233,13 +3677,16 @@ const oauthProvider = (options) => {
|
|
|
3233
3677
|
code_verifier: z.string().optional(),
|
|
3234
3678
|
redirect_uri: SafeUrlSchema.optional(),
|
|
3235
3679
|
refresh_token: z.string().optional(),
|
|
3236
|
-
resource: z.
|
|
3680
|
+
resource: z.union([ResourceUriSchema, z.array(ResourceUriSchema).min(1)]).optional(),
|
|
3237
3681
|
scope: z.string().optional()
|
|
3238
3682
|
}),
|
|
3239
|
-
errorCodesByField: {
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3683
|
+
errorCodesByField: {
|
|
3684
|
+
grant_type: {
|
|
3685
|
+
missing: "invalid_request",
|
|
3686
|
+
invalid: "unsupported_grant_type"
|
|
3687
|
+
},
|
|
3688
|
+
resource: { invalid: "invalid_target" }
|
|
3689
|
+
},
|
|
3243
3690
|
metadata: {
|
|
3244
3691
|
allowedMediaTypes: ["application/x-www-form-urlencoded"],
|
|
3245
3692
|
openapi: {
|
|
@@ -3284,8 +3731,15 @@ const oauthProvider = (options) => {
|
|
|
3284
3731
|
description: "Refresh token (for refresh_token grant)"
|
|
3285
3732
|
},
|
|
3286
3733
|
resource: {
|
|
3287
|
-
|
|
3288
|
-
|
|
3734
|
+
oneOf: [{
|
|
3735
|
+
type: "string",
|
|
3736
|
+
description: "Single resource (URL)"
|
|
3737
|
+
}, {
|
|
3738
|
+
type: "array",
|
|
3739
|
+
items: { type: "string" },
|
|
3740
|
+
description: "Multiple resources (URLs)"
|
|
3741
|
+
}],
|
|
3742
|
+
description: "Requested token resource(s) (ie audience) to obtain a JWT formatted access token"
|
|
3289
3743
|
},
|
|
3290
3744
|
scope: {
|
|
3291
3745
|
type: "string",
|
|
@@ -3386,10 +3840,6 @@ const oauthProvider = (options) => {
|
|
|
3386
3840
|
token_type_hint: {
|
|
3387
3841
|
type: "string",
|
|
3388
3842
|
description: "Hint about the token type. Recognized values: `access_token`, `refresh_token`."
|
|
3389
|
-
},
|
|
3390
|
-
resource: {
|
|
3391
|
-
type: "string",
|
|
3392
|
-
description: "Introspects a token for a specific resource."
|
|
3393
3843
|
}
|
|
3394
3844
|
},
|
|
3395
3845
|
required: ["token"]
|
|
@@ -3537,7 +3987,7 @@ const oauthProvider = (options) => {
|
|
|
3537
3987
|
return revokeEndpoint(ctx, opts);
|
|
3538
3988
|
}),
|
|
3539
3989
|
oauth2UserInfo: createAuthEndpoint("/oauth2/userinfo", {
|
|
3540
|
-
method: "GET",
|
|
3990
|
+
method: ["GET", "POST"],
|
|
3541
3991
|
metadata: { openapi: {
|
|
3542
3992
|
description: "Get OpenID Connect user information (UserInfo endpoint)",
|
|
3543
3993
|
security: [{ bearerAuth: [] }, { OAuth2: [
|
|
@@ -3671,13 +4121,15 @@ const oauthProvider = (options) => {
|
|
|
3671
4121
|
software_version: z.string().optional(),
|
|
3672
4122
|
software_statement: z.string().optional(),
|
|
3673
4123
|
post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
|
|
4124
|
+
backchannel_logout_uri: SafeUrlSchema.optional(),
|
|
4125
|
+
backchannel_logout_session_required: z.boolean().optional(),
|
|
3674
4126
|
token_endpoint_auth_method: z.enum([
|
|
3675
4127
|
"none",
|
|
3676
4128
|
"client_secret_basic",
|
|
3677
4129
|
"client_secret_post",
|
|
3678
4130
|
"private_key_jwt"
|
|
3679
4131
|
]).default("client_secret_basic").optional(),
|
|
3680
|
-
jwks: z.union([z.array(z.record(z.string(), z.unknown())), z.object({ keys: z.array(z.record(z.string(), z.unknown())) })]).optional(),
|
|
4132
|
+
jwks: z.union([z.array(z.record(z.string(), z.unknown())).min(1), z.object({ keys: z.array(z.record(z.string(), z.unknown())).min(1) })]).optional(),
|
|
3681
4133
|
jwks_uri: z.string().optional(),
|
|
3682
4134
|
grant_types: z.array(z.enum([
|
|
3683
4135
|
"authorization_code",
|
|
@@ -3783,6 +4235,15 @@ const oauthProvider = (options) => {
|
|
|
3783
4235
|
},
|
|
3784
4236
|
description: "List of allowed logout redirect uris"
|
|
3785
4237
|
},
|
|
4238
|
+
backchannel_logout_uri: {
|
|
4239
|
+
type: "string",
|
|
4240
|
+
format: "uri",
|
|
4241
|
+
description: "RP URL to receive signed Logout Tokens when the end-user's OP session terminates"
|
|
4242
|
+
},
|
|
4243
|
+
backchannel_logout_session_required: {
|
|
4244
|
+
type: "boolean",
|
|
4245
|
+
description: "Whether the RP requires a `sid` claim in every Logout Token"
|
|
4246
|
+
},
|
|
3786
4247
|
token_endpoint_auth_method: {
|
|
3787
4248
|
type: "string",
|
|
3788
4249
|
description: "Requested authentication method for the token endpoint",
|
|
@@ -3891,6 +4352,19 @@ const oauthProvider = (options) => {
|
|
|
3891
4352
|
//#endregion
|
|
3892
4353
|
//#region src/authorize.ts
|
|
3893
4354
|
/**
|
|
4355
|
+
* Whether a past authentication is still fresh enough for an OIDC `max_age`
|
|
4356
|
+
* request: true when no more than `maxAge` seconds have elapsed since the user
|
|
4357
|
+
* last authenticated. The caller supplies `now`, keeping this pure.
|
|
4358
|
+
*/
|
|
4359
|
+
function isWithinMaxAge(sessionCreatedAt, maxAgeSeconds, now) {
|
|
4360
|
+
if (maxAgeSeconds === 0) return false;
|
|
4361
|
+
return now.getTime() - sessionCreatedAt.getTime() <= maxAgeSeconds * 1e3;
|
|
4362
|
+
}
|
|
4363
|
+
function removeMaxAgeFromAuthorizationQuery(query) {
|
|
4364
|
+
const { max_age: _maxAge, ...queryWithoutMaxAge } = query;
|
|
4365
|
+
return queryWithoutMaxAge;
|
|
4366
|
+
}
|
|
4367
|
+
/**
|
|
3894
4368
|
* Formats an error url. Per OIDC Core 1.0 §5 / RFC 6749 §4.2.2.1, errors on
|
|
3895
4369
|
* implicit and hybrid flows are delivered in the URL fragment, not the query.
|
|
3896
4370
|
* Callers on the code flow (default) omit `mode` and get query delivery.
|
|
@@ -4037,6 +4511,7 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
4037
4511
|
error_description: "request not found",
|
|
4038
4512
|
error: "invalid_request"
|
|
4039
4513
|
});
|
|
4514
|
+
const request = ctx.request;
|
|
4040
4515
|
let query = ctx.query;
|
|
4041
4516
|
if (query.request_uri) {
|
|
4042
4517
|
if (!opts.requestUriResolver) return handleRedirect(ctx, getErrorURL(ctx, "invalid_request_uri", "request_uri not supported"));
|
|
@@ -4051,6 +4526,14 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
4051
4526
|
if (urlClientId) query.client_id = urlClientId;
|
|
4052
4527
|
}
|
|
4053
4528
|
ctx.query = query;
|
|
4529
|
+
const parsedQuery = authorizationQuerySchema.safeParse(query);
|
|
4530
|
+
if (!parsedQuery.success) return authorizeRedirectOnError(opts)({
|
|
4531
|
+
error: "invalid_request",
|
|
4532
|
+
error_description: "invalid authorization request",
|
|
4533
|
+
ctx
|
|
4534
|
+
});
|
|
4535
|
+
query = parsedQuery.data;
|
|
4536
|
+
ctx.query = query;
|
|
4054
4537
|
await oAuthState.set({ query: serializeAuthorizationQuery(query).toString() });
|
|
4055
4538
|
if (!query.client_id) return handleRedirect(ctx, getErrorURL(ctx, "invalid_client", "client_id is required"));
|
|
4056
4539
|
if (!query.response_type) return handleRedirect(ctx, getErrorURL(ctx, "invalid_request", "response_type is required"));
|
|
@@ -4061,6 +4544,7 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
4061
4544
|
const client = await getClient(ctx, opts, query.client_id);
|
|
4062
4545
|
if (!client) return handleRedirect(ctx, getErrorURL(ctx, "invalid_client", "client_id is required"));
|
|
4063
4546
|
if (client.disabled) return handleRedirect(ctx, getErrorURL(ctx, "client_disabled", "client is disabled"));
|
|
4547
|
+
if (!clientAllowsGrant(client, "authorization_code")) return handleRedirect(ctx, getErrorURL(ctx, "unauthorized_client", "client is not authorized to use the authorization_code grant"));
|
|
4064
4548
|
if (!findRegisteredRedirectUri(client.redirectUris, query.redirect_uri) || !query.redirect_uri) return handleRedirect(ctx, getErrorURL(ctx, "invalid_redirect", "invalid redirect uri"));
|
|
4065
4549
|
let requestedScopes = query.scope?.split(" ").filter((s) => s);
|
|
4066
4550
|
if (requestedScopes) {
|
|
@@ -4082,15 +4566,24 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
4082
4566
|
if (!query.code_challenge || !query.code_challenge_method) return handleRedirect(ctx, formatErrorURL(query.redirect_uri, "invalid_request", "code_challenge and code_challenge_method must both be provided", query.state, getIssuer(ctx, opts)));
|
|
4083
4567
|
if (!["S256"].includes(query.code_challenge_method)) return handleRedirect(ctx, formatErrorURL(query.redirect_uri, "invalid_request", "invalid code_challenge method, only S256 is supported", query.state, getIssuer(ctx, opts)));
|
|
4084
4568
|
}
|
|
4569
|
+
const resource = query.resource;
|
|
4570
|
+
if (!(await checkResource(ctx, opts, resource, requestedScopes)).success) return handleRedirect(ctx, formatErrorURL(query.redirect_uri, "invalid_target", "requested resource invalid", query.state, getIssuer(ctx, opts)));
|
|
4571
|
+
const requestedResources = toResourceList(resource) ?? [];
|
|
4085
4572
|
const session = await getSessionFromCtx(ctx);
|
|
4086
|
-
|
|
4573
|
+
const maxAgeSeconds = query.max_age;
|
|
4574
|
+
const hasSatisfiedMaxAge = session != null && maxAgeSeconds !== void 0 && isWithinMaxAge(new Date(session.session.createdAt), maxAgeSeconds, /* @__PURE__ */ new Date());
|
|
4575
|
+
if (!session || session != null && maxAgeSeconds !== void 0 && !hasSatisfiedMaxAge || promptSet?.has("login") || promptSet?.has("create")) {
|
|
4087
4576
|
if (promptNone) return redirectWithPromptNoneError(ctx, opts, query, "login_required", "authentication required");
|
|
4088
4577
|
return redirectWithPromptCode(ctx, opts, promptSet?.has("create") ? "create" : "login");
|
|
4089
4578
|
}
|
|
4579
|
+
if (hasSatisfiedMaxAge) {
|
|
4580
|
+
query = removeMaxAgeFromAuthorizationQuery(query);
|
|
4581
|
+
ctx.query = query;
|
|
4582
|
+
}
|
|
4090
4583
|
if (settings?.isAuthorize && promptSet?.has("select_account")) return redirectWithPromptCode(ctx, opts, "select_account");
|
|
4091
4584
|
if (settings?.isAuthorize && opts.selectAccount) {
|
|
4092
4585
|
if (await opts.selectAccount.shouldRedirect({
|
|
4093
|
-
headers:
|
|
4586
|
+
headers: request.headers,
|
|
4094
4587
|
user: session.user,
|
|
4095
4588
|
session: session.session,
|
|
4096
4589
|
scopes: requestedScopes
|
|
@@ -4101,7 +4594,7 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
4101
4594
|
}
|
|
4102
4595
|
if (opts.signup?.shouldRedirect) {
|
|
4103
4596
|
const signupRedirect = await opts.signup.shouldRedirect({
|
|
4104
|
-
headers:
|
|
4597
|
+
headers: request.headers,
|
|
4105
4598
|
user: session.user,
|
|
4106
4599
|
session: session.session,
|
|
4107
4600
|
scopes: requestedScopes
|
|
@@ -4113,7 +4606,7 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
4113
4606
|
}
|
|
4114
4607
|
if (!settings?.postLogin && opts.postLogin) {
|
|
4115
4608
|
if (await opts.postLogin.shouldRedirect({
|
|
4116
|
-
headers:
|
|
4609
|
+
headers: request.headers,
|
|
4117
4610
|
user: session.user,
|
|
4118
4611
|
session: session.session,
|
|
4119
4612
|
scopes: requestedScopes
|
|
@@ -4134,7 +4627,8 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
4134
4627
|
userId: session.user.id,
|
|
4135
4628
|
sessionId: session.session.id,
|
|
4136
4629
|
authTime: new Date(session.session.createdAt).getTime(),
|
|
4137
|
-
referenceId
|
|
4630
|
+
referenceId,
|
|
4631
|
+
resource: requestedResources
|
|
4138
4632
|
});
|
|
4139
4633
|
const consent = await ctx.context.adapter.findOne({
|
|
4140
4634
|
model: "oauthConsent",
|
|
@@ -4157,13 +4651,19 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
4157
4651
|
if (promptNone) return redirectWithPromptNoneError(ctx, opts, query, "consent_required", "End-User consent is required");
|
|
4158
4652
|
return redirectWithPromptCode(ctx, opts, "consent", { sessionId: session.session.id });
|
|
4159
4653
|
}
|
|
4654
|
+
const consentedResources = consent?.resources ?? [];
|
|
4655
|
+
if (requestedResources.some((requestedResource) => !consentedResources.includes(requestedResource))) {
|
|
4656
|
+
if (promptNone) return redirectWithPromptNoneError(ctx, opts, query, "consent_required", "End-User consent is required");
|
|
4657
|
+
return redirectWithPromptCode(ctx, opts, "consent", { sessionId: session.session.id });
|
|
4658
|
+
}
|
|
4160
4659
|
return redirectWithAuthorizationCode(ctx, opts, {
|
|
4161
4660
|
query,
|
|
4162
4661
|
clientId: client.clientId,
|
|
4163
4662
|
userId: session.user.id,
|
|
4164
4663
|
sessionId: session.session.id,
|
|
4165
4664
|
authTime: new Date(session.session.createdAt).getTime(),
|
|
4166
|
-
referenceId
|
|
4665
|
+
referenceId,
|
|
4666
|
+
resource: requestedResources
|
|
4167
4667
|
});
|
|
4168
4668
|
}
|
|
4169
4669
|
function serializeAuthorizationQuery(query) {
|
|
@@ -4189,7 +4689,8 @@ async function redirectWithAuthorizationCode(ctx, opts, verificationValue) {
|
|
|
4189
4689
|
userId: verificationValue.userId,
|
|
4190
4690
|
sessionId: verificationValue?.sessionId,
|
|
4191
4691
|
referenceId: verificationValue.referenceId,
|
|
4192
|
-
authTime: verificationValue.authTime
|
|
4692
|
+
authTime: verificationValue.authTime,
|
|
4693
|
+
resource: verificationValue.resource
|
|
4193
4694
|
})
|
|
4194
4695
|
};
|
|
4195
4696
|
await ctx.context.internalAdapter.createVerificationValue({
|
|
@@ -4229,13 +4730,14 @@ async function signParams(ctx, opts, flags) {
|
|
|
4229
4730
|
//#region src/metadata.ts
|
|
4230
4731
|
function authServerMetadata(ctx, opts, overrides) {
|
|
4231
4732
|
const baseURL = ctx.context.baseURL;
|
|
4733
|
+
const backchannelSupported = !overrides?.jwt_disabled;
|
|
4232
4734
|
return {
|
|
4233
4735
|
scopes_supported: overrides?.scopes_supported,
|
|
4234
4736
|
issuer: validateIssuerUrl(opts?.jwt?.issuer ?? baseURL),
|
|
4235
4737
|
authorization_endpoint: `${baseURL}/oauth2/authorize`,
|
|
4236
4738
|
token_endpoint: `${baseURL}/oauth2/token`,
|
|
4237
4739
|
jwks_uri: overrides?.jwt_disabled ? void 0 : opts?.jwks?.remoteUrl ?? `${baseURL}${opts?.jwks?.jwksPath ?? "/jwks"}`,
|
|
4238
|
-
registration_endpoint: `${baseURL}/oauth2/register
|
|
4740
|
+
registration_endpoint: overrides?.dynamic_client_registration_supported ? `${baseURL}/oauth2/register` : void 0,
|
|
4239
4741
|
introspection_endpoint: `${baseURL}/oauth2/introspect`,
|
|
4240
4742
|
revocation_endpoint: `${baseURL}/oauth2/revoke`,
|
|
4241
4743
|
response_types_supported: overrides?.grant_types_supported && !overrides.grant_types_supported.includes("authorization_code") ? [] : ["code"],
|
|
@@ -4251,21 +4753,23 @@ function authServerMetadata(ctx, opts, overrides) {
|
|
|
4251
4753
|
"client_secret_post",
|
|
4252
4754
|
"private_key_jwt"
|
|
4253
4755
|
],
|
|
4254
|
-
token_endpoint_auth_signing_alg_values_supported: [...
|
|
4756
|
+
token_endpoint_auth_signing_alg_values_supported: [...PRIVATE_KEY_JWT_SIGNING_ALGORITHMS],
|
|
4255
4757
|
introspection_endpoint_auth_methods_supported: [
|
|
4256
4758
|
"client_secret_basic",
|
|
4257
4759
|
"client_secret_post",
|
|
4258
4760
|
"private_key_jwt"
|
|
4259
4761
|
],
|
|
4260
|
-
introspection_endpoint_auth_signing_alg_values_supported: [...
|
|
4762
|
+
introspection_endpoint_auth_signing_alg_values_supported: [...PRIVATE_KEY_JWT_SIGNING_ALGORITHMS],
|
|
4261
4763
|
revocation_endpoint_auth_methods_supported: [
|
|
4262
4764
|
"client_secret_basic",
|
|
4263
4765
|
"client_secret_post",
|
|
4264
4766
|
"private_key_jwt"
|
|
4265
4767
|
],
|
|
4266
|
-
revocation_endpoint_auth_signing_alg_values_supported: [...
|
|
4768
|
+
revocation_endpoint_auth_signing_alg_values_supported: [...PRIVATE_KEY_JWT_SIGNING_ALGORITHMS],
|
|
4267
4769
|
code_challenge_methods_supported: ["S256"],
|
|
4268
|
-
authorization_response_iss_parameter_supported: true
|
|
4770
|
+
authorization_response_iss_parameter_supported: true,
|
|
4771
|
+
backchannel_logout_supported: backchannelSupported,
|
|
4772
|
+
backchannel_logout_session_supported: backchannelSupported
|
|
4269
4773
|
};
|
|
4270
4774
|
}
|
|
4271
4775
|
function oidcServerMetadata(ctx, opts) {
|
|
@@ -4274,6 +4778,7 @@ function oidcServerMetadata(ctx, opts) {
|
|
|
4274
4778
|
return {
|
|
4275
4779
|
...authServerMetadata(ctx, jwtPluginOptions, {
|
|
4276
4780
|
scopes_supported: opts.advertisedMetadata?.scopes_supported ?? opts.scopes,
|
|
4781
|
+
dynamic_client_registration_supported: opts.allowDynamicClientRegistration,
|
|
4277
4782
|
public_client_supported: opts.allowUnauthenticatedClientRegistration || toClientDiscoveryArray(opts.clientDiscovery).length > 0,
|
|
4278
4783
|
grant_types_supported: opts.grantTypes,
|
|
4279
4784
|
jwt_disabled: opts.disableJwtPlugin
|