@better-auth/oauth-provider 1.7.0-beta.4 → 1.7.0-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{client-assertion-DLMKVgoj.mjs → client-assertion-CctbJywV.mjs} +102 -87
- package/dist/client-resource.d.mts +31 -2
- package/dist/client-resource.mjs +45 -25
- package/dist/client.d.mts +1 -1
- package/dist/client.mjs +3 -13
- package/dist/index.d.mts +102 -17
- package/dist/index.mjs +1747 -1886
- package/dist/introspect-BXqKFUQZ.mjs +2115 -0
- package/dist/{oauth-Vt3lTNHX.d.mts → oauth-CAeemjD7.d.mts} +364 -175
- package/dist/{oauth-q7dn10NU.d.mts → oauth-CaXmZpoL.d.mts} +922 -33
- package/dist/resource-challenge-B-cqv4ur.mjs +63 -0
- package/dist/rolldown-runtime-wcPFST8Q.mjs +13 -0
- package/dist/signed-query-CFv2jNMT.mjs +44 -0
- package/dist/utils-Baq6atYN.mjs +764 -0
- package/dist/{version-nFnRm-a3.mjs → version-CUu3vBtU.mjs} +1 -1
- package/package.json +8 -9
- package/dist/mcp-CYnz-MXn.mjs +0 -56
- package/dist/utils-DKBWQ8fe.mjs +0 -492
package/dist/index.mjs
CHANGED
|
@@ -1,25 +1,26 @@
|
|
|
1
|
-
import { n as
|
|
2
|
-
import { n as
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { APIError as APIError$1 } from "better-call";
|
|
8
|
-
import { PRIVATE_KEY_JWT_SIGNING_ALGORITHMS } from "@better-auth/core/oauth2";
|
|
1
|
+
import { D as applyOAuthProviderMetadataExtensions, E as verifyOAuthQueryParams, F as getSupportedGrantTypes, L as isExtensionTokenEndpointAuthMethod, M as getClientDiscoveries, P as getSupportedAuthMethods, R as validateOAuthProviderExtensions, S as storeToken, T as validateClientCredentials, _ as removePromptFromQuery, a as getClient, b as searchParamsToQuery, c as getStoredToken, d as mergeDiscoveryMetadata, g as removeMaxAgeFromQuery, h as parsePrompt, i as extractClientCredentials, j as extendOAuthProvider, l as isPKCERequired, m as parseClientMetadata, n as decryptStoredClientSecret, o as getJwtPlugin, p as parseBearerToken, r as destructureCredentials, t as clientAllowsGrant, u as isSessionFreshForSignedQuery, w as toResourceList, x as storeClientSecret, y as resolveSubjectIdentifier } from "./utils-Baq6atYN.mjs";
|
|
2
|
+
import { a as setSignedOAuthQueryParameterNames, i as postLoginClearedParam, n as canonicalizeOAuthQueryParams, o as signedQueryIssuedAtParam, r as getSignedQueryIssuedAt } from "./signed-query-CFv2jNMT.mjs";
|
|
3
|
+
import { _ as invalidateResourceCache, a as invalidateRefreshFamily, b as resolveResourcePolicy, c as ResourceUriSchema, d as clientRegistrationRequestSchema, f as JWS_ALGORITHMS, g as getResource, h as extractRepeatedResourceFromForm, i as getOAuthProviderApi, l as SafeUrlSchema, m as buildClientResourceLinkId, o as tokenEndpoint, p as assertIdentifierValid, r as decodeRefreshToken, s as userInfoEndpoint, t as introspectEndpoint, u as authorizationQuerySchema, v as isAudienceClaimAllowed, x as seedResources, y as logEnforcePerClientResourcesResolution } from "./introspect-BXqKFUQZ.mjs";
|
|
4
|
+
import { n as consumeClientAssertion, r as isPrivateHostname } from "./client-assertion-CctbJywV.mjs";
|
|
5
|
+
import { t as PACKAGE_VERSION } from "./version-CUu3vBtU.mjs";
|
|
6
|
+
import { t as raiseResourceServerChallenge } from "./resource-challenge-B-cqv4ur.mjs";
|
|
9
7
|
import { isBrowserFetchRequest } from "@better-auth/core/utils/fetch-metadata";
|
|
10
8
|
import { isLoopbackHost, isLoopbackIP } from "@better-auth/core/utils/host";
|
|
9
|
+
import { APIError, NO_STORE_HEADERS, addOAuthServerContext, createAuthEndpoint, createAuthMiddleware, dispatchAuthEndpoint, getOAuthState, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
|
|
11
10
|
import { generateRandomString, makeSignature } from "better-auth/crypto";
|
|
12
|
-
import {
|
|
11
|
+
import { APIError as APIError$1 } from "better-call";
|
|
12
|
+
import { defineRequestState, runWithTransaction } from "@better-auth/core/context";
|
|
13
13
|
import { logger } from "@better-auth/core/env";
|
|
14
14
|
import { BetterAuthError } from "@better-auth/core/error";
|
|
15
15
|
import { parseSetCookieHeader } from "better-auth/cookies";
|
|
16
16
|
import { mergeSchema } from "better-auth/db";
|
|
17
17
|
import * as z from "zod";
|
|
18
|
+
import { DPOP_SIGNING_ALGORITHMS, PRIVATE_KEY_JWT_SIGNING_ALGORITHMS } from "@better-auth/core/oauth2";
|
|
19
|
+
import { getJwks, stripAccessTokenAuthorizationScheme } from "better-auth/oauth2";
|
|
20
|
+
import { compactVerify, createLocalJWKSet, decodeJwt, jwtVerify } from "jose";
|
|
18
21
|
import { resolveSigningKey, signJWT, toExpJWT } from "better-auth/plugins";
|
|
19
|
-
import { SignJWT, base64url, compactVerify, createLocalJWKSet, decodeJwt, decodeProtectedHeader } from "jose";
|
|
20
|
-
import { SafeUrlSchema } from "@better-auth/core/utils/redirect-uri";
|
|
21
22
|
//#region src/consent.ts
|
|
22
|
-
async function consentEndpoint(ctx, opts) {
|
|
23
|
+
async function consentEndpoint(ctx, opts, authorize) {
|
|
23
24
|
const oauthRequest = await oAuthState.get();
|
|
24
25
|
const _query = oauthRequest?.query;
|
|
25
26
|
if (!_query) throw new APIError("BAD_REQUEST", {
|
|
@@ -46,15 +47,11 @@ async function consentEndpoint(ctx, opts) {
|
|
|
46
47
|
};
|
|
47
48
|
const session = await getSessionFromCtx(ctx);
|
|
48
49
|
const hasLoginPrompt = parsePrompt(query.get("prompt") ?? "").has("login");
|
|
49
|
-
const hasSatisfiedLoginPrompt = hasLoginPrompt &&
|
|
50
|
+
const hasSatisfiedLoginPrompt = hasLoginPrompt && isSessionFreshForSignedQuery(session?.session.createdAt, oauthRequest?.signedQueryIssuedAt);
|
|
50
51
|
if (hasLoginPrompt && !hasSatisfiedLoginPrompt) {
|
|
51
52
|
ctx?.headers?.set("accept", "application/json");
|
|
52
53
|
ctx.query = searchParamsToQuery(query);
|
|
53
|
-
|
|
54
|
-
return {
|
|
55
|
-
redirect: true,
|
|
56
|
-
url
|
|
57
|
-
};
|
|
54
|
+
return await authorize(ctx);
|
|
58
55
|
}
|
|
59
56
|
const referenceId = await opts.postLogin?.consentReferenceId?.({
|
|
60
57
|
user: session?.user,
|
|
@@ -110,32 +107,25 @@ async function consentEndpoint(ctx, opts) {
|
|
|
110
107
|
if (requestedScopes) query.set("scope", consent.scopes.join(" "));
|
|
111
108
|
ctx?.headers?.set("accept", "application/json");
|
|
112
109
|
let authorizationQuery = removePromptFromQuery(query, "consent");
|
|
113
|
-
if (hasSatisfiedLoginPrompt)
|
|
110
|
+
if (hasSatisfiedLoginPrompt) {
|
|
111
|
+
authorizationQuery = removePromptFromQuery(authorizationQuery, "login");
|
|
112
|
+
authorizationQuery = removeMaxAgeFromQuery(authorizationQuery);
|
|
113
|
+
}
|
|
114
114
|
ctx.query = searchParamsToQuery(authorizationQuery);
|
|
115
|
-
|
|
116
|
-
return {
|
|
117
|
-
redirect: true,
|
|
118
|
-
url
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
function sessionSatisfiesLoginPrompt(sessionCreatedAt, signedQueryIssuedAt) {
|
|
122
|
-
if (!signedQueryIssuedAt) return false;
|
|
123
|
-
const normalized = normalizeTimestampValue(sessionCreatedAt);
|
|
124
|
-
if (!normalized) return false;
|
|
125
|
-
return normalized.getTime() >= signedQueryIssuedAt.getTime();
|
|
115
|
+
return await authorize(ctx, { postLogin: oauthRequest?.postLoginClearedForSession !== void 0 && oauthRequest.postLoginClearedForSession === session?.session.id });
|
|
126
116
|
}
|
|
127
117
|
//#endregion
|
|
128
118
|
//#region src/continue.ts
|
|
129
|
-
async function continueEndpoint(ctx,
|
|
130
|
-
if (ctx.body.selected === true) return await selected(ctx,
|
|
131
|
-
else if (ctx.body.created === true) return await created(ctx,
|
|
132
|
-
else if (ctx.body.postLogin === true) return await postLogin(ctx,
|
|
119
|
+
async function continueEndpoint(ctx, authorize) {
|
|
120
|
+
if (ctx.body.selected === true) return await selected(ctx, authorize);
|
|
121
|
+
else if (ctx.body.created === true) return await created(ctx, authorize);
|
|
122
|
+
else if (ctx.body.postLogin === true) return await postLogin(ctx, authorize);
|
|
133
123
|
else throw new APIError("BAD_REQUEST", {
|
|
134
124
|
error_description: "Missing parameters",
|
|
135
125
|
error: "invalid_request"
|
|
136
126
|
});
|
|
137
127
|
}
|
|
138
|
-
async function selected(ctx,
|
|
128
|
+
async function selected(ctx, authorize) {
|
|
139
129
|
const _query = (await oAuthState.get())?.query;
|
|
140
130
|
if (!_query) throw new APIError("BAD_REQUEST", {
|
|
141
131
|
error_description: "missing oauth query",
|
|
@@ -143,13 +133,9 @@ async function selected(ctx, opts) {
|
|
|
143
133
|
});
|
|
144
134
|
ctx.headers?.set("accept", "application/json");
|
|
145
135
|
ctx.query = searchParamsToQuery(removePromptFromQuery(new URLSearchParams(_query), "select_account"));
|
|
146
|
-
|
|
147
|
-
return {
|
|
148
|
-
redirect: true,
|
|
149
|
-
url
|
|
150
|
-
};
|
|
136
|
+
return await authorize(ctx);
|
|
151
137
|
}
|
|
152
|
-
async function created(ctx,
|
|
138
|
+
async function created(ctx, authorize) {
|
|
153
139
|
const _query = (await oAuthState.get())?.query;
|
|
154
140
|
if (!_query) throw new APIError("BAD_REQUEST", {
|
|
155
141
|
error_description: "missing oauth query",
|
|
@@ -158,14 +144,11 @@ async function created(ctx, opts) {
|
|
|
158
144
|
const query = new URLSearchParams(_query);
|
|
159
145
|
ctx.headers?.set("accept", "application/json");
|
|
160
146
|
ctx.query = searchParamsToQuery(removePromptFromQuery(query, "create"));
|
|
161
|
-
|
|
162
|
-
return {
|
|
163
|
-
redirect: true,
|
|
164
|
-
url
|
|
165
|
-
};
|
|
147
|
+
return await authorize(ctx);
|
|
166
148
|
}
|
|
167
|
-
async function postLogin(ctx,
|
|
168
|
-
const
|
|
149
|
+
async function postLogin(ctx, authorize) {
|
|
150
|
+
const state = await oAuthState.get();
|
|
151
|
+
const _query = state?.query;
|
|
169
152
|
if (!_query) throw new APIError("BAD_REQUEST", {
|
|
170
153
|
error_description: "missing oauth query",
|
|
171
154
|
error: "invalid_request"
|
|
@@ -173,981 +156,178 @@ async function postLogin(ctx, opts) {
|
|
|
173
156
|
const query = new URLSearchParams(_query);
|
|
174
157
|
ctx.headers?.set("accept", "application/json");
|
|
175
158
|
ctx.query = searchParamsToQuery(query);
|
|
176
|
-
const
|
|
177
|
-
return {
|
|
178
|
-
redirect: true,
|
|
179
|
-
url
|
|
180
|
-
};
|
|
159
|
+
const session = await getSessionFromCtx(ctx);
|
|
160
|
+
return await authorize(ctx, { postLogin: state?.postLoginClearedForSession !== void 0 && state.postLoginClearedForSession === session?.session.id });
|
|
181
161
|
}
|
|
182
162
|
//#endregion
|
|
183
|
-
//#region src/
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const oauthAuthorizationQuerySchema = z.object({
|
|
189
|
-
response_type: z.literal("code").optional(),
|
|
190
|
-
request_uri: z.string().optional(),
|
|
191
|
-
redirect_uri: z.string(),
|
|
192
|
-
scope: z.string().optional(),
|
|
193
|
-
state: z.string().optional(),
|
|
194
|
-
client_id: z.string(),
|
|
195
|
-
prompt: z.string().optional(),
|
|
196
|
-
display: z.string().optional(),
|
|
197
|
-
ui_locales: z.string().optional(),
|
|
198
|
-
max_age: z.coerce.number().optional(),
|
|
199
|
-
acr_values: z.string().optional(),
|
|
200
|
-
login_hint: z.string().optional(),
|
|
201
|
-
id_token_hint: z.string().optional(),
|
|
202
|
-
code_challenge: z.string().optional(),
|
|
203
|
-
code_challenge_method: z.literal("S256").optional(),
|
|
204
|
-
nonce: z.string().optional(),
|
|
205
|
-
resource: z.union([z.string(), z.array(z.string())]).optional()
|
|
206
|
-
}).passthrough();
|
|
207
|
-
/**
|
|
208
|
-
* Runtime schema for the authorization code verification value.
|
|
209
|
-
* Validates structure on deserialization from the JSON blob stored in the DB.
|
|
210
|
-
* Uses passthrough so future fields (e.g. from authorization challenge) don't break parsing.
|
|
211
|
-
*/
|
|
212
|
-
const verificationValueSchema = z.object({
|
|
213
|
-
type: z.literal("authorization_code"),
|
|
214
|
-
query: oauthAuthorizationQuerySchema,
|
|
215
|
-
sessionId: z.string(),
|
|
216
|
-
userId: z.string(),
|
|
217
|
-
referenceId: z.string().optional(),
|
|
218
|
-
authTime: z.number().optional(),
|
|
219
|
-
resource: z.array(z.string()).optional()
|
|
220
|
-
}).passthrough();
|
|
221
|
-
const DANGEROUS_SCHEMES = [
|
|
222
|
-
"javascript:",
|
|
223
|
-
"data:",
|
|
224
|
-
"vbscript:"
|
|
225
|
-
];
|
|
226
|
-
/**
|
|
227
|
-
* Validates an RFC 8707 resource indicator. The value must be an absolute URI
|
|
228
|
-
* with no fragment (RFC 8707 §2). Unlike a redirect URI it is not restricted to
|
|
229
|
-
* HTTPS, because a resource server identifier may use any absolute URI scheme;
|
|
230
|
-
* the configured `validAudiences` allowlist is the authoritative control over
|
|
231
|
-
* which resources a token may target.
|
|
232
|
-
*/
|
|
233
|
-
const ResourceUriSchema = z.string().superRefine((val, ctx) => {
|
|
234
|
-
if (!URL.canParse(val)) {
|
|
235
|
-
ctx.addIssue({
|
|
236
|
-
code: "custom",
|
|
237
|
-
message: "resource must be an absolute URI",
|
|
238
|
-
fatal: true
|
|
239
|
-
});
|
|
240
|
-
return z.NEVER;
|
|
241
|
-
}
|
|
242
|
-
if (val.includes("#")) {
|
|
243
|
-
ctx.addIssue({
|
|
244
|
-
code: "custom",
|
|
245
|
-
message: "resource must not contain a fragment"
|
|
246
|
-
});
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
if (DANGEROUS_SCHEMES.includes(new URL(val).protocol)) ctx.addIssue({
|
|
250
|
-
code: "custom",
|
|
251
|
-
message: "resource cannot use javascript:, data:, or vbscript: scheme"
|
|
252
|
-
});
|
|
253
|
-
});
|
|
254
|
-
//#endregion
|
|
255
|
-
//#region src/userinfo.ts
|
|
163
|
+
//#region src/logout.ts
|
|
164
|
+
const BACKCHANNEL_LOGOUT_EVENT_URI = "http://schemas.openid.net/event/backchannel-logout";
|
|
165
|
+
const LOGOUT_TOKEN_JWT_TYP = "logout+jwt";
|
|
166
|
+
const LOGOUT_TOKEN_LIFETIME_SECONDS = 120;
|
|
167
|
+
const BACKCHANNEL_DISPATCH_TIMEOUT_MS = 5e3;
|
|
256
168
|
/**
|
|
257
|
-
*
|
|
169
|
+
* Signs a Back-Channel Logout Token per OIDC Back-Channel Logout 1.0 §2.4.
|
|
258
170
|
*
|
|
259
|
-
*
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
family_name: name.length > 1 ? name.at(-1) : void 0
|
|
268
|
-
};
|
|
269
|
-
const email = {
|
|
270
|
-
email: user.email ?? void 0,
|
|
271
|
-
email_verified: user.emailVerified ?? false
|
|
272
|
-
};
|
|
273
|
-
return {
|
|
274
|
-
sub: user.id ?? void 0,
|
|
275
|
-
...scopes.includes("profile") ? profile : {},
|
|
276
|
-
...scopes.includes("email") ? email : {}
|
|
277
|
-
};
|
|
278
|
-
}
|
|
279
|
-
/**
|
|
280
|
-
* Handles the /oauth2/userinfo endpoint
|
|
281
|
-
*/
|
|
282
|
-
async function userInfoEndpoint(ctx, opts) {
|
|
283
|
-
const authorization = ctx.headers?.get("authorization");
|
|
284
|
-
const token = typeof authorization === "string" && authorization?.startsWith("Bearer ") ? authorization?.replace("Bearer ", "") : authorization;
|
|
285
|
-
if (!token?.length) throw new APIError("UNAUTHORIZED", {
|
|
286
|
-
error_description: "authorization header not found",
|
|
287
|
-
error: "invalid_request"
|
|
288
|
-
});
|
|
289
|
-
const jwt = await validateAccessToken(ctx, opts, token);
|
|
290
|
-
const scopes = jwt.scope?.split(" ");
|
|
291
|
-
if (!scopes?.includes("openid")) throw new APIError("BAD_REQUEST", {
|
|
292
|
-
error_description: "Missing required scope",
|
|
293
|
-
error: "invalid_scope"
|
|
294
|
-
});
|
|
295
|
-
if (!jwt.sub) throw new APIError("BAD_REQUEST", {
|
|
296
|
-
error_description: "user not found",
|
|
297
|
-
error: "invalid_request"
|
|
298
|
-
});
|
|
299
|
-
const user = await ctx.context.internalAdapter.findUserById(jwt.sub);
|
|
300
|
-
if (!user) throw new APIError("BAD_REQUEST", {
|
|
301
|
-
error_description: "user not found",
|
|
302
|
-
error: "invalid_request"
|
|
303
|
-
});
|
|
304
|
-
const baseUserClaims = userNormalClaims(user, scopes ?? []);
|
|
305
|
-
if (opts.pairwiseSecret) {
|
|
306
|
-
const clientId = jwt.client_id ?? jwt.azp;
|
|
307
|
-
if (clientId) {
|
|
308
|
-
const client = await getClient(ctx, opts, clientId);
|
|
309
|
-
if (client) baseUserClaims.sub = await resolveSubjectIdentifier(user.id, client, opts);
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
const additionalInfoUserClaims = opts.customUserInfoClaims && scopes?.length ? await opts.customUserInfoClaims({
|
|
313
|
-
user,
|
|
314
|
-
scopes,
|
|
315
|
-
jwt
|
|
316
|
-
}) : {};
|
|
317
|
-
return {
|
|
318
|
-
...baseUserClaims,
|
|
319
|
-
...additionalInfoUserClaims
|
|
320
|
-
};
|
|
321
|
-
}
|
|
322
|
-
//#endregion
|
|
323
|
-
//#region src/token.ts
|
|
324
|
-
/**
|
|
325
|
-
* Handles the /oauth2/token endpoint by delegating
|
|
326
|
-
* the grant types
|
|
171
|
+
* The token reuses the ID Token signing key so any RP that validates ID Tokens
|
|
172
|
+
* from this OP can validate Logout Tokens without extra configuration. The
|
|
173
|
+
* caller resolves that key once and passes it in so a fan-out to many RPs does
|
|
174
|
+
* not re-read it per target.
|
|
175
|
+
*
|
|
176
|
+
* §2.4 mandates `iss`, `aud`, `iat`, `exp`, `jti`, `events`, and at least one
|
|
177
|
+
* of `sub` / `sid` (we send both). A `nonce` claim MUST NOT be present, and
|
|
178
|
+
* `alg: none` is forbidden (§2.6).
|
|
327
179
|
*/
|
|
328
|
-
async function
|
|
329
|
-
const grantType = ctx.body.grant_type;
|
|
330
|
-
if (opts.grantTypes && !opts.grantTypes.includes(grantType)) throw new APIError("BAD_REQUEST", {
|
|
331
|
-
error_description: `unsupported grant_type ${grantType}`,
|
|
332
|
-
error: "unsupported_grant_type"
|
|
333
|
-
});
|
|
334
|
-
switch (grantType) {
|
|
335
|
-
case "authorization_code": return handleAuthorizationCodeGrant(ctx, opts);
|
|
336
|
-
case "client_credentials": return handleClientCredentialsGrant(ctx, opts);
|
|
337
|
-
case "refresh_token": return handleRefreshTokenGrant(ctx, opts);
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
async function createJwtAccessToken(ctx, opts, user, client, audience, scopes, resources, referenceId, overrides) {
|
|
341
|
-
const iat = overrides?.iat ?? Math.floor(Date.now() / 1e3);
|
|
342
|
-
const exp = overrides?.exp ?? iat + (opts.accessTokenExpiresIn ?? 3600);
|
|
343
|
-
const customClaims = opts.customAccessTokenClaims ? await opts.customAccessTokenClaims({
|
|
344
|
-
user,
|
|
345
|
-
scopes,
|
|
346
|
-
resources,
|
|
347
|
-
referenceId,
|
|
348
|
-
metadata: parseClientMetadata(client.metadata)
|
|
349
|
-
}) : {};
|
|
350
|
-
const jwtPluginOptions = getJwtPlugin(ctx.context).options;
|
|
180
|
+
async function signLogoutToken(ctx, options, resolvedKey, claims) {
|
|
351
181
|
return signJWT(ctx, {
|
|
352
|
-
options
|
|
182
|
+
options,
|
|
353
183
|
payload: {
|
|
354
|
-
...
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
scope: scopes.join(" "),
|
|
359
|
-
sid: overrides?.sid,
|
|
360
|
-
iss: jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL,
|
|
361
|
-
iat,
|
|
362
|
-
exp
|
|
363
|
-
}
|
|
364
|
-
});
|
|
365
|
-
}
|
|
366
|
-
/**
|
|
367
|
-
* Computes an OIDC hash (at_hash, c_hash) per OIDC Core §3.1.3.6.
|
|
368
|
-
* Hashes the token, takes the left half, and base64url-encodes it.
|
|
369
|
-
*/
|
|
370
|
-
async function computeOidcHash(token, signingAlg) {
|
|
371
|
-
let hashAlg;
|
|
372
|
-
if (signingAlg === "EdDSA") hashAlg = "SHA-512";
|
|
373
|
-
else if (signingAlg.endsWith("384")) hashAlg = "SHA-384";
|
|
374
|
-
else if (signingAlg.endsWith("512")) hashAlg = "SHA-512";
|
|
375
|
-
else hashAlg = "SHA-256";
|
|
376
|
-
const digest = new Uint8Array(await crypto.subtle.digest(hashAlg, new TextEncoder().encode(token)));
|
|
377
|
-
return base64url.encode(digest.slice(0, digest.length / 2));
|
|
378
|
-
}
|
|
379
|
-
/**
|
|
380
|
-
* Creates a user id token in code_authorization with scope of 'openid'
|
|
381
|
-
* and hybrid/implicit (not yet implemented) flows
|
|
382
|
-
*/
|
|
383
|
-
async function createIdToken(ctx, opts, user, client, scopes, nonce, sessionId, authTime, accessToken) {
|
|
384
|
-
const iat = Math.floor(Date.now() / 1e3);
|
|
385
|
-
const exp = iat + (opts.idTokenExpiresIn ?? 36e3);
|
|
386
|
-
const userClaims = userNormalClaims(user, scopes);
|
|
387
|
-
const resolvedSub = await resolveSubjectIdentifier(user.id, client, opts);
|
|
388
|
-
const authTimeSec = authTime != null ? Math.floor(authTime.getTime() / 1e3) : void 0;
|
|
389
|
-
const acr = "urn:mace:incommon:iap:bronze";
|
|
390
|
-
const customClaims = opts.customIdTokenClaims ? await opts.customIdTokenClaims({
|
|
391
|
-
user,
|
|
392
|
-
scopes,
|
|
393
|
-
metadata: parseClientMetadata(client.metadata)
|
|
394
|
-
}) : {};
|
|
395
|
-
const jwtPluginOptions = opts.disableJwtPlugin ? void 0 : getJwtPlugin(ctx.context).options;
|
|
396
|
-
const resolvedKey = !opts.disableJwtPlugin && !jwtPluginOptions?.jwt?.sign ? await resolveSigningKey(ctx, jwtPluginOptions) : void 0;
|
|
397
|
-
const signingAlg = opts.disableJwtPlugin ? "HS256" : resolvedKey?.alg ?? jwtPluginOptions?.jwks?.keyPairConfig?.alg;
|
|
398
|
-
const atHash = accessToken ? await computeOidcHash(accessToken, signingAlg) : void 0;
|
|
399
|
-
const payload = {
|
|
400
|
-
...userClaims,
|
|
401
|
-
auth_time: authTimeSec,
|
|
402
|
-
acr,
|
|
403
|
-
...customClaims,
|
|
404
|
-
at_hash: atHash,
|
|
405
|
-
iss: jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL,
|
|
406
|
-
sub: resolvedSub,
|
|
407
|
-
aud: client.clientId,
|
|
408
|
-
nonce,
|
|
409
|
-
iat,
|
|
410
|
-
exp,
|
|
411
|
-
sid: client.enableEndSession ? sessionId : void 0
|
|
412
|
-
};
|
|
413
|
-
if (opts.disableJwtPlugin && !client.clientSecret) return;
|
|
414
|
-
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, {
|
|
415
|
-
options: jwtPluginOptions,
|
|
416
|
-
payload,
|
|
184
|
+
...claims,
|
|
185
|
+
events: { [BACKCHANNEL_LOGOUT_EVENT_URI]: {} }
|
|
186
|
+
},
|
|
187
|
+
header: { typ: LOGOUT_TOKEN_JWT_TYP },
|
|
417
188
|
resolvedKey: resolvedKey ?? void 0
|
|
418
189
|
});
|
|
419
|
-
if (idToken && atHash && jwtPluginOptions?.jwt?.sign) {
|
|
420
|
-
const header = decodeProtectedHeader(idToken);
|
|
421
|
-
if (header.alg !== signingAlg) throw new APIError("INTERNAL_SERVER_ERROR", {
|
|
422
|
-
error_description: `ID token signed with "${header.alg}" but at_hash was computed for "${signingAlg}". Ensure jwt.sign uses the algorithm declared in keyPairConfig.alg.`,
|
|
423
|
-
error: "server_error"
|
|
424
|
-
});
|
|
425
|
-
}
|
|
426
|
-
return idToken;
|
|
427
190
|
}
|
|
428
191
|
/**
|
|
429
|
-
*
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
}
|
|
434
|
-
/**
|
|
435
|
-
* Decodes a refresh token for a client
|
|
192
|
+
* Synchronous phase: enumerate tokens for the session being terminated, revoke
|
|
193
|
+
* them, and return a plan for the asynchronous delivery phase. Runs inline in
|
|
194
|
+
* the `session.delete.before` hook so the DB state is consistent before the
|
|
195
|
+
* session row disappears.
|
|
436
196
|
*
|
|
437
|
-
*
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
return opts.formatRefreshToken?.decrypt ? opts.formatRefreshToken?.decrypt(token) : { token };
|
|
446
|
-
}
|
|
447
|
-
async function createOpaqueAccessToken(ctx, opts, user, client, scopes, payload, resources, referenceId, refreshId) {
|
|
448
|
-
const iat = payload.iat ?? Math.floor(Date.now() / 1e3);
|
|
449
|
-
const exp = payload?.exp ?? iat + (opts.accessTokenExpiresIn ?? 3600);
|
|
450
|
-
const token = opts.generateOpaqueAccessToken ? await opts.generateOpaqueAccessToken() : generateRandomString(32, "A-Z", "a-z");
|
|
451
|
-
await ctx.context.adapter.create({
|
|
452
|
-
model: "oauthAccessToken",
|
|
453
|
-
data: {
|
|
454
|
-
token: await storeToken(opts.storeTokens, token, "access_token"),
|
|
455
|
-
clientId: client.clientId,
|
|
456
|
-
sessionId: payload?.sid,
|
|
457
|
-
userId: user?.id,
|
|
458
|
-
referenceId,
|
|
459
|
-
resources,
|
|
460
|
-
refreshId,
|
|
461
|
-
scopes,
|
|
462
|
-
createdAt: /* @__PURE__ */ new Date(iat * 1e3),
|
|
463
|
-
expiresAt: /* @__PURE__ */ new Date(exp * 1e3)
|
|
464
|
-
}
|
|
465
|
-
});
|
|
466
|
-
return (opts.prefix?.opaqueAccessToken ?? "") + token;
|
|
467
|
-
}
|
|
468
|
-
/**
|
|
469
|
-
* Tear down the entire refresh-token family for a (client, user) pair, plus
|
|
470
|
-
* any access tokens that reference those refresh rows, per RFC 9700 §4.14.
|
|
471
|
-
* Access tokens are deleted first so the parent rows' foreign-key children
|
|
472
|
-
* do not block the refresh-row delete.
|
|
197
|
+
* Revocation is the stored backstop, not the primary enforcement: introspection
|
|
198
|
+
* and `/userinfo` already treat a token whose session has ended as inactive
|
|
199
|
+
* (see `validateOpaqueAccessToken` / `validateJwtAccessToken`), so a missed
|
|
200
|
+
* `revoked` write cannot keep a session-bound token alive on its own. Access
|
|
201
|
+
* tokens bound to the session are revoked as OP hardening. Refresh tokens
|
|
202
|
+
* follow OIDC Back-Channel Logout 1.0 §2.7: those without `offline_access` are
|
|
203
|
+
* revoked; `offline_access` refresh tokens survive so long-lived API access can
|
|
204
|
+
* outlive the browser session.
|
|
473
205
|
*
|
|
474
|
-
*
|
|
475
|
-
*
|
|
476
|
-
*
|
|
477
|
-
* an access-token row referencing it) for the same (client, user) pair,
|
|
478
|
-
* leaving the family partially rebuilt and the new refresh row orphaned of
|
|
479
|
-
* any deletion. Closing this window requires the same transactional adapter
|
|
480
|
-
* contract tracked under FIXME(strict-family-invalidation) in
|
|
481
|
-
* `createRefreshToken`.
|
|
206
|
+
* Revocation runs regardless of the JWT plugin (refresh-token revocation has no
|
|
207
|
+
* dependency on signing). Only the Logout Token delivery plan needs the JWT
|
|
208
|
+
* plugin, so when it is disabled we still revoke but never build a plan.
|
|
482
209
|
*
|
|
483
|
-
*
|
|
210
|
+
* Returns `null` when there is nothing to do, so the caller can skip the
|
|
211
|
+
* background handoff entirely.
|
|
484
212
|
*/
|
|
485
|
-
async function
|
|
486
|
-
const
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
field: "clientId",
|
|
490
|
-
value: clientId
|
|
491
|
-
}, {
|
|
492
|
-
field: "userId",
|
|
493
|
-
value: userId
|
|
494
|
-
}]
|
|
495
|
-
});
|
|
496
|
-
if (refreshTokens.length) await ctx.context.adapter.deleteMany({
|
|
497
|
-
model: "oauthAccessToken",
|
|
498
|
-
where: [{
|
|
499
|
-
field: "refreshId",
|
|
500
|
-
operator: "in",
|
|
501
|
-
value: refreshTokens.map((r) => r.id)
|
|
502
|
-
}]
|
|
503
|
-
});
|
|
504
|
-
await ctx.context.adapter.deleteMany({
|
|
505
|
-
model: "oauthRefreshToken",
|
|
506
|
-
where: [{
|
|
507
|
-
field: "clientId",
|
|
508
|
-
value: clientId
|
|
509
|
-
}, {
|
|
510
|
-
field: "userId",
|
|
511
|
-
value: userId
|
|
512
|
-
}]
|
|
513
|
-
});
|
|
514
|
-
}
|
|
515
|
-
async function createRefreshToken(ctx, opts, user, referenceId, client, scopes, payload, originalRefresh, authTime, resources) {
|
|
516
|
-
const iat = payload.iat ?? Math.floor(Date.now() / 1e3);
|
|
517
|
-
const exp = payload?.exp ?? iat + (opts.refreshTokenExpiresIn ?? 2592e3);
|
|
518
|
-
const token = opts.generateRefreshToken ? await opts.generateRefreshToken() : generateRandomString(32, "A-Z", "a-z");
|
|
519
|
-
const sessionId = payload?.sid;
|
|
520
|
-
const newRow = {
|
|
521
|
-
token: await storeToken(opts.storeTokens, token, "refresh_token"),
|
|
522
|
-
clientId: client.clientId,
|
|
523
|
-
sessionId,
|
|
524
|
-
userId: user.id,
|
|
525
|
-
referenceId,
|
|
526
|
-
authTime,
|
|
527
|
-
scopes,
|
|
528
|
-
resources,
|
|
529
|
-
createdAt: /* @__PURE__ */ new Date(iat * 1e3),
|
|
530
|
-
expiresAt: /* @__PURE__ */ new Date(exp * 1e3)
|
|
531
|
-
};
|
|
532
|
-
if (!originalRefresh?.id) return {
|
|
533
|
-
id: (await ctx.context.adapter.create({
|
|
534
|
-
model: "oauthRefreshToken",
|
|
535
|
-
data: newRow
|
|
536
|
-
})).id,
|
|
537
|
-
token: await encodeRefreshToken(opts, token, sessionId)
|
|
538
|
-
};
|
|
539
|
-
if (!await ctx.context.adapter.update({
|
|
540
|
-
model: "oauthRefreshToken",
|
|
541
|
-
where: [{
|
|
542
|
-
field: "id",
|
|
543
|
-
value: originalRefresh.id
|
|
544
|
-
}, {
|
|
545
|
-
field: "revoked",
|
|
546
|
-
operator: "eq",
|
|
547
|
-
value: null
|
|
548
|
-
}],
|
|
549
|
-
update: { revoked: /* @__PURE__ */ new Date(iat * 1e3) }
|
|
550
|
-
})) throw new APIError("BAD_REQUEST", {
|
|
551
|
-
error_description: "invalid refresh token",
|
|
552
|
-
error: "invalid_grant"
|
|
553
|
-
});
|
|
554
|
-
return {
|
|
555
|
-
id: (await ctx.context.adapter.create({
|
|
556
|
-
model: "oauthRefreshToken",
|
|
557
|
-
data: newRow
|
|
558
|
-
})).id,
|
|
559
|
-
token: await encodeRefreshToken(opts, token, sessionId)
|
|
560
|
-
};
|
|
561
|
-
}
|
|
562
|
-
async function createUserTokens(ctx, opts, params) {
|
|
563
|
-
const { client, scopes, user, grantType, referenceId, sessionId, nonce, refreshToken: existingRefreshToken, authTime, verificationValue } = params;
|
|
564
|
-
const iat = Math.floor(Date.now() / 1e3);
|
|
565
|
-
const defaultExp = iat + (user ? opts.accessTokenExpiresIn ?? 3600 : opts.m2mAccessTokenExpiresIn ?? 3600);
|
|
566
|
-
const exp = opts.scopeExpirations ? scopes.map((sc) => opts.scopeExpirations?.[sc] ? toExpJWT(opts.scopeExpirations[sc], iat) : defaultExp).reduce((prev, curr) => {
|
|
567
|
-
return prev < curr ? prev : curr;
|
|
568
|
-
}, defaultExp) : defaultExp;
|
|
569
|
-
const resourceResult = await checkResource(ctx, opts, params?.resources, scopes);
|
|
570
|
-
if (!resourceResult.success) throw new APIError("BAD_REQUEST", {
|
|
571
|
-
error_description: "requested resource invalid",
|
|
572
|
-
error: "invalid_target"
|
|
573
|
-
});
|
|
574
|
-
const audience = resourceResult.audience;
|
|
575
|
-
const isRefreshToken = user && (existingRefreshToken?.scopes?.includes("offline_access") || scopes.includes("offline_access"));
|
|
576
|
-
const isJwtAccessToken = audience && !opts.disableJwtPlugin;
|
|
577
|
-
const isIdToken = user && scopes.includes("openid");
|
|
578
|
-
const customFields = opts.customTokenResponseFields ? await opts.customTokenResponseFields({
|
|
579
|
-
grantType,
|
|
580
|
-
user,
|
|
581
|
-
scopes,
|
|
582
|
-
metadata: parseClientMetadata(client.metadata),
|
|
583
|
-
verificationValue
|
|
584
|
-
}) : void 0;
|
|
585
|
-
const refreshResources = params?.refreshToken?.resources ?? params?.originalResources ?? params?.resources;
|
|
586
|
-
const earlyRefreshToken = isRefreshToken && user && !isJwtAccessToken ? await createRefreshToken(ctx, opts, user, referenceId, client, scopes, {
|
|
587
|
-
iat,
|
|
588
|
-
exp: iat + (opts.refreshTokenExpiresIn ?? 2592e3),
|
|
589
|
-
sid: sessionId
|
|
590
|
-
}, existingRefreshToken, authTime, refreshResources) : void 0;
|
|
591
|
-
const [accessToken, refreshToken] = await Promise.all([isJwtAccessToken ? createJwtAccessToken(ctx, opts, user, client, audience, scopes, params?.resources, referenceId, {
|
|
592
|
-
iat,
|
|
593
|
-
exp,
|
|
594
|
-
sid: sessionId
|
|
595
|
-
}) : createOpaqueAccessToken(ctx, opts, user, client, scopes, {
|
|
596
|
-
iat,
|
|
597
|
-
exp,
|
|
598
|
-
sid: sessionId
|
|
599
|
-
}, params?.resources, referenceId, earlyRefreshToken?.id), earlyRefreshToken ? earlyRefreshToken : isRefreshToken && user ? createRefreshToken(ctx, opts, user, referenceId, client, scopes, {
|
|
600
|
-
iat,
|
|
601
|
-
exp: iat + (opts.refreshTokenExpiresIn ?? 2592e3),
|
|
602
|
-
sid: sessionId
|
|
603
|
-
}, existingRefreshToken, authTime, refreshResources) : void 0]);
|
|
604
|
-
const idToken = isIdToken ? await createIdToken(ctx, opts, user, client, scopes, nonce, sessionId, authTime, accessToken) : void 0;
|
|
605
|
-
return ctx.json({
|
|
606
|
-
...customFields,
|
|
607
|
-
access_token: accessToken,
|
|
608
|
-
expires_in: exp - iat,
|
|
609
|
-
expires_at: exp,
|
|
610
|
-
token_type: "Bearer",
|
|
611
|
-
refresh_token: refreshToken?.token,
|
|
612
|
-
scope: scopes.join(" "),
|
|
613
|
-
id_token: idToken
|
|
614
|
-
}, { headers: {
|
|
615
|
-
"Cache-Control": "no-store",
|
|
616
|
-
Pragma: "no-cache"
|
|
617
|
-
} });
|
|
618
|
-
}
|
|
619
|
-
/** Checks verification value */
|
|
620
|
-
async function checkVerificationValue(ctx, opts, code, client_id, redirect_uri, resource) {
|
|
621
|
-
const verification = await ctx.context.internalAdapter.consumeVerificationValue(await storeToken(opts.storeTokens, code, "authorization_code"));
|
|
622
|
-
if (!verification) throw new APIError("UNAUTHORIZED", {
|
|
623
|
-
error_description: "invalid code",
|
|
624
|
-
error: "invalid_grant"
|
|
625
|
-
});
|
|
626
|
-
let rawValue;
|
|
213
|
+
async function revokeAndPlanBackchannelLogout(ctx, opts, input) {
|
|
214
|
+
const { sessionId, userId } = input;
|
|
215
|
+
if (!userId) return null;
|
|
216
|
+
const logger = ctx.context.logger;
|
|
627
217
|
try {
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
if (verificationValue.query?.redirect_uri && verificationValue.query?.redirect_uri !== redirect_uri) throw new APIError("BAD_REQUEST", {
|
|
646
|
-
error_description: "redirect_uri mismatch",
|
|
647
|
-
error: "invalid_request"
|
|
648
|
-
});
|
|
649
|
-
const storedResources = toResourceList(verificationValue.resource) ?? toResourceList(verificationValue.query.resource);
|
|
650
|
-
const effectiveResources = resource ?? storedResources;
|
|
651
|
-
if (resource && storedResources) {
|
|
652
|
-
const requestedSet = new Set(resource);
|
|
653
|
-
const authorizedSet = new Set(storedResources);
|
|
654
|
-
for (const r of requestedSet) if (!authorizedSet.has(r)) throw new APIError("BAD_REQUEST", {
|
|
655
|
-
error_description: "requested resource not authorized",
|
|
656
|
-
error: "invalid_target"
|
|
657
|
-
});
|
|
658
|
-
}
|
|
659
|
-
return {
|
|
660
|
-
verificationValue,
|
|
661
|
-
effectiveResources,
|
|
662
|
-
authorizedResources: storedResources
|
|
663
|
-
};
|
|
664
|
-
}
|
|
665
|
-
/**
|
|
666
|
-
* Obtains new Session Jwt and Refresh Tokens using a code
|
|
667
|
-
*/
|
|
668
|
-
async function handleAuthorizationCodeGrant(ctx, opts) {
|
|
669
|
-
const { clientId: client_id, clientSecret: client_secret, preVerifiedClient } = destructureCredentials(await extractClientCredentials(ctx, opts, `${ctx.context.baseURL}/oauth2/token`));
|
|
670
|
-
const { code, code_verifier, redirect_uri, resource } = ctx.body;
|
|
671
|
-
const resources = toResourceList(resource);
|
|
672
|
-
if (!client_id) throw new APIError("BAD_REQUEST", {
|
|
673
|
-
error_description: "client_id is required",
|
|
674
|
-
error: "invalid_request"
|
|
675
|
-
});
|
|
676
|
-
if (!code) throw new APIError("BAD_REQUEST", {
|
|
677
|
-
error_description: "code is required",
|
|
678
|
-
error: "invalid_request"
|
|
679
|
-
});
|
|
680
|
-
if (!redirect_uri) throw new APIError("BAD_REQUEST", {
|
|
681
|
-
error_description: "redirect_uri is required",
|
|
682
|
-
error: "invalid_request"
|
|
683
|
-
});
|
|
684
|
-
const isAuthCodeWithSecret = client_id && client_secret;
|
|
685
|
-
const isAuthCodeWithPkce = client_id && code && code_verifier;
|
|
686
|
-
if (!isAuthCodeWithSecret && !isAuthCodeWithPkce && !preVerifiedClient) throw new APIError("BAD_REQUEST", {
|
|
687
|
-
error_description: "Either code_verifier or client_secret is required",
|
|
688
|
-
error: "invalid_request"
|
|
689
|
-
});
|
|
690
|
-
/** Get and check Verification Value */
|
|
691
|
-
const { verificationValue, effectiveResources, authorizedResources } = await checkVerificationValue(ctx, opts, code, client_id, redirect_uri, resources);
|
|
692
|
-
const scopes = verificationValue.query.scope?.split(" ");
|
|
693
|
-
if (!scopes) throw new APIError("INTERNAL_SERVER_ERROR", {
|
|
694
|
-
error_description: "verification scope unset",
|
|
695
|
-
error: "invalid_scope"
|
|
696
|
-
});
|
|
697
|
-
/** Verify Client */
|
|
698
|
-
const client = await validateClientCredentials(ctx, opts, client_id, client_secret, scopes, preVerifiedClient);
|
|
699
|
-
if (isPKCERequired(client, (verificationValue.query?.scope)?.split(" ") || [])) {
|
|
700
|
-
if (!isAuthCodeWithPkce) throw new APIError("BAD_REQUEST", {
|
|
701
|
-
error_description: "PKCE is required for this client",
|
|
702
|
-
error: "invalid_request"
|
|
703
|
-
});
|
|
704
|
-
} else if (!(isAuthCodeWithPkce || isAuthCodeWithSecret || preVerifiedClient)) throw new APIError("BAD_REQUEST", {
|
|
705
|
-
error_description: "Either PKCE (code_verifier) or client authentication (client_secret or client_assertion) is required",
|
|
706
|
-
error: "invalid_request"
|
|
707
|
-
});
|
|
708
|
-
/** Check PKCE challenge if verifier is provided */
|
|
709
|
-
const pkceUsedInAuth = !!verificationValue.query?.code_challenge;
|
|
710
|
-
const pkceUsedInToken = !!code_verifier;
|
|
711
|
-
if (pkceUsedInAuth || pkceUsedInToken) {
|
|
712
|
-
if (pkceUsedInAuth && !pkceUsedInToken) throw new APIError("UNAUTHORIZED", {
|
|
713
|
-
error_description: "code_verifier required because PKCE was used in authorization",
|
|
714
|
-
error: "invalid_request"
|
|
715
|
-
});
|
|
716
|
-
if (!pkceUsedInAuth && pkceUsedInToken) throw new APIError("UNAUTHORIZED", {
|
|
717
|
-
error_description: "code_verifier provided but PKCE was not used in authorization",
|
|
718
|
-
error: "invalid_request"
|
|
719
|
-
});
|
|
720
|
-
if ((verificationValue.query?.code_challenge_method === "S256" ? await generateCodeChallenge(code_verifier) : void 0) !== verificationValue.query?.code_challenge) throw new APIError("UNAUTHORIZED", {
|
|
721
|
-
error_description: "code verification failed",
|
|
722
|
-
error: "invalid_request"
|
|
723
|
-
});
|
|
724
|
-
}
|
|
725
|
-
/** Get user */
|
|
726
|
-
if (!verificationValue.userId) throw new APIError("BAD_REQUEST", {
|
|
727
|
-
error_description: "missing user, user may have been deleted",
|
|
728
|
-
error: "invalid_user"
|
|
729
|
-
});
|
|
730
|
-
const user = await ctx.context.internalAdapter.findUserById(verificationValue.userId);
|
|
731
|
-
if (!user) throw new APIError("BAD_REQUEST", {
|
|
732
|
-
error_description: "missing user, user may have been deleted",
|
|
733
|
-
error: "invalid_user"
|
|
734
|
-
});
|
|
735
|
-
const session = await ctx.context.adapter.findOne({
|
|
736
|
-
model: "session",
|
|
737
|
-
where: [{
|
|
738
|
-
field: "id",
|
|
739
|
-
value: verificationValue.sessionId
|
|
740
|
-
}]
|
|
741
|
-
});
|
|
742
|
-
if (!session || session.expiresAt < /* @__PURE__ */ new Date()) throw new APIError("BAD_REQUEST", {
|
|
743
|
-
error_description: "session no longer exists",
|
|
744
|
-
error: "invalid_request"
|
|
745
|
-
});
|
|
746
|
-
const authTime = verificationValue.authTime != null ? normalizeTimestampValue(verificationValue.authTime) : resolveSessionAuthTime(session);
|
|
747
|
-
return createUserTokens(ctx, opts, {
|
|
748
|
-
client,
|
|
749
|
-
scopes: verificationValue.query.scope?.split(" ") ?? [],
|
|
750
|
-
user,
|
|
751
|
-
grantType: "authorization_code",
|
|
752
|
-
referenceId: verificationValue.referenceId,
|
|
753
|
-
sessionId: session.id,
|
|
754
|
-
nonce: verificationValue.query?.nonce,
|
|
755
|
-
authTime,
|
|
756
|
-
verificationValue,
|
|
757
|
-
resources: effectiveResources,
|
|
758
|
-
originalResources: authorizedResources
|
|
759
|
-
});
|
|
760
|
-
}
|
|
761
|
-
/**
|
|
762
|
-
* Grant that allows direct access to an API using the application's credentials
|
|
763
|
-
* This grant is for M2M so the concept of a user id does not exist on the token.
|
|
764
|
-
*
|
|
765
|
-
* MUST follow https://datatracker.ietf.org/doc/html/rfc6749#section-4.4
|
|
766
|
-
*/
|
|
767
|
-
async function handleClientCredentialsGrant(ctx, opts) {
|
|
768
|
-
const { clientId: client_id, clientSecret: client_secret, preVerifiedClient } = destructureCredentials(await extractClientCredentials(ctx, opts, `${ctx.context.baseURL}/oauth2/token`));
|
|
769
|
-
const { scope, resource } = ctx.body;
|
|
770
|
-
const resources = toResourceList(resource);
|
|
771
|
-
if (!client_id) throw new APIError("BAD_REQUEST", {
|
|
772
|
-
error_description: "Missing required client_id",
|
|
773
|
-
error: "invalid_grant"
|
|
774
|
-
});
|
|
775
|
-
if (!client_secret && !preVerifiedClient) throw new APIError("BAD_REQUEST", {
|
|
776
|
-
error_description: "Missing a required client_secret",
|
|
777
|
-
error: "invalid_grant"
|
|
778
|
-
});
|
|
779
|
-
const client = await validateClientCredentials(ctx, opts, client_id, client_secret, void 0, preVerifiedClient);
|
|
780
|
-
let requestedScopes = scope?.split(" ");
|
|
781
|
-
if (requestedScopes) {
|
|
782
|
-
const validScopes = new Set(client.scopes ?? opts.scopes);
|
|
783
|
-
const oidcScopes = new Set([
|
|
784
|
-
"openid",
|
|
785
|
-
"profile",
|
|
786
|
-
"email",
|
|
787
|
-
"offline_access"
|
|
788
|
-
]);
|
|
789
|
-
const invalidScopes = requestedScopes.filter((scope) => {
|
|
790
|
-
return !validScopes?.has(scope) || oidcScopes.has(scope);
|
|
791
|
-
});
|
|
792
|
-
if (invalidScopes.length) throw new APIError("BAD_REQUEST", {
|
|
793
|
-
error_description: `The following scopes are invalid: ${invalidScopes.join(", ")}`,
|
|
794
|
-
error: "invalid_scope"
|
|
795
|
-
});
|
|
796
|
-
}
|
|
797
|
-
if (!requestedScopes) requestedScopes = client.scopes ?? opts.clientCredentialGrantDefaultScopes ?? opts.scopes ?? [];
|
|
798
|
-
return createUserTokens(ctx, opts, {
|
|
799
|
-
client,
|
|
800
|
-
scopes: requestedScopes,
|
|
801
|
-
grantType: "client_credentials",
|
|
802
|
-
resources
|
|
803
|
-
});
|
|
804
|
-
}
|
|
805
|
-
/**
|
|
806
|
-
* Obtains new Session Jwt and Refresh Tokens using a refresh token
|
|
807
|
-
*
|
|
808
|
-
* Refresh tokens will only allow the same or lesser scopes as the initial authorize request.
|
|
809
|
-
* To add scopes, you must restart the authorize process again.
|
|
810
|
-
*/
|
|
811
|
-
async function handleRefreshTokenGrant(ctx, opts) {
|
|
812
|
-
const { clientId: client_id, clientSecret: client_secret, preVerifiedClient } = destructureCredentials(await extractClientCredentials(ctx, opts, `${ctx.context.baseURL}/oauth2/token`));
|
|
813
|
-
const { refresh_token, scope, resource } = ctx.body;
|
|
814
|
-
const resources = toResourceList(resource);
|
|
815
|
-
if (!client_id) throw new APIError("BAD_REQUEST", {
|
|
816
|
-
error_description: "Missing required client_id",
|
|
817
|
-
error: "invalid_grant"
|
|
818
|
-
});
|
|
819
|
-
if (!refresh_token) throw new APIError("BAD_REQUEST", {
|
|
820
|
-
error_description: "Missing a required refresh_token for refresh_token grant",
|
|
821
|
-
error: "invalid_grant"
|
|
822
|
-
});
|
|
823
|
-
const decodedRefresh = await decodeRefreshToken(opts, refresh_token);
|
|
824
|
-
const refreshToken = await ctx.context.adapter.findOne({
|
|
825
|
-
model: "oauthRefreshToken",
|
|
826
|
-
where: [{
|
|
827
|
-
field: "token",
|
|
828
|
-
value: await getStoredToken(opts.storeTokens, decodedRefresh.token, "refresh_token")
|
|
829
|
-
}]
|
|
830
|
-
});
|
|
831
|
-
if (!refreshToken) throw new APIError("BAD_REQUEST", {
|
|
832
|
-
error_description: "session not found",
|
|
833
|
-
error: "invalid_grant"
|
|
834
|
-
});
|
|
835
|
-
if (refreshToken.clientId !== client_id) throw new APIError("BAD_REQUEST", {
|
|
836
|
-
error_description: "invalid client_id",
|
|
837
|
-
error: "invalid_client"
|
|
838
|
-
});
|
|
839
|
-
if (refreshToken.expiresAt < /* @__PURE__ */ new Date()) throw new APIError("BAD_REQUEST", {
|
|
840
|
-
error_description: "invalid refresh token",
|
|
841
|
-
error: "invalid_grant"
|
|
842
|
-
});
|
|
843
|
-
if (refreshToken.revoked) {
|
|
844
|
-
await invalidateRefreshFamily(ctx, client_id, refreshToken.userId);
|
|
845
|
-
throw new APIError("BAD_REQUEST", {
|
|
846
|
-
error_description: "invalid refresh token",
|
|
847
|
-
error: "invalid_grant"
|
|
848
|
-
});
|
|
849
|
-
}
|
|
850
|
-
if (resources && refreshToken.resources && !resources.every((v) => refreshToken.resources?.includes(v))) throw new APIError("BAD_REQUEST", {
|
|
851
|
-
error_description: "requested resource invalid",
|
|
852
|
-
error: "invalid_target"
|
|
853
|
-
});
|
|
854
|
-
const scopes = refreshToken?.scopes;
|
|
855
|
-
const requestedScopes = scope?.split(" ");
|
|
856
|
-
if (requestedScopes) {
|
|
857
|
-
const validScopes = new Set(scopes);
|
|
858
|
-
for (const requestedScope of requestedScopes) if (!validScopes.has(requestedScope)) throw new APIError("BAD_REQUEST", {
|
|
859
|
-
error_description: `unable to issue scope ${requestedScope}`,
|
|
860
|
-
error: "invalid_scope"
|
|
861
|
-
});
|
|
862
|
-
}
|
|
863
|
-
const client = await validateClientCredentials(ctx, opts, client_id, client_secret, requestedScopes ?? scopes, preVerifiedClient);
|
|
864
|
-
const user = await ctx.context.internalAdapter.findUserById(refreshToken.userId);
|
|
865
|
-
if (!user) throw new APIError("BAD_REQUEST", {
|
|
866
|
-
error_description: "user not found",
|
|
867
|
-
error: "invalid_request"
|
|
868
|
-
});
|
|
869
|
-
const authTime = refreshToken.authTime != null ? normalizeTimestampValue(refreshToken.authTime) : void 0;
|
|
870
|
-
return createUserTokens(ctx, opts, {
|
|
871
|
-
client,
|
|
872
|
-
scopes: requestedScopes ?? scopes,
|
|
873
|
-
user,
|
|
874
|
-
grantType: "refresh_token",
|
|
875
|
-
referenceId: refreshToken.referenceId,
|
|
876
|
-
sessionId: refreshToken.sessionId,
|
|
877
|
-
refreshToken,
|
|
878
|
-
resources: resources ?? refreshToken.resources,
|
|
879
|
-
authTime
|
|
880
|
-
});
|
|
881
|
-
}
|
|
882
|
-
//#endregion
|
|
883
|
-
//#region src/introspect.ts
|
|
884
|
-
/**
|
|
885
|
-
* IMPORTANT NOTES:
|
|
886
|
-
* Introspection follows RFC7662
|
|
887
|
-
* https://datatracker.ietf.org/doc/html/rfc7662
|
|
888
|
-
* - APIError: Continue catches (returnable to client)
|
|
889
|
-
* - Error: Should immediately stop catches (internal error)
|
|
890
|
-
*/
|
|
891
|
-
/**
|
|
892
|
-
* Validates a JWT access token against the configured JWKs.
|
|
893
|
-
*
|
|
894
|
-
* @returns RFC7662 introspection format
|
|
895
|
-
*/
|
|
896
|
-
async function validateJwtAccessToken(ctx, opts, token, clientId) {
|
|
897
|
-
const jwtPlugin = opts.disableJwtPlugin ? void 0 : getJwtPlugin(ctx.context);
|
|
898
|
-
const jwtPluginOptions = jwtPlugin?.options;
|
|
899
|
-
let jwtPayload;
|
|
900
|
-
try {
|
|
901
|
-
jwtPayload = await verifyJwsAccessToken(token, {
|
|
902
|
-
jwksFetch: jwtPluginOptions?.jwks?.remoteUrl ? jwtPluginOptions.jwks.remoteUrl : async () => {
|
|
903
|
-
return (await jwtPlugin?.endpoints.getJwks(ctx))?.response;
|
|
904
|
-
},
|
|
905
|
-
verifyOptions: {
|
|
906
|
-
audience: opts.validAudiences ?? ctx.context.baseURL,
|
|
907
|
-
issuer: jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL
|
|
908
|
-
}
|
|
909
|
-
});
|
|
910
|
-
} catch (error) {
|
|
911
|
-
if (error instanceof Error) {
|
|
912
|
-
if (error.name === "TypeError" || error.name === "JWSInvalid") throw new APIError$1("BAD_REQUEST", {
|
|
913
|
-
error_description: "invalid JWT signature",
|
|
914
|
-
error: "invalid_request"
|
|
915
|
-
});
|
|
916
|
-
else if (error.name === "JWTExpired") return { active: false };
|
|
917
|
-
else if (error.name === "JWTInvalid") return { active: false };
|
|
918
|
-
throw error;
|
|
919
|
-
}
|
|
920
|
-
throw new Error(error);
|
|
921
|
-
}
|
|
922
|
-
let client;
|
|
923
|
-
if (jwtPayload.azp) {
|
|
924
|
-
client = await getClient(ctx, opts, jwtPayload.azp);
|
|
925
|
-
if (!client || client?.disabled) return { active: false };
|
|
926
|
-
if (clientId && jwtPayload.azp !== clientId) return { active: false };
|
|
927
|
-
}
|
|
928
|
-
const sessionId = jwtPayload.sid;
|
|
929
|
-
if (sessionId) {
|
|
930
|
-
const session = await ctx.context.adapter.findOne({
|
|
931
|
-
model: "session",
|
|
218
|
+
const where = [{
|
|
219
|
+
field: "sessionId",
|
|
220
|
+
value: sessionId
|
|
221
|
+
}];
|
|
222
|
+
const [accessTokens, refreshTokens] = await Promise.all([ctx.context.adapter.findMany({
|
|
223
|
+
model: "oauthAccessToken",
|
|
224
|
+
where
|
|
225
|
+
}), ctx.context.adapter.findMany({
|
|
226
|
+
model: "oauthRefreshToken",
|
|
227
|
+
where
|
|
228
|
+
})]);
|
|
229
|
+
const affectedClientIds = /* @__PURE__ */ new Set();
|
|
230
|
+
for (const t of accessTokens) affectedClientIds.add(t.clientId);
|
|
231
|
+
for (const t of refreshTokens) affectedClientIds.add(t.clientId);
|
|
232
|
+
if (affectedClientIds.size === 0) return null;
|
|
233
|
+
const clients = await ctx.context.adapter.findMany({
|
|
234
|
+
model: "oauthClient",
|
|
932
235
|
where: [{
|
|
933
|
-
field: "
|
|
934
|
-
|
|
236
|
+
field: "clientId",
|
|
237
|
+
operator: "in",
|
|
238
|
+
value: Array.from(affectedClientIds)
|
|
935
239
|
}]
|
|
936
240
|
});
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
}
|
|
943
|
-
/**
|
|
944
|
-
* Searches for an opaque access token in the database and validates it
|
|
945
|
-
*
|
|
946
|
-
* @returns RFC7662 introspection format
|
|
947
|
-
*/
|
|
948
|
-
async function validateOpaqueAccessToken(ctx, opts, token, clientId) {
|
|
949
|
-
let tokenValue = token;
|
|
950
|
-
if (opts.prefix?.opaqueAccessToken) if (tokenValue.startsWith(opts.prefix.opaqueAccessToken)) tokenValue = tokenValue.replace(opts.prefix.opaqueAccessToken, "");
|
|
951
|
-
else throw new APIError$1("BAD_REQUEST", {
|
|
952
|
-
error_description: "opaque access token not found",
|
|
953
|
-
error: "invalid_request"
|
|
954
|
-
});
|
|
955
|
-
const accessToken = await ctx.context.adapter.findOne({
|
|
956
|
-
model: "oauthAccessToken",
|
|
957
|
-
where: [{
|
|
958
|
-
field: "token",
|
|
959
|
-
value: await getStoredToken(opts.storeTokens, tokenValue, "access_token")
|
|
960
|
-
}]
|
|
961
|
-
});
|
|
962
|
-
if (!accessToken) throw new APIError$1("BAD_REQUEST", {
|
|
963
|
-
error_description: "opaque access token not found",
|
|
964
|
-
error: "invalid_token"
|
|
965
|
-
});
|
|
966
|
-
if (!accessToken.expiresAt || accessToken.expiresAt < /* @__PURE__ */ new Date()) return { active: false };
|
|
967
|
-
let client;
|
|
968
|
-
if (accessToken.clientId) {
|
|
969
|
-
client = await getClient(ctx, opts, accessToken.clientId);
|
|
970
|
-
if (!client || client?.disabled) return { active: false };
|
|
971
|
-
if (clientId && accessToken.clientId !== clientId) return { active: false };
|
|
972
|
-
}
|
|
973
|
-
let sessionId = accessToken.sessionId ?? void 0;
|
|
974
|
-
if (sessionId) {
|
|
975
|
-
const session = await ctx.context.adapter.findOne({
|
|
976
|
-
model: "session",
|
|
241
|
+
const revokedAt = /* @__PURE__ */ new Date();
|
|
242
|
+
const accessToRevokeIds = accessTokens.filter((t) => !t.revoked).map((t) => t.id);
|
|
243
|
+
const refreshToRevokeIds = refreshTokens.filter((t) => !t.revoked && !t.scopes?.includes("offline_access")).map((t) => t.id);
|
|
244
|
+
const revocations = await Promise.allSettled([accessToRevokeIds.length > 0 ? ctx.context.adapter.updateMany({
|
|
245
|
+
model: "oauthAccessToken",
|
|
977
246
|
where: [{
|
|
978
247
|
field: "id",
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
if (accessToken.userId) user = await ctx.context.internalAdapter.findUserById(accessToken?.userId);
|
|
986
|
-
const resources = Array.isArray(accessToken.resources) ? accessToken.resources : void 0;
|
|
987
|
-
const audience = resources ? [...resources] : void 0;
|
|
988
|
-
if (audience?.length && accessToken.scopes?.includes("openid")) {
|
|
989
|
-
const userInfoEndpoint = `${ctx.context.baseURL}/oauth2/userinfo`;
|
|
990
|
-
if (!audience.includes(userInfoEndpoint)) audience.push(userInfoEndpoint);
|
|
991
|
-
}
|
|
992
|
-
const customClaims = opts.customAccessTokenClaims ? await opts.customAccessTokenClaims({
|
|
993
|
-
user,
|
|
994
|
-
scopes: accessToken.scopes,
|
|
995
|
-
referenceId: accessToken?.referenceId,
|
|
996
|
-
resources,
|
|
997
|
-
metadata: parseClientMetadata(client?.metadata)
|
|
998
|
-
}) : {};
|
|
999
|
-
const jwtPluginOptions = (opts.disableJwtPlugin ? void 0 : getJwtPlugin(ctx.context))?.options;
|
|
1000
|
-
return {
|
|
1001
|
-
...customClaims,
|
|
1002
|
-
active: true,
|
|
1003
|
-
iss: jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL,
|
|
1004
|
-
aud: toAudienceClaim(audience),
|
|
1005
|
-
client_id: accessToken.clientId,
|
|
1006
|
-
sub: user?.id,
|
|
1007
|
-
sid: sessionId,
|
|
1008
|
-
exp: Math.floor(new Date(accessToken.expiresAt).getTime() / 1e3),
|
|
1009
|
-
iat: Math.floor(new Date(accessToken.createdAt).getTime() / 1e3),
|
|
1010
|
-
scope: accessToken.scopes?.join(" ")
|
|
1011
|
-
};
|
|
1012
|
-
}
|
|
1013
|
-
/**
|
|
1014
|
-
* Validates a refresh token in the session store.
|
|
1015
|
-
*
|
|
1016
|
-
* @returns payload in RFC7662 introspection format
|
|
1017
|
-
*/
|
|
1018
|
-
async function validateRefreshToken(ctx, opts, token, clientId) {
|
|
1019
|
-
const refreshToken = await ctx.context.adapter.findOne({
|
|
1020
|
-
model: "oauthRefreshToken",
|
|
1021
|
-
where: [{
|
|
1022
|
-
field: "token",
|
|
1023
|
-
value: await getStoredToken(opts.storeTokens, token, "refresh_token")
|
|
1024
|
-
}]
|
|
1025
|
-
});
|
|
1026
|
-
if (!refreshToken) throw new APIError$1("BAD_REQUEST", {
|
|
1027
|
-
error_description: "token not found",
|
|
1028
|
-
error: "invalid_token"
|
|
1029
|
-
});
|
|
1030
|
-
if (!refreshToken.clientId || refreshToken.clientId !== clientId) return { active: false };
|
|
1031
|
-
if (!refreshToken.expiresAt || refreshToken.expiresAt < /* @__PURE__ */ new Date()) return { active: false };
|
|
1032
|
-
if (refreshToken.revoked) return { active: false };
|
|
1033
|
-
let sessionId = refreshToken.sessionId ?? void 0;
|
|
1034
|
-
if (sessionId) {
|
|
1035
|
-
const session = await ctx.context.adapter.findOne({
|
|
1036
|
-
model: "session",
|
|
248
|
+
operator: "in",
|
|
249
|
+
value: accessToRevokeIds
|
|
250
|
+
}],
|
|
251
|
+
update: { revoked: revokedAt }
|
|
252
|
+
}) : Promise.resolve(), refreshToRevokeIds.length > 0 ? ctx.context.adapter.updateMany({
|
|
253
|
+
model: "oauthRefreshToken",
|
|
1037
254
|
where: [{
|
|
1038
255
|
field: "id",
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
256
|
+
operator: "in",
|
|
257
|
+
value: refreshToRevokeIds
|
|
258
|
+
}],
|
|
259
|
+
update: { revoked: revokedAt }
|
|
260
|
+
}) : Promise.resolve()]);
|
|
261
|
+
for (const result of revocations) if (result.status === "rejected") logger.error("back-channel logout: token revocation update failed", result.reason);
|
|
262
|
+
const eligibleClients = opts.disableJwtPlugin ? [] : clients.filter((c) => Boolean(c.backchannelLogoutUri) && !c.disabled);
|
|
263
|
+
if (eligibleClients.length === 0) return null;
|
|
264
|
+
return {
|
|
265
|
+
sessionId,
|
|
266
|
+
targets: await Promise.all(eligibleClients.map(async (client) => ({
|
|
267
|
+
client,
|
|
268
|
+
sub: await resolveSubjectIdentifier(userId, client, opts)
|
|
269
|
+
})))
|
|
270
|
+
};
|
|
271
|
+
} catch (error) {
|
|
272
|
+
logger.error("back-channel logout revocation failed", error);
|
|
273
|
+
return null;
|
|
1043
274
|
}
|
|
1044
|
-
let user = void 0;
|
|
1045
|
-
if (refreshToken.userId) user = await ctx.context.internalAdapter.findUserById(refreshToken?.userId) ?? void 0;
|
|
1046
|
-
return {
|
|
1047
|
-
active: true,
|
|
1048
|
-
client_id: clientId,
|
|
1049
|
-
iss: ((opts.disableJwtPlugin ? void 0 : getJwtPlugin(ctx.context))?.options)?.jwt?.issuer ?? ctx.context.baseURL,
|
|
1050
|
-
sub: user?.id,
|
|
1051
|
-
sid: sessionId,
|
|
1052
|
-
exp: Math.floor(new Date(refreshToken.expiresAt).getTime() / 1e3),
|
|
1053
|
-
iat: Math.floor(new Date(refreshToken.createdAt).getTime() / 1e3),
|
|
1054
|
-
scope: refreshToken.scopes?.join(" ")
|
|
1055
|
-
};
|
|
1056
275
|
}
|
|
1057
276
|
/**
|
|
1058
|
-
*
|
|
1059
|
-
*
|
|
277
|
+
* Asynchronous phase: sign one Logout Token per target client and POST it to
|
|
278
|
+
* the registered `backchannel_logout_uri`. The caller hands this to
|
|
279
|
+
* `runInBackgroundOrAwait`, so when a background handler is configured (Vercel
|
|
280
|
+
* `waitUntil`, Cloudflare `ctx.waitUntil`) it runs after the response; without
|
|
281
|
+
* one it completes inline so delivery is not lost on request teardown.
|
|
1060
282
|
*
|
|
1061
|
-
*
|
|
1062
|
-
*
|
|
1063
|
-
*
|
|
283
|
+
* Spec §2.5: "the OP SHOULD NOT retransmit", so each RP gets a single attempt
|
|
284
|
+
* within `BACKCHANNEL_DISPATCH_TIMEOUT_MS`. Every per-client failure (fetch
|
|
285
|
+
* error, non-2xx response, signing error, subject resolution error) is
|
|
286
|
+
* logged; none of them can reject the outer promise.
|
|
1064
287
|
*/
|
|
1065
|
-
async function
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
}
|
|
1096
|
-
return payload;
|
|
1097
|
-
}
|
|
1098
|
-
async function introspectEndpoint(ctx, opts) {
|
|
1099
|
-
let { token, token_type_hint } = ctx.body;
|
|
1100
|
-
if (token_type_hint !== "access_token" && token_type_hint !== "refresh_token") token_type_hint = void 0;
|
|
1101
|
-
const { clientId: client_id, clientSecret: client_secret, preVerifiedClient } = destructureCredentials(await extractClientCredentials(ctx, opts, `${ctx.context.baseURL}/oauth2/introspect`));
|
|
1102
|
-
if (!client_id || !client_secret && !preVerifiedClient) throw new APIError$1("UNAUTHORIZED", {
|
|
1103
|
-
error_description: "missing required credentials",
|
|
1104
|
-
error: "invalid_client"
|
|
1105
|
-
});
|
|
1106
|
-
if (token && typeof token === "string" && token.startsWith("Bearer ")) token = token.replace("Bearer ", "");
|
|
1107
|
-
if (!token?.length) throw new APIError$1("BAD_REQUEST", {
|
|
1108
|
-
error_description: "missing a required token for introspection",
|
|
1109
|
-
error: "invalid_request"
|
|
1110
|
-
});
|
|
1111
|
-
const client = await validateClientCredentials(ctx, opts, client_id, client_secret, void 0, preVerifiedClient);
|
|
1112
|
-
try {
|
|
1113
|
-
if (token_type_hint === void 0 || token_type_hint === "access_token") try {
|
|
1114
|
-
return resolveIntrospectionSub(opts, await validateAccessToken(ctx, opts, token, client.clientId), client);
|
|
1115
|
-
} catch (error) {
|
|
1116
|
-
if (error instanceof APIError$1) {
|
|
1117
|
-
if (token_type_hint === "access_token") throw error;
|
|
1118
|
-
} else if (error instanceof Error) throw error;
|
|
1119
|
-
else throw new Error(error);
|
|
1120
|
-
}
|
|
1121
|
-
if (token_type_hint === void 0 || token_type_hint === "refresh_token") try {
|
|
1122
|
-
return resolveIntrospectionSub(opts, await validateRefreshToken(ctx, opts, (await decodeRefreshToken(opts, token)).token, client.clientId), client);
|
|
288
|
+
async function deliverBackchannelLogoutTokens(ctx, plan) {
|
|
289
|
+
const logger = ctx.context.logger;
|
|
290
|
+
const jwtPluginOptions = getJwtPlugin(ctx.context)?.options;
|
|
291
|
+
const iss = jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL;
|
|
292
|
+
const iat = Math.floor(Date.now() / 1e3);
|
|
293
|
+
const exp = iat + LOGOUT_TOKEN_LIFETIME_SECONDS;
|
|
294
|
+
const resolvedKey = jwtPluginOptions?.jwt?.sign ? null : await resolveSigningKey(ctx, jwtPluginOptions);
|
|
295
|
+
await Promise.allSettled(plan.targets.map(async ({ client, sub }) => {
|
|
296
|
+
try {
|
|
297
|
+
const jti = generateRandomString(32, "a-z", "A-Z", "0-9");
|
|
298
|
+
const token = await signLogoutToken(ctx, jwtPluginOptions, resolvedKey, {
|
|
299
|
+
iss,
|
|
300
|
+
aud: client.clientId,
|
|
301
|
+
sub,
|
|
302
|
+
sid: plan.sessionId,
|
|
303
|
+
iat,
|
|
304
|
+
exp,
|
|
305
|
+
jti
|
|
306
|
+
});
|
|
307
|
+
const response = await fetch(client.backchannelLogoutUri, {
|
|
308
|
+
method: "POST",
|
|
309
|
+
headers: {
|
|
310
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
311
|
+
Accept: "application/json"
|
|
312
|
+
},
|
|
313
|
+
body: new URLSearchParams({ logout_token: token }),
|
|
314
|
+
signal: AbortSignal.timeout(BACKCHANNEL_DISPATCH_TIMEOUT_MS),
|
|
315
|
+
redirect: "error"
|
|
316
|
+
});
|
|
317
|
+
if (response.status !== 200 && response.status !== 204) logger.warn(`back-channel logout to client ${client.clientId} returned ${response.status}`);
|
|
1123
318
|
} catch (error) {
|
|
1124
|
-
|
|
1125
|
-
if (token_type_hint === "refresh_token") throw error;
|
|
1126
|
-
} else if (error instanceof Error) throw error;
|
|
1127
|
-
else throw new Error(error);
|
|
1128
|
-
}
|
|
1129
|
-
throw new APIError$1("BAD_REQUEST", {
|
|
1130
|
-
error_description: "token not found",
|
|
1131
|
-
error: "invalid_request"
|
|
1132
|
-
});
|
|
1133
|
-
} catch (error) {
|
|
1134
|
-
if (error instanceof APIError$1) {
|
|
1135
|
-
if (error.name === "BAD_REQUEST") return { active: false };
|
|
1136
|
-
throw error;
|
|
1137
|
-
} else if (error instanceof Error) {
|
|
1138
|
-
logger.error("Introspection error:", error.message, error.stack);
|
|
1139
|
-
throw new APIError$1("INTERNAL_SERVER_ERROR");
|
|
1140
|
-
} else {
|
|
1141
|
-
logger.error("Introspection error:", error);
|
|
1142
|
-
throw new APIError$1("INTERNAL_SERVER_ERROR");
|
|
319
|
+
logger.warn(`back-channel logout to client ${client.clientId} failed`, error);
|
|
1143
320
|
}
|
|
1144
|
-
}
|
|
321
|
+
}));
|
|
1145
322
|
}
|
|
1146
|
-
//#endregion
|
|
1147
|
-
//#region src/logout.ts
|
|
1148
323
|
/**
|
|
1149
|
-
*
|
|
1150
|
-
*
|
|
324
|
+
* RP-Initiated Logout (OIDC RP-Initiated Logout 1.0). The RP presents a signed
|
|
325
|
+
* `id_token_hint`; after verification, the OP terminates the matching session
|
|
326
|
+
* and optionally redirects to `post_logout_redirect_uri`.
|
|
327
|
+
*
|
|
328
|
+
* Session termination goes through `internalAdapter.deleteSession`, which fires
|
|
329
|
+
* `session.delete.before` so the hook drives revocation and back-channel
|
|
330
|
+
* notifications to every RP with tokens on the session.
|
|
1151
331
|
*
|
|
1152
332
|
* @see https://openid.net/specs/openid-connect-rpinitiated-1_0.html
|
|
1153
333
|
*/
|
|
@@ -1195,12 +375,10 @@ async function rpInitiatedLogoutEndpoint(ctx, opts) {
|
|
|
1195
375
|
});
|
|
1196
376
|
const secret = await decryptStoredClientSecret(ctx, opts.storeClientSecret, clientSecret);
|
|
1197
377
|
const { payload } = await compactVerify(id_token_hint, new TextEncoder().encode(secret));
|
|
1198
|
-
|
|
1199
|
-
idTokenPayload = JSON.parse(idToken);
|
|
378
|
+
idTokenPayload = JSON.parse(new TextDecoder().decode(payload));
|
|
1200
379
|
} else {
|
|
1201
380
|
const { payload } = await compactVerify(id_token_hint, createLocalJWKSet(await getJwks(id_token_hint, { jwksFetch: jwksUrl })));
|
|
1202
|
-
|
|
1203
|
-
idTokenPayload = JSON.parse(idToken);
|
|
381
|
+
idTokenPayload = JSON.parse(new TextDecoder().decode(payload));
|
|
1204
382
|
}
|
|
1205
383
|
if (!idTokenPayload) throw new APIError$1("INTERNAL_SERVER_ERROR", {
|
|
1206
384
|
error_description: "missing payload",
|
|
@@ -1232,18 +410,13 @@ async function rpInitiatedLogoutEndpoint(ctx, opts) {
|
|
|
1232
410
|
value: sessionId
|
|
1233
411
|
}]
|
|
1234
412
|
});
|
|
1235
|
-
session?.token
|
|
413
|
+
if (session?.token) await ctx.context.internalAdapter.deleteSession(session.token);
|
|
414
|
+
else if (session) await ctx.context.adapter.delete({
|
|
1236
415
|
model: "session",
|
|
1237
416
|
where: [{
|
|
1238
417
|
field: "id",
|
|
1239
418
|
value: session.id
|
|
1240
419
|
}]
|
|
1241
|
-
}) : await ctx.context.adapter.delete({
|
|
1242
|
-
model: "session",
|
|
1243
|
-
where: [{
|
|
1244
|
-
field: "id",
|
|
1245
|
-
value: sessionId
|
|
1246
|
-
}]
|
|
1247
420
|
});
|
|
1248
421
|
} catch {}
|
|
1249
422
|
if (post_logout_redirect_uri) {
|
|
@@ -1255,6 +428,155 @@ async function rpInitiatedLogoutEndpoint(ctx, opts) {
|
|
|
1255
428
|
}
|
|
1256
429
|
}
|
|
1257
430
|
//#endregion
|
|
431
|
+
//#region src/metadata.ts
|
|
432
|
+
function authServerMetadata(ctx, opts, overrides) {
|
|
433
|
+
const baseURL = ctx.context.baseURL;
|
|
434
|
+
const backchannelSupported = !overrides?.jwt_disabled;
|
|
435
|
+
return {
|
|
436
|
+
scopes_supported: overrides?.scopes_supported,
|
|
437
|
+
issuer: validateIssuerUrl(opts?.jwt?.issuer ?? baseURL),
|
|
438
|
+
authorization_endpoint: `${baseURL}/oauth2/authorize`,
|
|
439
|
+
token_endpoint: `${baseURL}/oauth2/token`,
|
|
440
|
+
jwks_uri: overrides?.jwt_disabled ? void 0 : opts?.jwks?.remoteUrl ?? `${baseURL}${opts?.jwks?.jwksPath ?? "/jwks"}`,
|
|
441
|
+
registration_endpoint: overrides?.dynamic_client_registration_supported ? `${baseURL}/oauth2/register` : void 0,
|
|
442
|
+
introspection_endpoint: `${baseURL}/oauth2/introspect`,
|
|
443
|
+
revocation_endpoint: `${baseURL}/oauth2/revoke`,
|
|
444
|
+
response_types_supported: overrides?.grant_types_supported && !overrides.grant_types_supported.includes("authorization_code") ? [] : ["code"],
|
|
445
|
+
response_modes_supported: ["query"],
|
|
446
|
+
grant_types_supported: overrides?.grant_types_supported ?? [
|
|
447
|
+
"authorization_code",
|
|
448
|
+
"client_credentials",
|
|
449
|
+
"refresh_token"
|
|
450
|
+
],
|
|
451
|
+
token_endpoint_auth_methods_supported: overrides?.token_endpoint_auth_methods_supported ?? [
|
|
452
|
+
...overrides?.public_client_supported ? ["none"] : [],
|
|
453
|
+
"client_secret_basic",
|
|
454
|
+
"client_secret_post",
|
|
455
|
+
"private_key_jwt"
|
|
456
|
+
],
|
|
457
|
+
token_endpoint_auth_signing_alg_values_supported: [...PRIVATE_KEY_JWT_SIGNING_ALGORITHMS],
|
|
458
|
+
introspection_endpoint_auth_methods_supported: overrides?.endpoint_auth_methods_supported ?? [
|
|
459
|
+
"client_secret_basic",
|
|
460
|
+
"client_secret_post",
|
|
461
|
+
"private_key_jwt"
|
|
462
|
+
],
|
|
463
|
+
introspection_endpoint_auth_signing_alg_values_supported: [...PRIVATE_KEY_JWT_SIGNING_ALGORITHMS],
|
|
464
|
+
revocation_endpoint_auth_methods_supported: overrides?.endpoint_auth_methods_supported ?? [
|
|
465
|
+
"client_secret_basic",
|
|
466
|
+
"client_secret_post",
|
|
467
|
+
"private_key_jwt"
|
|
468
|
+
],
|
|
469
|
+
revocation_endpoint_auth_signing_alg_values_supported: [...PRIVATE_KEY_JWT_SIGNING_ALGORITHMS],
|
|
470
|
+
code_challenge_methods_supported: ["S256"],
|
|
471
|
+
authorization_response_iss_parameter_supported: true,
|
|
472
|
+
dpop_signing_alg_values_supported: overrides?.dpop_signing_alg_values_supported ?? [...DPOP_SIGNING_ALGORITHMS],
|
|
473
|
+
backchannel_logout_supported: backchannelSupported,
|
|
474
|
+
backchannel_logout_session_supported: backchannelSupported
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Builds the authorization-server metadata shared by the
|
|
479
|
+
* `/.well-known/oauth-authorization-server` and `/.well-known/openid-configuration`
|
|
480
|
+
* responses, plus the inputs both need to finish their own document.
|
|
481
|
+
*/
|
|
482
|
+
function buildAuthServerMetadata(ctx, opts) {
|
|
483
|
+
const jwtPluginOptions = opts.disableJwtPlugin ? void 0 : getJwtPlugin(ctx.context).options;
|
|
484
|
+
const clientDiscoveries = getClientDiscoveries(opts);
|
|
485
|
+
const publicClientSupported = opts.allowUnauthenticatedClientRegistration || clientDiscoveries.length > 0;
|
|
486
|
+
return {
|
|
487
|
+
jwtPluginOptions,
|
|
488
|
+
clientDiscoveries,
|
|
489
|
+
authMetadata: authServerMetadata(ctx, jwtPluginOptions, {
|
|
490
|
+
scopes_supported: opts.advertisedMetadata?.scopes_supported ?? opts.scopes,
|
|
491
|
+
dynamic_client_registration_supported: opts.allowDynamicClientRegistration,
|
|
492
|
+
public_client_supported: publicClientSupported,
|
|
493
|
+
grant_types_supported: getSupportedGrantTypes(opts),
|
|
494
|
+
token_endpoint_auth_methods_supported: getSupportedAuthMethods(opts, { includeNone: publicClientSupported }),
|
|
495
|
+
endpoint_auth_methods_supported: getSupportedAuthMethods(opts),
|
|
496
|
+
jwt_disabled: opts.disableJwtPlugin,
|
|
497
|
+
dpop_signing_alg_values_supported: opts.dpop?.signingAlgorithms
|
|
498
|
+
})
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
function oauthAuthorizationServerMetadata(ctx, opts) {
|
|
502
|
+
const { clientDiscoveries, authMetadata } = buildAuthServerMetadata(ctx, opts);
|
|
503
|
+
return applyOAuthProviderMetadataExtensions(ctx, opts, "oauth-authorization-server", {
|
|
504
|
+
...mergeDiscoveryMetadata(clientDiscoveries),
|
|
505
|
+
...authMetadata
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
function oidcServerMetadata(ctx, opts) {
|
|
509
|
+
const baseURL = ctx.context.baseURL;
|
|
510
|
+
const { jwtPluginOptions, clientDiscoveries, authMetadata } = buildAuthServerMetadata(ctx, opts);
|
|
511
|
+
const metadata = {
|
|
512
|
+
...authMetadata,
|
|
513
|
+
claims_supported: opts?.advertisedMetadata?.claims_supported ?? opts?.claims ?? [],
|
|
514
|
+
userinfo_endpoint: `${baseURL}/oauth2/userinfo`,
|
|
515
|
+
subject_types_supported: opts.pairwiseSecret ? ["public", "pairwise"] : ["public"],
|
|
516
|
+
id_token_signing_alg_values_supported: (() => {
|
|
517
|
+
if (opts.disableJwtPlugin) return ["HS256"];
|
|
518
|
+
const primary = jwtPluginOptions?.jwks?.keyPairConfig?.alg ?? "EdDSA";
|
|
519
|
+
const extras = jwtPluginOptions?.jwks?.keyPairConfigs?.map((c) => c.alg) ?? [];
|
|
520
|
+
return Array.from(new Set([primary, ...extras]));
|
|
521
|
+
})(),
|
|
522
|
+
end_session_endpoint: `${baseURL}/oauth2/end-session`,
|
|
523
|
+
acr_values_supported: ["urn:mace:incommon:iap:bronze"],
|
|
524
|
+
prompt_values_supported: [
|
|
525
|
+
"login",
|
|
526
|
+
"consent",
|
|
527
|
+
"create",
|
|
528
|
+
"select_account",
|
|
529
|
+
"none"
|
|
530
|
+
]
|
|
531
|
+
};
|
|
532
|
+
return applyOAuthProviderMetadataExtensions(ctx, opts, "openid-configuration", {
|
|
533
|
+
...mergeDiscoveryMetadata(clientDiscoveries),
|
|
534
|
+
...metadata
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
const METADATA_CACHE_CONTROL = "public, max-age=15, stale-while-revalidate=15, stale-if-error=86400";
|
|
538
|
+
function metadataResponse(body, extraHeaders) {
|
|
539
|
+
const headers = new Headers(extraHeaders);
|
|
540
|
+
if (!headers.has("Cache-Control")) headers.set("Cache-Control", METADATA_CACHE_CONTROL);
|
|
541
|
+
headers.set("Content-Type", "application/json");
|
|
542
|
+
return new Response(JSON.stringify(body), {
|
|
543
|
+
status: 200,
|
|
544
|
+
headers
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Provides an exportable `/.well-known/oauth-authorization-server`.
|
|
549
|
+
*
|
|
550
|
+
* Useful when basePath prevents the endpoint from being located at the root
|
|
551
|
+
* and must be provided manually.
|
|
552
|
+
*
|
|
553
|
+
* @external
|
|
554
|
+
*/
|
|
555
|
+
const oauthProviderAuthServerMetadata = (auth, opts) => {
|
|
556
|
+
return async (request) => {
|
|
557
|
+
return metadataResponse(await auth.api.getOAuthServerConfig({
|
|
558
|
+
request,
|
|
559
|
+
asResponse: false
|
|
560
|
+
}), opts?.headers);
|
|
561
|
+
};
|
|
562
|
+
};
|
|
563
|
+
/**
|
|
564
|
+
* Provides an exportable `/.well-known/openid-configuration`.
|
|
565
|
+
*
|
|
566
|
+
* Useful when basePath prevents the endpoint from being located at the root
|
|
567
|
+
* and must be provided manually.
|
|
568
|
+
*
|
|
569
|
+
* @external
|
|
570
|
+
*/
|
|
571
|
+
const oauthProviderOpenIdConfigMetadata = (auth, opts) => {
|
|
572
|
+
return async (request) => {
|
|
573
|
+
return metadataResponse(await auth.api.getOpenIdConfig({
|
|
574
|
+
request,
|
|
575
|
+
asResponse: false
|
|
576
|
+
}), opts?.headers);
|
|
577
|
+
};
|
|
578
|
+
};
|
|
579
|
+
//#endregion
|
|
1258
580
|
//#region src/oauth-endpoint.ts
|
|
1259
581
|
/**
|
|
1260
582
|
* Wraps `createAuthEndpoint` so zod schemas stay the single source of truth
|
|
@@ -1387,6 +709,64 @@ async function assertClientPrivileges(ctx, session, opts, action) {
|
|
|
1387
709
|
})) throw new APIError("UNAUTHORIZED");
|
|
1388
710
|
}
|
|
1389
711
|
//#endregion
|
|
712
|
+
//#region src/utils/initial-access-token.ts
|
|
713
|
+
/**
|
|
714
|
+
* Builds an RFC 6750 §3 Bearer challenge for the registration endpoint. The
|
|
715
|
+
* `error` code maps to the status per RFC 6750 §3.1 (`invalid_request` → 400,
|
|
716
|
+
* `invalid_token` → 401) and is echoed in both the `WWW-Authenticate` challenge
|
|
717
|
+
* and the JSON body.
|
|
718
|
+
*
|
|
719
|
+
* @see https://datatracker.ietf.org/doc/html/rfc6750#section-3
|
|
720
|
+
*/
|
|
721
|
+
function registrationBearerError(status, error, errorDescription) {
|
|
722
|
+
return new APIError$1(status, {
|
|
723
|
+
error,
|
|
724
|
+
error_description: errorDescription
|
|
725
|
+
}, {
|
|
726
|
+
"WWW-Authenticate": `Bearer error="${error}"`,
|
|
727
|
+
...NO_STORE_HEADERS
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Resolves an RFC 7591 initial access token from the request and authorizes it
|
|
732
|
+
* against the deployment-supplied validator.
|
|
733
|
+
*
|
|
734
|
+
* Returns the authorization (optionally carrying a `referenceId`) when a valid
|
|
735
|
+
* token is present, or `undefined` when no Bearer credentials were sent so the
|
|
736
|
+
* caller can fall back to session or open registration. Throws an RFC 6750
|
|
737
|
+
* Bearer challenge when the header is malformed, no validator is configured, or
|
|
738
|
+
* the token is rejected.
|
|
739
|
+
*
|
|
740
|
+
* @see https://datatracker.ietf.org/doc/html/rfc7591#section-3
|
|
741
|
+
*/
|
|
742
|
+
async function authorizeInitialAccessToken(ctx, opts, clientMetadata) {
|
|
743
|
+
const headers = ctx.headers;
|
|
744
|
+
let initialAccessToken;
|
|
745
|
+
try {
|
|
746
|
+
initialAccessToken = parseBearerToken(headers?.get("authorization"));
|
|
747
|
+
} catch {
|
|
748
|
+
throw registrationBearerError("BAD_REQUEST", "invalid_request", "Malformed initial access token Authorization header");
|
|
749
|
+
}
|
|
750
|
+
if (!initialAccessToken || !headers) return;
|
|
751
|
+
if (!opts.validateInitialAccessToken) throw registrationBearerError("UNAUTHORIZED", "invalid_token", "Invalid initial access token");
|
|
752
|
+
let authorization;
|
|
753
|
+
try {
|
|
754
|
+
authorization = await opts.validateInitialAccessToken({
|
|
755
|
+
initialAccessToken,
|
|
756
|
+
headers,
|
|
757
|
+
clientMetadata
|
|
758
|
+
});
|
|
759
|
+
} catch (error) {
|
|
760
|
+
if (error instanceof APIError$1) throw error;
|
|
761
|
+
throw new APIError$1("INTERNAL_SERVER_ERROR", {
|
|
762
|
+
error: "server_error",
|
|
763
|
+
error_description: "Initial access token validation failed"
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
if (!authorization) throw registrationBearerError("UNAUTHORIZED", "invalid_token", "Invalid initial access token");
|
|
767
|
+
return authorization;
|
|
768
|
+
}
|
|
769
|
+
//#endregion
|
|
1390
770
|
//#region src/register.ts
|
|
1391
771
|
/**
|
|
1392
772
|
* Resolves the auth method and type for unauthenticated DCR.
|
|
@@ -1403,18 +783,42 @@ function resolveUnauthenticatedAuth(body) {
|
|
|
1403
783
|
type: body.type === "web" ? void 0 : body.type
|
|
1404
784
|
};
|
|
1405
785
|
}
|
|
786
|
+
const DEFAULT_REGISTRATION_GRANT_TYPES = ["authorization_code"];
|
|
787
|
+
function resolveRegistrationGrantTypes(client) {
|
|
788
|
+
const grantTypes = client.grant_types ?? [...DEFAULT_REGISTRATION_GRANT_TYPES];
|
|
789
|
+
if (grantTypes.length > 0) return grantTypes;
|
|
790
|
+
throw new APIError("BAD_REQUEST", {
|
|
791
|
+
error: "invalid_client_metadata",
|
|
792
|
+
error_description: "grant_types must contain at least one grant type"
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
function resolveRegistrationResponseTypes(client, grantTypes) {
|
|
796
|
+
if (client.response_types) return client.response_types;
|
|
797
|
+
return grantTypes.includes("authorization_code") ? ["code"] : void 0;
|
|
798
|
+
}
|
|
799
|
+
function applyOAuthClientRegistrationDefaults(client) {
|
|
800
|
+
const grantTypes = resolveRegistrationGrantTypes(client);
|
|
801
|
+
return {
|
|
802
|
+
...client,
|
|
803
|
+
token_endpoint_auth_method: client.token_endpoint_auth_method ?? "client_secret_basic",
|
|
804
|
+
grant_types: grantTypes,
|
|
805
|
+
response_types: resolveRegistrationResponseTypes(client, grantTypes)
|
|
806
|
+
};
|
|
807
|
+
}
|
|
1406
808
|
async function registerEndpoint(ctx, opts) {
|
|
809
|
+
const body = ctx.body;
|
|
1407
810
|
if (!opts.allowDynamicClientRegistration) throw new APIError("FORBIDDEN", {
|
|
1408
811
|
error: "access_denied",
|
|
1409
812
|
error_description: "Client registration is disabled"
|
|
1410
813
|
});
|
|
1411
|
-
const body = ctx.body;
|
|
1412
814
|
const session = await getSessionFromCtx(ctx);
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
815
|
+
const tokenAuthorization = session ? void 0 : await authorizeInitialAccessToken(ctx, opts, body);
|
|
816
|
+
const isTokenAuthorized = Boolean(tokenAuthorization);
|
|
817
|
+
if (!(session || isTokenAuthorized || opts.allowUnauthenticatedClientRegistration)) throw new APIError("UNAUTHORIZED", { error_description: "Authentication required for client registration" }, {
|
|
818
|
+
"WWW-Authenticate": "Bearer",
|
|
819
|
+
...NO_STORE_HEADERS
|
|
1416
820
|
});
|
|
1417
|
-
if (!session) {
|
|
821
|
+
if (!session && !isTokenAuthorized) {
|
|
1418
822
|
if (body.grant_types?.includes("client_credentials")) throw new APIError("BAD_REQUEST", {
|
|
1419
823
|
error: "invalid_client_metadata",
|
|
1420
824
|
error_description: "client_credentials grant requires authenticated registration"
|
|
@@ -1424,47 +828,83 @@ async function registerEndpoint(ctx, opts) {
|
|
|
1424
828
|
body.type = resolved.type;
|
|
1425
829
|
}
|
|
1426
830
|
if (!body.scope) body.scope = (opts.clientRegistrationDefaultScopes ?? opts.scopes)?.join(" ");
|
|
1427
|
-
|
|
831
|
+
const requestedResources = Array.isArray(body.resources) ? [...new Set(body.resources.filter((resource) => typeof resource === "string" && resource.length > 0))] : [];
|
|
832
|
+
if (requestedResources.length > 0) for (const identifier of requestedResources) {
|
|
833
|
+
const row = await getResource(ctx, opts, identifier);
|
|
834
|
+
if (!row) throw new APIError("BAD_REQUEST", {
|
|
835
|
+
error: "invalid_target",
|
|
836
|
+
error_description: `requested resource ${identifier} does not exist`
|
|
837
|
+
});
|
|
838
|
+
if (row.disabled) throw new APIError("BAD_REQUEST", {
|
|
839
|
+
error: "invalid_target",
|
|
840
|
+
error_description: `requested resource ${identifier} is disabled`
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
return createOAuthClientEndpoint(ctx, opts, {
|
|
844
|
+
isRegister: true,
|
|
845
|
+
session,
|
|
846
|
+
referenceId: tokenAuthorization?.referenceId,
|
|
847
|
+
resources: requestedResources.length > 0 ? requestedResources : void 0
|
|
848
|
+
});
|
|
1428
849
|
}
|
|
1429
850
|
async function checkOAuthClient(client, opts, settings) {
|
|
1430
|
-
const
|
|
1431
|
-
|
|
1432
|
-
|
|
851
|
+
const clientWithDefaults = applyOAuthClientRegistrationDefaults(client);
|
|
852
|
+
const isPublic = clientWithDefaults.token_endpoint_auth_method === "none";
|
|
853
|
+
const tokenEndpointAuthMethod = clientWithDefaults.token_endpoint_auth_method ?? "client_secret_basic";
|
|
854
|
+
if (!new Set(getSupportedAuthMethods(opts, { includeNone: true })).has(tokenEndpointAuthMethod)) throw new APIError("BAD_REQUEST", {
|
|
855
|
+
error: "invalid_client_metadata",
|
|
856
|
+
error_description: `unsupported token_endpoint_auth_method ${tokenEndpointAuthMethod}`
|
|
857
|
+
});
|
|
858
|
+
if (clientWithDefaults.dpop_bound_access_tokens !== void 0 && typeof clientWithDefaults.dpop_bound_access_tokens !== "boolean") throw new APIError("BAD_REQUEST", {
|
|
859
|
+
error: "invalid_client_metadata",
|
|
860
|
+
error_description: "dpop_bound_access_tokens must be a boolean"
|
|
861
|
+
});
|
|
862
|
+
if (clientWithDefaults.type) {
|
|
863
|
+
if (isPublic && !(clientWithDefaults.type === "native" || clientWithDefaults.type === "user-agent-based")) throw new APIError("BAD_REQUEST", {
|
|
1433
864
|
error: "invalid_client_metadata",
|
|
1434
865
|
error_description: `Type must be 'native' or 'user-agent-based' for public applications`
|
|
1435
866
|
});
|
|
1436
|
-
else if (!isPublic && !(
|
|
867
|
+
else if (!isPublic && !(clientWithDefaults.type === "web")) throw new APIError("BAD_REQUEST", {
|
|
1437
868
|
error: "invalid_client_metadata",
|
|
1438
869
|
error_description: `Type must be 'web' for confidential applications`
|
|
1439
870
|
});
|
|
1440
871
|
}
|
|
1441
|
-
|
|
872
|
+
const grantTypes = clientWithDefaults.grant_types ?? [];
|
|
873
|
+
const responseTypes = clientWithDefaults.response_types;
|
|
874
|
+
if (grantTypes.includes("authorization_code") && (!clientWithDefaults.redirect_uris || clientWithDefaults.redirect_uris.length === 0)) throw new APIError("BAD_REQUEST", {
|
|
1442
875
|
error: "invalid_redirect_uri",
|
|
1443
876
|
error_description: "Redirect URIs are required for authorization_code and implicit grant types"
|
|
1444
877
|
});
|
|
1445
|
-
const
|
|
1446
|
-
const
|
|
1447
|
-
|
|
878
|
+
const supportedGrantTypes = new Set(getSupportedGrantTypes(opts));
|
|
879
|
+
for (const grantType of grantTypes) if (!supportedGrantTypes.has(grantType)) throw new APIError("BAD_REQUEST", {
|
|
880
|
+
error: "invalid_client_metadata",
|
|
881
|
+
error_description: `unsupported grant_type ${grantType}`
|
|
882
|
+
});
|
|
883
|
+
if (grantTypes.includes("authorization_code") && !responseTypes?.includes("code")) throw new APIError("BAD_REQUEST", {
|
|
1448
884
|
error: "invalid_client_metadata",
|
|
1449
885
|
error_description: "When 'authorization_code' grant type is used, 'code' response type must be included"
|
|
1450
886
|
});
|
|
1451
|
-
if (
|
|
1452
|
-
|
|
887
|
+
if (!grantTypes.includes("authorization_code") && responseTypes?.includes("code")) throw new APIError("BAD_REQUEST", {
|
|
888
|
+
error: "invalid_client_metadata",
|
|
889
|
+
error_description: "When 'code' response type is used, 'authorization_code' grant type must be included"
|
|
890
|
+
});
|
|
891
|
+
if (clientWithDefaults.subject_type !== void 0) {
|
|
892
|
+
if (clientWithDefaults.subject_type !== "public" && clientWithDefaults.subject_type !== "pairwise") throw new APIError("BAD_REQUEST", {
|
|
1453
893
|
error: "invalid_client_metadata",
|
|
1454
894
|
error_description: `subject_type must be "public" or "pairwise"`
|
|
1455
895
|
});
|
|
1456
|
-
if (
|
|
896
|
+
if (clientWithDefaults.subject_type === "pairwise" && !opts.pairwiseSecret) throw new APIError("BAD_REQUEST", {
|
|
1457
897
|
error: "invalid_client_metadata",
|
|
1458
898
|
error_description: "pairwise subject_type requires server pairwiseSecret configuration"
|
|
1459
899
|
});
|
|
1460
|
-
if (
|
|
1461
|
-
if (new Set(
|
|
900
|
+
if (clientWithDefaults.subject_type === "pairwise" && clientWithDefaults.redirect_uris && clientWithDefaults.redirect_uris.length > 1) {
|
|
901
|
+
if (new Set(clientWithDefaults.redirect_uris.map((uri) => new URL(uri).host)).size > 1) throw new APIError("BAD_REQUEST", {
|
|
1462
902
|
error: "invalid_client_metadata",
|
|
1463
903
|
error_description: "pairwise clients with redirect_uris on different hosts require a sector_identifier_uri, which is not yet supported. All redirect_uris must share the same host."
|
|
1464
904
|
});
|
|
1465
905
|
}
|
|
1466
906
|
}
|
|
1467
|
-
const requestedScopes = (
|
|
907
|
+
const requestedScopes = (clientWithDefaults?.scope)?.split(" ").filter((v) => v.length);
|
|
1468
908
|
const allowedScopes = settings?.isRegister ? opts.clientRegistrationAllowedScopes ?? opts.scopes : opts.scopes;
|
|
1469
909
|
if (allowedScopes) {
|
|
1470
910
|
const validScopes = new Set(allowedScopes);
|
|
@@ -1473,21 +913,22 @@ async function checkOAuthClient(client, opts, settings) {
|
|
|
1473
913
|
error_description: `cannot request scope ${requestedScope}`
|
|
1474
914
|
});
|
|
1475
915
|
}
|
|
1476
|
-
if (settings?.isRegister &&
|
|
916
|
+
if (settings?.isRegister && clientWithDefaults.require_pkce === false) throw new APIError("BAD_REQUEST", {
|
|
1477
917
|
error: "invalid_client_metadata",
|
|
1478
918
|
error_description: `pkce is required for registered clients.`
|
|
1479
919
|
});
|
|
1480
|
-
|
|
1481
|
-
|
|
920
|
+
const usesAssertionKeyMaterial = tokenEndpointAuthMethod === "private_key_jwt" || isExtensionTokenEndpointAuthMethod(opts, tokenEndpointAuthMethod);
|
|
921
|
+
if (clientWithDefaults.jwks || clientWithDefaults.jwks_uri) {
|
|
922
|
+
if (!usesAssertionKeyMaterial) throw new APIError("BAD_REQUEST", {
|
|
1482
923
|
error: "invalid_client_metadata",
|
|
1483
|
-
error_description: "jwks and jwks_uri are
|
|
924
|
+
error_description: "jwks and jwks_uri are only allowed with private_key_jwt or an assertion-based authentication method"
|
|
1484
925
|
});
|
|
1485
|
-
if (
|
|
926
|
+
if (clientWithDefaults.jwks && clientWithDefaults.jwks_uri) throw new APIError("BAD_REQUEST", {
|
|
1486
927
|
error: "invalid_client_metadata",
|
|
1487
|
-
error_description: "
|
|
928
|
+
error_description: "jwks and jwks_uri are mutually exclusive"
|
|
1488
929
|
});
|
|
1489
|
-
if (
|
|
1490
|
-
const uri = new URL(
|
|
930
|
+
if (clientWithDefaults.jwks_uri) try {
|
|
931
|
+
const uri = new URL(clientWithDefaults.jwks_uri);
|
|
1491
932
|
if (uri.protocol !== "https:") throw new APIError("BAD_REQUEST", {
|
|
1492
933
|
error: "invalid_client_metadata",
|
|
1493
934
|
error_description: "jwks_uri must use HTTPS"
|
|
@@ -1507,40 +948,73 @@ async function checkOAuthClient(client, opts, settings) {
|
|
|
1507
948
|
error_description: "jwks_uri must be a valid URL"
|
|
1508
949
|
});
|
|
1509
950
|
}
|
|
1510
|
-
if (
|
|
1511
|
-
const keys = Array.isArray(
|
|
951
|
+
if (clientWithDefaults.jwks) {
|
|
952
|
+
const keys = Array.isArray(clientWithDefaults.jwks) ? clientWithDefaults.jwks : clientWithDefaults.jwks.keys;
|
|
1512
953
|
if (!Array.isArray(keys) || keys.length === 0) throw new APIError("BAD_REQUEST", {
|
|
1513
954
|
error: "invalid_client_metadata",
|
|
1514
955
|
error_description: "jwks must be a non-empty array of JWK objects or a JWKS document {keys:[...]}"
|
|
1515
956
|
});
|
|
1516
957
|
}
|
|
1517
|
-
}
|
|
958
|
+
}
|
|
959
|
+
if (tokenEndpointAuthMethod === "private_key_jwt" && !clientWithDefaults.jwks && !clientWithDefaults.jwks_uri) throw new APIError("BAD_REQUEST", {
|
|
1518
960
|
error: "invalid_client_metadata",
|
|
1519
|
-
error_description: "
|
|
961
|
+
error_description: "private_key_jwt requires either jwks or jwks_uri"
|
|
1520
962
|
});
|
|
963
|
+
if (clientWithDefaults.backchannel_logout_uri !== void 0) {
|
|
964
|
+
if (opts.disableJwtPlugin) throw new APIError("BAD_REQUEST", {
|
|
965
|
+
error: "invalid_client_metadata",
|
|
966
|
+
error_description: "backchannel_logout_uri requires the jwt plugin (disableJwtPlugin must be false)"
|
|
967
|
+
});
|
|
968
|
+
let url;
|
|
969
|
+
try {
|
|
970
|
+
url = new URL(clientWithDefaults.backchannel_logout_uri);
|
|
971
|
+
} catch {
|
|
972
|
+
throw new APIError("BAD_REQUEST", {
|
|
973
|
+
error: "invalid_client_metadata",
|
|
974
|
+
error_description: "backchannel_logout_uri must be an absolute URL"
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") throw new APIError("BAD_REQUEST", {
|
|
978
|
+
error: "invalid_client_metadata",
|
|
979
|
+
error_description: "backchannel_logout_uri must use http or https"
|
|
980
|
+
});
|
|
981
|
+
if (clientWithDefaults.backchannel_logout_uri.includes("#")) throw new APIError("BAD_REQUEST", {
|
|
982
|
+
error: "invalid_client_metadata",
|
|
983
|
+
error_description: "backchannel_logout_uri must not include a fragment component"
|
|
984
|
+
});
|
|
985
|
+
const loopback = isLoopbackHost(url.hostname);
|
|
986
|
+
if (!isPublic && url.protocol !== "https:" && !loopback) throw new APIError("BAD_REQUEST", {
|
|
987
|
+
error: "invalid_client_metadata",
|
|
988
|
+
error_description: "backchannel_logout_uri must use https for confidential clients"
|
|
989
|
+
});
|
|
990
|
+
if (isPrivateHostname(url.hostname) && !loopback) throw new APIError("BAD_REQUEST", {
|
|
991
|
+
error: "invalid_client_metadata",
|
|
992
|
+
error_description: "backchannel_logout_uri must not point to a private or reserved address"
|
|
993
|
+
});
|
|
994
|
+
}
|
|
1521
995
|
}
|
|
1522
996
|
async function createOAuthClientEndpoint(ctx, opts, settings) {
|
|
1523
|
-
const body = ctx.body;
|
|
1524
|
-
const session = await getSessionFromCtx(ctx);
|
|
1525
|
-
if (settings.isRegister)
|
|
1526
|
-
if (session) await assertClientPrivileges(ctx, session, opts, "create");
|
|
1527
|
-
} else await assertClientPrivileges(ctx, session, opts, "create");
|
|
997
|
+
const body = applyOAuthClientRegistrationDefaults(ctx.body);
|
|
998
|
+
const session = settings.session !== void 0 ? settings.session : await getSessionFromCtx(ctx);
|
|
999
|
+
if (!settings.isRegister || session) await assertClientPrivileges(ctx, session, opts, "create");
|
|
1528
1000
|
const isPublic = body.token_endpoint_auth_method === "none";
|
|
1529
1001
|
const isPrivateKeyJwt = body.token_endpoint_auth_method === "private_key_jwt";
|
|
1530
|
-
|
|
1002
|
+
const isExtensionAuthMethod = isExtensionTokenEndpointAuthMethod(opts, body.token_endpoint_auth_method);
|
|
1003
|
+
await checkOAuthClient(body, opts, {
|
|
1531
1004
|
...settings,
|
|
1532
1005
|
ctx
|
|
1533
1006
|
});
|
|
1534
1007
|
const clientId = opts.generateClientId?.() || generateRandomString(32, "a-z", "A-Z");
|
|
1535
|
-
const clientSecret = isPublic || isPrivateKeyJwt ? void 0 : opts.generateClientSecret?.() || generateRandomString(32, "a-z", "A-Z");
|
|
1008
|
+
const clientSecret = isPublic || isPrivateKeyJwt || isExtensionAuthMethod ? void 0 : opts.generateClientSecret?.() || generateRandomString(32, "a-z", "A-Z");
|
|
1536
1009
|
const storedClientSecret = clientSecret ? await storeClientSecret(ctx, opts, clientSecret) : void 0;
|
|
1537
1010
|
const iat = Math.floor(Date.now() / 1e3);
|
|
1538
|
-
const referenceId = opts.clientReference ? await opts.clientReference({
|
|
1539
|
-
user: session
|
|
1540
|
-
session: session
|
|
1541
|
-
}) : void 0;
|
|
1011
|
+
const referenceId = settings.referenceId ?? (session && opts.clientReference ? await opts.clientReference({
|
|
1012
|
+
user: session.user,
|
|
1013
|
+
session: session.session
|
|
1014
|
+
}) : void 0);
|
|
1542
1015
|
const schema = oauthToSchema({
|
|
1543
|
-
...body
|
|
1016
|
+
...body,
|
|
1017
|
+
redirect_uris: body.redirect_uris ?? [],
|
|
1544
1018
|
disabled: void 0,
|
|
1545
1019
|
client_secret_expires_at: storedClientSecret ? settings.isRegister && opts?.clientRegistrationClientSecretExpiration ? toExpJWT(opts.clientRegistrationClientSecretExpiration, iat) : 0 : void 0,
|
|
1546
1020
|
client_id: clientId,
|
|
@@ -1550,24 +1024,38 @@ async function createOAuthClientEndpoint(ctx, opts, settings) {
|
|
|
1550
1024
|
user_id: referenceId ? void 0 : session?.session.userId,
|
|
1551
1025
|
reference_id: referenceId
|
|
1552
1026
|
});
|
|
1553
|
-
const
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1027
|
+
const resources = settings.resources ?? [];
|
|
1028
|
+
const responseBody = schemaToOAuth({
|
|
1029
|
+
...await runWithTransaction(ctx.context.adapter, async () => {
|
|
1030
|
+
const createdClient = await ctx.context.adapter.create({
|
|
1031
|
+
model: "oauthClient",
|
|
1032
|
+
data: {
|
|
1033
|
+
...schema,
|
|
1034
|
+
createdAt: /* @__PURE__ */ new Date(iat * 1e3),
|
|
1035
|
+
updatedAt: /* @__PURE__ */ new Date(iat * 1e3)
|
|
1036
|
+
}
|
|
1037
|
+
});
|
|
1038
|
+
if (resources.length > 0) {
|
|
1039
|
+
const linkModel = opts.schema?.oauthClientResource?.modelName ?? "oauthClientResource";
|
|
1040
|
+
const now = /* @__PURE__ */ new Date();
|
|
1041
|
+
for (const resourceId of resources) await ctx.context.adapter.create({
|
|
1042
|
+
model: linkModel,
|
|
1043
|
+
forceAllowId: true,
|
|
1044
|
+
data: {
|
|
1045
|
+
id: buildClientResourceLinkId(clientId, resourceId),
|
|
1046
|
+
clientId,
|
|
1047
|
+
resourceId,
|
|
1048
|
+
createdAt: now
|
|
1049
|
+
}
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
return createdClient;
|
|
1053
|
+
}),
|
|
1563
1054
|
clientSecret: clientSecret ? (opts.prefix?.clientSecret ?? "") + clientSecret : void 0
|
|
1564
|
-
}), {
|
|
1565
|
-
status: 201,
|
|
1566
|
-
headers: {
|
|
1567
|
-
"Cache-Control": "no-store",
|
|
1568
|
-
Pragma: "no-cache"
|
|
1569
|
-
}
|
|
1570
1055
|
});
|
|
1056
|
+
if (resources.length > 0) responseBody.resources = resources;
|
|
1057
|
+
ctx.setStatus(201);
|
|
1058
|
+
return ctx.json(responseBody);
|
|
1571
1059
|
}
|
|
1572
1060
|
/**
|
|
1573
1061
|
* Converts an OAuth 2.0 Dynamic Client Schema to a Database Schema
|
|
@@ -1576,7 +1064,7 @@ async function createOAuthClientEndpoint(ctx, opts, settings) {
|
|
|
1576
1064
|
* @returns
|
|
1577
1065
|
*/
|
|
1578
1066
|
function oauthToSchema(input) {
|
|
1579
|
-
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;
|
|
1067
|
+
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, dpop_bound_access_tokens: dpopBoundAccessTokens, subject_type: subjectType, reference_id: referenceId, metadata: inputMetadata, ...rest } = input;
|
|
1580
1068
|
const expiresAt = _expiresAt ? /* @__PURE__ */ new Date(_expiresAt * 1e3) : void 0;
|
|
1581
1069
|
const createdAt = _createdAt ? /* @__PURE__ */ new Date(_createdAt * 1e3) : void 0;
|
|
1582
1070
|
const scopes = _scope?.split(" ");
|
|
@@ -1604,6 +1092,8 @@ function oauthToSchema(input) {
|
|
|
1604
1092
|
softwareStatement,
|
|
1605
1093
|
redirectUris,
|
|
1606
1094
|
postLogoutRedirectUris,
|
|
1095
|
+
backchannelLogoutUri,
|
|
1096
|
+
backchannelLogoutSessionRequired,
|
|
1607
1097
|
tokenEndpointAuthMethod,
|
|
1608
1098
|
grantTypes,
|
|
1609
1099
|
responseTypes,
|
|
@@ -1614,6 +1104,7 @@ function oauthToSchema(input) {
|
|
|
1614
1104
|
skipConsent,
|
|
1615
1105
|
enableEndSession,
|
|
1616
1106
|
requirePKCE,
|
|
1107
|
+
dpopBoundAccessTokens,
|
|
1617
1108
|
subjectType,
|
|
1618
1109
|
referenceId,
|
|
1619
1110
|
metadata
|
|
@@ -1626,7 +1117,7 @@ function oauthToSchema(input) {
|
|
|
1626
1117
|
* @returns
|
|
1627
1118
|
*/
|
|
1628
1119
|
function schemaToOAuth(input) {
|
|
1629
|
-
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;
|
|
1120
|
+
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, dpopBoundAccessTokens, subjectType, referenceId, metadata } = input;
|
|
1630
1121
|
const _expiresAt = expiresAt ? Math.round(new Date(expiresAt).getTime() / 1e3) : void 0;
|
|
1631
1122
|
const _createdAt = createdAt ? Math.round(new Date(createdAt).getTime() / 1e3) : void 0;
|
|
1632
1123
|
const _scopes = scopes?.join(" ");
|
|
@@ -1651,6 +1142,8 @@ function schemaToOAuth(input) {
|
|
|
1651
1142
|
software_statement: softwareStatement ?? void 0,
|
|
1652
1143
|
redirect_uris: redirectUris ?? [],
|
|
1653
1144
|
post_logout_redirect_uris: postLogoutRedirectUris ?? void 0,
|
|
1145
|
+
backchannel_logout_uri: backchannelLogoutUri ?? void 0,
|
|
1146
|
+
backchannel_logout_session_required: backchannelLogoutSessionRequired ?? void 0,
|
|
1654
1147
|
token_endpoint_auth_method: tokenEndpointAuthMethod ?? void 0,
|
|
1655
1148
|
grant_types: grantTypes ?? void 0,
|
|
1656
1149
|
response_types: responseTypes ?? void 0,
|
|
@@ -1660,6 +1153,7 @@ function schemaToOAuth(input) {
|
|
|
1660
1153
|
skip_consent: skipConsent ?? void 0,
|
|
1661
1154
|
enable_end_session: enableEndSession ?? void 0,
|
|
1662
1155
|
require_pkce: requirePKCE ?? void 0,
|
|
1156
|
+
dpop_bound_access_tokens: dpopBoundAccessTokens ?? void 0,
|
|
1663
1157
|
subject_type: subjectType ?? void 0,
|
|
1664
1158
|
reference_id: referenceId ?? void 0
|
|
1665
1159
|
};
|
|
@@ -1860,22 +1354,193 @@ async function rotateClientSecretEndpoint(ctx, opts) {
|
|
|
1860
1354
|
clientSecret: storedClientSecret,
|
|
1861
1355
|
updatedAt: /* @__PURE__ */ new Date(Math.floor(Date.now() / 1e3) * 1e3)
|
|
1862
1356
|
}
|
|
1863
|
-
});
|
|
1864
|
-
if (!updatedClient) throw new APIError("INTERNAL_SERVER_ERROR", {
|
|
1865
|
-
error_description: "unable to update client",
|
|
1866
|
-
error: "invalid_client"
|
|
1867
|
-
});
|
|
1868
|
-
return schemaToOAuth({
|
|
1869
|
-
...updatedClient,
|
|
1870
|
-
clientSecret: (opts.prefix?.clientSecret ?? "") + clientSecret
|
|
1871
|
-
});
|
|
1872
|
-
}
|
|
1873
|
-
//#endregion
|
|
1874
|
-
//#region src/oauthClient/index.ts
|
|
1875
|
-
const
|
|
1357
|
+
});
|
|
1358
|
+
if (!updatedClient) throw new APIError("INTERNAL_SERVER_ERROR", {
|
|
1359
|
+
error_description: "unable to update client",
|
|
1360
|
+
error: "invalid_client"
|
|
1361
|
+
});
|
|
1362
|
+
return schemaToOAuth({
|
|
1363
|
+
...updatedClient,
|
|
1364
|
+
clientSecret: (opts.prefix?.clientSecret ?? "") + clientSecret
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
//#endregion
|
|
1368
|
+
//#region src/oauthClient/index.ts
|
|
1369
|
+
const tokenEndpointAuthMethodSchema = z.string().trim().min(1);
|
|
1370
|
+
const grantTypesSchema = z.array(z.string().trim().min(1)).min(1);
|
|
1371
|
+
const adminCreateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/create-client", {
|
|
1372
|
+
method: "POST",
|
|
1373
|
+
body: z.object({
|
|
1374
|
+
redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
|
|
1375
|
+
scope: z.string().optional(),
|
|
1376
|
+
client_name: z.string().optional(),
|
|
1377
|
+
client_uri: z.string().optional(),
|
|
1378
|
+
logo_uri: z.string().optional(),
|
|
1379
|
+
contacts: z.array(z.string().min(1)).min(1).optional(),
|
|
1380
|
+
tos_uri: z.string().optional(),
|
|
1381
|
+
policy_uri: z.string().optional(),
|
|
1382
|
+
software_id: z.string().optional(),
|
|
1383
|
+
software_version: z.string().optional(),
|
|
1384
|
+
software_statement: z.string().optional(),
|
|
1385
|
+
post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
|
|
1386
|
+
backchannel_logout_uri: SafeUrlSchema.optional(),
|
|
1387
|
+
backchannel_logout_session_required: z.boolean().optional(),
|
|
1388
|
+
token_endpoint_auth_method: tokenEndpointAuthMethodSchema.optional(),
|
|
1389
|
+
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(),
|
|
1390
|
+
jwks_uri: z.string().optional(),
|
|
1391
|
+
grant_types: grantTypesSchema.optional(),
|
|
1392
|
+
response_types: z.array(z.enum(["code"])).optional(),
|
|
1393
|
+
type: z.enum([
|
|
1394
|
+
"web",
|
|
1395
|
+
"native",
|
|
1396
|
+
"user-agent-based"
|
|
1397
|
+
]).optional(),
|
|
1398
|
+
client_secret_expires_at: z.union([z.string(), z.number()]).optional().default(0),
|
|
1399
|
+
skip_consent: z.boolean().optional(),
|
|
1400
|
+
enable_end_session: z.boolean().optional(),
|
|
1401
|
+
require_pkce: z.boolean().optional(),
|
|
1402
|
+
dpop_bound_access_tokens: z.boolean().optional(),
|
|
1403
|
+
subject_type: z.enum(["public", "pairwise"]).optional(),
|
|
1404
|
+
metadata: z.record(z.string(), z.unknown()).optional()
|
|
1405
|
+
}),
|
|
1406
|
+
metadata: {
|
|
1407
|
+
noStore: true,
|
|
1408
|
+
SERVER_ONLY: true,
|
|
1409
|
+
openapi: {
|
|
1410
|
+
description: "Register an OAuth2 application",
|
|
1411
|
+
responses: { "201": {
|
|
1412
|
+
description: "OAuth2 application registered successfully",
|
|
1413
|
+
content: { "application/json": { schema: {
|
|
1414
|
+
type: "object",
|
|
1415
|
+
properties: {
|
|
1416
|
+
client_id: {
|
|
1417
|
+
type: "string",
|
|
1418
|
+
description: "Unique identifier for the client"
|
|
1419
|
+
},
|
|
1420
|
+
client_secret: {
|
|
1421
|
+
type: "string",
|
|
1422
|
+
description: "Secret key for the client"
|
|
1423
|
+
},
|
|
1424
|
+
client_secret_expires_at: {
|
|
1425
|
+
type: "number",
|
|
1426
|
+
description: "Time the client secret will expire. If 0, the client secret will never expire."
|
|
1427
|
+
},
|
|
1428
|
+
scope: {
|
|
1429
|
+
type: "string",
|
|
1430
|
+
description: "Space-separated scopes allowed by the client"
|
|
1431
|
+
},
|
|
1432
|
+
user_id: {
|
|
1433
|
+
type: "string",
|
|
1434
|
+
description: "ID of the user who registered the client, null if registered anonymously"
|
|
1435
|
+
},
|
|
1436
|
+
client_id_issued_at: {
|
|
1437
|
+
type: "number",
|
|
1438
|
+
description: "Creation timestamp of this client"
|
|
1439
|
+
},
|
|
1440
|
+
client_name: {
|
|
1441
|
+
type: "string",
|
|
1442
|
+
description: "Name of the OAuth2 application"
|
|
1443
|
+
},
|
|
1444
|
+
client_uri: {
|
|
1445
|
+
type: "string",
|
|
1446
|
+
description: "URI of the OAuth2 application"
|
|
1447
|
+
},
|
|
1448
|
+
logo_uri: {
|
|
1449
|
+
type: "string",
|
|
1450
|
+
description: "Icon URI for the application"
|
|
1451
|
+
},
|
|
1452
|
+
contacts: {
|
|
1453
|
+
type: "array",
|
|
1454
|
+
items: { type: "string" },
|
|
1455
|
+
description: "List representing ways to contact people responsible for this client, typically email addresses"
|
|
1456
|
+
},
|
|
1457
|
+
tos_uri: {
|
|
1458
|
+
type: "string",
|
|
1459
|
+
description: "Client's terms of service uri"
|
|
1460
|
+
},
|
|
1461
|
+
policy_uri: {
|
|
1462
|
+
type: "string",
|
|
1463
|
+
description: "Client's policy uri"
|
|
1464
|
+
},
|
|
1465
|
+
software_id: {
|
|
1466
|
+
type: "string",
|
|
1467
|
+
description: "Unique identifier assigned by the developer to help in the dynamic registration process"
|
|
1468
|
+
},
|
|
1469
|
+
software_version: {
|
|
1470
|
+
type: "string",
|
|
1471
|
+
description: "Version identifier for the software_id"
|
|
1472
|
+
},
|
|
1473
|
+
software_statement: {
|
|
1474
|
+
type: "string",
|
|
1475
|
+
description: "JWT containing metadata values about the client software as claims"
|
|
1476
|
+
},
|
|
1477
|
+
redirect_uris: {
|
|
1478
|
+
type: "array",
|
|
1479
|
+
items: {
|
|
1480
|
+
type: "string",
|
|
1481
|
+
format: "uri"
|
|
1482
|
+
},
|
|
1483
|
+
description: "List of allowed redirect uris"
|
|
1484
|
+
},
|
|
1485
|
+
token_endpoint_auth_method: {
|
|
1486
|
+
type: "string",
|
|
1487
|
+
description: "Requested authentication method for the token endpoint"
|
|
1488
|
+
},
|
|
1489
|
+
grant_types: {
|
|
1490
|
+
type: "array",
|
|
1491
|
+
items: { type: "string" },
|
|
1492
|
+
description: "Grant types the client may use at the token endpoint"
|
|
1493
|
+
},
|
|
1494
|
+
response_types: {
|
|
1495
|
+
type: "array",
|
|
1496
|
+
items: {
|
|
1497
|
+
type: "string",
|
|
1498
|
+
enum: ["code"]
|
|
1499
|
+
},
|
|
1500
|
+
description: "Response types the client may use at the authorization endpoint"
|
|
1501
|
+
},
|
|
1502
|
+
public: {
|
|
1503
|
+
type: "boolean",
|
|
1504
|
+
description: "Whether the client is public as determined by the type"
|
|
1505
|
+
},
|
|
1506
|
+
type: {
|
|
1507
|
+
type: "string",
|
|
1508
|
+
description: "Type of the client",
|
|
1509
|
+
enum: [
|
|
1510
|
+
"web",
|
|
1511
|
+
"native",
|
|
1512
|
+
"user-agent-based"
|
|
1513
|
+
]
|
|
1514
|
+
},
|
|
1515
|
+
disabled: {
|
|
1516
|
+
type: "boolean",
|
|
1517
|
+
description: "Whether the client is disabled"
|
|
1518
|
+
},
|
|
1519
|
+
require_pkce: {
|
|
1520
|
+
type: "boolean",
|
|
1521
|
+
description: "Whether the client requires PKCE",
|
|
1522
|
+
default: true
|
|
1523
|
+
},
|
|
1524
|
+
metadata: {
|
|
1525
|
+
type: "object",
|
|
1526
|
+
additionalProperties: true,
|
|
1527
|
+
nullable: true,
|
|
1528
|
+
description: "Additional metadata for the application"
|
|
1529
|
+
}
|
|
1530
|
+
},
|
|
1531
|
+
required: ["client_id"]
|
|
1532
|
+
} } }
|
|
1533
|
+
} }
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
}, async (ctx) => {
|
|
1537
|
+
return createOAuthClientEndpoint(ctx, opts, { isRegister: false });
|
|
1538
|
+
});
|
|
1539
|
+
const createOAuthClient = (opts) => createAuthEndpoint("/oauth2/create-client", {
|
|
1876
1540
|
method: "POST",
|
|
1541
|
+
use: [sessionMiddleware],
|
|
1877
1542
|
body: z.object({
|
|
1878
|
-
redirect_uris: z.array(SafeUrlSchema).min(1),
|
|
1543
|
+
redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
|
|
1879
1544
|
scope: z.string().optional(),
|
|
1880
1545
|
client_name: z.string().optional(),
|
|
1881
1546
|
client_uri: z.string().optional(),
|
|
@@ -1887,37 +1552,25 @@ const adminCreateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/creat
|
|
|
1887
1552
|
software_version: z.string().optional(),
|
|
1888
1553
|
software_statement: z.string().optional(),
|
|
1889
1554
|
post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
"client_secret_post",
|
|
1894
|
-
"private_key_jwt"
|
|
1895
|
-
]).default("client_secret_basic").optional(),
|
|
1555
|
+
backchannel_logout_uri: SafeUrlSchema.optional(),
|
|
1556
|
+
backchannel_logout_session_required: z.boolean().optional(),
|
|
1557
|
+
token_endpoint_auth_method: tokenEndpointAuthMethodSchema.optional(),
|
|
1896
1558
|
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(),
|
|
1897
1559
|
jwks_uri: z.string().optional(),
|
|
1898
|
-
grant_types:
|
|
1899
|
-
|
|
1900
|
-
"client_credentials",
|
|
1901
|
-
"refresh_token"
|
|
1902
|
-
])).default(["authorization_code"]).optional(),
|
|
1903
|
-
response_types: z.array(z.enum(["code"])).default(["code"]).optional(),
|
|
1560
|
+
grant_types: grantTypesSchema.optional(),
|
|
1561
|
+
response_types: z.array(z.enum(["code"])).optional(),
|
|
1904
1562
|
type: z.enum([
|
|
1905
1563
|
"web",
|
|
1906
1564
|
"native",
|
|
1907
1565
|
"user-agent-based"
|
|
1908
1566
|
]).optional(),
|
|
1909
|
-
|
|
1910
|
-
skip_consent: z.boolean().optional(),
|
|
1911
|
-
enable_end_session: z.boolean().optional(),
|
|
1912
|
-
require_pkce: z.boolean().optional(),
|
|
1913
|
-
subject_type: z.enum(["public", "pairwise"]).optional(),
|
|
1914
|
-
metadata: z.record(z.string(), z.unknown()).optional()
|
|
1567
|
+
dpop_bound_access_tokens: z.boolean().optional()
|
|
1915
1568
|
}),
|
|
1916
1569
|
metadata: {
|
|
1917
|
-
|
|
1570
|
+
noStore: true,
|
|
1918
1571
|
openapi: {
|
|
1919
1572
|
description: "Register an OAuth2 application",
|
|
1920
|
-
responses: { "
|
|
1573
|
+
responses: { "201": {
|
|
1921
1574
|
description: "OAuth2 application registered successfully",
|
|
1922
1575
|
content: { "application/json": { schema: {
|
|
1923
1576
|
type: "object",
|
|
@@ -1993,24 +1646,12 @@ const adminCreateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/creat
|
|
|
1993
1646
|
},
|
|
1994
1647
|
token_endpoint_auth_method: {
|
|
1995
1648
|
type: "string",
|
|
1996
|
-
description: "Requested authentication method for the token endpoint"
|
|
1997
|
-
enum: [
|
|
1998
|
-
"none",
|
|
1999
|
-
"client_secret_basic",
|
|
2000
|
-
"client_secret_post"
|
|
2001
|
-
]
|
|
1649
|
+
description: "Requested authentication method for the token endpoint"
|
|
2002
1650
|
},
|
|
2003
1651
|
grant_types: {
|
|
2004
1652
|
type: "array",
|
|
2005
|
-
items: {
|
|
2006
|
-
|
|
2007
|
-
enum: [
|
|
2008
|
-
"authorization_code",
|
|
2009
|
-
"client_credentials",
|
|
2010
|
-
"refresh_token"
|
|
2011
|
-
]
|
|
2012
|
-
},
|
|
2013
|
-
description: "Requested authentication method for the token endpoint"
|
|
1653
|
+
items: { type: "string" },
|
|
1654
|
+
description: "Grant types the client may use at the token endpoint"
|
|
2014
1655
|
},
|
|
2015
1656
|
response_types: {
|
|
2016
1657
|
type: "array",
|
|
@@ -2018,7 +1659,7 @@ const adminCreateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/creat
|
|
|
2018
1659
|
type: "string",
|
|
2019
1660
|
enum: ["code"]
|
|
2020
1661
|
},
|
|
2021
|
-
description: "
|
|
1662
|
+
description: "Response types the client may use at the authorization endpoint"
|
|
2022
1663
|
},
|
|
2023
1664
|
public: {
|
|
2024
1665
|
type: "boolean",
|
|
@@ -2037,11 +1678,6 @@ const adminCreateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/creat
|
|
|
2037
1678
|
type: "boolean",
|
|
2038
1679
|
description: "Whether the client is disabled"
|
|
2039
1680
|
},
|
|
2040
|
-
require_pkce: {
|
|
2041
|
-
type: "boolean",
|
|
2042
|
-
description: "Whether the client requires PKCE",
|
|
2043
|
-
default: true
|
|
2044
|
-
},
|
|
2045
1681
|
metadata: {
|
|
2046
1682
|
type: "object",
|
|
2047
1683
|
additionalProperties: true,
|
|
@@ -2057,178 +1693,6 @@ const adminCreateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/creat
|
|
|
2057
1693
|
}, async (ctx) => {
|
|
2058
1694
|
return createOAuthClientEndpoint(ctx, opts, { isRegister: false });
|
|
2059
1695
|
});
|
|
2060
|
-
const createOAuthClient = (opts) => createAuthEndpoint("/oauth2/create-client", {
|
|
2061
|
-
method: "POST",
|
|
2062
|
-
use: [sessionMiddleware],
|
|
2063
|
-
body: z.object({
|
|
2064
|
-
redirect_uris: z.array(SafeUrlSchema).min(1),
|
|
2065
|
-
scope: z.string().optional(),
|
|
2066
|
-
client_name: z.string().optional(),
|
|
2067
|
-
client_uri: z.string().optional(),
|
|
2068
|
-
logo_uri: z.string().optional(),
|
|
2069
|
-
contacts: z.array(z.string().min(1)).min(1).optional(),
|
|
2070
|
-
tos_uri: z.string().optional(),
|
|
2071
|
-
policy_uri: z.string().optional(),
|
|
2072
|
-
software_id: z.string().optional(),
|
|
2073
|
-
software_version: z.string().optional(),
|
|
2074
|
-
software_statement: z.string().optional(),
|
|
2075
|
-
post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
|
|
2076
|
-
token_endpoint_auth_method: z.enum([
|
|
2077
|
-
"none",
|
|
2078
|
-
"client_secret_basic",
|
|
2079
|
-
"client_secret_post",
|
|
2080
|
-
"private_key_jwt"
|
|
2081
|
-
]).default("client_secret_basic").optional(),
|
|
2082
|
-
jwks: z.union([z.array(z.record(z.string(), z.unknown())).min(1), z.object({ keys: z.array(z.record(z.string(), z.unknown())).min(1) })]).optional(),
|
|
2083
|
-
jwks_uri: z.string().optional(),
|
|
2084
|
-
grant_types: z.array(z.enum([
|
|
2085
|
-
"authorization_code",
|
|
2086
|
-
"client_credentials",
|
|
2087
|
-
"refresh_token"
|
|
2088
|
-
])).default(["authorization_code"]).optional(),
|
|
2089
|
-
response_types: z.array(z.enum(["code"])).default(["code"]).optional(),
|
|
2090
|
-
type: z.enum([
|
|
2091
|
-
"web",
|
|
2092
|
-
"native",
|
|
2093
|
-
"user-agent-based"
|
|
2094
|
-
]).optional()
|
|
2095
|
-
}),
|
|
2096
|
-
metadata: { openapi: {
|
|
2097
|
-
description: "Register an OAuth2 application",
|
|
2098
|
-
responses: { "200": {
|
|
2099
|
-
description: "OAuth2 application registered successfully",
|
|
2100
|
-
content: { "application/json": { schema: {
|
|
2101
|
-
type: "object",
|
|
2102
|
-
properties: {
|
|
2103
|
-
client_id: {
|
|
2104
|
-
type: "string",
|
|
2105
|
-
description: "Unique identifier for the client"
|
|
2106
|
-
},
|
|
2107
|
-
client_secret: {
|
|
2108
|
-
type: "string",
|
|
2109
|
-
description: "Secret key for the client"
|
|
2110
|
-
},
|
|
2111
|
-
client_secret_expires_at: {
|
|
2112
|
-
type: "number",
|
|
2113
|
-
description: "Time the client secret will expire. If 0, the client secret will never expire."
|
|
2114
|
-
},
|
|
2115
|
-
scope: {
|
|
2116
|
-
type: "string",
|
|
2117
|
-
description: "Space-separated scopes allowed by the client"
|
|
2118
|
-
},
|
|
2119
|
-
user_id: {
|
|
2120
|
-
type: "string",
|
|
2121
|
-
description: "ID of the user who registered the client, null if registered anonymously"
|
|
2122
|
-
},
|
|
2123
|
-
client_id_issued_at: {
|
|
2124
|
-
type: "number",
|
|
2125
|
-
description: "Creation timestamp of this client"
|
|
2126
|
-
},
|
|
2127
|
-
client_name: {
|
|
2128
|
-
type: "string",
|
|
2129
|
-
description: "Name of the OAuth2 application"
|
|
2130
|
-
},
|
|
2131
|
-
client_uri: {
|
|
2132
|
-
type: "string",
|
|
2133
|
-
description: "URI of the OAuth2 application"
|
|
2134
|
-
},
|
|
2135
|
-
logo_uri: {
|
|
2136
|
-
type: "string",
|
|
2137
|
-
description: "Icon URI for the application"
|
|
2138
|
-
},
|
|
2139
|
-
contacts: {
|
|
2140
|
-
type: "array",
|
|
2141
|
-
items: { type: "string" },
|
|
2142
|
-
description: "List representing ways to contact people responsible for this client, typically email addresses"
|
|
2143
|
-
},
|
|
2144
|
-
tos_uri: {
|
|
2145
|
-
type: "string",
|
|
2146
|
-
description: "Client's terms of service uri"
|
|
2147
|
-
},
|
|
2148
|
-
policy_uri: {
|
|
2149
|
-
type: "string",
|
|
2150
|
-
description: "Client's policy uri"
|
|
2151
|
-
},
|
|
2152
|
-
software_id: {
|
|
2153
|
-
type: "string",
|
|
2154
|
-
description: "Unique identifier assigned by the developer to help in the dynamic registration process"
|
|
2155
|
-
},
|
|
2156
|
-
software_version: {
|
|
2157
|
-
type: "string",
|
|
2158
|
-
description: "Version identifier for the software_id"
|
|
2159
|
-
},
|
|
2160
|
-
software_statement: {
|
|
2161
|
-
type: "string",
|
|
2162
|
-
description: "JWT containing metadata values about the client software as claims"
|
|
2163
|
-
},
|
|
2164
|
-
redirect_uris: {
|
|
2165
|
-
type: "array",
|
|
2166
|
-
items: {
|
|
2167
|
-
type: "string",
|
|
2168
|
-
format: "uri"
|
|
2169
|
-
},
|
|
2170
|
-
description: "List of allowed redirect uris"
|
|
2171
|
-
},
|
|
2172
|
-
token_endpoint_auth_method: {
|
|
2173
|
-
type: "string",
|
|
2174
|
-
description: "Response types the client may use",
|
|
2175
|
-
enum: [
|
|
2176
|
-
"none",
|
|
2177
|
-
"client_secret_basic",
|
|
2178
|
-
"client_secret_post"
|
|
2179
|
-
]
|
|
2180
|
-
},
|
|
2181
|
-
grant_types: {
|
|
2182
|
-
type: "array",
|
|
2183
|
-
items: {
|
|
2184
|
-
type: "string",
|
|
2185
|
-
enum: [
|
|
2186
|
-
"authorization_code",
|
|
2187
|
-
"client_credentials",
|
|
2188
|
-
"refresh_token"
|
|
2189
|
-
]
|
|
2190
|
-
},
|
|
2191
|
-
description: "Requested authentication method for the token endpoint"
|
|
2192
|
-
},
|
|
2193
|
-
response_types: {
|
|
2194
|
-
type: "array",
|
|
2195
|
-
items: {
|
|
2196
|
-
type: "string",
|
|
2197
|
-
enum: ["code"]
|
|
2198
|
-
},
|
|
2199
|
-
description: "Requested authentication method for the token endpoint"
|
|
2200
|
-
},
|
|
2201
|
-
public: {
|
|
2202
|
-
type: "boolean",
|
|
2203
|
-
description: "Whether the client is public as determined by the type"
|
|
2204
|
-
},
|
|
2205
|
-
type: {
|
|
2206
|
-
type: "string",
|
|
2207
|
-
description: "Type of the client",
|
|
2208
|
-
enum: [
|
|
2209
|
-
"web",
|
|
2210
|
-
"native",
|
|
2211
|
-
"user-agent-based"
|
|
2212
|
-
]
|
|
2213
|
-
},
|
|
2214
|
-
disabled: {
|
|
2215
|
-
type: "boolean",
|
|
2216
|
-
description: "Whether the client is disabled"
|
|
2217
|
-
},
|
|
2218
|
-
metadata: {
|
|
2219
|
-
type: "object",
|
|
2220
|
-
additionalProperties: true,
|
|
2221
|
-
nullable: true,
|
|
2222
|
-
description: "Additional metadata for the application"
|
|
2223
|
-
}
|
|
2224
|
-
},
|
|
2225
|
-
required: ["client_id"]
|
|
2226
|
-
} } }
|
|
2227
|
-
} }
|
|
2228
|
-
} }
|
|
2229
|
-
}, async (ctx) => {
|
|
2230
|
-
return createOAuthClientEndpoint(ctx, opts, { isRegister: false });
|
|
2231
|
-
});
|
|
2232
1696
|
const getOAuthClient = (opts) => createAuthEndpoint("/oauth2/get-client", {
|
|
2233
1697
|
method: "GET",
|
|
2234
1698
|
use: [sessionMiddleware],
|
|
@@ -2282,11 +1746,9 @@ const adminUpdateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/updat
|
|
|
2282
1746
|
software_version: z.string().optional(),
|
|
2283
1747
|
software_statement: z.string().optional(),
|
|
2284
1748
|
post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
"refresh_token"
|
|
2289
|
-
])).optional(),
|
|
1749
|
+
backchannel_logout_uri: SafeUrlSchema.optional(),
|
|
1750
|
+
backchannel_logout_session_required: z.boolean().optional(),
|
|
1751
|
+
grant_types: grantTypesSchema.optional(),
|
|
2290
1752
|
response_types: z.array(z.enum(["code"])).optional(),
|
|
2291
1753
|
type: z.enum([
|
|
2292
1754
|
"web",
|
|
@@ -2296,6 +1758,7 @@ const adminUpdateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/updat
|
|
|
2296
1758
|
client_secret_expires_at: z.union([z.string(), z.number()]).optional(),
|
|
2297
1759
|
skip_consent: z.boolean().optional(),
|
|
2298
1760
|
enable_end_session: z.boolean().optional(),
|
|
1761
|
+
dpop_bound_access_tokens: z.boolean().optional(),
|
|
2299
1762
|
metadata: z.record(z.string(), z.unknown()).optional()
|
|
2300
1763
|
})
|
|
2301
1764
|
}),
|
|
@@ -2324,11 +1787,9 @@ const updateOAuthClient = (opts) => createAuthEndpoint("/oauth2/update-client",
|
|
|
2324
1787
|
software_version: z.string().optional(),
|
|
2325
1788
|
software_statement: z.string().optional(),
|
|
2326
1789
|
post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
"refresh_token"
|
|
2331
|
-
])).optional(),
|
|
1790
|
+
backchannel_logout_uri: SafeUrlSchema.optional(),
|
|
1791
|
+
backchannel_logout_session_required: z.boolean().optional(),
|
|
1792
|
+
grant_types: grantTypesSchema.optional(),
|
|
2332
1793
|
response_types: z.array(z.enum(["code"])).optional(),
|
|
2333
1794
|
type: z.enum([
|
|
2334
1795
|
"web",
|
|
@@ -2345,7 +1806,10 @@ const rotateClientSecret = (opts) => createAuthEndpoint("/oauth2/client/rotate-s
|
|
|
2345
1806
|
method: "POST",
|
|
2346
1807
|
use: [sessionMiddleware],
|
|
2347
1808
|
body: z.object({ client_id: z.string() }),
|
|
2348
|
-
metadata: {
|
|
1809
|
+
metadata: {
|
|
1810
|
+
noStore: true,
|
|
1811
|
+
openapi: { description: "Rotates a confidential client's secret" }
|
|
1812
|
+
}
|
|
2349
1813
|
}, async (ctx) => {
|
|
2350
1814
|
return rotateClientSecretEndpoint(ctx, opts);
|
|
2351
1815
|
});
|
|
@@ -2484,14 +1948,341 @@ const updateOAuthConsent = (opts) => createAuthEndpoint("/oauth2/update-consent"
|
|
|
2484
1948
|
}, async (ctx) => {
|
|
2485
1949
|
return updateConsentEndpoint(ctx, opts);
|
|
2486
1950
|
});
|
|
2487
|
-
const deleteOAuthConsent = (opts) => createAuthEndpoint("/oauth2/delete-consent", {
|
|
1951
|
+
const deleteOAuthConsent = (opts) => createAuthEndpoint("/oauth2/delete-consent", {
|
|
1952
|
+
method: "POST",
|
|
1953
|
+
use: [sessionMiddleware],
|
|
1954
|
+
body: z.object({ id: z.string() }),
|
|
1955
|
+
metadata: { openapi: { description: "Deletes consent granted to a client" } }
|
|
1956
|
+
}, async (ctx) => {
|
|
1957
|
+
return deleteConsentEndpoint(ctx, opts);
|
|
1958
|
+
});
|
|
1959
|
+
//#endregion
|
|
1960
|
+
//#region src/oauthResource/endpoints.ts
|
|
1961
|
+
/**
|
|
1962
|
+
* Gate every admin resource endpoint. Mirrors `assertClientPrivileges`:
|
|
1963
|
+
* a missing session → 401; a defined `resourcePrivileges` callback that
|
|
1964
|
+
* returns falsy → 401 with the original action context preserved.
|
|
1965
|
+
*
|
|
1966
|
+
* When `resourcePrivileges` is undefined, the gate degrades to "any
|
|
1967
|
+
* authenticated session can manage resources" — same forgiving default
|
|
1968
|
+
* as `clientPrivileges`. Operators who care about RBAC must define the
|
|
1969
|
+
* callback.
|
|
1970
|
+
*
|
|
1971
|
+
* @internal
|
|
1972
|
+
*/
|
|
1973
|
+
async function assertResourcePrivileges(ctx, session, opts, action, resourceId) {
|
|
1974
|
+
if (!session) throw new APIError("UNAUTHORIZED");
|
|
1975
|
+
if (!ctx.headers) throw new APIError("BAD_REQUEST");
|
|
1976
|
+
if (!opts.resourcePrivileges) return;
|
|
1977
|
+
if (!await opts.resourcePrivileges({
|
|
1978
|
+
headers: ctx.headers,
|
|
1979
|
+
action,
|
|
1980
|
+
session: session.session,
|
|
1981
|
+
user: session.user,
|
|
1982
|
+
resourceId
|
|
1983
|
+
})) throw new APIError("UNAUTHORIZED");
|
|
1984
|
+
}
|
|
1985
|
+
const resourceModel = (opts) => opts.schema?.oauthResource?.modelName ?? "oauthResource";
|
|
1986
|
+
const linkModel = (opts) => opts.schema?.oauthClientResource?.modelName ?? "oauthClientResource";
|
|
1987
|
+
const clientModel = (opts) => opts.schema?.oauthClient?.modelName ?? "oauthClient";
|
|
1988
|
+
/**
|
|
1989
|
+
* Decode a URL path-segment parameter.
|
|
1990
|
+
*
|
|
1991
|
+
* better-call's router (better-call@1.3.5) does NOT decode path params —
|
|
1992
|
+
* `tryDecode` is wired into cookie parsing only. So a raw HTTP caller hitting
|
|
1993
|
+
* `/admin/oauth2/resources/https%3A%2F%2Fapi.example.com` lands here with
|
|
1994
|
+
* `ctx.params.identifier === "https%3A%2F%2Fapi.example.com"`, which never
|
|
1995
|
+
* matches the stored `https://api.example.com` row. Decode every path
|
|
1996
|
+
* segment that holds a URI-valued identifier so the admin handlers behave
|
|
1997
|
+
* identically to in-process `auth.api.*` calls (which pass already-decoded
|
|
1998
|
+
* JS strings).
|
|
1999
|
+
*
|
|
2000
|
+
* Falls back to the raw string when decode fails so a malformed identifier
|
|
2001
|
+
* surfaces as a clean NOT_FOUND from the row lookup rather than a 500.
|
|
2002
|
+
*
|
|
2003
|
+
* @internal
|
|
2004
|
+
*/
|
|
2005
|
+
function decodePathParam(value) {
|
|
2006
|
+
try {
|
|
2007
|
+
return decodeURIComponent(value);
|
|
2008
|
+
} catch {
|
|
2009
|
+
return value;
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
/**
|
|
2013
|
+
* Builds the create-payload from a normalized input. Fills in defaults
|
|
2014
|
+
* for the fields seedResources uses so the admin-CRUD and seed paths
|
|
2015
|
+
* write identical rows.
|
|
2016
|
+
*
|
|
2017
|
+
* @internal
|
|
2018
|
+
*/
|
|
2019
|
+
function buildResourceRow(input, now) {
|
|
2020
|
+
return {
|
|
2021
|
+
identifier: input.identifier,
|
|
2022
|
+
name: input.name ?? input.identifier,
|
|
2023
|
+
accessTokenTtl: input.accessTokenTtl ?? null,
|
|
2024
|
+
refreshTokenTtl: input.refreshTokenTtl ?? null,
|
|
2025
|
+
signingAlgorithm: input.signingAlgorithm ?? null,
|
|
2026
|
+
signingKeyId: input.signingKeyId ?? null,
|
|
2027
|
+
allowedScopes: input.allowedScopes ?? null,
|
|
2028
|
+
customClaims: input.customClaims ?? null,
|
|
2029
|
+
dpopBoundAccessTokensRequired: input.dpopBoundAccessTokensRequired ?? false,
|
|
2030
|
+
disabled: input.disabled ?? false,
|
|
2031
|
+
policyVersion: 1,
|
|
2032
|
+
metadata: input.metadata ?? null,
|
|
2033
|
+
createdAt: now,
|
|
2034
|
+
updatedAt: now
|
|
2035
|
+
};
|
|
2036
|
+
}
|
|
2037
|
+
async function createResourceEndpoint(ctx, opts) {
|
|
2038
|
+
await assertResourcePrivileges(ctx, await getSessionFromCtx(ctx), opts, "create");
|
|
2039
|
+
const input = ctx.body;
|
|
2040
|
+
if (!input?.identifier) throw new APIError("BAD_REQUEST", {
|
|
2041
|
+
error: "invalid_request",
|
|
2042
|
+
error_description: "identifier is required"
|
|
2043
|
+
});
|
|
2044
|
+
await assertIdentifierValid(opts, input.identifier);
|
|
2045
|
+
const now = /* @__PURE__ */ new Date();
|
|
2046
|
+
let created;
|
|
2047
|
+
try {
|
|
2048
|
+
created = await ctx.context.adapter.create({
|
|
2049
|
+
model: resourceModel(opts),
|
|
2050
|
+
data: buildResourceRow(input, now)
|
|
2051
|
+
});
|
|
2052
|
+
} catch (err) {
|
|
2053
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2054
|
+
if (/unique|duplicate|UNIQUE/i.test(message)) throw new APIError("BAD_REQUEST", {
|
|
2055
|
+
error: "invalid_request",
|
|
2056
|
+
error_description: `resource ${input.identifier} already exists`
|
|
2057
|
+
});
|
|
2058
|
+
throw err;
|
|
2059
|
+
}
|
|
2060
|
+
if (!created) throw new APIError("BAD_REQUEST", {
|
|
2061
|
+
error: "invalid_request",
|
|
2062
|
+
error_description: `resource ${input.identifier} could not be created`
|
|
2063
|
+
});
|
|
2064
|
+
invalidateResourceCache(created.identifier);
|
|
2065
|
+
return ctx.json(created, { status: 201 });
|
|
2066
|
+
}
|
|
2067
|
+
async function listResourcesEndpoint(ctx, opts) {
|
|
2068
|
+
await assertResourcePrivileges(ctx, await getSessionFromCtx(ctx), opts, "list");
|
|
2069
|
+
const rows = await ctx.context.adapter.findMany({ model: resourceModel(opts) });
|
|
2070
|
+
return ctx.json(rows ?? []);
|
|
2071
|
+
}
|
|
2072
|
+
async function getResourceByIdentifierEndpoint(ctx, opts) {
|
|
2073
|
+
const identifier = decodePathParam(ctx.params.identifier);
|
|
2074
|
+
await assertResourcePrivileges(ctx, await getSessionFromCtx(ctx), opts, "read", identifier);
|
|
2075
|
+
const row = await ctx.context.adapter.findOne({
|
|
2076
|
+
model: resourceModel(opts),
|
|
2077
|
+
where: [{
|
|
2078
|
+
field: "identifier",
|
|
2079
|
+
value: identifier
|
|
2080
|
+
}]
|
|
2081
|
+
});
|
|
2082
|
+
if (!row) throw new APIError("NOT_FOUND", {
|
|
2083
|
+
error: "not_found",
|
|
2084
|
+
error_description: `resource ${identifier} not found`
|
|
2085
|
+
});
|
|
2086
|
+
return ctx.json(row);
|
|
2087
|
+
}
|
|
2088
|
+
async function updateResourceEndpoint(ctx, opts) {
|
|
2089
|
+
const identifier = decodePathParam(ctx.params.identifier);
|
|
2090
|
+
await assertResourcePrivileges(ctx, await getSessionFromCtx(ctx), opts, "update", identifier);
|
|
2091
|
+
if (!await ctx.context.adapter.findOne({
|
|
2092
|
+
model: resourceModel(opts),
|
|
2093
|
+
where: [{
|
|
2094
|
+
field: "identifier",
|
|
2095
|
+
value: identifier
|
|
2096
|
+
}]
|
|
2097
|
+
})) throw new APIError("NOT_FOUND", {
|
|
2098
|
+
error: "not_found",
|
|
2099
|
+
error_description: `resource ${identifier} not found`
|
|
2100
|
+
});
|
|
2101
|
+
const update = { updatedAt: /* @__PURE__ */ new Date() };
|
|
2102
|
+
for (const key of [
|
|
2103
|
+
"name",
|
|
2104
|
+
"accessTokenTtl",
|
|
2105
|
+
"refreshTokenTtl",
|
|
2106
|
+
"signingAlgorithm",
|
|
2107
|
+
"signingKeyId",
|
|
2108
|
+
"allowedScopes",
|
|
2109
|
+
"customClaims",
|
|
2110
|
+
"dpopBoundAccessTokensRequired",
|
|
2111
|
+
"disabled",
|
|
2112
|
+
"metadata"
|
|
2113
|
+
]) if (Object.prototype.hasOwnProperty.call(ctx.body, key)) update[key] = ctx.body[key];
|
|
2114
|
+
await ctx.context.adapter.update({
|
|
2115
|
+
model: resourceModel(opts),
|
|
2116
|
+
where: [{
|
|
2117
|
+
field: "identifier",
|
|
2118
|
+
value: identifier
|
|
2119
|
+
}],
|
|
2120
|
+
update
|
|
2121
|
+
});
|
|
2122
|
+
invalidateResourceCache(identifier);
|
|
2123
|
+
const refreshed = await ctx.context.adapter.findOne({
|
|
2124
|
+
model: resourceModel(opts),
|
|
2125
|
+
where: [{
|
|
2126
|
+
field: "identifier",
|
|
2127
|
+
value: identifier
|
|
2128
|
+
}]
|
|
2129
|
+
});
|
|
2130
|
+
if (!refreshed) throw new APIError("NOT_FOUND", {
|
|
2131
|
+
error: "not_found",
|
|
2132
|
+
error_description: `resource ${identifier} not found`
|
|
2133
|
+
});
|
|
2134
|
+
return ctx.json(refreshed);
|
|
2135
|
+
}
|
|
2136
|
+
async function deleteResourceEndpoint(ctx, opts) {
|
|
2137
|
+
const identifier = decodePathParam(ctx.params.identifier);
|
|
2138
|
+
await assertResourcePrivileges(ctx, await getSessionFromCtx(ctx), opts, "delete", identifier);
|
|
2139
|
+
if (!await ctx.context.adapter.findOne({
|
|
2140
|
+
model: resourceModel(opts),
|
|
2141
|
+
where: [{
|
|
2142
|
+
field: "identifier",
|
|
2143
|
+
value: identifier
|
|
2144
|
+
}]
|
|
2145
|
+
})) throw new APIError("NOT_FOUND", {
|
|
2146
|
+
error: "not_found",
|
|
2147
|
+
error_description: `resource ${identifier} not found`
|
|
2148
|
+
});
|
|
2149
|
+
await ctx.context.adapter.delete({
|
|
2150
|
+
model: resourceModel(opts),
|
|
2151
|
+
where: [{
|
|
2152
|
+
field: "identifier",
|
|
2153
|
+
value: identifier
|
|
2154
|
+
}]
|
|
2155
|
+
});
|
|
2156
|
+
invalidateResourceCache(identifier);
|
|
2157
|
+
return ctx.json({ deleted: true });
|
|
2158
|
+
}
|
|
2159
|
+
/**
|
|
2160
|
+
* Link a client to a resource.
|
|
2161
|
+
*
|
|
2162
|
+
* Route: `POST /admin/oauth2/resources/:identifier/clients/:client_id`.
|
|
2163
|
+
* Path params carry the identifiers (RESTful linkage) — no body required.
|
|
2164
|
+
* Used by admins when {@link OAuthOptions.enforcePerClientResources} is on.
|
|
2165
|
+
*/
|
|
2166
|
+
async function linkClientResourceEndpoint(ctx, opts) {
|
|
2167
|
+
const session = await getSessionFromCtx(ctx);
|
|
2168
|
+
const resourceId = decodePathParam(ctx.params.identifier);
|
|
2169
|
+
const clientId = decodePathParam(ctx.params.client_id);
|
|
2170
|
+
await assertResourcePrivileges(ctx, session, opts, "link", resourceId);
|
|
2171
|
+
if (!await ctx.context.adapter.findOne({
|
|
2172
|
+
model: resourceModel(opts),
|
|
2173
|
+
where: [{
|
|
2174
|
+
field: "identifier",
|
|
2175
|
+
value: resourceId
|
|
2176
|
+
}]
|
|
2177
|
+
})) throw new APIError("NOT_FOUND", {
|
|
2178
|
+
error: "not_found",
|
|
2179
|
+
error_description: `resource ${resourceId} not found`
|
|
2180
|
+
});
|
|
2181
|
+
if (!await ctx.context.adapter.findOne({
|
|
2182
|
+
model: clientModel(opts),
|
|
2183
|
+
where: [{
|
|
2184
|
+
field: "clientId",
|
|
2185
|
+
value: clientId
|
|
2186
|
+
}]
|
|
2187
|
+
})) throw new APIError("NOT_FOUND", {
|
|
2188
|
+
error: "not_found",
|
|
2189
|
+
error_description: `client ${clientId} not found`
|
|
2190
|
+
});
|
|
2191
|
+
const id = buildClientResourceLinkId(clientId, resourceId);
|
|
2192
|
+
try {
|
|
2193
|
+
await ctx.context.adapter.create({
|
|
2194
|
+
model: linkModel(opts),
|
|
2195
|
+
forceAllowId: true,
|
|
2196
|
+
data: {
|
|
2197
|
+
id,
|
|
2198
|
+
clientId,
|
|
2199
|
+
resourceId,
|
|
2200
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
2201
|
+
}
|
|
2202
|
+
});
|
|
2203
|
+
} catch (err) {
|
|
2204
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2205
|
+
if (/unique|duplicate|UNIQUE/i.test(message)) return ctx.json({
|
|
2206
|
+
linked: true,
|
|
2207
|
+
alreadyLinked: true
|
|
2208
|
+
});
|
|
2209
|
+
throw err;
|
|
2210
|
+
}
|
|
2211
|
+
return ctx.json({ linked: true });
|
|
2212
|
+
}
|
|
2213
|
+
/**
|
|
2214
|
+
* Unlink a client from a resource.
|
|
2215
|
+
*
|
|
2216
|
+
* Route: `DELETE /admin/oauth2/resources/:identifier/clients/:client_id`.
|
|
2217
|
+
* Path params carry the identifiers (RESTful linkage) — no body required.
|
|
2218
|
+
*/
|
|
2219
|
+
async function unlinkClientResourceEndpoint(ctx, opts) {
|
|
2220
|
+
const session = await getSessionFromCtx(ctx);
|
|
2221
|
+
const resourceId = decodePathParam(ctx.params.identifier);
|
|
2222
|
+
const clientId = decodePathParam(ctx.params.client_id);
|
|
2223
|
+
await assertResourcePrivileges(ctx, session, opts, "unlink", resourceId);
|
|
2224
|
+
await ctx.context.adapter.deleteMany({
|
|
2225
|
+
model: linkModel(opts),
|
|
2226
|
+
where: [{
|
|
2227
|
+
field: "clientId",
|
|
2228
|
+
value: clientId
|
|
2229
|
+
}, {
|
|
2230
|
+
field: "resourceId",
|
|
2231
|
+
value: resourceId
|
|
2232
|
+
}]
|
|
2233
|
+
});
|
|
2234
|
+
return ctx.json({ unlinked: true });
|
|
2235
|
+
}
|
|
2236
|
+
//#endregion
|
|
2237
|
+
//#region src/oauthResource/index.ts
|
|
2238
|
+
/**
|
|
2239
|
+
* Shared body schema for create/update — every field is optional except
|
|
2240
|
+
* `identifier` on create (validated in the handler). Update accepts a
|
|
2241
|
+
* subset; the handler only writes fields that are explicitly present.
|
|
2242
|
+
*/
|
|
2243
|
+
const resourceBodySchema = z.object({
|
|
2244
|
+
identifier: z.string().min(1).optional(),
|
|
2245
|
+
name: z.string().optional(),
|
|
2246
|
+
accessTokenTtl: z.number().int().positive().nullable().optional(),
|
|
2247
|
+
refreshTokenTtl: z.number().int().positive().nullable().optional(),
|
|
2248
|
+
signingAlgorithm: z.enum(JWS_ALGORITHMS).nullable().optional(),
|
|
2249
|
+
signingKeyId: z.string().nullable().optional(),
|
|
2250
|
+
allowedScopes: z.array(z.string()).nullable().optional(),
|
|
2251
|
+
customClaims: z.record(z.string(), z.unknown()).nullable().optional(),
|
|
2252
|
+
dpopBoundAccessTokensRequired: z.boolean().optional(),
|
|
2253
|
+
disabled: z.boolean().optional(),
|
|
2254
|
+
metadata: z.record(z.string(), z.unknown()).nullable().optional()
|
|
2255
|
+
});
|
|
2256
|
+
const adminCreateOAuthResource = (opts) => createAuthEndpoint("/admin/oauth2/resources", {
|
|
2488
2257
|
method: "POST",
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
}
|
|
2258
|
+
body: resourceBodySchema.required({ identifier: true }),
|
|
2259
|
+
metadata: { SERVER_ONLY: true }
|
|
2260
|
+
}, async (ctx) => createResourceEndpoint(ctx, opts));
|
|
2261
|
+
const adminListOAuthResources = (opts) => createAuthEndpoint("/admin/oauth2/resources", {
|
|
2262
|
+
method: "GET",
|
|
2263
|
+
metadata: { SERVER_ONLY: true }
|
|
2264
|
+
}, async (ctx) => listResourcesEndpoint(ctx, opts));
|
|
2265
|
+
const adminGetOAuthResource = (opts) => createAuthEndpoint("/admin/oauth2/resources/:identifier", {
|
|
2266
|
+
method: "GET",
|
|
2267
|
+
metadata: { SERVER_ONLY: true }
|
|
2268
|
+
}, async (ctx) => getResourceByIdentifierEndpoint(ctx, opts));
|
|
2269
|
+
const adminUpdateOAuthResource = (opts) => createAuthEndpoint("/admin/oauth2/resources/:identifier", {
|
|
2270
|
+
method: "PATCH",
|
|
2271
|
+
body: resourceBodySchema,
|
|
2272
|
+
metadata: { SERVER_ONLY: true }
|
|
2273
|
+
}, async (ctx) => updateResourceEndpoint(ctx, opts));
|
|
2274
|
+
const adminDeleteOAuthResource = (opts) => createAuthEndpoint("/admin/oauth2/resources/:identifier", {
|
|
2275
|
+
method: "DELETE",
|
|
2276
|
+
metadata: { SERVER_ONLY: true }
|
|
2277
|
+
}, async (ctx) => deleteResourceEndpoint(ctx, opts));
|
|
2278
|
+
const adminLinkClientResource = (opts) => createAuthEndpoint("/admin/oauth2/resources/:identifier/clients/:client_id", {
|
|
2279
|
+
method: "POST",
|
|
2280
|
+
metadata: { SERVER_ONLY: true }
|
|
2281
|
+
}, async (ctx) => linkClientResourceEndpoint(ctx, opts));
|
|
2282
|
+
const adminUnlinkClientResource = (opts) => createAuthEndpoint("/admin/oauth2/resources/:identifier/clients/:client_id", {
|
|
2283
|
+
method: "DELETE",
|
|
2284
|
+
metadata: { SERVER_ONLY: true }
|
|
2285
|
+
}, async (ctx) => unlinkClientResourceEndpoint(ctx, opts));
|
|
2495
2286
|
//#endregion
|
|
2496
2287
|
//#region src/revoke.ts
|
|
2497
2288
|
/**
|
|
@@ -2503,21 +2294,28 @@ const deleteOAuthConsent = (opts) => createAuthEndpoint("/oauth2/delete-consent"
|
|
|
2503
2294
|
*/
|
|
2504
2295
|
/**
|
|
2505
2296
|
* Revokes a JWT access token against the configured JWKs.
|
|
2506
|
-
*
|
|
2297
|
+
*
|
|
2298
|
+
* A JWT access token is self-contained and never stored, so there is nothing to
|
|
2299
|
+
* delete. Once the token is confirmed to be a valid JWT for this server, the
|
|
2300
|
+
* endpoint reports `unsupported_token_type` (RFC 7009 §2.2.1) instead of a
|
|
2301
|
+
* silent success, so callers can tell that no server-side revocation happened.
|
|
2302
|
+
* An expired JWT or a JWT with an audience rejected by the OAuth resource model
|
|
2303
|
+
* is already inactive and still resolves as a successful no-op. Session-bound
|
|
2304
|
+
* tokens (carrying `sid`) are cut off early by the session-liveness check in
|
|
2305
|
+
* introspection and userinfo.
|
|
2507
2306
|
*/
|
|
2508
2307
|
async function revokeJwtAccessToken(ctx, opts, token) {
|
|
2509
2308
|
const jwtPlugin = opts.disableJwtPlugin ? void 0 : getJwtPlugin(ctx.context);
|
|
2510
2309
|
const jwtPluginOptions = jwtPlugin?.options;
|
|
2511
2310
|
try {
|
|
2512
|
-
await
|
|
2311
|
+
const verified = await jwtVerify(token, createLocalJWKSet(await getJwks(token, {
|
|
2513
2312
|
jwksFetch: jwtPluginOptions?.jwks?.remoteUrl ? jwtPluginOptions.jwks.remoteUrl : async () => {
|
|
2514
2313
|
return (await jwtPlugin?.endpoints.getJwks(ctx))?.response;
|
|
2515
2314
|
},
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
});
|
|
2315
|
+
jwksCacheKey: jwtPlugin
|
|
2316
|
+
})), { issuer: jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL });
|
|
2317
|
+
const userInfoAudience = `${ctx.context.baseURL}/oauth2/userinfo`;
|
|
2318
|
+
if (!verified.payload.azp || !await isAudienceClaimAllowed(ctx, opts, verified.payload.aud, [userInfoAudience])) return null;
|
|
2521
2319
|
} catch (error) {
|
|
2522
2320
|
if (error instanceof Error) {
|
|
2523
2321
|
if (error.name === "TypeError" || error.name === "JWSInvalid") throw new APIError$1("BAD_REQUEST", {
|
|
@@ -2530,6 +2328,10 @@ async function revokeJwtAccessToken(ctx, opts, token) {
|
|
|
2530
2328
|
}
|
|
2531
2329
|
throw new Error(error);
|
|
2532
2330
|
}
|
|
2331
|
+
throw new APIError$1("BAD_REQUEST", {
|
|
2332
|
+
error_description: "JWT access tokens are self-contained and cannot be revoked server-side",
|
|
2333
|
+
error: "unsupported_token_type"
|
|
2334
|
+
});
|
|
2533
2335
|
}
|
|
2534
2336
|
/**
|
|
2535
2337
|
* Searches for an opaque access token in the database and validates it
|
|
@@ -2591,7 +2393,7 @@ async function revokeRefreshToken(ctx, opts, token, clientId) {
|
|
|
2591
2393
|
}
|
|
2592
2394
|
if (!refreshToken.clientId || refreshToken.clientId !== clientId) return null;
|
|
2593
2395
|
const iat = Math.floor(Date.now() / 1e3);
|
|
2594
|
-
if (!await ctx.context.adapter.
|
|
2396
|
+
if (!await ctx.context.adapter.incrementOne({
|
|
2595
2397
|
model: "oauthRefreshToken",
|
|
2596
2398
|
where: [{
|
|
2597
2399
|
field: "id",
|
|
@@ -2601,7 +2403,8 @@ async function revokeRefreshToken(ctx, opts, token, clientId) {
|
|
|
2601
2403
|
operator: "eq",
|
|
2602
2404
|
value: null
|
|
2603
2405
|
}],
|
|
2604
|
-
|
|
2406
|
+
increment: {},
|
|
2407
|
+
set: { revoked: /* @__PURE__ */ new Date(iat * 1e3) }
|
|
2605
2408
|
})) {
|
|
2606
2409
|
await invalidateRefreshFamily(ctx, clientId, refreshToken.userId);
|
|
2607
2410
|
throw new APIError$1("BAD_REQUEST", {
|
|
@@ -2625,7 +2428,9 @@ async function revokeAccessToken(ctx, opts, clientId, token) {
|
|
|
2625
2428
|
try {
|
|
2626
2429
|
return await revokeJwtAccessToken(ctx, opts, token);
|
|
2627
2430
|
} catch (err) {
|
|
2628
|
-
if (err instanceof APIError$1) {
|
|
2431
|
+
if (err instanceof APIError$1) {
|
|
2432
|
+
if (err.body?.error === "unsupported_token_type") throw err;
|
|
2433
|
+
} else if (err instanceof Error) throw err;
|
|
2629
2434
|
else throw new Error(err);
|
|
2630
2435
|
}
|
|
2631
2436
|
try {
|
|
@@ -2642,23 +2447,23 @@ async function revokeAccessToken(ctx, opts, clientId, token) {
|
|
|
2642
2447
|
async function revokeEndpoint(ctx, opts) {
|
|
2643
2448
|
let { token, token_type_hint } = ctx.body;
|
|
2644
2449
|
if (token_type_hint !== "access_token" && token_type_hint !== "refresh_token") token_type_hint = void 0;
|
|
2645
|
-
const { clientId: client_id, clientSecret: client_secret,
|
|
2450
|
+
const { clientId: client_id, clientSecret: client_secret, preVerified, authMethod } = destructureCredentials(await extractClientCredentials(ctx, opts, `${ctx.context.baseURL}/oauth2/revoke`));
|
|
2646
2451
|
if (!client_id) throw new APIError$1("UNAUTHORIZED", {
|
|
2647
2452
|
error_description: "missing required credentials",
|
|
2648
2453
|
error: "invalid_client"
|
|
2649
2454
|
});
|
|
2650
|
-
if (typeof token === "string"
|
|
2455
|
+
if (typeof token === "string") token = stripAccessTokenAuthorizationScheme(token);
|
|
2651
2456
|
if (!token?.length) throw new APIError$1("BAD_REQUEST", {
|
|
2652
2457
|
error_description: "missing a required token for introspection",
|
|
2653
2458
|
error: "invalid_request"
|
|
2654
2459
|
});
|
|
2655
|
-
const client = await validateClientCredentials(ctx, opts, client_id, client_secret, void 0,
|
|
2460
|
+
const client = await validateClientCredentials(ctx, opts, client_id, client_secret, void 0, preVerified, void 0, authMethod);
|
|
2656
2461
|
try {
|
|
2657
2462
|
if (token_type_hint === void 0 || token_type_hint === "access_token") try {
|
|
2658
2463
|
return await revokeAccessToken(ctx, opts, client.clientId, token);
|
|
2659
2464
|
} catch (error) {
|
|
2660
2465
|
if (error instanceof APIError$1) {
|
|
2661
|
-
if (token_type_hint === "access_token") throw error;
|
|
2466
|
+
if (token_type_hint === "access_token" || error.body?.error === "unsupported_token_type") throw error;
|
|
2662
2467
|
} else if (error instanceof Error) throw error;
|
|
2663
2468
|
else throw new Error(error);
|
|
2664
2469
|
}
|
|
@@ -2676,6 +2481,7 @@ async function revokeEndpoint(ctx, opts) {
|
|
|
2676
2481
|
});
|
|
2677
2482
|
} catch (error) {
|
|
2678
2483
|
if (error instanceof APIError$1) {
|
|
2484
|
+
if (error.body?.error === "unsupported_token_type") throw error;
|
|
2679
2485
|
if (error.name === "BAD_REQUEST") return null;
|
|
2680
2486
|
throw error;
|
|
2681
2487
|
} else if (error instanceof Error) {
|
|
@@ -2784,6 +2590,14 @@ const schema = {
|
|
|
2784
2590
|
type: "string[]",
|
|
2785
2591
|
required: false
|
|
2786
2592
|
},
|
|
2593
|
+
backchannelLogoutUri: {
|
|
2594
|
+
type: "string",
|
|
2595
|
+
required: false
|
|
2596
|
+
},
|
|
2597
|
+
backchannelLogoutSessionRequired: {
|
|
2598
|
+
type: "boolean",
|
|
2599
|
+
required: false
|
|
2600
|
+
},
|
|
2787
2601
|
tokenEndpointAuthMethod: {
|
|
2788
2602
|
type: "string",
|
|
2789
2603
|
required: false
|
|
@@ -2816,6 +2630,11 @@ const schema = {
|
|
|
2816
2630
|
type: "boolean",
|
|
2817
2631
|
required: false
|
|
2818
2632
|
},
|
|
2633
|
+
dpopBoundAccessTokens: {
|
|
2634
|
+
type: "boolean",
|
|
2635
|
+
required: false,
|
|
2636
|
+
defaultValue: false
|
|
2637
|
+
},
|
|
2819
2638
|
referenceId: {
|
|
2820
2639
|
type: "string",
|
|
2821
2640
|
required: false
|
|
@@ -2826,6 +2645,104 @@ const schema = {
|
|
|
2826
2645
|
}
|
|
2827
2646
|
}
|
|
2828
2647
|
},
|
|
2648
|
+
oauthResource: {
|
|
2649
|
+
modelName: "oauthResource",
|
|
2650
|
+
fields: {
|
|
2651
|
+
identifier: {
|
|
2652
|
+
type: "string",
|
|
2653
|
+
required: true,
|
|
2654
|
+
unique: true
|
|
2655
|
+
},
|
|
2656
|
+
name: {
|
|
2657
|
+
type: "string",
|
|
2658
|
+
required: true
|
|
2659
|
+
},
|
|
2660
|
+
accessTokenTtl: {
|
|
2661
|
+
type: "number",
|
|
2662
|
+
required: false
|
|
2663
|
+
},
|
|
2664
|
+
refreshTokenTtl: {
|
|
2665
|
+
type: "number",
|
|
2666
|
+
required: false
|
|
2667
|
+
},
|
|
2668
|
+
signingAlgorithm: {
|
|
2669
|
+
type: "string",
|
|
2670
|
+
required: false
|
|
2671
|
+
},
|
|
2672
|
+
signingKeyId: {
|
|
2673
|
+
type: "string",
|
|
2674
|
+
required: false
|
|
2675
|
+
},
|
|
2676
|
+
allowedScopes: {
|
|
2677
|
+
type: "string[]",
|
|
2678
|
+
required: false
|
|
2679
|
+
},
|
|
2680
|
+
customClaims: {
|
|
2681
|
+
type: "json",
|
|
2682
|
+
required: false
|
|
2683
|
+
},
|
|
2684
|
+
dpopBoundAccessTokensRequired: {
|
|
2685
|
+
type: "boolean",
|
|
2686
|
+
required: false,
|
|
2687
|
+
defaultValue: false
|
|
2688
|
+
},
|
|
2689
|
+
disabled: {
|
|
2690
|
+
type: "boolean",
|
|
2691
|
+
required: false,
|
|
2692
|
+
defaultValue: false
|
|
2693
|
+
},
|
|
2694
|
+
createdAt: {
|
|
2695
|
+
type: "date",
|
|
2696
|
+
required: false
|
|
2697
|
+
},
|
|
2698
|
+
updatedAt: {
|
|
2699
|
+
type: "date",
|
|
2700
|
+
required: false
|
|
2701
|
+
},
|
|
2702
|
+
policyVersion: {
|
|
2703
|
+
type: "number",
|
|
2704
|
+
required: false,
|
|
2705
|
+
defaultValue: 1
|
|
2706
|
+
},
|
|
2707
|
+
metadata: {
|
|
2708
|
+
type: "json",
|
|
2709
|
+
required: false
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
},
|
|
2713
|
+
oauthClientResource: {
|
|
2714
|
+
modelName: "oauthClientResource",
|
|
2715
|
+
fields: {
|
|
2716
|
+
clientId: {
|
|
2717
|
+
type: "string",
|
|
2718
|
+
required: true,
|
|
2719
|
+
references: {
|
|
2720
|
+
model: "oauthClient",
|
|
2721
|
+
field: "clientId",
|
|
2722
|
+
onDelete: "cascade"
|
|
2723
|
+
},
|
|
2724
|
+
index: true
|
|
2725
|
+
},
|
|
2726
|
+
resourceId: {
|
|
2727
|
+
type: "string",
|
|
2728
|
+
required: true,
|
|
2729
|
+
references: {
|
|
2730
|
+
model: "oauthResource",
|
|
2731
|
+
field: "identifier",
|
|
2732
|
+
onDelete: "cascade"
|
|
2733
|
+
},
|
|
2734
|
+
index: true
|
|
2735
|
+
},
|
|
2736
|
+
metadata: {
|
|
2737
|
+
type: "json",
|
|
2738
|
+
required: false
|
|
2739
|
+
},
|
|
2740
|
+
createdAt: {
|
|
2741
|
+
type: "date",
|
|
2742
|
+
required: false
|
|
2743
|
+
}
|
|
2744
|
+
}
|
|
2745
|
+
},
|
|
2829
2746
|
oauthRefreshToken: { fields: {
|
|
2830
2747
|
token: {
|
|
2831
2748
|
type: "string",
|
|
@@ -2878,6 +2795,10 @@ const schema = {
|
|
|
2878
2795
|
type: "date",
|
|
2879
2796
|
required: false
|
|
2880
2797
|
},
|
|
2798
|
+
confirmation: {
|
|
2799
|
+
type: "json",
|
|
2800
|
+
required: false
|
|
2801
|
+
},
|
|
2881
2802
|
scopes: {
|
|
2882
2803
|
type: "string[]",
|
|
2883
2804
|
required: true
|
|
@@ -2937,6 +2858,14 @@ const schema = {
|
|
|
2937
2858
|
},
|
|
2938
2859
|
expiresAt: { type: "date" },
|
|
2939
2860
|
createdAt: { type: "date" },
|
|
2861
|
+
revoked: {
|
|
2862
|
+
type: "date",
|
|
2863
|
+
required: false
|
|
2864
|
+
},
|
|
2865
|
+
confirmation: {
|
|
2866
|
+
type: "json",
|
|
2867
|
+
required: false
|
|
2868
|
+
},
|
|
2940
2869
|
scopes: {
|
|
2941
2870
|
type: "string[]",
|
|
2942
2871
|
required: true
|
|
@@ -2979,12 +2908,36 @@ const schema = {
|
|
|
2979
2908
|
createdAt: { type: "date" },
|
|
2980
2909
|
updatedAt: { type: "date" }
|
|
2981
2910
|
}
|
|
2911
|
+
},
|
|
2912
|
+
oauthClientAssertion: {
|
|
2913
|
+
modelName: "oauthClientAssertion",
|
|
2914
|
+
fields: { expiresAt: {
|
|
2915
|
+
type: "date",
|
|
2916
|
+
required: true
|
|
2917
|
+
} }
|
|
2982
2918
|
}
|
|
2983
2919
|
};
|
|
2984
2920
|
//#endregion
|
|
2985
2921
|
//#region src/oauth.ts
|
|
2922
|
+
/**
|
|
2923
|
+
* Default scopes advertised and accepted when a configuration sets none. Shared
|
|
2924
|
+
* with the MCP preset so the resource metadata it serves matches what the
|
|
2925
|
+
* authorization-server metadata advertises.
|
|
2926
|
+
*/
|
|
2927
|
+
const DEFAULT_OAUTH_SCOPES = [
|
|
2928
|
+
"openid",
|
|
2929
|
+
"profile",
|
|
2930
|
+
"email",
|
|
2931
|
+
"offline_access"
|
|
2932
|
+
];
|
|
2986
2933
|
const oAuthState = defineRequestState(() => null);
|
|
2987
2934
|
const getOAuthProviderState = oAuthState.get;
|
|
2935
|
+
const signedQueryIssuedAtMsKey = "signedQueryIssuedAtMs";
|
|
2936
|
+
function getServerContextSignedQueryIssuedAt(value) {
|
|
2937
|
+
const issuedAtMs = typeof value === "number" ? value : typeof value === "string" ? Number(value) : void 0;
|
|
2938
|
+
if (!issuedAtMs || !Number.isFinite(issuedAtMs) || issuedAtMs <= 0) return;
|
|
2939
|
+
return new Date(issuedAtMs);
|
|
2940
|
+
}
|
|
2988
2941
|
/**
|
|
2989
2942
|
* oAuth 2.1 provider plugin for Better Auth.
|
|
2990
2943
|
*
|
|
@@ -2998,12 +2951,7 @@ const oauthProvider = (options) => {
|
|
|
2998
2951
|
const _allowedScopes = clientRegistrationAllowedScopes ? new Set([...clientRegistrationAllowedScopes, ...options.clientRegistrationDefaultScopes]) : new Set([...options.clientRegistrationDefaultScopes]);
|
|
2999
2952
|
clientRegistrationAllowedScopes = Array.from(_allowedScopes);
|
|
3000
2953
|
}
|
|
3001
|
-
const scopes = new Set((options.scopes ??
|
|
3002
|
-
"openid",
|
|
3003
|
-
"profile",
|
|
3004
|
-
"email",
|
|
3005
|
-
"offline_access"
|
|
3006
|
-
]).filter((val) => val.length));
|
|
2954
|
+
const scopes = new Set((options.scopes ?? DEFAULT_OAUTH_SCOPES).filter((val) => val.length));
|
|
3007
2955
|
if (clientRegistrationAllowedScopes) {
|
|
3008
2956
|
for (const sc of clientRegistrationAllowedScopes) if (!scopes.has(sc)) throw new BetterAuthError(`clientRegistrationAllowedScope ${sc} not found in scopes`);
|
|
3009
2957
|
}
|
|
@@ -3045,6 +2993,7 @@ const oauthProvider = (options) => {
|
|
|
3045
2993
|
claims: Array.from(claims),
|
|
3046
2994
|
clientRegistrationAllowedScopes
|
|
3047
2995
|
};
|
|
2996
|
+
validateOAuthProviderExtensions(opts.extensions);
|
|
3048
2997
|
if (opts.pairwiseSecret && opts.pairwiseSecret.length < 32) throw new BetterAuthError("pairwiseSecret must be at least 32 characters long for adequate HMAC-SHA256 security");
|
|
3049
2998
|
if (opts.grantTypes && opts.grantTypes.includes("refresh_token") && !opts.grantTypes.includes("authorization_code")) throw new BetterAuthError("refresh_token grant requires authorization_code grant");
|
|
3050
2999
|
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");
|
|
@@ -3080,26 +3029,159 @@ const oauthProvider = (options) => {
|
|
|
3080
3029
|
}
|
|
3081
3030
|
if (isAuthServerMetadataRequest) {
|
|
3082
3031
|
if (opts.scopes?.includes("openid")) return { response: createMetadataResponse(oidcServerMetadata(endpointCtx, opts)) };
|
|
3083
|
-
return { response: createMetadataResponse(
|
|
3084
|
-
...authServerMetadata(endpointCtx, opts.disableJwtPlugin ? void 0 : getJwtPlugin(ctx)?.options, {
|
|
3085
|
-
scopes_supported: opts.advertisedMetadata?.scopes_supported ?? opts.scopes,
|
|
3086
|
-
dynamic_client_registration_supported: opts.allowDynamicClientRegistration,
|
|
3087
|
-
public_client_supported: opts.allowUnauthenticatedClientRegistration || toClientDiscoveryArray(opts.clientDiscovery).length > 0,
|
|
3088
|
-
grant_types_supported: opts.grantTypes,
|
|
3089
|
-
jwt_disabled: opts.disableJwtPlugin
|
|
3090
|
-
}),
|
|
3091
|
-
...mergeDiscoveryMetadata(opts.clientDiscovery)
|
|
3092
|
-
}) };
|
|
3032
|
+
return { response: createMetadataResponse(oauthAuthorizationServerMetadata(endpointCtx, opts)) };
|
|
3093
3033
|
}
|
|
3094
3034
|
if (isOpenIdConfigRequest) return { response: createMetadataResponse(oidcServerMetadata(endpointCtx, opts)) };
|
|
3095
3035
|
};
|
|
3036
|
+
const oauth2AuthorizeEndpoint = createOAuthEndpoint("/oauth2/authorize", {
|
|
3037
|
+
method: "GET",
|
|
3038
|
+
query: authorizationQuerySchema,
|
|
3039
|
+
redirectOnError: authorizeRedirectOnError(opts),
|
|
3040
|
+
errorCodesByField: {
|
|
3041
|
+
response_type: { invalid: "unsupported_response_type" },
|
|
3042
|
+
resource: { invalid: "invalid_target" }
|
|
3043
|
+
},
|
|
3044
|
+
metadata: { openapi: {
|
|
3045
|
+
description: "Authorize an OAuth2 request",
|
|
3046
|
+
parameters: [
|
|
3047
|
+
{
|
|
3048
|
+
name: "response_type",
|
|
3049
|
+
in: "query",
|
|
3050
|
+
required: false,
|
|
3051
|
+
schema: { type: "string" },
|
|
3052
|
+
description: "OAuth2 response type (e.g., 'code')"
|
|
3053
|
+
},
|
|
3054
|
+
{
|
|
3055
|
+
name: "client_id",
|
|
3056
|
+
in: "query",
|
|
3057
|
+
required: true,
|
|
3058
|
+
schema: { type: "string" },
|
|
3059
|
+
description: "OAuth2 client ID"
|
|
3060
|
+
},
|
|
3061
|
+
{
|
|
3062
|
+
name: "redirect_uri",
|
|
3063
|
+
in: "query",
|
|
3064
|
+
required: false,
|
|
3065
|
+
schema: {
|
|
3066
|
+
type: "string",
|
|
3067
|
+
format: "uri"
|
|
3068
|
+
},
|
|
3069
|
+
description: "OAuth2 redirect URI"
|
|
3070
|
+
},
|
|
3071
|
+
{
|
|
3072
|
+
name: "scope",
|
|
3073
|
+
in: "query",
|
|
3074
|
+
required: false,
|
|
3075
|
+
schema: { type: "string" },
|
|
3076
|
+
description: "OAuth2 scopes (space-separated)"
|
|
3077
|
+
},
|
|
3078
|
+
{
|
|
3079
|
+
name: "state",
|
|
3080
|
+
in: "query",
|
|
3081
|
+
required: false,
|
|
3082
|
+
schema: { type: "string" },
|
|
3083
|
+
description: "OAuth2 state parameter"
|
|
3084
|
+
},
|
|
3085
|
+
{
|
|
3086
|
+
name: "request_uri",
|
|
3087
|
+
in: "query",
|
|
3088
|
+
required: false,
|
|
3089
|
+
schema: { type: "string" },
|
|
3090
|
+
description: "Pushed Authorization Request URI referencing stored parameters"
|
|
3091
|
+
},
|
|
3092
|
+
{
|
|
3093
|
+
name: "code_challenge",
|
|
3094
|
+
in: "query",
|
|
3095
|
+
required: false,
|
|
3096
|
+
schema: { type: "string" },
|
|
3097
|
+
description: "PKCE code challenge"
|
|
3098
|
+
},
|
|
3099
|
+
{
|
|
3100
|
+
name: "code_challenge_method",
|
|
3101
|
+
in: "query",
|
|
3102
|
+
required: false,
|
|
3103
|
+
schema: { type: "string" },
|
|
3104
|
+
description: "PKCE code challenge method"
|
|
3105
|
+
},
|
|
3106
|
+
{
|
|
3107
|
+
name: "nonce",
|
|
3108
|
+
in: "query",
|
|
3109
|
+
required: false,
|
|
3110
|
+
schema: { type: "string" },
|
|
3111
|
+
description: "OpenID Connect nonce"
|
|
3112
|
+
},
|
|
3113
|
+
{
|
|
3114
|
+
name: "max_age",
|
|
3115
|
+
in: "query",
|
|
3116
|
+
required: false,
|
|
3117
|
+
schema: {
|
|
3118
|
+
type: "integer",
|
|
3119
|
+
minimum: 0
|
|
3120
|
+
},
|
|
3121
|
+
description: "Maximum authentication age in seconds; forces re-authentication when exceeded"
|
|
3122
|
+
},
|
|
3123
|
+
{
|
|
3124
|
+
name: "resource",
|
|
3125
|
+
in: "query",
|
|
3126
|
+
required: false,
|
|
3127
|
+
schema: {
|
|
3128
|
+
type: "array",
|
|
3129
|
+
items: { type: "string" }
|
|
3130
|
+
},
|
|
3131
|
+
description: "Requested protected resource(s) for the access token. May be supplied multiple times as repeated 'resource' query parameters (RFC 8707) or as an array of strings."
|
|
3132
|
+
},
|
|
3133
|
+
{
|
|
3134
|
+
name: "prompt",
|
|
3135
|
+
in: "query",
|
|
3136
|
+
required: false,
|
|
3137
|
+
schema: { type: "string" },
|
|
3138
|
+
description: "OAuth2 prompt parameter"
|
|
3139
|
+
}
|
|
3140
|
+
],
|
|
3141
|
+
responses: {
|
|
3142
|
+
"302": {
|
|
3143
|
+
description: "Redirect to client with code or error",
|
|
3144
|
+
headers: { Location: {
|
|
3145
|
+
description: "Redirect URI with code or error",
|
|
3146
|
+
schema: {
|
|
3147
|
+
type: "string",
|
|
3148
|
+
format: "uri"
|
|
3149
|
+
}
|
|
3150
|
+
} }
|
|
3151
|
+
},
|
|
3152
|
+
"400": {
|
|
3153
|
+
description: "Invalid request",
|
|
3154
|
+
content: { "application/json": { schema: {
|
|
3155
|
+
type: "object",
|
|
3156
|
+
properties: {
|
|
3157
|
+
error: { type: "string" },
|
|
3158
|
+
error_description: { type: "string" },
|
|
3159
|
+
state: { type: "string" }
|
|
3160
|
+
},
|
|
3161
|
+
required: ["error"]
|
|
3162
|
+
} } }
|
|
3163
|
+
}
|
|
3164
|
+
}
|
|
3165
|
+
} }
|
|
3166
|
+
}, async (ctx) => {
|
|
3167
|
+
return authorizeEndpoint(ctx, opts, ctx.authorizeSettings ?? { isAuthorize: true });
|
|
3168
|
+
});
|
|
3169
|
+
const runOAuth2Authorize = (ctx, settings) => dispatchAuthEndpoint(oauth2AuthorizeEndpoint, {
|
|
3170
|
+
...ctx,
|
|
3171
|
+
asResponse: false,
|
|
3172
|
+
returnHeaders: false,
|
|
3173
|
+
returnStatus: false,
|
|
3174
|
+
authorizeSettings: settings ?? {}
|
|
3175
|
+
});
|
|
3096
3176
|
return {
|
|
3097
3177
|
id: "oauth-provider",
|
|
3098
3178
|
version: PACKAGE_VERSION,
|
|
3099
3179
|
options: opts,
|
|
3100
3180
|
onRequest: handleIssuerMetadataRequest,
|
|
3101
|
-
init: (ctx) => {
|
|
3181
|
+
init: async (ctx) => {
|
|
3102
3182
|
if (ctx.options.secondaryStorage && ctx.options.session?.storeSessionInDatabase !== true) throw new BetterAuthError("OAuth Provider requires `session.storeSessionInDatabase: true` when using secondaryStorage");
|
|
3183
|
+
await seedResources(ctx, opts);
|
|
3184
|
+
logEnforcePerClientResourcesResolution(opts);
|
|
3103
3185
|
if (!opts.disableJwtPlugin) {
|
|
3104
3186
|
const jwtPluginOptions = getJwtPlugin(ctx)?.options;
|
|
3105
3187
|
const issuer = jwtPluginOptions?.jwt?.issuer ?? ctx.baseURL;
|
|
@@ -3108,12 +3190,20 @@ const oauthProvider = (options) => {
|
|
|
3108
3190
|
try {
|
|
3109
3191
|
issuerPath = new URL(issuer).pathname;
|
|
3110
3192
|
} catch (error) {
|
|
3111
|
-
if (isDynamicBaseURLInit
|
|
3112
|
-
throw error;
|
|
3193
|
+
if (!isDynamicBaseURLInit || issuer !== "") throw error;
|
|
3113
3194
|
}
|
|
3114
|
-
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.`);
|
|
3115
|
-
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.`);
|
|
3195
|
+
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.`);
|
|
3196
|
+
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.`);
|
|
3116
3197
|
}
|
|
3198
|
+
return { options: { databaseHooks: { session: { delete: { async before(session, hookCtx) {
|
|
3199
|
+
if (!hookCtx) return;
|
|
3200
|
+
const plan = await revokeAndPlanBackchannelLogout(hookCtx, opts, {
|
|
3201
|
+
sessionId: session.id,
|
|
3202
|
+
userId: session.userId
|
|
3203
|
+
});
|
|
3204
|
+
if (!plan) return;
|
|
3205
|
+
await hookCtx.context.runInBackgroundOrAwait(deliverBackchannelLogoutTokens(hookCtx, plan));
|
|
3206
|
+
} } } } } };
|
|
3117
3207
|
},
|
|
3118
3208
|
hooks: {
|
|
3119
3209
|
before: [{
|
|
@@ -3135,11 +3225,10 @@ const oauthProvider = (options) => {
|
|
|
3135
3225
|
signedQueryIssuedAt: signedQueryIssuedAt ?? void 0,
|
|
3136
3226
|
postLoginClearedForSession
|
|
3137
3227
|
});
|
|
3138
|
-
if (ctx.path === "/sign-in/social") {
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
}
|
|
3228
|
+
if (ctx.path === "/sign-in/social") await addOAuthServerContext({
|
|
3229
|
+
query: queryParams.toString(),
|
|
3230
|
+
...signedQueryIssuedAt ? { [signedQueryIssuedAtMsKey]: signedQueryIssuedAt.getTime() } : {}
|
|
3231
|
+
});
|
|
3143
3232
|
})
|
|
3144
3233
|
}],
|
|
3145
3234
|
after: [{
|
|
@@ -3149,7 +3238,9 @@ const oauthProvider = (options) => {
|
|
|
3149
3238
|
handler: createAuthMiddleware(async (ctx) => {
|
|
3150
3239
|
const sessionToken = parseSetCookieHeader(ctx.context.responseHeaders?.get("set-cookie") || "").get(ctx.context.authCookies.sessionToken.name)?.value.split(".")[0];
|
|
3151
3240
|
if (!sessionToken) return;
|
|
3152
|
-
const
|
|
3241
|
+
const oauthRequest = await oAuthState.get();
|
|
3242
|
+
const serverContext = (await getOAuthState())?.serverContext;
|
|
3243
|
+
const _query = oauthRequest?.query ?? serverContext?.query;
|
|
3153
3244
|
if (!_query) return;
|
|
3154
3245
|
const query = new URLSearchParams(_query);
|
|
3155
3246
|
const session = await ctx.context.internalAdapter.findSession(sessionToken);
|
|
@@ -3158,8 +3249,11 @@ const oauthProvider = (options) => {
|
|
|
3158
3249
|
const secFetchMode = ctx.request?.headers?.get("sec-fetch-mode")?.toLowerCase();
|
|
3159
3250
|
const acceptHeader = ctx.request?.headers?.get("accept")?.toLowerCase() ?? "";
|
|
3160
3251
|
if (!(secFetchMode === "navigate" || !secFetchMode && (acceptHeader.includes("text/html") || acceptHeader.includes("application/xhtml+xml")))) ctx.headers?.set("accept", "application/json");
|
|
3161
|
-
|
|
3162
|
-
|
|
3252
|
+
const signedQueryIssuedAt = oauthRequest?.signedQueryIssuedAt ?? getServerContextSignedQueryIssuedAt(serverContext?.[signedQueryIssuedAtMsKey]);
|
|
3253
|
+
let authorizationQuery = removePromptFromQuery(query, "login");
|
|
3254
|
+
if (isSessionFreshForSignedQuery(session.session.createdAt, signedQueryIssuedAt)) authorizationQuery = removeMaxAgeFromQuery(authorizationQuery);
|
|
3255
|
+
ctx.query = searchParamsToQuery(authorizationQuery);
|
|
3256
|
+
return await runOAuth2Authorize(ctx);
|
|
3163
3257
|
})
|
|
3164
3258
|
}]
|
|
3165
3259
|
},
|
|
@@ -3169,16 +3263,7 @@ const oauthProvider = (options) => {
|
|
|
3169
3263
|
metadata: { SERVER_ONLY: true }
|
|
3170
3264
|
}, async (ctx) => {
|
|
3171
3265
|
if (opts.scopes && opts.scopes.includes("openid")) return oidcServerMetadata(ctx, opts);
|
|
3172
|
-
else return
|
|
3173
|
-
...authServerMetadata(ctx, opts.disableJwtPlugin ? void 0 : getJwtPlugin(ctx.context)?.options, {
|
|
3174
|
-
scopes_supported: opts.advertisedMetadata?.scopes_supported ?? opts.scopes,
|
|
3175
|
-
dynamic_client_registration_supported: opts.allowDynamicClientRegistration,
|
|
3176
|
-
public_client_supported: opts.allowUnauthenticatedClientRegistration || toClientDiscoveryArray(opts.clientDiscovery).length > 0,
|
|
3177
|
-
grant_types_supported: opts.grantTypes,
|
|
3178
|
-
jwt_disabled: opts.disableJwtPlugin
|
|
3179
|
-
}),
|
|
3180
|
-
...mergeDiscoveryMetadata(opts.clientDiscovery)
|
|
3181
|
-
};
|
|
3266
|
+
else return oauthAuthorizationServerMetadata(ctx, opts);
|
|
3182
3267
|
}),
|
|
3183
3268
|
getOpenIdConfig: createAuthEndpoint("/.well-known/openid-configuration", {
|
|
3184
3269
|
method: "GET",
|
|
@@ -3187,149 +3272,7 @@ const oauthProvider = (options) => {
|
|
|
3187
3272
|
if (opts.scopes && !opts.scopes.includes("openid")) throw new APIError("NOT_FOUND");
|
|
3188
3273
|
return oidcServerMetadata(ctx, opts);
|
|
3189
3274
|
}),
|
|
3190
|
-
oauth2Authorize:
|
|
3191
|
-
method: "GET",
|
|
3192
|
-
query: z.object({
|
|
3193
|
-
response_type: z.string().pipe(z.enum(["code"])).optional(),
|
|
3194
|
-
client_id: z.string(),
|
|
3195
|
-
redirect_uri: SafeUrlSchema.optional(),
|
|
3196
|
-
scope: z.string().optional(),
|
|
3197
|
-
state: z.string().optional(),
|
|
3198
|
-
request_uri: z.string().optional(),
|
|
3199
|
-
code_challenge: z.string().optional(),
|
|
3200
|
-
code_challenge_method: z.string().pipe(z.enum(["S256"])).optional(),
|
|
3201
|
-
nonce: z.string().optional(),
|
|
3202
|
-
resource: z.union([ResourceUriSchema, z.array(ResourceUriSchema).min(1)]).optional(),
|
|
3203
|
-
prompt: z.string().pipe(z.enum([
|
|
3204
|
-
"none",
|
|
3205
|
-
"consent",
|
|
3206
|
-
"login",
|
|
3207
|
-
"create",
|
|
3208
|
-
"select_account",
|
|
3209
|
-
"login consent",
|
|
3210
|
-
"select_account consent"
|
|
3211
|
-
])).optional()
|
|
3212
|
-
}),
|
|
3213
|
-
redirectOnError: authorizeRedirectOnError(opts),
|
|
3214
|
-
errorCodesByField: {
|
|
3215
|
-
response_type: { invalid: "unsupported_response_type" },
|
|
3216
|
-
resource: { invalid: "invalid_target" }
|
|
3217
|
-
},
|
|
3218
|
-
metadata: { openapi: {
|
|
3219
|
-
description: "Authorize an OAuth2 request",
|
|
3220
|
-
parameters: [
|
|
3221
|
-
{
|
|
3222
|
-
name: "response_type",
|
|
3223
|
-
in: "query",
|
|
3224
|
-
required: false,
|
|
3225
|
-
schema: { type: "string" },
|
|
3226
|
-
description: "OAuth2 response type (e.g., 'code')"
|
|
3227
|
-
},
|
|
3228
|
-
{
|
|
3229
|
-
name: "client_id",
|
|
3230
|
-
in: "query",
|
|
3231
|
-
required: true,
|
|
3232
|
-
schema: { type: "string" },
|
|
3233
|
-
description: "OAuth2 client ID"
|
|
3234
|
-
},
|
|
3235
|
-
{
|
|
3236
|
-
name: "redirect_uri",
|
|
3237
|
-
in: "query",
|
|
3238
|
-
required: false,
|
|
3239
|
-
schema: {
|
|
3240
|
-
type: "string",
|
|
3241
|
-
format: "uri"
|
|
3242
|
-
},
|
|
3243
|
-
description: "OAuth2 redirect URI"
|
|
3244
|
-
},
|
|
3245
|
-
{
|
|
3246
|
-
name: "scope",
|
|
3247
|
-
in: "query",
|
|
3248
|
-
required: false,
|
|
3249
|
-
schema: { type: "string" },
|
|
3250
|
-
description: "OAuth2 scopes (space-separated)"
|
|
3251
|
-
},
|
|
3252
|
-
{
|
|
3253
|
-
name: "state",
|
|
3254
|
-
in: "query",
|
|
3255
|
-
required: false,
|
|
3256
|
-
schema: { type: "string" },
|
|
3257
|
-
description: "OAuth2 state parameter"
|
|
3258
|
-
},
|
|
3259
|
-
{
|
|
3260
|
-
name: "request_uri",
|
|
3261
|
-
in: "query",
|
|
3262
|
-
required: false,
|
|
3263
|
-
schema: { type: "string" },
|
|
3264
|
-
description: "Pushed Authorization Request URI referencing stored parameters"
|
|
3265
|
-
},
|
|
3266
|
-
{
|
|
3267
|
-
name: "code_challenge",
|
|
3268
|
-
in: "query",
|
|
3269
|
-
required: false,
|
|
3270
|
-
schema: { type: "string" },
|
|
3271
|
-
description: "PKCE code challenge"
|
|
3272
|
-
},
|
|
3273
|
-
{
|
|
3274
|
-
name: "code_challenge_method",
|
|
3275
|
-
in: "query",
|
|
3276
|
-
required: false,
|
|
3277
|
-
schema: { type: "string" },
|
|
3278
|
-
description: "PKCE code challenge method"
|
|
3279
|
-
},
|
|
3280
|
-
{
|
|
3281
|
-
name: "nonce",
|
|
3282
|
-
in: "query",
|
|
3283
|
-
required: false,
|
|
3284
|
-
schema: { type: "string" },
|
|
3285
|
-
description: "OpenID Connect nonce"
|
|
3286
|
-
},
|
|
3287
|
-
{
|
|
3288
|
-
name: "resource",
|
|
3289
|
-
in: "query",
|
|
3290
|
-
required: false,
|
|
3291
|
-
schema: {
|
|
3292
|
-
type: "array",
|
|
3293
|
-
items: { type: "string" }
|
|
3294
|
-
},
|
|
3295
|
-
description: "Requested token resource(s) (ie audience) to obtain a JWT formatted access token. May be supplied multiple times as repeated 'resource' query parameters (RFC 8707) or as an array of strings."
|
|
3296
|
-
},
|
|
3297
|
-
{
|
|
3298
|
-
name: "prompt",
|
|
3299
|
-
in: "query",
|
|
3300
|
-
required: false,
|
|
3301
|
-
schema: { type: "string" },
|
|
3302
|
-
description: "OAuth2 prompt parameter"
|
|
3303
|
-
}
|
|
3304
|
-
],
|
|
3305
|
-
responses: {
|
|
3306
|
-
"302": {
|
|
3307
|
-
description: "Redirect to client with code or error",
|
|
3308
|
-
headers: { Location: {
|
|
3309
|
-
description: "Redirect URI with code or error",
|
|
3310
|
-
schema: {
|
|
3311
|
-
type: "string",
|
|
3312
|
-
format: "uri"
|
|
3313
|
-
}
|
|
3314
|
-
} }
|
|
3315
|
-
},
|
|
3316
|
-
"400": {
|
|
3317
|
-
description: "Invalid request",
|
|
3318
|
-
content: { "application/json": { schema: {
|
|
3319
|
-
type: "object",
|
|
3320
|
-
properties: {
|
|
3321
|
-
error: { type: "string" },
|
|
3322
|
-
error_description: { type: "string" },
|
|
3323
|
-
state: { type: "string" }
|
|
3324
|
-
},
|
|
3325
|
-
required: ["error"]
|
|
3326
|
-
} } }
|
|
3327
|
-
}
|
|
3328
|
-
}
|
|
3329
|
-
} }
|
|
3330
|
-
}, async (ctx) => {
|
|
3331
|
-
return authorizeEndpoint(ctx, opts, { isAuthorize: true });
|
|
3332
|
-
}),
|
|
3275
|
+
oauth2Authorize: oauth2AuthorizeEndpoint,
|
|
3333
3276
|
oauth2Consent: createAuthEndpoint("/oauth2/consent", {
|
|
3334
3277
|
method: "POST",
|
|
3335
3278
|
body: z.object({
|
|
@@ -3354,7 +3297,7 @@ const oauthProvider = (options) => {
|
|
|
3354
3297
|
} }
|
|
3355
3298
|
} }
|
|
3356
3299
|
}, async (ctx) => {
|
|
3357
|
-
return consentEndpoint(ctx, opts);
|
|
3300
|
+
return consentEndpoint(ctx, opts, runOAuth2Authorize);
|
|
3358
3301
|
}),
|
|
3359
3302
|
oauth2Continue: createAuthEndpoint("/oauth2/continue", {
|
|
3360
3303
|
method: "POST",
|
|
@@ -3381,16 +3324,13 @@ const oauthProvider = (options) => {
|
|
|
3381
3324
|
} }
|
|
3382
3325
|
} }
|
|
3383
3326
|
}, async (ctx) => {
|
|
3384
|
-
return continueEndpoint(ctx,
|
|
3327
|
+
return continueEndpoint(ctx, runOAuth2Authorize);
|
|
3385
3328
|
}),
|
|
3386
3329
|
oauth2Token: createOAuthEndpoint("/oauth2/token", {
|
|
3387
3330
|
method: "POST",
|
|
3331
|
+
cloneRequest: true,
|
|
3388
3332
|
body: z.object({
|
|
3389
|
-
grant_type: z.string().
|
|
3390
|
-
"authorization_code",
|
|
3391
|
-
"client_credentials",
|
|
3392
|
-
"refresh_token"
|
|
3393
|
-
])),
|
|
3333
|
+
grant_type: z.string().trim().min(1),
|
|
3394
3334
|
client_id: z.string().optional(),
|
|
3395
3335
|
client_secret: z.string().optional(),
|
|
3396
3336
|
client_assertion: z.string().optional(),
|
|
@@ -3401,7 +3341,7 @@ const oauthProvider = (options) => {
|
|
|
3401
3341
|
refresh_token: z.string().optional(),
|
|
3402
3342
|
resource: z.union([ResourceUriSchema, z.array(ResourceUriSchema).min(1)]).optional(),
|
|
3403
3343
|
scope: z.string().optional()
|
|
3404
|
-
}),
|
|
3344
|
+
}).passthrough(),
|
|
3405
3345
|
errorCodesByField: {
|
|
3406
3346
|
grant_type: {
|
|
3407
3347
|
missing: "invalid_request",
|
|
@@ -3410,9 +3350,17 @@ const oauthProvider = (options) => {
|
|
|
3410
3350
|
resource: { invalid: "invalid_target" }
|
|
3411
3351
|
},
|
|
3412
3352
|
metadata: {
|
|
3353
|
+
noStore: true,
|
|
3413
3354
|
allowedMediaTypes: ["application/x-www-form-urlencoded"],
|
|
3414
3355
|
openapi: {
|
|
3415
3356
|
description: "Obtain an OAuth2.1 access token",
|
|
3357
|
+
parameters: [{
|
|
3358
|
+
name: "DPoP",
|
|
3359
|
+
in: "header",
|
|
3360
|
+
required: false,
|
|
3361
|
+
schema: { type: "string" },
|
|
3362
|
+
description: "RFC 9449 DPoP proof JWT for issuing DPoP-bound tokens"
|
|
3363
|
+
}],
|
|
3416
3364
|
requestBody: {
|
|
3417
3365
|
required: true,
|
|
3418
3366
|
content: { "application/json": { schema: {
|
|
@@ -3420,11 +3368,6 @@ const oauthProvider = (options) => {
|
|
|
3420
3368
|
properties: {
|
|
3421
3369
|
grant_type: {
|
|
3422
3370
|
type: "string",
|
|
3423
|
-
enum: [
|
|
3424
|
-
"authorization_code",
|
|
3425
|
-
"client_credentials",
|
|
3426
|
-
"refresh_token"
|
|
3427
|
-
],
|
|
3428
3371
|
description: "OAuth2 grant type"
|
|
3429
3372
|
},
|
|
3430
3373
|
client_id: {
|
|
@@ -3461,7 +3404,7 @@ const oauthProvider = (options) => {
|
|
|
3461
3404
|
items: { type: "string" },
|
|
3462
3405
|
description: "Multiple resources (URLs)"
|
|
3463
3406
|
}],
|
|
3464
|
-
description: "Requested
|
|
3407
|
+
description: "Requested protected resource(s) for the access token"
|
|
3465
3408
|
},
|
|
3466
3409
|
scope: {
|
|
3467
3410
|
type: "string",
|
|
@@ -3484,7 +3427,7 @@ const oauthProvider = (options) => {
|
|
|
3484
3427
|
token_type: {
|
|
3485
3428
|
type: "string",
|
|
3486
3429
|
description: "The type of the token issued",
|
|
3487
|
-
enum: ["Bearer"]
|
|
3430
|
+
enum: ["Bearer", "DPoP"]
|
|
3488
3431
|
},
|
|
3489
3432
|
expires_in: {
|
|
3490
3433
|
type: "number",
|
|
@@ -3526,6 +3469,10 @@ const oauthProvider = (options) => {
|
|
|
3526
3469
|
}
|
|
3527
3470
|
}
|
|
3528
3471
|
}, async (ctx) => {
|
|
3472
|
+
if (ctx.request) {
|
|
3473
|
+
const repeated = await extractRepeatedResourceFromForm(ctx.request);
|
|
3474
|
+
if (repeated && repeated.length > 1) ctx.body.resource = repeated;
|
|
3475
|
+
}
|
|
3529
3476
|
return tokenEndpoint(ctx, opts);
|
|
3530
3477
|
}),
|
|
3531
3478
|
oauth2Introspect: createOAuthEndpoint("/oauth2/introspect", {
|
|
@@ -3539,6 +3486,7 @@ const oauthProvider = (options) => {
|
|
|
3539
3486
|
token_type_hint: z.string().optional()
|
|
3540
3487
|
}),
|
|
3541
3488
|
metadata: {
|
|
3489
|
+
noStore: true,
|
|
3542
3490
|
allowedMediaTypes: ["application/x-www-form-urlencoded"],
|
|
3543
3491
|
openapi: {
|
|
3544
3492
|
description: "Introspect an OAuth2 access or refresh token",
|
|
@@ -3709,91 +3657,100 @@ const oauthProvider = (options) => {
|
|
|
3709
3657
|
return revokeEndpoint(ctx, opts);
|
|
3710
3658
|
}),
|
|
3711
3659
|
oauth2UserInfo: createAuthEndpoint("/oauth2/userinfo", {
|
|
3712
|
-
method: "GET",
|
|
3713
|
-
metadata: {
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
"
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3660
|
+
method: ["GET", "POST"],
|
|
3661
|
+
metadata: {
|
|
3662
|
+
noStore: true,
|
|
3663
|
+
openapi: {
|
|
3664
|
+
description: "Get OpenID Connect user information (UserInfo endpoint)",
|
|
3665
|
+
security: [{ bearerAuth: [] }, { OAuth2: [
|
|
3666
|
+
"openid",
|
|
3667
|
+
"profile",
|
|
3668
|
+
"email"
|
|
3669
|
+
] }],
|
|
3670
|
+
parameters: [{
|
|
3671
|
+
name: "Authorization",
|
|
3672
|
+
in: "header",
|
|
3673
|
+
required: false,
|
|
3674
|
+
schema: { type: "string" },
|
|
3675
|
+
description: "Bearer or DPoP access token"
|
|
3676
|
+
}, {
|
|
3677
|
+
name: "DPoP",
|
|
3678
|
+
in: "header",
|
|
3679
|
+
required: false,
|
|
3680
|
+
schema: { type: "string" },
|
|
3681
|
+
description: "RFC 9449 DPoP proof JWT when using a DPoP-bound access token"
|
|
3682
|
+
}],
|
|
3683
|
+
responses: {
|
|
3684
|
+
"200": {
|
|
3685
|
+
description: "User information retrieved successfully",
|
|
3686
|
+
content: { "application/json": { schema: {
|
|
3687
|
+
type: "object",
|
|
3688
|
+
properties: {
|
|
3689
|
+
sub: {
|
|
3690
|
+
type: "string",
|
|
3691
|
+
description: "Subject identifier (user ID)"
|
|
3692
|
+
},
|
|
3693
|
+
email: {
|
|
3694
|
+
type: "string",
|
|
3695
|
+
format: "email",
|
|
3696
|
+
nullable: true,
|
|
3697
|
+
description: "User's email address, included if 'email' scope is granted"
|
|
3698
|
+
},
|
|
3699
|
+
name: {
|
|
3700
|
+
type: "string",
|
|
3701
|
+
nullable: true,
|
|
3702
|
+
description: "User's full name, included if 'profile' scope is granted"
|
|
3703
|
+
},
|
|
3704
|
+
picture: {
|
|
3705
|
+
type: "string",
|
|
3706
|
+
format: "uri",
|
|
3707
|
+
nullable: true,
|
|
3708
|
+
description: "User's profile picture URL, included if 'profile' scope is granted"
|
|
3709
|
+
},
|
|
3710
|
+
given_name: {
|
|
3711
|
+
type: "string",
|
|
3712
|
+
nullable: true,
|
|
3713
|
+
description: "User's given name, included if 'profile' scope is granted"
|
|
3714
|
+
},
|
|
3715
|
+
family_name: {
|
|
3716
|
+
type: "string",
|
|
3717
|
+
nullable: true,
|
|
3718
|
+
description: "User's family name, included if 'profile' scope is granted"
|
|
3719
|
+
},
|
|
3720
|
+
email_verified: {
|
|
3721
|
+
type: "boolean",
|
|
3722
|
+
nullable: true,
|
|
3723
|
+
description: "Whether the email is verified, included if 'email' scope is granted"
|
|
3724
|
+
}
|
|
3753
3725
|
},
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3726
|
+
required: ["sub"]
|
|
3727
|
+
} } }
|
|
3728
|
+
},
|
|
3729
|
+
"401": {
|
|
3730
|
+
description: "Unauthorized - invalid or missing access token",
|
|
3731
|
+
content: { "application/json": { schema: {
|
|
3732
|
+
type: "object",
|
|
3733
|
+
properties: {
|
|
3734
|
+
error: { type: "string" },
|
|
3735
|
+
error_description: { type: "string" }
|
|
3758
3736
|
},
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3737
|
+
required: ["error"]
|
|
3738
|
+
} } }
|
|
3739
|
+
},
|
|
3740
|
+
"403": {
|
|
3741
|
+
description: "Forbidden - insufficient scope",
|
|
3742
|
+
content: { "application/json": { schema: {
|
|
3743
|
+
type: "object",
|
|
3744
|
+
properties: {
|
|
3745
|
+
error: { type: "string" },
|
|
3746
|
+
error_description: { type: "string" }
|
|
3763
3747
|
},
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
description: "Whether the email is verified, included if 'email' scope is granted"
|
|
3768
|
-
}
|
|
3769
|
-
},
|
|
3770
|
-
required: ["sub"]
|
|
3771
|
-
} } }
|
|
3772
|
-
},
|
|
3773
|
-
"401": {
|
|
3774
|
-
description: "Unauthorized - invalid or missing access token",
|
|
3775
|
-
content: { "application/json": { schema: {
|
|
3776
|
-
type: "object",
|
|
3777
|
-
properties: {
|
|
3778
|
-
error: { type: "string" },
|
|
3779
|
-
error_description: { type: "string" }
|
|
3780
|
-
},
|
|
3781
|
-
required: ["error"]
|
|
3782
|
-
} } }
|
|
3783
|
-
},
|
|
3784
|
-
"403": {
|
|
3785
|
-
description: "Forbidden - insufficient scope",
|
|
3786
|
-
content: { "application/json": { schema: {
|
|
3787
|
-
type: "object",
|
|
3788
|
-
properties: {
|
|
3789
|
-
error: { type: "string" },
|
|
3790
|
-
error_description: { type: "string" }
|
|
3791
|
-
},
|
|
3792
|
-
required: ["error"]
|
|
3793
|
-
} } }
|
|
3748
|
+
required: ["error"]
|
|
3749
|
+
} } }
|
|
3750
|
+
}
|
|
3794
3751
|
}
|
|
3795
3752
|
}
|
|
3796
|
-
}
|
|
3753
|
+
}
|
|
3797
3754
|
}, async (ctx) => {
|
|
3798
3755
|
return userInfoEndpoint(ctx, opts);
|
|
3799
3756
|
}),
|
|
@@ -3830,183 +3787,149 @@ const oauthProvider = (options) => {
|
|
|
3830
3787
|
}),
|
|
3831
3788
|
registerOAuthClient: createOAuthEndpoint("/oauth2/register", {
|
|
3832
3789
|
method: "POST",
|
|
3833
|
-
body:
|
|
3834
|
-
redirect_uris: z.array(SafeUrlSchema).min(1).min(1),
|
|
3835
|
-
scope: z.string().optional(),
|
|
3836
|
-
client_name: z.string().optional(),
|
|
3837
|
-
client_uri: z.string().optional(),
|
|
3838
|
-
logo_uri: z.string().optional(),
|
|
3839
|
-
contacts: z.array(z.string().min(1)).min(1).optional(),
|
|
3840
|
-
tos_uri: z.string().optional(),
|
|
3841
|
-
policy_uri: z.string().optional(),
|
|
3842
|
-
software_id: z.string().optional(),
|
|
3843
|
-
software_version: z.string().optional(),
|
|
3844
|
-
software_statement: z.string().optional(),
|
|
3845
|
-
post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
|
|
3846
|
-
token_endpoint_auth_method: z.enum([
|
|
3847
|
-
"none",
|
|
3848
|
-
"client_secret_basic",
|
|
3849
|
-
"client_secret_post",
|
|
3850
|
-
"private_key_jwt"
|
|
3851
|
-
]).default("client_secret_basic").optional(),
|
|
3852
|
-
jwks: z.union([z.array(z.record(z.string(), z.unknown())).min(1), z.object({ keys: z.array(z.record(z.string(), z.unknown())).min(1) })]).optional(),
|
|
3853
|
-
jwks_uri: z.string().optional(),
|
|
3854
|
-
grant_types: z.array(z.enum([
|
|
3855
|
-
"authorization_code",
|
|
3856
|
-
"client_credentials",
|
|
3857
|
-
"refresh_token"
|
|
3858
|
-
])).default(["authorization_code"]).optional(),
|
|
3859
|
-
response_types: z.array(z.enum(["code"])).default(["code"]).optional(),
|
|
3860
|
-
type: z.enum([
|
|
3861
|
-
"web",
|
|
3862
|
-
"native",
|
|
3863
|
-
"user-agent-based"
|
|
3864
|
-
]).optional(),
|
|
3865
|
-
subject_type: z.enum(["public", "pairwise"]).optional(),
|
|
3866
|
-
skip_consent: z.never({ error: "skip_consent cannot be set during dynamic client registration" }).optional()
|
|
3867
|
-
}),
|
|
3790
|
+
body: clientRegistrationRequestSchema,
|
|
3868
3791
|
errorCodesByField: {
|
|
3869
3792
|
redirect_uris: "invalid_redirect_uri",
|
|
3870
3793
|
post_logout_redirect_uris: "invalid_redirect_uri",
|
|
3871
|
-
software_statement: "invalid_software_statement"
|
|
3794
|
+
software_statement: "invalid_software_statement",
|
|
3795
|
+
resources: "invalid_target"
|
|
3872
3796
|
},
|
|
3873
3797
|
defaultError: "invalid_client_metadata",
|
|
3874
|
-
metadata: {
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
description: "OAuth2 application
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
},
|
|
3885
|
-
client_secret: {
|
|
3886
|
-
type: "string",
|
|
3887
|
-
description: "Secret key for the client"
|
|
3888
|
-
},
|
|
3889
|
-
client_secret_expires_at: {
|
|
3890
|
-
type: "number",
|
|
3891
|
-
description: "Time the client secret will expire. If 0, the client secret will never expire."
|
|
3892
|
-
},
|
|
3893
|
-
scope: {
|
|
3894
|
-
type: "string",
|
|
3895
|
-
description: "Space-separated scopes allowed by the client"
|
|
3896
|
-
},
|
|
3897
|
-
user_id: {
|
|
3898
|
-
type: "string",
|
|
3899
|
-
description: "ID of the user who registered the client, null if registered anonymously"
|
|
3900
|
-
},
|
|
3901
|
-
client_id_issued_at: {
|
|
3902
|
-
type: "number",
|
|
3903
|
-
description: "Creation timestamp of this client"
|
|
3904
|
-
},
|
|
3905
|
-
client_name: {
|
|
3906
|
-
type: "string",
|
|
3907
|
-
description: "Name of the OAuth2 application"
|
|
3908
|
-
},
|
|
3909
|
-
client_uri: {
|
|
3910
|
-
type: "string",
|
|
3911
|
-
description: "Name of the OAuth2 application"
|
|
3912
|
-
},
|
|
3913
|
-
logo_uri: {
|
|
3914
|
-
type: "string",
|
|
3915
|
-
description: "Icon URL for the application"
|
|
3916
|
-
},
|
|
3917
|
-
contacts: {
|
|
3918
|
-
type: "array",
|
|
3919
|
-
items: { type: "string" },
|
|
3920
|
-
description: "List representing ways to contact people responsible for this client, typically email addresses"
|
|
3921
|
-
},
|
|
3922
|
-
tos_uri: {
|
|
3923
|
-
type: "string",
|
|
3924
|
-
description: "Client's terms of service uri"
|
|
3925
|
-
},
|
|
3926
|
-
policy_uri: {
|
|
3927
|
-
type: "string",
|
|
3928
|
-
description: "Client's policy uri"
|
|
3929
|
-
},
|
|
3930
|
-
software_id: {
|
|
3931
|
-
type: "string",
|
|
3932
|
-
description: "Unique identifier assigned by the developer to help in the dynamic registration process"
|
|
3933
|
-
},
|
|
3934
|
-
software_version: {
|
|
3935
|
-
type: "string",
|
|
3936
|
-
description: "Version identifier for the software_id"
|
|
3937
|
-
},
|
|
3938
|
-
software_statement: {
|
|
3939
|
-
type: "string",
|
|
3940
|
-
description: "JWT containing metadata values about the client software as claims"
|
|
3941
|
-
},
|
|
3942
|
-
redirect_uris: {
|
|
3943
|
-
type: "array",
|
|
3944
|
-
items: {
|
|
3798
|
+
metadata: {
|
|
3799
|
+
noStore: true,
|
|
3800
|
+
openapi: {
|
|
3801
|
+
description: "Register an OAuth2 application",
|
|
3802
|
+
responses: { "201": {
|
|
3803
|
+
description: "OAuth2 application registered successfully",
|
|
3804
|
+
content: { "application/json": { schema: {
|
|
3805
|
+
type: "object",
|
|
3806
|
+
properties: {
|
|
3807
|
+
client_id: {
|
|
3945
3808
|
type: "string",
|
|
3946
|
-
|
|
3809
|
+
description: "Unique identifier for the client"
|
|
3947
3810
|
},
|
|
3948
|
-
|
|
3949
|
-
},
|
|
3950
|
-
post_logout_redirect_uris: {
|
|
3951
|
-
type: "array",
|
|
3952
|
-
items: {
|
|
3811
|
+
client_secret: {
|
|
3953
3812
|
type: "string",
|
|
3954
|
-
|
|
3813
|
+
description: "Secret key for the client"
|
|
3955
3814
|
},
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
enum: [
|
|
3962
|
-
"none",
|
|
3963
|
-
"client_secret_basic",
|
|
3964
|
-
"client_secret_post",
|
|
3965
|
-
"private_key_jwt"
|
|
3966
|
-
]
|
|
3967
|
-
},
|
|
3968
|
-
grant_types: {
|
|
3969
|
-
type: "array",
|
|
3970
|
-
items: {
|
|
3815
|
+
client_secret_expires_at: {
|
|
3816
|
+
type: "number",
|
|
3817
|
+
description: "Time the client secret will expire. If 0, the client secret will never expire."
|
|
3818
|
+
},
|
|
3819
|
+
scope: {
|
|
3971
3820
|
type: "string",
|
|
3972
|
-
|
|
3973
|
-
"authorization_code",
|
|
3974
|
-
"client_credentials",
|
|
3975
|
-
"refresh_token"
|
|
3976
|
-
]
|
|
3821
|
+
description: "Space-separated scopes allowed by the client"
|
|
3977
3822
|
},
|
|
3978
|
-
|
|
3979
|
-
},
|
|
3980
|
-
response_types: {
|
|
3981
|
-
type: "array",
|
|
3982
|
-
items: {
|
|
3823
|
+
user_id: {
|
|
3983
3824
|
type: "string",
|
|
3984
|
-
|
|
3825
|
+
description: "ID of the user who registered the client, null if registered anonymously"
|
|
3985
3826
|
},
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
|
|
3996
|
-
"
|
|
3997
|
-
|
|
3998
|
-
|
|
3999
|
-
|
|
3827
|
+
client_id_issued_at: {
|
|
3828
|
+
type: "number",
|
|
3829
|
+
description: "Creation timestamp of this client"
|
|
3830
|
+
},
|
|
3831
|
+
client_name: {
|
|
3832
|
+
type: "string",
|
|
3833
|
+
description: "Name of the OAuth2 application"
|
|
3834
|
+
},
|
|
3835
|
+
client_uri: {
|
|
3836
|
+
type: "string",
|
|
3837
|
+
description: "Name of the OAuth2 application"
|
|
3838
|
+
},
|
|
3839
|
+
logo_uri: {
|
|
3840
|
+
type: "string",
|
|
3841
|
+
description: "Icon URL for the application"
|
|
3842
|
+
},
|
|
3843
|
+
contacts: {
|
|
3844
|
+
type: "array",
|
|
3845
|
+
items: { type: "string" },
|
|
3846
|
+
description: "List representing ways to contact people responsible for this client, typically email addresses"
|
|
3847
|
+
},
|
|
3848
|
+
tos_uri: {
|
|
3849
|
+
type: "string",
|
|
3850
|
+
description: "Client's terms of service uri"
|
|
3851
|
+
},
|
|
3852
|
+
policy_uri: {
|
|
3853
|
+
type: "string",
|
|
3854
|
+
description: "Client's policy uri"
|
|
3855
|
+
},
|
|
3856
|
+
software_id: {
|
|
3857
|
+
type: "string",
|
|
3858
|
+
description: "Unique identifier assigned by the developer to help in the dynamic registration process"
|
|
3859
|
+
},
|
|
3860
|
+
software_version: {
|
|
3861
|
+
type: "string",
|
|
3862
|
+
description: "Version identifier for the software_id"
|
|
3863
|
+
},
|
|
3864
|
+
software_statement: {
|
|
3865
|
+
type: "string",
|
|
3866
|
+
description: "JWT containing metadata values about the client software as claims"
|
|
3867
|
+
},
|
|
3868
|
+
redirect_uris: {
|
|
3869
|
+
type: "array",
|
|
3870
|
+
items: {
|
|
3871
|
+
type: "string",
|
|
3872
|
+
format: "uri"
|
|
3873
|
+
},
|
|
3874
|
+
description: "List of allowed redirect uris"
|
|
3875
|
+
},
|
|
3876
|
+
post_logout_redirect_uris: {
|
|
3877
|
+
type: "array",
|
|
3878
|
+
items: {
|
|
3879
|
+
type: "string",
|
|
3880
|
+
format: "uri"
|
|
3881
|
+
},
|
|
3882
|
+
description: "List of allowed logout redirect uris"
|
|
3883
|
+
},
|
|
3884
|
+
backchannel_logout_uri: {
|
|
3885
|
+
type: "string",
|
|
3886
|
+
format: "uri",
|
|
3887
|
+
description: "RP URL to receive signed Logout Tokens when the end-user's OP session terminates"
|
|
3888
|
+
},
|
|
3889
|
+
backchannel_logout_session_required: {
|
|
3890
|
+
type: "boolean",
|
|
3891
|
+
description: "Whether the RP requires a `sid` claim in every Logout Token"
|
|
3892
|
+
},
|
|
3893
|
+
token_endpoint_auth_method: {
|
|
3894
|
+
type: "string",
|
|
3895
|
+
description: "Requested authentication method for the token endpoint"
|
|
3896
|
+
},
|
|
3897
|
+
grant_types: {
|
|
3898
|
+
type: "array",
|
|
3899
|
+
items: { type: "string" },
|
|
3900
|
+
description: "Grant types the client may use at the token endpoint"
|
|
3901
|
+
},
|
|
3902
|
+
response_types: {
|
|
3903
|
+
type: "array",
|
|
3904
|
+
items: {
|
|
3905
|
+
type: "string",
|
|
3906
|
+
enum: ["code"]
|
|
3907
|
+
},
|
|
3908
|
+
description: "Response types the client may use at the authorization endpoint"
|
|
3909
|
+
},
|
|
3910
|
+
public: {
|
|
3911
|
+
type: "boolean",
|
|
3912
|
+
description: "Whether the client is public as determined by the type"
|
|
3913
|
+
},
|
|
3914
|
+
type: {
|
|
3915
|
+
type: "string",
|
|
3916
|
+
description: "Type of the client",
|
|
3917
|
+
enum: [
|
|
3918
|
+
"web",
|
|
3919
|
+
"native",
|
|
3920
|
+
"user-agent-based"
|
|
3921
|
+
]
|
|
3922
|
+
},
|
|
3923
|
+
disabled: {
|
|
3924
|
+
type: "boolean",
|
|
3925
|
+
description: "Whether the client is disabled"
|
|
3926
|
+
}
|
|
4000
3927
|
},
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
required: ["client_id"]
|
|
4007
|
-
} } }
|
|
4008
|
-
} }
|
|
4009
|
-
} }
|
|
3928
|
+
required: ["client_id"]
|
|
3929
|
+
} } }
|
|
3930
|
+
} }
|
|
3931
|
+
}
|
|
3932
|
+
}
|
|
4010
3933
|
}, async (ctx) => {
|
|
4011
3934
|
return registerEndpoint(ctx, opts);
|
|
4012
3935
|
}),
|
|
@@ -4023,7 +3946,14 @@ const oauthProvider = (options) => {
|
|
|
4023
3946
|
getOAuthConsent: getOAuthConsent(opts),
|
|
4024
3947
|
getOAuthConsents: getOAuthConsents(opts),
|
|
4025
3948
|
updateOAuthConsent: updateOAuthConsent(opts),
|
|
4026
|
-
deleteOAuthConsent: deleteOAuthConsent(opts)
|
|
3949
|
+
deleteOAuthConsent: deleteOAuthConsent(opts),
|
|
3950
|
+
adminCreateOAuthResource: adminCreateOAuthResource(opts),
|
|
3951
|
+
adminListOAuthResources: adminListOAuthResources(opts),
|
|
3952
|
+
adminGetOAuthResource: adminGetOAuthResource(opts),
|
|
3953
|
+
adminUpdateOAuthResource: adminUpdateOAuthResource(opts),
|
|
3954
|
+
adminDeleteOAuthResource: adminDeleteOAuthResource(opts),
|
|
3955
|
+
adminLinkClientResource: adminLinkClientResource(opts),
|
|
3956
|
+
adminUnlinkClientResource: adminUnlinkClientResource(opts)
|
|
4027
3957
|
},
|
|
4028
3958
|
schema: mergeSchema(schema, opts?.schema),
|
|
4029
3959
|
rateLimit: [
|
|
@@ -4063,6 +3993,19 @@ const oauthProvider = (options) => {
|
|
|
4063
3993
|
//#endregion
|
|
4064
3994
|
//#region src/authorize.ts
|
|
4065
3995
|
/**
|
|
3996
|
+
* Whether a past authentication is still fresh enough for an OIDC `max_age`
|
|
3997
|
+
* request: true when no more than `maxAge` seconds have elapsed since the user
|
|
3998
|
+
* last authenticated. The caller supplies `now`, keeping this pure.
|
|
3999
|
+
*/
|
|
4000
|
+
function isWithinMaxAge(sessionCreatedAt, maxAgeSeconds, now) {
|
|
4001
|
+
if (maxAgeSeconds === 0) return false;
|
|
4002
|
+
return now.getTime() - sessionCreatedAt.getTime() <= maxAgeSeconds * 1e3;
|
|
4003
|
+
}
|
|
4004
|
+
function removeMaxAgeFromAuthorizationQuery(query) {
|
|
4005
|
+
const { max_age: _maxAge, ...queryWithoutMaxAge } = query;
|
|
4006
|
+
return queryWithoutMaxAge;
|
|
4007
|
+
}
|
|
4008
|
+
/**
|
|
4066
4009
|
* Formats an error url. Per OIDC Core 1.0 §5 / RFC 6749 §4.2.2.1, errors on
|
|
4067
4010
|
* implicit and hybrid flows are delivered in the URL fragment, not the query.
|
|
4068
4011
|
* Callers on the code flow (default) omit `mode` and get query delivery.
|
|
@@ -4209,6 +4152,7 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
4209
4152
|
error_description: "request not found",
|
|
4210
4153
|
error: "invalid_request"
|
|
4211
4154
|
});
|
|
4155
|
+
const request = ctx.request;
|
|
4212
4156
|
let query = ctx.query;
|
|
4213
4157
|
if (query.request_uri) {
|
|
4214
4158
|
if (!opts.requestUriResolver) return handleRedirect(ctx, getErrorURL(ctx, "invalid_request_uri", "request_uri not supported"));
|
|
@@ -4223,6 +4167,14 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
4223
4167
|
if (urlClientId) query.client_id = urlClientId;
|
|
4224
4168
|
}
|
|
4225
4169
|
ctx.query = query;
|
|
4170
|
+
const parsedQuery = authorizationQuerySchema.safeParse(query);
|
|
4171
|
+
if (!parsedQuery.success) return authorizeRedirectOnError(opts)({
|
|
4172
|
+
error: "invalid_request",
|
|
4173
|
+
error_description: "invalid authorization request",
|
|
4174
|
+
ctx
|
|
4175
|
+
});
|
|
4176
|
+
query = parsedQuery.data;
|
|
4177
|
+
ctx.query = query;
|
|
4226
4178
|
await oAuthState.set({ query: serializeAuthorizationQuery(query).toString() });
|
|
4227
4179
|
if (!query.client_id) return handleRedirect(ctx, getErrorURL(ctx, "invalid_client", "client_id is required"));
|
|
4228
4180
|
if (!query.response_type) return handleRedirect(ctx, getErrorURL(ctx, "invalid_request", "response_type is required"));
|
|
@@ -4233,6 +4185,7 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
4233
4185
|
const client = await getClient(ctx, opts, query.client_id);
|
|
4234
4186
|
if (!client) return handleRedirect(ctx, getErrorURL(ctx, "invalid_client", "client_id is required"));
|
|
4235
4187
|
if (client.disabled) return handleRedirect(ctx, getErrorURL(ctx, "client_disabled", "client is disabled"));
|
|
4188
|
+
if (!clientAllowsGrant(client, "authorization_code")) return handleRedirect(ctx, getErrorURL(ctx, "unauthorized_client", "client is not authorized to use the authorization_code grant"));
|
|
4236
4189
|
if (!findRegisteredRedirectUri(client.redirectUris, query.redirect_uri) || !query.redirect_uri) return handleRedirect(ctx, getErrorURL(ctx, "invalid_redirect", "invalid redirect uri"));
|
|
4237
4190
|
let requestedScopes = query.scope?.split(" ").filter((s) => s);
|
|
4238
4191
|
if (requestedScopes) {
|
|
@@ -4246,6 +4199,20 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
4246
4199
|
requestedScopes = client.scopes ?? opts.scopes ?? [];
|
|
4247
4200
|
query.scope = requestedScopes.join(" ");
|
|
4248
4201
|
}
|
|
4202
|
+
if (query.resource !== void 0) try {
|
|
4203
|
+
await resolveResourcePolicy(ctx, opts, {
|
|
4204
|
+
resource: query.resource,
|
|
4205
|
+
clientId: client.clientId,
|
|
4206
|
+
requestedScopes
|
|
4207
|
+
});
|
|
4208
|
+
} catch (err) {
|
|
4209
|
+
if (err instanceof APIError$1) {
|
|
4210
|
+
const error = err.body?.error ?? "invalid_target";
|
|
4211
|
+
const description = err.body?.error_description ?? "requested resource invalid";
|
|
4212
|
+
return handleRedirect(ctx, formatErrorURL(query.redirect_uri, error, description, query.state, getIssuer(ctx, opts)));
|
|
4213
|
+
}
|
|
4214
|
+
throw err;
|
|
4215
|
+
}
|
|
4249
4216
|
const pkceRequired = isPKCERequired(client, requestedScopes);
|
|
4250
4217
|
if (pkceRequired) {
|
|
4251
4218
|
if (!query.code_challenge || !query.code_challenge_method) return handleRedirect(ctx, formatErrorURL(query.redirect_uri, "invalid_request", pkceRequired.valueOf(), query.state, getIssuer(ctx, opts)));
|
|
@@ -4254,18 +4221,22 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
4254
4221
|
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)));
|
|
4255
4222
|
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)));
|
|
4256
4223
|
}
|
|
4257
|
-
const
|
|
4258
|
-
if (!(await checkResource(ctx, opts, resource, requestedScopes)).success) return handleRedirect(ctx, formatErrorURL(query.redirect_uri, "invalid_target", "requested resource invalid", query.state, getIssuer(ctx, opts)));
|
|
4259
|
-
const requestedResources = toResourceList(resource) ?? [];
|
|
4224
|
+
const requestedResources = toResourceList(query.resource) ?? [];
|
|
4260
4225
|
const session = await getSessionFromCtx(ctx);
|
|
4261
|
-
|
|
4226
|
+
const maxAgeSeconds = query.max_age;
|
|
4227
|
+
const hasSatisfiedMaxAge = session != null && maxAgeSeconds !== void 0 && isWithinMaxAge(new Date(session.session.createdAt), maxAgeSeconds, /* @__PURE__ */ new Date());
|
|
4228
|
+
if (!session || session != null && maxAgeSeconds !== void 0 && !hasSatisfiedMaxAge || promptSet?.has("login") || promptSet?.has("create")) {
|
|
4262
4229
|
if (promptNone) return redirectWithPromptNoneError(ctx, opts, query, "login_required", "authentication required");
|
|
4263
4230
|
return redirectWithPromptCode(ctx, opts, promptSet?.has("create") ? "create" : "login");
|
|
4264
4231
|
}
|
|
4232
|
+
if (hasSatisfiedMaxAge) {
|
|
4233
|
+
query = removeMaxAgeFromAuthorizationQuery(query);
|
|
4234
|
+
ctx.query = query;
|
|
4235
|
+
}
|
|
4265
4236
|
if (settings?.isAuthorize && promptSet?.has("select_account")) return redirectWithPromptCode(ctx, opts, "select_account");
|
|
4266
4237
|
if (settings?.isAuthorize && opts.selectAccount) {
|
|
4267
4238
|
if (await opts.selectAccount.shouldRedirect({
|
|
4268
|
-
headers:
|
|
4239
|
+
headers: request.headers,
|
|
4269
4240
|
user: session.user,
|
|
4270
4241
|
session: session.session,
|
|
4271
4242
|
scopes: requestedScopes
|
|
@@ -4276,7 +4247,7 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
4276
4247
|
}
|
|
4277
4248
|
if (opts.signup?.shouldRedirect) {
|
|
4278
4249
|
const signupRedirect = await opts.signup.shouldRedirect({
|
|
4279
|
-
headers:
|
|
4250
|
+
headers: request.headers,
|
|
4280
4251
|
user: session.user,
|
|
4281
4252
|
session: session.session,
|
|
4282
4253
|
scopes: requestedScopes
|
|
@@ -4288,7 +4259,7 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
4288
4259
|
}
|
|
4289
4260
|
if (!settings?.postLogin && opts.postLogin) {
|
|
4290
4261
|
if (await opts.postLogin.shouldRedirect({
|
|
4291
|
-
headers:
|
|
4262
|
+
headers: request.headers,
|
|
4292
4263
|
user: session.user,
|
|
4293
4264
|
session: session.session,
|
|
4294
4265
|
scopes: requestedScopes
|
|
@@ -4402,123 +4373,13 @@ async function signParams(ctx, opts, flags) {
|
|
|
4402
4373
|
const params = serializeAuthorizationQuery(ctx.query);
|
|
4403
4374
|
params.set("exp", String(exp));
|
|
4404
4375
|
params.set(signedQueryIssuedAtParam, String(issuedAt));
|
|
4376
|
+
params.delete("sig");
|
|
4405
4377
|
params.delete(postLoginClearedParam);
|
|
4406
4378
|
if (flags?.postLoginClearedForSession) params.set(postLoginClearedParam, flags.postLoginClearedForSession);
|
|
4407
|
-
|
|
4408
|
-
params.
|
|
4379
|
+
setSignedOAuthQueryParameterNames(params);
|
|
4380
|
+
const signature = await makeSignature(canonicalizeOAuthQueryParams(params).toString(), ctx.context.secret);
|
|
4381
|
+
params.set("sig", signature);
|
|
4409
4382
|
return params.toString();
|
|
4410
4383
|
}
|
|
4411
4384
|
//#endregion
|
|
4412
|
-
|
|
4413
|
-
function authServerMetadata(ctx, opts, overrides) {
|
|
4414
|
-
const baseURL = ctx.context.baseURL;
|
|
4415
|
-
return {
|
|
4416
|
-
scopes_supported: overrides?.scopes_supported,
|
|
4417
|
-
issuer: validateIssuerUrl(opts?.jwt?.issuer ?? baseURL),
|
|
4418
|
-
authorization_endpoint: `${baseURL}/oauth2/authorize`,
|
|
4419
|
-
token_endpoint: `${baseURL}/oauth2/token`,
|
|
4420
|
-
jwks_uri: overrides?.jwt_disabled ? void 0 : opts?.jwks?.remoteUrl ?? `${baseURL}${opts?.jwks?.jwksPath ?? "/jwks"}`,
|
|
4421
|
-
registration_endpoint: overrides?.dynamic_client_registration_supported ? `${baseURL}/oauth2/register` : void 0,
|
|
4422
|
-
introspection_endpoint: `${baseURL}/oauth2/introspect`,
|
|
4423
|
-
revocation_endpoint: `${baseURL}/oauth2/revoke`,
|
|
4424
|
-
response_types_supported: overrides?.grant_types_supported && !overrides.grant_types_supported.includes("authorization_code") ? [] : ["code"],
|
|
4425
|
-
response_modes_supported: ["query"],
|
|
4426
|
-
grant_types_supported: overrides?.grant_types_supported ?? [
|
|
4427
|
-
"authorization_code",
|
|
4428
|
-
"client_credentials",
|
|
4429
|
-
"refresh_token"
|
|
4430
|
-
],
|
|
4431
|
-
token_endpoint_auth_methods_supported: [
|
|
4432
|
-
...overrides?.public_client_supported ? ["none"] : [],
|
|
4433
|
-
"client_secret_basic",
|
|
4434
|
-
"client_secret_post",
|
|
4435
|
-
"private_key_jwt"
|
|
4436
|
-
],
|
|
4437
|
-
token_endpoint_auth_signing_alg_values_supported: [...PRIVATE_KEY_JWT_SIGNING_ALGORITHMS],
|
|
4438
|
-
introspection_endpoint_auth_methods_supported: [
|
|
4439
|
-
"client_secret_basic",
|
|
4440
|
-
"client_secret_post",
|
|
4441
|
-
"private_key_jwt"
|
|
4442
|
-
],
|
|
4443
|
-
introspection_endpoint_auth_signing_alg_values_supported: [...PRIVATE_KEY_JWT_SIGNING_ALGORITHMS],
|
|
4444
|
-
revocation_endpoint_auth_methods_supported: [
|
|
4445
|
-
"client_secret_basic",
|
|
4446
|
-
"client_secret_post",
|
|
4447
|
-
"private_key_jwt"
|
|
4448
|
-
],
|
|
4449
|
-
revocation_endpoint_auth_signing_alg_values_supported: [...PRIVATE_KEY_JWT_SIGNING_ALGORITHMS],
|
|
4450
|
-
code_challenge_methods_supported: ["S256"],
|
|
4451
|
-
authorization_response_iss_parameter_supported: true
|
|
4452
|
-
};
|
|
4453
|
-
}
|
|
4454
|
-
function oidcServerMetadata(ctx, opts) {
|
|
4455
|
-
const baseURL = ctx.context.baseURL;
|
|
4456
|
-
const jwtPluginOptions = opts.disableJwtPlugin ? void 0 : getJwtPlugin(ctx.context).options;
|
|
4457
|
-
return {
|
|
4458
|
-
...authServerMetadata(ctx, jwtPluginOptions, {
|
|
4459
|
-
scopes_supported: opts.advertisedMetadata?.scopes_supported ?? opts.scopes,
|
|
4460
|
-
dynamic_client_registration_supported: opts.allowDynamicClientRegistration,
|
|
4461
|
-
public_client_supported: opts.allowUnauthenticatedClientRegistration || toClientDiscoveryArray(opts.clientDiscovery).length > 0,
|
|
4462
|
-
grant_types_supported: opts.grantTypes,
|
|
4463
|
-
jwt_disabled: opts.disableJwtPlugin
|
|
4464
|
-
}),
|
|
4465
|
-
claims_supported: opts?.advertisedMetadata?.claims_supported ?? opts?.claims ?? [],
|
|
4466
|
-
userinfo_endpoint: `${baseURL}/oauth2/userinfo`,
|
|
4467
|
-
subject_types_supported: opts.pairwiseSecret ? ["public", "pairwise"] : ["public"],
|
|
4468
|
-
id_token_signing_alg_values_supported: jwtPluginOptions?.jwks?.keyPairConfig?.alg ? [jwtPluginOptions?.jwks?.keyPairConfig?.alg] : opts.disableJwtPlugin ? ["HS256"] : ["EdDSA"],
|
|
4469
|
-
end_session_endpoint: `${baseURL}/oauth2/end-session`,
|
|
4470
|
-
acr_values_supported: ["urn:mace:incommon:iap:bronze"],
|
|
4471
|
-
prompt_values_supported: [
|
|
4472
|
-
"login",
|
|
4473
|
-
"consent",
|
|
4474
|
-
"create",
|
|
4475
|
-
"select_account",
|
|
4476
|
-
"none"
|
|
4477
|
-
],
|
|
4478
|
-
...mergeDiscoveryMetadata(opts.clientDiscovery)
|
|
4479
|
-
};
|
|
4480
|
-
}
|
|
4481
|
-
const METADATA_CACHE_CONTROL = "public, max-age=15, stale-while-revalidate=15, stale-if-error=86400";
|
|
4482
|
-
function metadataResponse(body, extraHeaders) {
|
|
4483
|
-
const headers = new Headers(extraHeaders);
|
|
4484
|
-
if (!headers.has("Cache-Control")) headers.set("Cache-Control", METADATA_CACHE_CONTROL);
|
|
4485
|
-
headers.set("Content-Type", "application/json");
|
|
4486
|
-
return new Response(JSON.stringify(body), {
|
|
4487
|
-
status: 200,
|
|
4488
|
-
headers
|
|
4489
|
-
});
|
|
4490
|
-
}
|
|
4491
|
-
/**
|
|
4492
|
-
* Provides an exportable `/.well-known/oauth-authorization-server`.
|
|
4493
|
-
*
|
|
4494
|
-
* Useful when basePath prevents the endpoint from being located at the root
|
|
4495
|
-
* and must be provided manually.
|
|
4496
|
-
*
|
|
4497
|
-
* @external
|
|
4498
|
-
*/
|
|
4499
|
-
const oauthProviderAuthServerMetadata = (auth, opts) => {
|
|
4500
|
-
return async (request) => {
|
|
4501
|
-
return metadataResponse(await auth.api.getOAuthServerConfig({
|
|
4502
|
-
request,
|
|
4503
|
-
asResponse: false
|
|
4504
|
-
}), opts?.headers);
|
|
4505
|
-
};
|
|
4506
|
-
};
|
|
4507
|
-
/**
|
|
4508
|
-
* Provides an exportable `/.well-known/openid-configuration`.
|
|
4509
|
-
*
|
|
4510
|
-
* Useful when basePath prevents the endpoint from being located at the root
|
|
4511
|
-
* and must be provided manually.
|
|
4512
|
-
*
|
|
4513
|
-
* @external
|
|
4514
|
-
*/
|
|
4515
|
-
const oauthProviderOpenIdConfigMetadata = (auth, opts) => {
|
|
4516
|
-
return async (request) => {
|
|
4517
|
-
return metadataResponse(await auth.api.getOpenIdConfig({
|
|
4518
|
-
request,
|
|
4519
|
-
asResponse: false
|
|
4520
|
-
}), opts?.headers);
|
|
4521
|
-
};
|
|
4522
|
-
};
|
|
4523
|
-
//#endregion
|
|
4524
|
-
export { authServerMetadata, checkOAuthClient, getOAuthProviderState, mcpHandler, oauthProvider, oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata, oauthToSchema, oidcServerMetadata };
|
|
4385
|
+
export { DEFAULT_OAUTH_SCOPES, ResourceUriSchema, authServerMetadata, checkOAuthClient, consumeClientAssertion, extendOAuthProvider, getIssuer, getOAuthProviderApi, getOAuthProviderState, metadataResponse, oauthAuthorizationServerMetadata, oauthProvider, oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata, oauthToSchema, oidcServerMetadata, raiseResourceServerChallenge };
|