@better-auth/oauth-provider 1.7.9 → 1.8.0-beta.0
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/{authorize-D8r6mvZx.mjs → authorize-92EtLsBi.mjs} +735 -502
- package/dist/client-resource.d.mts +1 -1
- package/dist/client-resource.mjs +2 -2
- package/dist/client.d.mts +1 -1
- package/dist/client.mjs +1 -1
- package/dist/index-sGnh4IfY.d.mts +275 -0
- package/dist/index.d.mts +4 -203
- package/dist/index.mjs +198 -41
- package/dist/internal.d.mts +108 -40
- package/dist/internal.mjs +4 -2
- package/dist/{introspect-7WCMPe5Z.mjs → introspect-CEo9Q06v.mjs} +318 -237
- package/dist/{oauth-DAMtT2vg.d.mts → oauth-BlEuA8oD.d.mts} +380 -20
- package/dist/{oauth-BWgg8X-A.d.mts → oauth-mdkaIwmm.d.mts} +191 -86
- package/dist/{utils-DKU5wblX.mjs → utils-BMNe8Sg2.mjs} +343 -5
- package/dist/{version-DVUrz-nv.mjs → version-DeABnmP9.mjs} +1 -1
- package/package.json +6 -6
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime-wcPFST8Q.mjs";
|
|
2
|
-
import { A as
|
|
3
|
-
import {
|
|
2
|
+
import { A as getAcrValuesSupported, C as authorizationQuerySchema, D as STANDARD_CLAIMS, F as getRequestedUserInfoClaims, I as isValidOidcClaimsRequest, M as canSatisfyEssentialAcrRequest, N as claimsRequestParameterSchema, O as STANDARD_CLAIM_NAMES, P as filterClaimsRequestUserInfoClaims, S as SafeUrlSchema, T as clientRegistrationRequestSchema, _ as logStartupSeedFailure, a as tokenEndpoint, b as userInfoResource, c as normalizeClientCredentialsScopes, d as JWS_ALGORITHMS, f as assertIdentifierValid, g as logEnforcePerClientResourcesResolution, h as isAudienceClaimAllowed, i as invalidateRefreshFamily, j as resolveAuthenticationContext, k as getSupportedClaims, l as validateClientCredentialsScopes, m as invalidateResourceCache, o as userInfoEndpoint, p as getResource, s as findToken, t as introspectEndpoint, u as getEndpointUrl, v as resolveResourcePolicy, w as clientJwksSchema, x as ResourceUriSchema, y as seedResourcesOnce } from "./introspect-CEo9Q06v.mjs";
|
|
3
|
+
import { $ as validateOAuthProviderExtensions, A as storeClientSecret, E as removePromptFromQuery, F as validateClientCredentials, J as getSupportedGrantTypes, K as getExtensionInteractionRequest, L as verifyOAuthQueryParams, N as toResourceList, O as resolveSubjectIdentifier, Q as validateExtensionClientMetadata, R as applyOAuthProviderMetadataExtensions, S as parsePrompt, T as removeMaxAgeFromQuery, U as getClientDiscoveries, W as getExtensionClientMetadataFields, Y as hasExtensionClientMetadata, Z as isExtensionTokenEndpointAuthMethod, _ as mergeDiscoveryMetadata, a as decryptStoredClientSecret, b as parseBearerToken, c as extractClientCredentials, d as getConsentReferenceId, f as getJwtPlugin, g as isSessionFreshForSignedQuery, h as isPKCERequired, i as consentCovers, j as storeToken, k as searchParamsToQuery, l as findConsent, n as assertNotTrustedClient, o as deriveSectorIdentifier, q as getSupportedAuthMethods, r as clientAllowsGrant, s as destructureCredentials, t as applyFormParams, tt as stripReservedOAuthClientMetadataExtensions, u as getClient, w as readFormParams, x as parseClientMetadata } from "./utils-BMNe8Sg2.mjs";
|
|
4
4
|
import { a as setSignedOAuthQueryParameterNames, i as postLoginClearedParam, n as canonicalizeOAuthQueryParams, o as signedQueryIssuedAtParam, r as getSignedQueryIssuedAt } from "./signed-query-Df1MNiSH.mjs";
|
|
5
|
-
import { t as PACKAGE_VERSION } from "./version-
|
|
5
|
+
import { t as PACKAGE_VERSION } from "./version-DeABnmP9.mjs";
|
|
6
6
|
import { isBrowserFetchRequest } from "@better-auth/core/utils/fetch-metadata";
|
|
7
7
|
import { isLoopbackHost, isLoopbackIP, isPublicRoutableHost } from "@better-auth/core/utils/host";
|
|
8
8
|
import { appendQueryParams } from "@better-auth/core/utils/url";
|
|
@@ -11,16 +11,16 @@ import { generateRandomString, makeSignature } from "better-auth/crypto";
|
|
|
11
11
|
import { APIError as APIError$1 } from "better-call";
|
|
12
12
|
import { logger } from "@better-auth/core/env";
|
|
13
13
|
import * as z from "zod";
|
|
14
|
-
import { defineRequestState, getCurrentAdapter, runWithTransaction } from "@better-auth/core/context";
|
|
15
14
|
import { BetterAuthError } from "@better-auth/core/error";
|
|
15
|
+
import { CLIENT_ASSERTION_TYPE, DPOP_SIGNING_ALGORITHMS, PRIVATE_KEY_JWT_SIGNING_ALGORITHMS } from "@better-auth/core/oauth2";
|
|
16
|
+
import { defineRequestState, getCurrentAdapter, hasRequestState, runWithTransaction } from "@better-auth/core/context";
|
|
16
17
|
import { deleteSessionCookie, parseSetCookieHeader } from "better-auth/cookies";
|
|
17
18
|
import { mergeSchema } from "better-auth/db";
|
|
18
|
-
import { CLIENT_ASSERTION_TYPE, DPOP_SIGNING_ALGORITHMS, PRIVATE_KEY_JWT_SIGNING_ALGORITHMS } from "@better-auth/core/oauth2";
|
|
19
19
|
import { createHash } from "@better-auth/utils/hash";
|
|
20
20
|
import { getJwks, stripAccessTokenAuthorizationScheme } from "better-auth/oauth2";
|
|
21
|
-
import { compactVerify, createLocalJWKSet, decodeJwt, decodeProtectedHeader, jwtVerify } from "jose";
|
|
22
21
|
import { isNativePrivateUseRedirectUri } from "@better-auth/core/utils/redirect-uri";
|
|
23
22
|
import { resolveSigningKey, signJWT, toExpJWT } from "better-auth/plugins";
|
|
23
|
+
import { compactVerify, createLocalJWKSet, decodeJwt, decodeProtectedHeader, jwtVerify } from "jose";
|
|
24
24
|
import { encodeDeterministicId } from "@better-auth/core/db/internal";
|
|
25
25
|
//#region src/consent.ts
|
|
26
26
|
async function consentEndpoint(ctx, opts, authorize) {
|
|
@@ -31,7 +31,7 @@ async function consentEndpoint(ctx, opts, authorize) {
|
|
|
31
31
|
error: "invalid_request"
|
|
32
32
|
});
|
|
33
33
|
const query = new URLSearchParams(_query);
|
|
34
|
-
const originalRequestedScopes = query.get("scope")?.split(" ") ?? [];
|
|
34
|
+
const originalRequestedScopes = query.get("scope")?.split(" ").filter((scope) => scope) ?? [];
|
|
35
35
|
const supportedClaims = getSupportedClaims(opts);
|
|
36
36
|
const originalRequestedUserInfoClaims = getRequestedUserInfoClaims(query.get("claims"), supportedClaims);
|
|
37
37
|
const clientId = query.get("client_id");
|
|
@@ -39,7 +39,7 @@ async function consentEndpoint(ctx, opts, authorize) {
|
|
|
39
39
|
error_description: "client_id is required",
|
|
40
40
|
error: "invalid_client"
|
|
41
41
|
});
|
|
42
|
-
const requestedScopes = ctx.body.scope?.split(" ");
|
|
42
|
+
const requestedScopes = ctx.body.scope?.split(" ").filter((scope) => scope);
|
|
43
43
|
if (requestedScopes) {
|
|
44
44
|
if (!requestedScopes.every((sc) => originalRequestedScopes?.includes(sc))) throw new APIError("BAD_REQUEST", {
|
|
45
45
|
error_description: "Scope not originally requested",
|
|
@@ -52,11 +52,25 @@ async function consentEndpoint(ctx, opts, authorize) {
|
|
|
52
52
|
error_description: "Claim not originally requested",
|
|
53
53
|
error: "invalid_request"
|
|
54
54
|
});
|
|
55
|
-
if (!(ctx.body.accept === true)) return {
|
|
56
|
-
redirect: true,
|
|
57
|
-
url: formatErrorURL(query.get("redirect_uri") ?? "", "access_denied", "User denied access", query.get("state") ?? void 0, getIssuer(ctx, opts))
|
|
58
|
-
};
|
|
59
55
|
const session = await getSessionFromCtx(ctx);
|
|
56
|
+
if (!(ctx.body.accept === true)) {
|
|
57
|
+
const interaction = getExtensionInteractionRequest(opts, searchParamsToQuery(query));
|
|
58
|
+
if (interaction) return {
|
|
59
|
+
redirect: true,
|
|
60
|
+
url: await interaction.handler.deny({
|
|
61
|
+
ctx,
|
|
62
|
+
value: interaction.value,
|
|
63
|
+
userId: session?.user.id
|
|
64
|
+
}).catch((error) => getAPIErrorURL(ctx, error, interaction.handler.errorPage?.({
|
|
65
|
+
ctx,
|
|
66
|
+
value: interaction.value
|
|
67
|
+
})))
|
|
68
|
+
};
|
|
69
|
+
return {
|
|
70
|
+
redirect: true,
|
|
71
|
+
url: formatErrorURL(query.get("redirect_uri") ?? "", "access_denied", "User denied access", query.get("state") ?? void 0, getIssuer(ctx, opts))
|
|
72
|
+
};
|
|
73
|
+
}
|
|
60
74
|
const hasLoginPrompt = parsePrompt(query.get("prompt") ?? "").has("login");
|
|
61
75
|
const hasSatisfiedLoginPrompt = hasLoginPrompt && isSessionFreshForSignedQuery(session?.session.createdAt, oauthRequest?.signedQueryIssuedAt);
|
|
62
76
|
if (hasLoginPrompt && !hasSatisfiedLoginPrompt) {
|
|
@@ -64,28 +78,12 @@ async function consentEndpoint(ctx, opts, authorize) {
|
|
|
64
78
|
ctx.query = searchParamsToQuery(query);
|
|
65
79
|
return await authorize(ctx);
|
|
66
80
|
}
|
|
67
|
-
const referenceId = await opts
|
|
81
|
+
const referenceId = await getConsentReferenceId(opts, {
|
|
68
82
|
user: session?.user,
|
|
69
83
|
session: session?.session,
|
|
70
84
|
scopes: requestedScopes ?? originalRequestedScopes
|
|
71
85
|
});
|
|
72
|
-
const foundConsent = await ctx.
|
|
73
|
-
model: "oauthConsent",
|
|
74
|
-
where: [
|
|
75
|
-
{
|
|
76
|
-
field: "clientId",
|
|
77
|
-
value: clientId
|
|
78
|
-
},
|
|
79
|
-
{
|
|
80
|
-
field: "userId",
|
|
81
|
-
value: session?.user.id
|
|
82
|
-
},
|
|
83
|
-
...referenceId ? [{
|
|
84
|
-
field: "referenceId",
|
|
85
|
-
value: referenceId
|
|
86
|
-
}] : []
|
|
87
|
-
]
|
|
88
|
-
});
|
|
86
|
+
const foundConsent = await findConsent(ctx, clientId, session?.user.id, referenceId);
|
|
89
87
|
const iat = Math.floor(Date.now() / 1e3);
|
|
90
88
|
const resource = query.getAll("resource");
|
|
91
89
|
const consent = {
|
|
@@ -130,7 +128,10 @@ async function consentEndpoint(ctx, opts, authorize) {
|
|
|
130
128
|
authorizationQuery = removeMaxAgeFromQuery(authorizationQuery);
|
|
131
129
|
}
|
|
132
130
|
ctx.query = searchParamsToQuery(authorizationQuery);
|
|
133
|
-
return await authorize(ctx, {
|
|
131
|
+
return await authorize(ctx, {
|
|
132
|
+
postLogin: oauthRequest?.postLoginClearedForSession !== void 0 && oauthRequest.postLoginClearedForSession === session?.session.id,
|
|
133
|
+
consentAccepted: true
|
|
134
|
+
});
|
|
134
135
|
}
|
|
135
136
|
//#endregion
|
|
136
137
|
//#region src/continue.ts
|
|
@@ -186,6 +187,7 @@ const BACKCHANNEL_LOGOUT_EVENT_URI = "http://schemas.openid.net/event/backchanne
|
|
|
186
187
|
const LOGOUT_TOKEN_JWT_TYP = "logout+jwt";
|
|
187
188
|
const LOGOUT_TOKEN_LIFETIME_SECONDS = 120;
|
|
188
189
|
const BACKCHANNEL_DISPATCH_TIMEOUT_MS = 5e3;
|
|
190
|
+
const FRONTCHANNEL_LOGOUT_TIMEOUT_MS = 3e3;
|
|
189
191
|
/**
|
|
190
192
|
* Signs a Back-Channel Logout Token per OIDC Back-Channel Logout 1.0 §2.4.
|
|
191
193
|
*
|
|
@@ -227,11 +229,12 @@ async function signLogoutToken(ctx, options, resolvedKey, claims) {
|
|
|
227
229
|
* Token revocation runs regardless of the JWT plugin (refresh-token revocation
|
|
228
230
|
* has no dependency on signing). Only Logout Token delivery needs the JWT
|
|
229
231
|
* plugin, so a plan may contain token identifiers with no delivery targets.
|
|
232
|
+
* Front-channel URIs sign nothing and are planned in either mode.
|
|
230
233
|
*
|
|
231
234
|
* Returns `null` when there is nothing to do, so the caller can skip the
|
|
232
235
|
* background handoff entirely.
|
|
233
236
|
*/
|
|
234
|
-
async function
|
|
237
|
+
async function prepareSessionLogoutPlan(ctx, opts, input) {
|
|
235
238
|
const { sessionId, userId } = input;
|
|
236
239
|
if (!userId) return null;
|
|
237
240
|
const logger = ctx.context.logger;
|
|
@@ -263,24 +266,38 @@ async function prepareBackchannelLogoutPlan(ctx, opts, input) {
|
|
|
263
266
|
const accessToRevokeIds = accessTokens.filter((t) => !t.revoked).map((t) => t.id);
|
|
264
267
|
const refreshToRevokeIds = refreshTokens.filter((t) => !t.revoked && !t.scopes?.includes("offline_access")).map((t) => t.id);
|
|
265
268
|
const eligibleClients = opts.disableJwtPlugin ? [] : clients.filter((c) => Boolean(c.backchannelLogoutUri) && !c.disabled);
|
|
269
|
+
const backchannelTargets = (await Promise.all(eligibleClients.map(async (client) => {
|
|
270
|
+
try {
|
|
271
|
+
return {
|
|
272
|
+
client,
|
|
273
|
+
sub: await resolveSubjectIdentifier(userId, client, opts)
|
|
274
|
+
};
|
|
275
|
+
} catch (error) {
|
|
276
|
+
logger.warn(`back-channel logout: unable to resolve subject for client ${client.clientId}`, error);
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
}))).filter((target) => target !== null);
|
|
280
|
+
const issuer = getIssuer(ctx, opts);
|
|
266
281
|
return {
|
|
267
282
|
accessTokenIds: accessToRevokeIds,
|
|
268
283
|
refreshTokenIds: refreshToRevokeIds,
|
|
269
284
|
sessionId,
|
|
270
|
-
|
|
285
|
+
backchannelTargets,
|
|
286
|
+
frontchannelLogoutUris: clients.flatMap((client) => {
|
|
287
|
+
if (!client.frontchannelLogoutUri || client.disabled) return [];
|
|
271
288
|
try {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
289
|
+
const uri = new URL(client.frontchannelLogoutUri);
|
|
290
|
+
uri.searchParams.set("iss", issuer);
|
|
291
|
+
uri.searchParams.set("sid", sessionId);
|
|
292
|
+
return [uri.toString()];
|
|
276
293
|
} catch (error) {
|
|
277
|
-
logger.warn(`
|
|
278
|
-
return
|
|
294
|
+
logger.warn(`front-channel logout: invalid frontchannel_logout_uri for client ${client.clientId}`, error);
|
|
295
|
+
return [];
|
|
279
296
|
}
|
|
280
|
-
})
|
|
297
|
+
})
|
|
281
298
|
};
|
|
282
299
|
} catch (error) {
|
|
283
|
-
logger.error("
|
|
300
|
+
logger.error("logout planning failed", error);
|
|
284
301
|
return null;
|
|
285
302
|
}
|
|
286
303
|
}
|
|
@@ -289,7 +306,7 @@ async function prepareBackchannelLogoutPlan(ctx, opts, input) {
|
|
|
289
306
|
* `session.delete.after` after the row is consumed and, for transactional
|
|
290
307
|
* callers, only after the transaction commits.
|
|
291
308
|
*/
|
|
292
|
-
async function
|
|
309
|
+
async function applySessionLogoutPlan(ctx, plan) {
|
|
293
310
|
const revokedAt = /* @__PURE__ */ new Date();
|
|
294
311
|
const adapter = await getCurrentAdapter(ctx.context.adapter);
|
|
295
312
|
const revocations = await Promise.allSettled([plan.accessTokenIds.length > 0 ? adapter.updateMany({
|
|
@@ -310,7 +327,7 @@ async function applyBackchannelLogoutPlan(ctx, plan) {
|
|
|
310
327
|
update: { revoked: revokedAt }
|
|
311
328
|
}) : Promise.resolve()]);
|
|
312
329
|
for (const result of revocations) if (result.status === "rejected") ctx.context.logger.error("back-channel logout: token revocation update failed", result.reason);
|
|
313
|
-
if (plan.
|
|
330
|
+
if (plan.backchannelTargets.length > 0) await deliverBackchannelLogoutTokens(ctx, plan);
|
|
314
331
|
}
|
|
315
332
|
/**
|
|
316
333
|
* Asynchronous phase: sign one Logout Token per target client and POST it to
|
|
@@ -331,7 +348,7 @@ async function deliverBackchannelLogoutTokens(ctx, plan) {
|
|
|
331
348
|
const iat = Math.floor(Date.now() / 1e3);
|
|
332
349
|
const exp = iat + LOGOUT_TOKEN_LIFETIME_SECONDS;
|
|
333
350
|
const resolvedKey = jwtPluginOptions?.jwt?.sign ? null : await resolveSigningKey(ctx, jwtPluginOptions);
|
|
334
|
-
await Promise.allSettled(plan.
|
|
351
|
+
await Promise.allSettled(plan.backchannelTargets.map(async ({ client, sub }) => {
|
|
335
352
|
try {
|
|
336
353
|
const jti = generateRandomString(32, "a-z", "A-Z", "0-9");
|
|
337
354
|
const token = await signLogoutToken(ctx, jwtPluginOptions, resolvedKey, {
|
|
@@ -359,6 +376,30 @@ async function deliverBackchannelLogoutTokens(ctx, plan) {
|
|
|
359
376
|
}
|
|
360
377
|
}));
|
|
361
378
|
}
|
|
379
|
+
/**
|
|
380
|
+
* Front-channel logout URIs collected from sessions deleted during the current
|
|
381
|
+
* request. `session.delete.after` records them only once the deletion has
|
|
382
|
+
* committed, so a vetoed or rolled-back delete never reaches the logout page.
|
|
383
|
+
*/
|
|
384
|
+
const frontchannelLogoutUrisState = defineRequestState(() => []);
|
|
385
|
+
/**
|
|
386
|
+
* Records a committed plan's front-channel URIs for the logout page of the
|
|
387
|
+
* current request. Deletions outside a request (no browser to render iframes)
|
|
388
|
+
* have nowhere to deliver them, so they are dropped.
|
|
389
|
+
*/
|
|
390
|
+
async function recordFrontchannelLogoutUris(uris) {
|
|
391
|
+
if (uris.length === 0 || !await hasRequestState()) return;
|
|
392
|
+
const recorded = await frontchannelLogoutUrisState.get();
|
|
393
|
+
await frontchannelLogoutUrisState.set([...recorded, ...uris]);
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Returns and clears the front-channel URIs recorded during this request.
|
|
397
|
+
*/
|
|
398
|
+
async function takeFrontchannelLogoutUris() {
|
|
399
|
+
const recorded = await frontchannelLogoutUrisState.get();
|
|
400
|
+
await frontchannelLogoutUrisState.set([]);
|
|
401
|
+
return recorded;
|
|
402
|
+
}
|
|
362
403
|
const LOGOUT_CONFIRMATION_TTL_SECONDS = 300;
|
|
363
404
|
const LOGOUT_CONFIRMATION_COOKIE_SUFFIX = ".oauth_logout_confirmation";
|
|
364
405
|
function escapeHtml(value) {
|
|
@@ -379,14 +420,16 @@ function isBrowserNavigation(ctx) {
|
|
|
379
420
|
const accept = headers.get("accept") ?? "";
|
|
380
421
|
return headers.get("sec-fetch-mode") === "navigate" || accept.includes("text/html") || accept.includes("application/xhtml+xml");
|
|
381
422
|
}
|
|
382
|
-
|
|
383
|
-
|
|
423
|
+
const LOGOUT_PAGE_CSP = "default-src 'none'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'";
|
|
424
|
+
function logoutPage(title, body, status = 200, page = {}) {
|
|
425
|
+
return new Response(`<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title>${page.head ?? ""}</head><body>${body}</body></html>`, {
|
|
384
426
|
status,
|
|
385
427
|
headers: {
|
|
386
428
|
"cache-control": "no-store",
|
|
387
|
-
"content-security-policy":
|
|
429
|
+
"content-security-policy": page.contentSecurityPolicy ?? LOGOUT_PAGE_CSP,
|
|
388
430
|
"content-type": "text/html; charset=utf-8",
|
|
389
431
|
pragma: "no-cache",
|
|
432
|
+
"referrer-policy": "no-referrer",
|
|
390
433
|
"x-content-type-options": "nosniff"
|
|
391
434
|
}
|
|
392
435
|
});
|
|
@@ -407,6 +450,53 @@ function logoutConfirmationPage(ctx) {
|
|
|
407
450
|
function logoutSuccessPage(note) {
|
|
408
451
|
return logoutPage("Logged out", `<main><p data-oidc-logout-state="logged-out">${note ? `Logged out. ${escapeHtml(note)}` : "Logged out."}</p></main>`);
|
|
409
452
|
}
|
|
453
|
+
const FRONTCHANNEL_LOGOUT_SCRIPT = `(function () {
|
|
454
|
+
var status = document.querySelector("[data-oidc-logout-state]");
|
|
455
|
+
var redirectUri = status.getAttribute("data-post-logout-redirect-uri");
|
|
456
|
+
var frames = document.querySelectorAll("iframe");
|
|
457
|
+
var pending = frames.length;
|
|
458
|
+
var finished = false;
|
|
459
|
+
function finish() {
|
|
460
|
+
if (finished) return;
|
|
461
|
+
finished = true;
|
|
462
|
+
if (redirectUri) {
|
|
463
|
+
window.location.replace(redirectUri);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
status.setAttribute("data-oidc-logout-state", "logged-out");
|
|
467
|
+
status.textContent = status.getAttribute("data-logged-out-message");
|
|
468
|
+
}
|
|
469
|
+
function settle() {
|
|
470
|
+
pending -= 1;
|
|
471
|
+
if (pending <= 0) finish();
|
|
472
|
+
}
|
|
473
|
+
for (var i = 0; i < frames.length; i++) {
|
|
474
|
+
frames[i].addEventListener("load", settle);
|
|
475
|
+
frames[i].addEventListener("error", settle);
|
|
476
|
+
}
|
|
477
|
+
setTimeout(finish, ${FRONTCHANNEL_LOGOUT_TIMEOUT_MS});
|
|
478
|
+
})();`;
|
|
479
|
+
let frontchannelLogoutScriptHash;
|
|
480
|
+
/**
|
|
481
|
+
* OIDC Front-Channel Logout 1.0 §3 logout page: one hidden iframe per relying
|
|
482
|
+
* party, then a redirect to the verified `post_logout_redirect_uri` or a
|
|
483
|
+
* logged-out message. The OP session has already ended when this renders.
|
|
484
|
+
* Without JavaScript the iframes still load, and a meta refresh performs the
|
|
485
|
+
* redirect.
|
|
486
|
+
*/
|
|
487
|
+
async function frontchannelLogoutPage(uris, redirect) {
|
|
488
|
+
frontchannelLogoutScriptHash ??= createHash("SHA-256", "base64").digest(FRONTCHANNEL_LOGOUT_SCRIPT);
|
|
489
|
+
const scriptHash = await frontchannelLogoutScriptHash;
|
|
490
|
+
const frameOrigins = [...new Set(uris.map((uri) => new URL(uri).origin))];
|
|
491
|
+
const loggedOutMessage = redirect.note ? `Logged out. ${redirect.note}` : "Logged out.";
|
|
492
|
+
const iframes = uris.map((uri) => `<iframe hidden src="${escapeHtml(uri)}"></iframe>`).join("");
|
|
493
|
+
const redirectAttribute = redirect.uri ? ` data-post-logout-redirect-uri="${escapeHtml(redirect.uri)}"` : "";
|
|
494
|
+
const noscript = redirect.uri ? `<noscript><a href="${escapeHtml(redirect.uri)}">Continue</a></noscript>` : `<noscript><p>${escapeHtml(loggedOutMessage)}</p></noscript>`;
|
|
495
|
+
return logoutPage("Signing out", `<main><p data-oidc-logout-state="pending" data-logged-out-message="${escapeHtml(loggedOutMessage)}"${redirectAttribute}>Signing out of connected applications…</p>${noscript}</main>${iframes}<script>${FRONTCHANNEL_LOGOUT_SCRIPT}<\/script>`, 200, {
|
|
496
|
+
head: redirect.uri ? `<noscript><meta http-equiv="refresh" content="${Math.ceil(FRONTCHANNEL_LOGOUT_TIMEOUT_MS / 1e3)};url=${escapeHtml(redirect.uri)}"></noscript>` : void 0,
|
|
497
|
+
contentSecurityPolicy: `${LOGOUT_PAGE_CSP}; frame-src ${frameOrigins.join(" ")}; script-src 'sha256-${scriptHash}'`
|
|
498
|
+
});
|
|
499
|
+
}
|
|
410
500
|
function logoutErrorPage(description, status) {
|
|
411
501
|
return logoutPage("Logout error", `<main><h1>Logout error</h1><p data-oidc-logout-state="error">${escapeHtml(description)}</p></main>`, status);
|
|
412
502
|
}
|
|
@@ -604,6 +694,22 @@ async function confirmationRequired(ctx, currentSession, confirmation = {}) {
|
|
|
604
694
|
await setLogoutConfirmationState(ctx, currentSession.session.id, confirmation);
|
|
605
695
|
return logoutConfirmationPage(ctx);
|
|
606
696
|
}
|
|
697
|
+
const UNREGISTERED_REDIRECT_NOTE = "The requested post-logout redirect was not registered.";
|
|
698
|
+
/**
|
|
699
|
+
* Response once the session has ended. A browser navigation renders the
|
|
700
|
+
* front-channel logout page when relying parties must be notified; otherwise
|
|
701
|
+
* the caller gets the verified redirect, the success page, or an empty result.
|
|
702
|
+
*/
|
|
703
|
+
async function loggedOutResponse(ctx, redirect) {
|
|
704
|
+
const note = redirect.invalid ? UNREGISTERED_REDIRECT_NOTE : void 0;
|
|
705
|
+
const frontchannelLogoutUris = await takeFrontchannelLogoutUris();
|
|
706
|
+
if (frontchannelLogoutUris.length > 0 && isBrowserNavigation(ctx)) return frontchannelLogoutPage(frontchannelLogoutUris, {
|
|
707
|
+
uri: redirect.uri,
|
|
708
|
+
note
|
|
709
|
+
});
|
|
710
|
+
if (redirect.uri) return handleRedirect(ctx, redirect.uri);
|
|
711
|
+
return isBrowserNavigation(ctx) ? logoutSuccessPage(note) : null;
|
|
712
|
+
}
|
|
607
713
|
async function completeConfirmedLogout(ctx, opts) {
|
|
608
714
|
const state = await getLogoutConfirmationState(ctx);
|
|
609
715
|
const currentSession = await getCurrentBrowserSession(ctx);
|
|
@@ -612,14 +718,13 @@ async function completeConfirmedLogout(ctx, opts) {
|
|
|
612
718
|
if (!currentSession) {
|
|
613
719
|
clearLogoutConfirmationState(ctx);
|
|
614
720
|
if (redirect.uri) return handleRedirect(ctx, redirect.uri);
|
|
615
|
-
return isBrowserNavigation(ctx) ? logoutSuccessPage(redirect.invalid ?
|
|
721
|
+
return isBrowserNavigation(ctx) ? logoutSuccessPage(redirect.invalid ? UNREGISTERED_REDIRECT_NOTE : void 0) : logoutProtocolError(ctx, "BAD_REQUEST", "invalid_request", "No active session is available for logout");
|
|
616
722
|
}
|
|
617
723
|
if (state.sessionId && state.sessionId !== currentSession.session.id) return logoutProtocolError(ctx, "BAD_REQUEST", "invalid_request", "The logout confirmation is invalid or expired");
|
|
618
724
|
await deleteLogoutSession(ctx, currentSession.session);
|
|
619
725
|
deleteSessionCookie(ctx);
|
|
620
726
|
clearLogoutConfirmationState(ctx);
|
|
621
|
-
|
|
622
|
-
return isBrowserNavigation(ctx) ? logoutSuccessPage(redirect.invalid ? "The requested post-logout redirect was not registered." : void 0) : null;
|
|
727
|
+
return loggedOutResponse(ctx, redirect);
|
|
623
728
|
}
|
|
624
729
|
/**
|
|
625
730
|
* RP-Initiated Logout (OIDC RP-Initiated Logout 1.0). The RP presents a signed
|
|
@@ -630,7 +735,9 @@ async function completeConfirmedLogout(ctx, opts) {
|
|
|
630
735
|
*
|
|
631
736
|
* Session termination goes through `internalAdapter.deleteSession`, which fires
|
|
632
737
|
* `session.delete.after` so the hook drives revocation and back-channel
|
|
633
|
-
* notifications to every RP with tokens on the session.
|
|
738
|
+
* notifications to every RP with tokens on the session. When the browser
|
|
739
|
+
* navigates here, RPs with a `frontchannel_logout_uri` are then notified
|
|
740
|
+
* through hidden iframes before the redirect.
|
|
634
741
|
*
|
|
635
742
|
* @see https://openid.net/specs/openid-connect-rpinitiated-1_0.html
|
|
636
743
|
*/
|
|
@@ -674,8 +781,7 @@ async function rpInitiatedLogoutEndpoint(ctx, opts) {
|
|
|
674
781
|
if (sessionToDelete) await deleteLogoutSession(ctx, sessionToDelete);
|
|
675
782
|
if (matchesCurrentSession) deleteSessionCookie(ctx);
|
|
676
783
|
clearLogoutConfirmationState(ctx);
|
|
677
|
-
|
|
678
|
-
return isBrowserNavigation(ctx) ? logoutSuccessPage(redirect.invalid ? "The requested post-logout redirect was not registered." : void 0) : null;
|
|
784
|
+
return loggedOutResponse(ctx, redirect);
|
|
679
785
|
}
|
|
680
786
|
/**
|
|
681
787
|
* Completes the browser confirmation flow for RP-Initiated Logout. This route
|
|
@@ -729,7 +835,9 @@ function authServerMetadata(ctx, opts, overrides) {
|
|
|
729
835
|
authorization_response_iss_parameter_supported: true,
|
|
730
836
|
dpop_signing_alg_values_supported: overrides?.dpop_signing_alg_values_supported ?? [...DPOP_SIGNING_ALGORITHMS],
|
|
731
837
|
backchannel_logout_supported: backchannelSupported,
|
|
732
|
-
backchannel_logout_session_supported: backchannelSupported
|
|
838
|
+
backchannel_logout_session_supported: backchannelSupported,
|
|
839
|
+
frontchannel_logout_supported: true,
|
|
840
|
+
frontchannel_logout_session_supported: true
|
|
733
841
|
};
|
|
734
842
|
}
|
|
735
843
|
/**
|
|
@@ -772,7 +880,7 @@ function oidcServerMetadata(ctx, opts) {
|
|
|
772
880
|
claims_parameter_supported: true,
|
|
773
881
|
userinfo_endpoint: `${baseURL}/oauth2/userinfo`,
|
|
774
882
|
subject_types_supported: opts.pairwiseSecret ? ["public", "pairwise"] : ["public"],
|
|
775
|
-
acr_values_supported:
|
|
883
|
+
acr_values_supported: getAcrValuesSupported(opts),
|
|
776
884
|
id_token_signing_alg_values_supported: (() => {
|
|
777
885
|
if (opts.disableJwtPlugin) return ["HS256"];
|
|
778
886
|
const primary = jwtPluginOptions?.jwks?.keyPairConfig?.alg ?? "EdDSA";
|
|
@@ -846,7 +954,8 @@ const oauthProviderOpenIdConfigMetadata = (auth, opts) => {
|
|
|
846
954
|
*
|
|
847
955
|
* A failing issue is routed by its first path segment via `errorCodesByField`:
|
|
848
956
|
* - missing required (`invalid_type` + "received undefined") → `.missing`
|
|
849
|
-
* - unsupported value (`invalid_value`)
|
|
957
|
+
* - unsupported value (`invalid_value`) or a value a refinement rejected
|
|
958
|
+
* (`custom`, also inside a union branch) → `.invalid`
|
|
850
959
|
* - anything else (wrong type, duplicated params, bad format) → `defaultError`
|
|
851
960
|
*
|
|
852
961
|
* For enum fields that need to distinguish missing from unsupported, compose
|
|
@@ -855,16 +964,19 @@ const oauthProviderOpenIdConfigMetadata = (auth, opts) => {
|
|
|
855
964
|
* enum value.
|
|
856
965
|
*/
|
|
857
966
|
function createOAuthEndpoint(path, options, handler) {
|
|
858
|
-
const { redirectOnError, onValidationError: userHook, errorCodesByField, defaultError = "invalid_request", ...rest } = options;
|
|
859
|
-
if (!redirectOnError
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
967
|
+
const { redirectOnError, onValidationError: userHook, errorCodesByField, defaultError = "invalid_request", formParams, ...rest } = options;
|
|
968
|
+
if (!redirectOnError && !formParams) {
|
|
969
|
+
const noStore = rest.metadata?.noStore === true;
|
|
970
|
+
return createAuthEndpoint(path, {
|
|
971
|
+
...rest,
|
|
972
|
+
onValidationError: async (args) => {
|
|
973
|
+
if (userHook) await userHook(args);
|
|
974
|
+
throw new APIError$1("BAD_REQUEST", mapIssuesToOAuthError(args.issues, errorCodesByField, defaultError), noStore ? NO_STORE_HEADERS : void 0);
|
|
975
|
+
}
|
|
976
|
+
}, handler);
|
|
977
|
+
}
|
|
867
978
|
const { body: bodySchema, query: querySchema, ...forwarded } = rest;
|
|
979
|
+
if (formParams) forwarded.cloneRequest = true;
|
|
868
980
|
async function validateSlot(ctx, slot, schema) {
|
|
869
981
|
if (!schema) return { ok: true };
|
|
870
982
|
const result = await schema.safeParseAsync(ctx[slot] ?? {});
|
|
@@ -876,15 +988,19 @@ function createOAuthEndpoint(path, options, handler) {
|
|
|
876
988
|
message: result.error.message,
|
|
877
989
|
issues: result.error.issues
|
|
878
990
|
});
|
|
991
|
+
const error = mapIssuesToOAuthError(result.error.issues, errorCodesByField, defaultError);
|
|
992
|
+
if (!redirectOnError) throw new APIError$1("BAD_REQUEST", error);
|
|
879
993
|
return {
|
|
880
994
|
ok: false,
|
|
881
|
-
response:
|
|
882
|
-
...
|
|
995
|
+
response: redirectOnError({
|
|
996
|
+
...error,
|
|
883
997
|
ctx
|
|
884
998
|
})
|
|
885
999
|
};
|
|
886
1000
|
}
|
|
887
1001
|
return createAuthEndpoint(path, forwarded, async (ctx) => {
|
|
1002
|
+
const form = formParams && ctx.request ? await readFormParams(ctx.request) : void 0;
|
|
1003
|
+
if (form) ctx.body = applyFormParams(form, ctx.body ?? {}, formParams?.repeatable);
|
|
888
1004
|
const body = await validateSlot(ctx, "body", bodySchema);
|
|
889
1005
|
if (!body.ok) return body.response;
|
|
890
1006
|
const query = await validateSlot(ctx, "query", querySchema);
|
|
@@ -902,18 +1018,32 @@ function mapIssuesToOAuthError(issues, errorCodesByField, defaultError = "invali
|
|
|
902
1018
|
const fieldKey = typeof first === "string" ? first : void 0;
|
|
903
1019
|
const mapping = fieldKey ? errorCodesByField?.[fieldKey] : void 0;
|
|
904
1020
|
const field = issue.path?.length ? z.core.toDotPath(issue.path) : "";
|
|
1021
|
+
const invalidValue = findInvalidValueIssue(issue);
|
|
905
1022
|
return {
|
|
906
|
-
error: resolveErrorCode(issue, mapping, defaultError),
|
|
907
|
-
error_description: describeIssue(issue, field)
|
|
1023
|
+
error: resolveErrorCode(issue, invalidValue, mapping, defaultError),
|
|
1024
|
+
error_description: describeIssue(invalidValue ?? issue, field)
|
|
908
1025
|
};
|
|
909
1026
|
}
|
|
910
|
-
function resolveErrorCode(issue, mapping, defaultError) {
|
|
1027
|
+
function resolveErrorCode(issue, invalidValue, mapping, defaultError) {
|
|
911
1028
|
if (typeof mapping === "string") return mapping;
|
|
912
1029
|
if (isMissingValueIssue(issue)) return mapping?.missing ?? defaultError;
|
|
913
|
-
if (
|
|
1030
|
+
if (invalidValue) return mapping?.invalid ?? defaultError;
|
|
914
1031
|
return defaultError;
|
|
915
1032
|
}
|
|
916
1033
|
/**
|
|
1034
|
+
* Returns the issue that routes to `errorCodesByField[field].invalid`: an
|
|
1035
|
+
* unsupported enum value, or a value of the expected type that a refinement
|
|
1036
|
+
* rejected, such as a resource URI with a fragment. A union reports a
|
|
1037
|
+
* refinement failure as `invalid_union`, so its branches are searched too.
|
|
1038
|
+
*/
|
|
1039
|
+
function findInvalidValueIssue(issue) {
|
|
1040
|
+
if (issue.code === "invalid_value" || issue.code === "custom") return issue;
|
|
1041
|
+
if (issue.code === "invalid_union") for (const branch of issue.errors) {
|
|
1042
|
+
const rejected = branch.find((nested) => nested.code === "custom");
|
|
1043
|
+
if (rejected) return rejected;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
917
1047
|
* Returns `true` for issues that represent an absent required value. Zod v4
|
|
918
1048
|
* strips `input` from published issues, so the signal is the `invalid_type`
|
|
919
1049
|
* code combined with a message suffix of "received undefined". The suffix is
|
|
@@ -1032,136 +1162,16 @@ function validatePublicClientJwks(input) {
|
|
|
1032
1162
|
jwks: { keys }
|
|
1033
1163
|
};
|
|
1034
1164
|
}
|
|
1035
|
-
//#endregion
|
|
1036
|
-
//#region src/client-metadata.ts
|
|
1037
|
-
const OAUTH_CLIENT_RECORD_FIELD_NAMES = Object.freeze(Object.keys({
|
|
1038
|
-
clientId: true,
|
|
1039
|
-
clientSecret: true,
|
|
1040
|
-
clientDiscoveryId: true,
|
|
1041
|
-
disabled: true,
|
|
1042
|
-
scopes: true,
|
|
1043
|
-
clientCredentialsScopes: true,
|
|
1044
|
-
userId: true,
|
|
1045
|
-
createdAt: true,
|
|
1046
|
-
updatedAt: true,
|
|
1047
|
-
expiresAt: true,
|
|
1048
|
-
name: true,
|
|
1049
|
-
uri: true,
|
|
1050
|
-
icon: true,
|
|
1051
|
-
contacts: true,
|
|
1052
|
-
tos: true,
|
|
1053
|
-
policy: true,
|
|
1054
|
-
softwareId: true,
|
|
1055
|
-
softwareVersion: true,
|
|
1056
|
-
softwareStatement: true,
|
|
1057
|
-
redirectUris: true,
|
|
1058
|
-
postLogoutRedirectUris: true,
|
|
1059
|
-
backchannelLogoutUri: true,
|
|
1060
|
-
backchannelLogoutSessionRequired: true,
|
|
1061
|
-
tokenEndpointAuthMethod: true,
|
|
1062
|
-
grantTypes: true,
|
|
1063
|
-
responseTypes: true,
|
|
1064
|
-
applicationType: true,
|
|
1065
|
-
jwks: true,
|
|
1066
|
-
jwksUri: true,
|
|
1067
|
-
requirePKCE: true,
|
|
1068
|
-
dpopBoundAccessTokens: true,
|
|
1069
|
-
skipConsent: true,
|
|
1070
|
-
enableEndSession: true,
|
|
1071
|
-
subjectType: true,
|
|
1072
|
-
referenceId: true,
|
|
1073
|
-
metadata: true
|
|
1074
|
-
}));
|
|
1075
1165
|
/**
|
|
1076
|
-
*
|
|
1077
|
-
*
|
|
1166
|
+
* Throws `invalid_client_metadata` unless `jwks` is a public JWK Set that
|
|
1167
|
+
* {@link validatePublicClientJwks} accepts.
|
|
1078
1168
|
*/
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
"
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
"client_id_issued_at",
|
|
1086
|
-
"client_name",
|
|
1087
|
-
"client_uri",
|
|
1088
|
-
"logo_uri",
|
|
1089
|
-
"contacts",
|
|
1090
|
-
"tos_uri",
|
|
1091
|
-
"policy_uri",
|
|
1092
|
-
"jwks",
|
|
1093
|
-
"jwks_uri",
|
|
1094
|
-
"software_id",
|
|
1095
|
-
"software_version",
|
|
1096
|
-
"software_statement",
|
|
1097
|
-
"redirect_uris",
|
|
1098
|
-
"post_logout_redirect_uris",
|
|
1099
|
-
"backchannel_logout_uri",
|
|
1100
|
-
"backchannel_logout_session_required",
|
|
1101
|
-
"token_endpoint_auth_method",
|
|
1102
|
-
"grant_types",
|
|
1103
|
-
"response_types",
|
|
1104
|
-
"application_type",
|
|
1105
|
-
"disabled",
|
|
1106
|
-
"skip_consent",
|
|
1107
|
-
"enable_end_session",
|
|
1108
|
-
"require_pkce",
|
|
1109
|
-
"dpop_bound_access_tokens",
|
|
1110
|
-
"subject_type",
|
|
1111
|
-
"reference_id"
|
|
1112
|
-
];
|
|
1113
|
-
const OPAQUE_METADATA_RESERVED_FIELDS = new Set([
|
|
1114
|
-
...OAUTH_CLIENT_RECORD_FIELD_NAMES,
|
|
1115
|
-
...OAUTH_CLIENT_WIRE_FIELD_NAMES,
|
|
1116
|
-
"public",
|
|
1117
|
-
"type",
|
|
1118
|
-
"resources",
|
|
1119
|
-
"client_credentials_scopes"
|
|
1120
|
-
]);
|
|
1121
|
-
const CIMD_FORBIDDEN_SERVER_FIELD_NAMES = new Set([
|
|
1122
|
-
"disabled",
|
|
1123
|
-
"client_secret",
|
|
1124
|
-
"client_secret_expires_at",
|
|
1125
|
-
"client_id_issued_at",
|
|
1126
|
-
"skip_consent",
|
|
1127
|
-
"enable_end_session",
|
|
1128
|
-
"require_pkce",
|
|
1129
|
-
"reference_id",
|
|
1130
|
-
"user_id",
|
|
1131
|
-
"resources",
|
|
1132
|
-
"clientSecret",
|
|
1133
|
-
"clientDiscoveryId",
|
|
1134
|
-
"skipConsent",
|
|
1135
|
-
"enableEndSession",
|
|
1136
|
-
"requirePKCE",
|
|
1137
|
-
"referenceId",
|
|
1138
|
-
"userId",
|
|
1139
|
-
"clientId",
|
|
1140
|
-
"applicationType",
|
|
1141
|
-
"tokenEndpointAuthMethod",
|
|
1142
|
-
"redirectUris",
|
|
1143
|
-
"postLogoutRedirectUris",
|
|
1144
|
-
"grantTypes",
|
|
1145
|
-
"responseTypes",
|
|
1146
|
-
"scopes",
|
|
1147
|
-
"expiresAt",
|
|
1148
|
-
"createdAt",
|
|
1149
|
-
"updatedAt",
|
|
1150
|
-
"softwareId",
|
|
1151
|
-
"softwareVersion",
|
|
1152
|
-
"softwareStatement",
|
|
1153
|
-
"backchannelLogoutUri",
|
|
1154
|
-
"backchannelLogoutSessionRequired",
|
|
1155
|
-
"jwksUri",
|
|
1156
|
-
"dpopBoundAccessTokens",
|
|
1157
|
-
"subjectType"
|
|
1158
|
-
]);
|
|
1159
|
-
function isForbiddenCimdClientMetadataField(field) {
|
|
1160
|
-
return CIMD_FORBIDDEN_SERVER_FIELD_NAMES.has(field);
|
|
1161
|
-
}
|
|
1162
|
-
function stripReservedOAuthClientMetadataExtensions(metadata) {
|
|
1163
|
-
if (!metadata) return void 0;
|
|
1164
|
-
return Object.fromEntries(Object.entries(metadata).filter(([field]) => !OPAQUE_METADATA_RESERVED_FIELDS.has(field)));
|
|
1169
|
+
function assertValidClientJwks(jwks) {
|
|
1170
|
+
const result = validatePublicClientJwks(jwks);
|
|
1171
|
+
if (!result.valid) throw new APIError("BAD_REQUEST", {
|
|
1172
|
+
error: "invalid_client_metadata",
|
|
1173
|
+
error_description: result.error
|
|
1174
|
+
});
|
|
1165
1175
|
}
|
|
1166
1176
|
//#endregion
|
|
1167
1177
|
//#region src/oauthClient/privileges.ts
|
|
@@ -1492,8 +1502,20 @@ async function consumeClientAssertion(ctx, opts, params) {
|
|
|
1492
1502
|
* Validates: signature, iss=client_id, sub=client_id, aud=the endpoint serving
|
|
1493
1503
|
* the assertion or the normalized OP issuer, exp, assertion max lifetime,
|
|
1494
1504
|
* jti uniqueness (replay prevention).
|
|
1505
|
+
*
|
|
1506
|
+
* Returns the client record the signature was checked against, so a caller that
|
|
1507
|
+
* mutates the client can guard its write on exactly that snapshot.
|
|
1508
|
+
*
|
|
1509
|
+
* With `endpointAudienceOnly`, the issuer is not an accepted audience, so only
|
|
1510
|
+
* an assertion whose `aud` includes `expectedAudience` verifies. Endpoints that
|
|
1511
|
+
* change client credentials use it, so an assertion minted for the token
|
|
1512
|
+
* endpoint or the issuer cannot be redirected to them.
|
|
1513
|
+
*
|
|
1514
|
+
* `assertClient` runs on the looked-up client before any key is fetched or the
|
|
1515
|
+
* jti is consumed, so a caller can refuse a client it does not serve without
|
|
1516
|
+
* spending the assertion or reaching out to a `jwks_uri`.
|
|
1495
1517
|
*/
|
|
1496
|
-
async function verifyClientAssertion(ctx, opts, clientAssertion, clientAssertionType, clientIdHint, expectedAudience) {
|
|
1518
|
+
async function verifyClientAssertion(ctx, opts, clientAssertion, clientAssertionType, clientIdHint, expectedAudience, options) {
|
|
1497
1519
|
if (clientAssertionType !== CLIENT_ASSERTION_TYPE) throw new APIError$1("BAD_REQUEST", {
|
|
1498
1520
|
error_description: "unsupported client_assertion_type",
|
|
1499
1521
|
error: "invalid_client"
|
|
@@ -1531,7 +1553,7 @@ async function verifyClientAssertion(ctx, opts, clientAssertion, clientAssertion
|
|
|
1531
1553
|
});
|
|
1532
1554
|
const client = await getClient(ctx, opts, clientId);
|
|
1533
1555
|
if (!client) throw new APIError$1("BAD_REQUEST", {
|
|
1534
|
-
error_description: "
|
|
1556
|
+
error_description: "client not found",
|
|
1535
1557
|
error: "invalid_client"
|
|
1536
1558
|
});
|
|
1537
1559
|
if (client.disabled) throw new APIError$1("BAD_REQUEST", {
|
|
@@ -1542,10 +1564,11 @@ async function verifyClientAssertion(ctx, opts, clientAssertion, clientAssertion
|
|
|
1542
1564
|
error_description: "client is not registered for private_key_jwt authentication",
|
|
1543
1565
|
error: "invalid_client"
|
|
1544
1566
|
});
|
|
1567
|
+
options?.assertClient?.(client);
|
|
1545
1568
|
const jwks = await fetchClientJwks(ctx, opts, client);
|
|
1546
1569
|
const endpointAudience = expectedAudience ?? `${ctx.context.baseURL}/oauth2/token`;
|
|
1547
1570
|
const issuerAudience = getIssuer(ctx, opts);
|
|
1548
|
-
const acceptedAudiences = [...new Set([endpointAudience, issuerAudience])];
|
|
1571
|
+
const acceptedAudiences = options?.endpointAudienceOnly ? [endpointAudience] : [...new Set([endpointAudience, issuerAudience])];
|
|
1549
1572
|
const verifyOpts = {
|
|
1550
1573
|
issuer: clientId,
|
|
1551
1574
|
subject: clientId,
|
|
@@ -1581,7 +1604,10 @@ async function verifyClientAssertion(ctx, opts, clientAssertion, clientAssertion
|
|
|
1581
1604
|
payload,
|
|
1582
1605
|
expectedAudience: acceptedAudience
|
|
1583
1606
|
});
|
|
1584
|
-
return {
|
|
1607
|
+
return {
|
|
1608
|
+
clientId,
|
|
1609
|
+
client
|
|
1610
|
+
};
|
|
1585
1611
|
}
|
|
1586
1612
|
//#endregion
|
|
1587
1613
|
//#region src/utils/initial-access-token.ts
|
|
@@ -1659,6 +1685,21 @@ function resolveRegistrationResponseTypes(client, grantTypes) {
|
|
|
1659
1685
|
if (client.response_types) return client.response_types;
|
|
1660
1686
|
return grantTypes.includes("authorization_code") ? ["code"] : void 0;
|
|
1661
1687
|
}
|
|
1688
|
+
/**
|
|
1689
|
+
* The requested grants this server supports. Clients such as MCP connectors
|
|
1690
|
+
* send one grant union to every server, so a metadata document or a dynamic
|
|
1691
|
+
* registration may list grants this server does not issue. Throws when the
|
|
1692
|
+
* request shares no grant with the server.
|
|
1693
|
+
*/
|
|
1694
|
+
function resolveMutuallySupportedGrantTypes(opts, grantTypes) {
|
|
1695
|
+
const supportedGrantTypes = new Set(getSupportedGrantTypes(opts));
|
|
1696
|
+
const mutualGrantTypes = grantTypes.filter((grantType) => supportedGrantTypes.has(grantType));
|
|
1697
|
+
if (mutualGrantTypes.length === 0) throw new APIError("BAD_REQUEST", {
|
|
1698
|
+
error: "invalid_client_metadata",
|
|
1699
|
+
error_description: `no mutually supported grant_type among ${grantTypes.join(", ")}`
|
|
1700
|
+
});
|
|
1701
|
+
return mutualGrantTypes;
|
|
1702
|
+
}
|
|
1662
1703
|
function applyOAuthClientRegistrationDefaults(client, defaultApplicationType = "web") {
|
|
1663
1704
|
const grantTypes = resolveRegistrationGrantTypes(client);
|
|
1664
1705
|
return {
|
|
@@ -1684,15 +1725,15 @@ function isNonHttpRedirectUri(redirectUri) {
|
|
|
1684
1725
|
/**
|
|
1685
1726
|
* Server-owned default for dynamic registrations that omit `application_type`.
|
|
1686
1727
|
* `"infer"` classifies as native when any redirect URI uses a non-http(s)
|
|
1687
|
-
* scheme, because a web client can never
|
|
1688
|
-
* validated against the native rules. A
|
|
1689
|
-
*
|
|
1690
|
-
* absent field.
|
|
1728
|
+
* scheme or http on an exact loopback host, because a web client can never
|
|
1729
|
+
* register either. Every URI is then validated against the native rules. A
|
|
1730
|
+
* sent `application_type` is never overridden because
|
|
1731
|
+
* `applyOAuthClientRegistrationDefaults` only fills the absent field.
|
|
1691
1732
|
*/
|
|
1692
1733
|
function resolveDynamicRegistrationDefaultApplicationType(opts, client) {
|
|
1693
1734
|
const configured = opts.clientRegistrationDefaultApplicationType ?? "web";
|
|
1694
1735
|
if (configured !== "infer") return configured;
|
|
1695
|
-
return (client.redirect_uris ?? []).some(isNonHttpRedirectUri) ? "native" : "web";
|
|
1736
|
+
return (client.redirect_uris ?? []).some((uri) => isNonHttpRedirectUri(uri) || isExactLoopbackHttpRedirectUri(uri)) ? "native" : "web";
|
|
1696
1737
|
}
|
|
1697
1738
|
function invalidRedirectUri(description) {
|
|
1698
1739
|
throw new APIError("BAD_REQUEST", {
|
|
@@ -1710,6 +1751,10 @@ function getRawHttpHostname(redirectUri) {
|
|
|
1710
1751
|
}
|
|
1711
1752
|
return (hostAndPort.split(":")[0] ?? "").toLowerCase();
|
|
1712
1753
|
}
|
|
1754
|
+
function isExactLoopbackHttpRedirectUri(redirectUri) {
|
|
1755
|
+
const hostname = getRawHttpHostname(redirectUri);
|
|
1756
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
|
|
1757
|
+
}
|
|
1713
1758
|
function assertDynamicRegistrationResourcesAllowed(opts, requestedResources) {
|
|
1714
1759
|
const allowedResources = new Set([...opts.clientRegistrationDefaultResources ?? [], ...opts.clientRegistrationAllowedResources ?? []]);
|
|
1715
1760
|
for (const identifier of requestedResources) if (!allowedResources.has(identifier)) throw new APIError("BAD_REQUEST", {
|
|
@@ -1745,8 +1790,6 @@ function validateClientRedirectUri(redirectUri, applicationType) {
|
|
|
1745
1790
|
const isHttps = url.protocol === "https:";
|
|
1746
1791
|
if (/^localhost\.+$/i.test(url.hostname)) invalidRedirectUri(`redirect URI localhost must not include trailing dots: ${redirectUri}`);
|
|
1747
1792
|
const isRedirectLoopback = isLoopbackIP(url.hostname) || url.hostname === "localhost";
|
|
1748
|
-
const rawHttpHostname = getRawHttpHostname(redirectUri);
|
|
1749
|
-
const isAllowedNativeHttpLoopback = rawHttpHostname === "localhost" || rawHttpHostname === "127.0.0.1" || rawHttpHostname === "[::1]";
|
|
1750
1793
|
if (applicationType === "web") {
|
|
1751
1794
|
if (!isHttps || isRedirectLoopback) invalidRedirectUri(`web clients require https redirect URIs on non-loopback hosts: ${redirectUri}`);
|
|
1752
1795
|
return;
|
|
@@ -1756,20 +1799,59 @@ function validateClientRedirectUri(redirectUri, applicationType) {
|
|
|
1756
1799
|
return;
|
|
1757
1800
|
}
|
|
1758
1801
|
if (isHttp) {
|
|
1759
|
-
if (!
|
|
1802
|
+
if (!isExactLoopbackHttpRedirectUri(redirectUri)) invalidRedirectUri(`native clients may use http only on the exact loopback hosts localhost, 127.0.0.1, or [::1]: ${redirectUri}`);
|
|
1760
1803
|
return;
|
|
1761
1804
|
}
|
|
1762
1805
|
if (!isNativePrivateUseRedirectUri(url)) invalidRedirectUri(`native private-use redirect URI schemes must not use a reserved scheme; they must be an authority-free reverse-domain URI or a custom-scheme URI with a host and a path: ${redirectUri}`);
|
|
1763
1806
|
}
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1807
|
+
/**
|
|
1808
|
+
* Rejects a URI the server will POST to unless it is an absolute https URL
|
|
1809
|
+
* with no fragment or credentials on a publicly routable host. Back-channel
|
|
1810
|
+
* endpoints use it as an SSRF guard, since the server, not the browser, calls
|
|
1811
|
+
* them.
|
|
1812
|
+
*/
|
|
1813
|
+
function assertPublicRoutableHttpsUri(field, uri) {
|
|
1814
|
+
const reject = (description) => {
|
|
1815
|
+
throw new APIError("BAD_REQUEST", {
|
|
1816
|
+
error: "invalid_client_metadata",
|
|
1817
|
+
error_description: `${field} ${description}`
|
|
1818
|
+
});
|
|
1819
|
+
};
|
|
1820
|
+
let url;
|
|
1821
|
+
try {
|
|
1822
|
+
url = new URL(uri);
|
|
1823
|
+
} catch {
|
|
1824
|
+
return reject("must be an absolute URL");
|
|
1825
|
+
}
|
|
1826
|
+
if (uri.includes("#")) reject("must not include a fragment component");
|
|
1827
|
+
if (url.protocol !== "https:") reject("must use https");
|
|
1828
|
+
if (url.username || url.password) reject("must not contain credentials");
|
|
1829
|
+
if (isPrivateHostname(url.hostname)) reject("must not point to a private or reserved address");
|
|
1830
|
+
}
|
|
1831
|
+
const REGISTRATION_REQUEST_FIELDS = new Set(Object.keys(clientRegistrationRequestSchema.shape));
|
|
1832
|
+
/**
|
|
1833
|
+
* Splits a dynamic registration request into its core fields and the fields
|
|
1834
|
+
* extensions define, which are stored in the client's `metadata`. Any other
|
|
1835
|
+
* unrecognized field is ignored (RFC 7591 §2), so a registrant cannot set
|
|
1836
|
+
* server-only fields or arbitrary `metadata`.
|
|
1837
|
+
*/
|
|
1838
|
+
function splitRegistrationRequest(input, opts) {
|
|
1839
|
+
return {
|
|
1840
|
+
body: Object.fromEntries(Object.entries(input).filter(([field]) => REGISTRATION_REQUEST_FIELDS.has(field))),
|
|
1841
|
+
metadata: pickExtensionClientMetadata(input, opts)
|
|
1842
|
+
};
|
|
1843
|
+
}
|
|
1844
|
+
/**
|
|
1845
|
+
* The top-level fields of a registration that extensions declare, which are
|
|
1846
|
+
* stored in the client's `metadata`.
|
|
1847
|
+
*/
|
|
1848
|
+
function pickExtensionClientMetadata(input, opts) {
|
|
1849
|
+
const extensionFields = getExtensionClientMetadataFields(opts);
|
|
1850
|
+
const metadata = Object.fromEntries(Object.entries(input).filter(([field]) => extensionFields.has(field)));
|
|
1851
|
+
return Object.keys(metadata).length ? metadata : void 0;
|
|
1770
1852
|
}
|
|
1771
1853
|
async function registerEndpoint(ctx, opts) {
|
|
1772
|
-
const body = ctx.body;
|
|
1854
|
+
const { body, metadata } = splitRegistrationRequest(ctx.body, opts);
|
|
1773
1855
|
if (!opts.allowDynamicClientRegistration) throw new APIError("FORBIDDEN", {
|
|
1774
1856
|
error: "access_denied",
|
|
1775
1857
|
error_description: "Client registration is disabled"
|
|
@@ -1781,8 +1863,15 @@ async function registerEndpoint(ctx, opts) {
|
|
|
1781
1863
|
"WWW-Authenticate": "Bearer",
|
|
1782
1864
|
...NO_STORE_HEADERS
|
|
1783
1865
|
});
|
|
1866
|
+
const grantTypes = body.grant_types?.length ? resolveMutuallySupportedGrantTypes(opts, body.grant_types) : body.grant_types;
|
|
1867
|
+
const droppedAuthorizationCode = body.grant_types?.includes("authorization_code") && !grantTypes?.includes("authorization_code");
|
|
1868
|
+
const registrationMetadata = {
|
|
1869
|
+
...body,
|
|
1870
|
+
grant_types: grantTypes,
|
|
1871
|
+
response_types: droppedAuthorizationCode ? void 0 : body.response_types
|
|
1872
|
+
};
|
|
1784
1873
|
if (!session && !isTokenAuthorized) {
|
|
1785
|
-
if (
|
|
1874
|
+
if (registrationMetadata.grant_types?.includes("client_credentials")) throw new APIError("BAD_REQUEST", {
|
|
1786
1875
|
error: "invalid_client_metadata",
|
|
1787
1876
|
error_description: "client_credentials grant requires authenticated registration"
|
|
1788
1877
|
});
|
|
@@ -1794,7 +1883,10 @@ async function registerEndpoint(ctx, opts) {
|
|
|
1794
1883
|
session: session.session
|
|
1795
1884
|
}) : void 0);
|
|
1796
1885
|
const response = await createOAuthClientRegistration(ctx, opts, {
|
|
1797
|
-
metadata:
|
|
1886
|
+
metadata: {
|
|
1887
|
+
...registrationMetadata,
|
|
1888
|
+
metadata
|
|
1889
|
+
},
|
|
1798
1890
|
registrationSource: "dynamic",
|
|
1799
1891
|
userId: referenceId ? void 0 : session?.session.userId,
|
|
1800
1892
|
referenceId,
|
|
@@ -1827,16 +1919,14 @@ async function checkOAuthClient(client, opts, settings) {
|
|
|
1827
1919
|
error_description: "Redirect URIs are required for authorization_code and implicit grant types"
|
|
1828
1920
|
});
|
|
1829
1921
|
for (const uri of clientWithDefaults.redirect_uris ?? []) validateClientRedirectUri(uri, applicationType ?? (isClientMetadataDocument ? "native" : "web"));
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1922
|
+
if (isClientMetadataDocument) resolveMutuallySupportedGrantTypes(opts, grantTypes);
|
|
1923
|
+
else {
|
|
1924
|
+
const supportedGrantTypes = new Set(getSupportedGrantTypes(opts));
|
|
1925
|
+
for (const grantType of grantTypes) if (!supportedGrantTypes.has(grantType)) throw new APIError("BAD_REQUEST", {
|
|
1833
1926
|
error: "invalid_client_metadata",
|
|
1834
|
-
error_description: `
|
|
1927
|
+
error_description: `unsupported grant_type ${grantType}`
|
|
1835
1928
|
});
|
|
1836
|
-
}
|
|
1837
|
-
error: "invalid_client_metadata",
|
|
1838
|
-
error_description: `unsupported grant_type ${grantType}`
|
|
1839
|
-
});
|
|
1929
|
+
}
|
|
1840
1930
|
if (grantTypes.includes("authorization_code") && !responseTypes?.includes("code")) throw new APIError("BAD_REQUEST", {
|
|
1841
1931
|
error: "invalid_client_metadata",
|
|
1842
1932
|
error_description: "When 'authorization_code' grant type is used, 'code' response type must be included"
|
|
@@ -1916,7 +2006,7 @@ async function checkOAuthClient(client, opts, settings) {
|
|
|
1916
2006
|
error_description: "jwks_uri must be a valid URL"
|
|
1917
2007
|
});
|
|
1918
2008
|
}
|
|
1919
|
-
if (clientWithDefaults.jwks)
|
|
2009
|
+
if (clientWithDefaults.jwks) assertValidClientJwks(clientWithDefaults.jwks);
|
|
1920
2010
|
}
|
|
1921
2011
|
if (tokenEndpointAuthMethod === "private_key_jwt" && !clientWithDefaults.jwks && !clientWithDefaults.jwks_uri) throw new APIError("BAD_REQUEST", {
|
|
1922
2012
|
error: "invalid_client_metadata",
|
|
@@ -1927,32 +2017,40 @@ async function checkOAuthClient(client, opts, settings) {
|
|
|
1927
2017
|
error: "invalid_client_metadata",
|
|
1928
2018
|
error_description: "backchannel_logout_uri requires the jwt plugin (disableJwtPlugin must be false)"
|
|
1929
2019
|
});
|
|
2020
|
+
assertPublicRoutableHttpsUri("backchannel_logout_uri", clientWithDefaults.backchannel_logout_uri);
|
|
2021
|
+
}
|
|
2022
|
+
if (clientWithDefaults.frontchannel_logout_uri !== void 0) {
|
|
1930
2023
|
let url;
|
|
1931
2024
|
try {
|
|
1932
|
-
url = new URL(clientWithDefaults.
|
|
2025
|
+
url = new URL(clientWithDefaults.frontchannel_logout_uri);
|
|
1933
2026
|
} catch {
|
|
1934
2027
|
throw new APIError("BAD_REQUEST", {
|
|
1935
2028
|
error: "invalid_client_metadata",
|
|
1936
|
-
error_description: "
|
|
2029
|
+
error_description: "frontchannel_logout_uri must be an absolute URL"
|
|
1937
2030
|
});
|
|
1938
2031
|
}
|
|
1939
|
-
if (clientWithDefaults.
|
|
1940
|
-
error: "invalid_client_metadata",
|
|
1941
|
-
error_description: "backchannel_logout_uri must not include a fragment component"
|
|
1942
|
-
});
|
|
1943
|
-
if (url.protocol !== "https:") throw new APIError("BAD_REQUEST", {
|
|
2032
|
+
if (clientWithDefaults.frontchannel_logout_uri.includes("#")) throw new APIError("BAD_REQUEST", {
|
|
1944
2033
|
error: "invalid_client_metadata",
|
|
1945
|
-
error_description: "
|
|
2034
|
+
error_description: "frontchannel_logout_uri must not include a fragment component"
|
|
1946
2035
|
});
|
|
1947
2036
|
if (url.username || url.password) throw new APIError("BAD_REQUEST", {
|
|
1948
2037
|
error: "invalid_client_metadata",
|
|
1949
|
-
error_description: "
|
|
2038
|
+
error_description: "frontchannel_logout_uri must not contain credentials"
|
|
2039
|
+
});
|
|
2040
|
+
const isHttpOrHttps = url.protocol === "https:" || url.protocol === "http:";
|
|
2041
|
+
const sharesRedirectOrigin = (clientWithDefaults.redirect_uris ?? []).some((redirectUri) => {
|
|
2042
|
+
try {
|
|
2043
|
+
return new URL(redirectUri).origin === url.origin;
|
|
2044
|
+
} catch {
|
|
2045
|
+
return false;
|
|
2046
|
+
}
|
|
1950
2047
|
});
|
|
1951
|
-
if (
|
|
2048
|
+
if (!isHttpOrHttps || !sharesRedirectOrigin) throw new APIError("BAD_REQUEST", {
|
|
1952
2049
|
error: "invalid_client_metadata",
|
|
1953
|
-
error_description: "
|
|
2050
|
+
error_description: "frontchannel_logout_uri must use the scheme, host, and port of a registered redirect_uri"
|
|
1954
2051
|
});
|
|
1955
2052
|
}
|
|
2053
|
+
if (!settings?.skipExtensionValidation) await validateExtensionClientMetadata(opts, clientWithDefaults, client.metadata);
|
|
1956
2054
|
}
|
|
1957
2055
|
const CLIENT_REGISTRATION_COLLISION = Symbol("client-registration-collision");
|
|
1958
2056
|
function isClientRegistrationCollision(error) {
|
|
@@ -1979,7 +2077,8 @@ async function persistOAuthClientRegistration(ctx, opts, input) {
|
|
|
1979
2077
|
const isExtensionAuthMethod = isExtensionTokenEndpointAuthMethod(opts, body.token_endpoint_auth_method);
|
|
1980
2078
|
await checkOAuthClient(body, opts, {
|
|
1981
2079
|
registrationSource: input.registrationSource,
|
|
1982
|
-
ctx
|
|
2080
|
+
ctx,
|
|
2081
|
+
skipExtensionValidation: input.registrationSource === "clientMetadataDocument" && Boolean(input.existingClient)
|
|
1983
2082
|
});
|
|
1984
2083
|
const clientId = input.clientId ?? opts.generateClientId?.() ?? generateRandomString(32, "a-z", "A-Z");
|
|
1985
2084
|
const clientSecret = isPublic || isPrivateKeyJwt || isExtensionAuthMethod ? void 0 : opts.generateClientSecret?.() || generateRandomString(32, "a-z", "A-Z");
|
|
@@ -2009,6 +2108,7 @@ async function persistOAuthClientRegistration(ctx, opts, input) {
|
|
|
2009
2108
|
});
|
|
2010
2109
|
schema.clientCredentialsScopes = input.registrationSource === "clientMetadataDocument" ? input.existingClient?.clientCredentialsScopes ?? [] : input.registrationSource === "managed" ? input.clientCredentialsScopes ?? [] : [];
|
|
2011
2110
|
schema.clientDiscoveryId = input.registrationSource === "clientMetadataDocument" ? input.clientDiscoveryId : null;
|
|
2111
|
+
schema.sectorIdentifier = deriveSectorIdentifier(clientId, body.redirect_uris);
|
|
2012
2112
|
if (input.registrationSource === "clientMetadataDocument" && body.application_type === void 0) schema.applicationType = null;
|
|
2013
2113
|
const requestedResources = input.registrationSource === "clientMetadataDocument" ? [] : input.requestedResources ?? [];
|
|
2014
2114
|
if (input.registrationSource === "dynamic") assertDynamicRegistrationResourcesAllowed(opts, requestedResources);
|
|
@@ -2022,6 +2122,19 @@ async function persistOAuthClientRegistration(ctx, opts, input) {
|
|
|
2022
2122
|
let storedClient;
|
|
2023
2123
|
let created = false;
|
|
2024
2124
|
if (existingClient) {
|
|
2125
|
+
let metadata;
|
|
2126
|
+
if (hasExtensionClientMetadata(opts)) {
|
|
2127
|
+
const extensionFields = getExtensionClientMetadataFields(opts);
|
|
2128
|
+
const merged = stripReservedOAuthClientMetadataExtensions(mergeDocumentExtensionMetadata(extensionFields, (await adapter.findOne({
|
|
2129
|
+
model: clientModel,
|
|
2130
|
+
where: [{
|
|
2131
|
+
field: "clientId",
|
|
2132
|
+
value: clientId
|
|
2133
|
+
}]
|
|
2134
|
+
}))?.metadata, body.metadata));
|
|
2135
|
+
await validateExtensionClientMetadata(opts, body, merged);
|
|
2136
|
+
if (extensionFields.size > 0) metadata = merged && Object.keys(merged).length > 0 ? JSON.stringify(merged) : null;
|
|
2137
|
+
}
|
|
2025
2138
|
const updatedClient = await adapter.update({
|
|
2026
2139
|
model: clientModel,
|
|
2027
2140
|
where: [{
|
|
@@ -2033,9 +2146,11 @@ async function persistOAuthClientRegistration(ctx, opts, input) {
|
|
|
2033
2146
|
}],
|
|
2034
2147
|
update: {
|
|
2035
2148
|
...schema,
|
|
2149
|
+
metadata,
|
|
2036
2150
|
disabled: existingClient.disabled,
|
|
2037
2151
|
skipConsent: existingClient.skipConsent,
|
|
2038
2152
|
enableEndSession: existingClient.enableEndSession,
|
|
2153
|
+
sectorIdentifier: existingClient.sectorIdentifier ? schema.sectorIdentifier : null,
|
|
2039
2154
|
createdAt: existingClient.createdAt,
|
|
2040
2155
|
updatedAt: /* @__PURE__ */ new Date(iat * 1e3)
|
|
2041
2156
|
}
|
|
@@ -2140,6 +2255,20 @@ function assertClientDiscoveryOwnership(client, clientDiscoveryId) {
|
|
|
2140
2255
|
});
|
|
2141
2256
|
}
|
|
2142
2257
|
/**
|
|
2258
|
+
* The `metadata` a metadata document registration stores. The document owns the
|
|
2259
|
+
* fields extensions declare, so a refresh replaces them, including removing
|
|
2260
|
+
* ones it no longer declares. Other stored keys, such as ones an admin set, are
|
|
2261
|
+
* kept.
|
|
2262
|
+
*/
|
|
2263
|
+
function mergeDocumentExtensionMetadata(extensionFields, storedMetadata, documentMetadata) {
|
|
2264
|
+
const owned = ([field]) => extensionFields.has(field);
|
|
2265
|
+
const metadata = {
|
|
2266
|
+
...Object.fromEntries(Object.entries(parseClientMetadata(storedMetadata) ?? {}).filter((entry) => !owned(entry))),
|
|
2267
|
+
...Object.fromEntries(Object.entries(documentMetadata ?? {}).filter(owned))
|
|
2268
|
+
};
|
|
2269
|
+
return Object.keys(metadata).length ? metadata : void 0;
|
|
2270
|
+
}
|
|
2271
|
+
/**
|
|
2143
2272
|
* First-party persistence seam for a validated Client ID Metadata Document.
|
|
2144
2273
|
*
|
|
2145
2274
|
* This operation owns both initial creation and atomic replacement. It never
|
|
@@ -2159,18 +2288,20 @@ async function registerClientMetadataDocument(ctx, opts, input) {
|
|
|
2159
2288
|
error: "invalid_client_metadata",
|
|
2160
2289
|
error_description: "Client ID Metadata Document clients cannot use a shared secret"
|
|
2161
2290
|
});
|
|
2162
|
-
const
|
|
2291
|
+
const toRegistrationInput = (existingClient) => ({
|
|
2163
2292
|
...input,
|
|
2293
|
+
existingClient,
|
|
2164
2294
|
metadata: {
|
|
2165
2295
|
...input.metadata,
|
|
2296
|
+
metadata: pickExtensionClientMetadata(input.metadata, opts),
|
|
2166
2297
|
token_endpoint_auth_method: input.metadata.token_endpoint_auth_method ?? "none",
|
|
2167
2298
|
client_secret: void 0,
|
|
2168
2299
|
client_secret_expires_at: void 0
|
|
2169
2300
|
},
|
|
2170
2301
|
registrationSource: "clientMetadataDocument"
|
|
2171
|
-
};
|
|
2302
|
+
});
|
|
2172
2303
|
try {
|
|
2173
|
-
return await persistOAuthClientRegistration(ctx, opts,
|
|
2304
|
+
return await persistOAuthClientRegistration(ctx, opts, toRegistrationInput(input.existingClient));
|
|
2174
2305
|
} catch (error) {
|
|
2175
2306
|
if (!isClientRegistrationCollision(error)) throw error;
|
|
2176
2307
|
const clientModel = opts.schema?.oauthClient?.modelName ?? "oauthClient";
|
|
@@ -2196,10 +2327,7 @@ async function registerClientMetadataDocument(ctx, opts, input) {
|
|
|
2196
2327
|
})) throw error.cause;
|
|
2197
2328
|
}
|
|
2198
2329
|
try {
|
|
2199
|
-
return await persistOAuthClientRegistration(ctx, opts,
|
|
2200
|
-
...registrationInput,
|
|
2201
|
-
existingClient: currentClient
|
|
2202
|
-
});
|
|
2330
|
+
return await persistOAuthClientRegistration(ctx, opts, toRegistrationInput(currentClient));
|
|
2203
2331
|
} catch (retryError) {
|
|
2204
2332
|
if (isClientRegistrationCollision(retryError)) throw retryError.cause;
|
|
2205
2333
|
throw retryError;
|
|
@@ -2248,7 +2376,7 @@ async function createOAuthClientEndpoint(ctx, opts, settings) {
|
|
|
2248
2376
|
return ctx.json(responseBody);
|
|
2249
2377
|
}
|
|
2250
2378
|
function oauthToSchema(input) {
|
|
2251
|
-
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, application_type: applicationType, disabled, skip_consent: skipConsent, enable_end_session: enableEndSession, require_pkce: requirePKCE, dpop_bound_access_tokens: dpopBoundAccessTokens, subject_type: subjectType, reference_id: referenceId, metadata: inputMetadata } = input;
|
|
2379
|
+
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, frontchannel_logout_uri: frontchannelLogoutUri, frontchannel_logout_session_required: frontchannelLogoutSessionRequired, token_endpoint_auth_method: tokenEndpointAuthMethod, grant_types: grantTypes, response_types: responseTypes, application_type: applicationType, disabled, skip_consent: skipConsent, enable_end_session: enableEndSession, require_pkce: requirePKCE, dpop_bound_access_tokens: dpopBoundAccessTokens, subject_type: subjectType, reference_id: referenceId, metadata: inputMetadata } = input;
|
|
2252
2380
|
const expiresAt = _expiresAt ? /* @__PURE__ */ new Date(Number(_expiresAt) * 1e3) : void 0;
|
|
2253
2381
|
const createdAt = _createdAt ? /* @__PURE__ */ new Date(_createdAt * 1e3) : void 0;
|
|
2254
2382
|
const scopes = _scope?.split(" ");
|
|
@@ -2275,6 +2403,8 @@ function oauthToSchema(input) {
|
|
|
2275
2403
|
postLogoutRedirectUris,
|
|
2276
2404
|
backchannelLogoutUri,
|
|
2277
2405
|
backchannelLogoutSessionRequired,
|
|
2406
|
+
frontchannelLogoutUri,
|
|
2407
|
+
frontchannelLogoutSessionRequired,
|
|
2278
2408
|
tokenEndpointAuthMethod,
|
|
2279
2409
|
grantTypes,
|
|
2280
2410
|
responseTypes,
|
|
@@ -2297,7 +2427,7 @@ function oauthToSchema(input) {
|
|
|
2297
2427
|
* @returns
|
|
2298
2428
|
*/
|
|
2299
2429
|
function schemaToOAuth(input) {
|
|
2300
|
-
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, applicationType, jwks, jwksUri, skipConsent, enableEndSession, requirePKCE, dpopBoundAccessTokens, subjectType, referenceId, metadata } = input;
|
|
2430
|
+
const { clientId, clientSecret, disabled, scopes, userId, createdAt, updatedAt: _updatedAt, expiresAt, name, uri, icon, contacts, tos, policy, softwareId, softwareVersion, softwareStatement, redirectUris, postLogoutRedirectUris, backchannelLogoutUri, backchannelLogoutSessionRequired, frontchannelLogoutUri, frontchannelLogoutSessionRequired, tokenEndpointAuthMethod, grantTypes, responseTypes, applicationType, jwks, jwksUri, skipConsent, enableEndSession, requirePKCE, dpopBoundAccessTokens, subjectType, referenceId, metadata } = input;
|
|
2301
2431
|
const _expiresAt = expiresAt ? Math.round(new Date(expiresAt).getTime() / 1e3) : void 0;
|
|
2302
2432
|
const _createdAt = createdAt ? Math.round(new Date(createdAt).getTime() / 1e3) : void 0;
|
|
2303
2433
|
const _scopes = scopes?.join(" ");
|
|
@@ -2324,6 +2454,8 @@ function schemaToOAuth(input) {
|
|
|
2324
2454
|
post_logout_redirect_uris: postLogoutRedirectUris ?? void 0,
|
|
2325
2455
|
backchannel_logout_uri: backchannelLogoutUri ?? void 0,
|
|
2326
2456
|
backchannel_logout_session_required: backchannelLogoutSessionRequired ?? void 0,
|
|
2457
|
+
frontchannel_logout_uri: frontchannelLogoutUri ?? void 0,
|
|
2458
|
+
frontchannel_logout_session_required: frontchannelLogoutSessionRequired ?? void 0,
|
|
2327
2459
|
token_endpoint_auth_method: tokenEndpointAuthMethod ?? void 0,
|
|
2328
2460
|
grant_types: grantTypes ?? void 0,
|
|
2329
2461
|
response_types: responseTypes ?? void 0,
|
|
@@ -2421,10 +2553,7 @@ async function deleteClientEndpoint(ctx, opts) {
|
|
|
2421
2553
|
await assertClientPrivileges(ctx, session, opts, "delete", ctx.body.client_id);
|
|
2422
2554
|
if (!session) throw new APIError("UNAUTHORIZED");
|
|
2423
2555
|
const clientId = ctx.body.client_id;
|
|
2424
|
-
|
|
2425
|
-
error_description: "trusted clients must be updated manually",
|
|
2426
|
-
error: "invalid_client"
|
|
2427
|
-
});
|
|
2556
|
+
assertNotTrustedClient(opts, clientId);
|
|
2428
2557
|
const client = await getClient(ctx, opts, clientId);
|
|
2429
2558
|
if (!client) throw new APIError("NOT_FOUND", {
|
|
2430
2559
|
error_description: "client not found",
|
|
@@ -2448,21 +2577,26 @@ async function updateClientEndpoint(ctx, opts, settings) {
|
|
|
2448
2577
|
await assertClientPrivileges(ctx, session, opts, "update", ctx.body.client_id);
|
|
2449
2578
|
if (!session) throw new APIError("UNAUTHORIZED");
|
|
2450
2579
|
const clientId = ctx.body.client_id;
|
|
2451
|
-
|
|
2452
|
-
error_description: "trusted clients must be updated manually",
|
|
2453
|
-
error: "invalid_client"
|
|
2454
|
-
});
|
|
2580
|
+
assertNotTrustedClient(opts, clientId);
|
|
2455
2581
|
const client = await getClient(ctx, opts, clientId);
|
|
2456
2582
|
if (!client) throw new APIError("NOT_FOUND", {
|
|
2457
2583
|
error_description: "client not found",
|
|
2458
2584
|
error: "not_found"
|
|
2459
2585
|
});
|
|
2460
|
-
const { client_credentials_scopes: rawClientCredentialsScopes, ...
|
|
2586
|
+
const { client_credentials_scopes: rawClientCredentialsScopes, backchannel_logout_uri: backchannelLogoutUri, frontchannel_logout_uri: frontchannelLogoutUri, ...otherUpdates } = ctx.body.update;
|
|
2587
|
+
const clearsBackchannelLogout = backchannelLogoutUri === null;
|
|
2588
|
+
const clearsFrontchannelLogout = frontchannelLogoutUri === null;
|
|
2589
|
+
const updates = {
|
|
2590
|
+
...otherUpdates,
|
|
2591
|
+
...backchannelLogoutUri != null ? { backchannel_logout_uri: backchannelLogoutUri } : {},
|
|
2592
|
+
...frontchannelLogoutUri != null ? { frontchannel_logout_uri: frontchannelLogoutUri } : {}
|
|
2593
|
+
};
|
|
2594
|
+
const hasUpdates = Object.keys(updates).length > 0 || clearsBackchannelLogout || clearsFrontchannelLogout;
|
|
2461
2595
|
const ownsClient = client.userId ? client.userId === session.user.id : client.referenceId && opts.clientReference ? client.referenceId === await opts.clientReference(session) : false;
|
|
2462
|
-
const isCrossOwnerScopeConfiguration = !ownsClient && settings?.admin && rawClientCredentialsScopes !== void 0 &&
|
|
2596
|
+
const isCrossOwnerScopeConfiguration = !ownsClient && settings?.admin && rawClientCredentialsScopes !== void 0 && !hasUpdates;
|
|
2463
2597
|
if (!ownsClient && !isCrossOwnerScopeConfiguration) throw new APIError("UNAUTHORIZED");
|
|
2464
2598
|
if (isCrossOwnerScopeConfiguration) await assertClientPrivileges(ctx, session, opts, "configure-client-credentials-scopes", clientId);
|
|
2465
|
-
if (
|
|
2599
|
+
if (!hasUpdates && rawClientCredentialsScopes === void 0) {
|
|
2466
2600
|
const res = schemaToOAuth(client);
|
|
2467
2601
|
res.client_secret = void 0;
|
|
2468
2602
|
if (settings?.admin) return {
|
|
@@ -2478,11 +2612,34 @@ async function updateClientEndpoint(ctx, opts, settings) {
|
|
|
2478
2612
|
validateClientCredentialsScopes(clientCredentialsScopes, finalGrantTypes, finalTokenEndpointAuthMethod, opts);
|
|
2479
2613
|
if (clientCredentialsScopes.length > 0 && !isCrossOwnerScopeConfiguration) await assertClientPrivileges(ctx, session, opts, "configure-client-credentials-scopes", clientId);
|
|
2480
2614
|
}
|
|
2481
|
-
|
|
2482
|
-
...schemaToOAuth(
|
|
2483
|
-
|
|
2484
|
-
|
|
2615
|
+
const updatedMetadata = {
|
|
2616
|
+
...schemaToOAuth({
|
|
2617
|
+
...client,
|
|
2618
|
+
metadata: void 0
|
|
2619
|
+
}),
|
|
2620
|
+
...updates,
|
|
2621
|
+
metadata: updates.metadata ?? parseClientMetadata(client.metadata)
|
|
2622
|
+
};
|
|
2623
|
+
if (clearsBackchannelLogout) {
|
|
2624
|
+
updatedMetadata.backchannel_logout_uri = void 0;
|
|
2625
|
+
updatedMetadata.backchannel_logout_session_required = void 0;
|
|
2626
|
+
}
|
|
2627
|
+
if (clearsFrontchannelLogout) {
|
|
2628
|
+
updatedMetadata.frontchannel_logout_uri = void 0;
|
|
2629
|
+
updatedMetadata.frontchannel_logout_session_required = void 0;
|
|
2630
|
+
}
|
|
2631
|
+
await checkOAuthClient(updatedMetadata, opts, { ctx });
|
|
2485
2632
|
const schemaUpdates = { ...oauthToSchema(updates) };
|
|
2633
|
+
if (updates.redirect_uris && client.sectorIdentifier) schemaUpdates.sectorIdentifier = deriveSectorIdentifier(client.clientId, updates.redirect_uris);
|
|
2634
|
+
if (clearsBackchannelLogout) {
|
|
2635
|
+
schemaUpdates.backchannelLogoutUri = null;
|
|
2636
|
+
schemaUpdates.backchannelLogoutSessionRequired = null;
|
|
2637
|
+
}
|
|
2638
|
+
if (clearsFrontchannelLogout) {
|
|
2639
|
+
schemaUpdates.frontchannelLogoutUri = null;
|
|
2640
|
+
schemaUpdates.frontchannelLogoutSessionRequired = null;
|
|
2641
|
+
}
|
|
2642
|
+
if (updates.metadata !== void 0 && schemaUpdates.metadata === void 0) schemaUpdates.metadata = null;
|
|
2486
2643
|
if (!finalGrantTypes.includes("client_credentials") || finalTokenEndpointAuthMethod === "none") schemaUpdates.clientCredentialsScopes = [];
|
|
2487
2644
|
else if (clientCredentialsScopes !== void 0) schemaUpdates.clientCredentialsScopes = clientCredentialsScopes;
|
|
2488
2645
|
if (updates.token_endpoint_auth_method) if (updates.token_endpoint_auth_method === "private_key_jwt") schemaUpdates.clientSecret = null;
|
|
@@ -2519,10 +2676,7 @@ async function rotateClientSecretEndpoint(ctx, opts) {
|
|
|
2519
2676
|
await assertClientPrivileges(ctx, session, opts, "rotate", ctx.body.client_id);
|
|
2520
2677
|
if (!session) throw new APIError("UNAUTHORIZED");
|
|
2521
2678
|
const clientId = ctx.body.client_id;
|
|
2522
|
-
|
|
2523
|
-
error_description: "trusted clients must be updated manually",
|
|
2524
|
-
error: "invalid_client"
|
|
2525
|
-
});
|
|
2679
|
+
assertNotTrustedClient(opts, clientId);
|
|
2526
2680
|
const client = await getClient(ctx, opts, clientId);
|
|
2527
2681
|
if (!client) throw new APIError("NOT_FOUND", {
|
|
2528
2682
|
error_description: "client not found",
|
|
@@ -2559,6 +2713,57 @@ async function rotateClientSecretEndpoint(ctx, opts) {
|
|
|
2559
2713
|
clientSecret: (opts.prefix?.clientSecret ?? "") + clientSecret
|
|
2560
2714
|
});
|
|
2561
2715
|
}
|
|
2716
|
+
/**
|
|
2717
|
+
* The write is guarded on the exact key set the assertion was verified against.
|
|
2718
|
+
* A concurrent rotation changes that value, so the stale request loses instead
|
|
2719
|
+
* of overwriting a newer key set.
|
|
2720
|
+
*
|
|
2721
|
+
* @see https://datatracker.ietf.org/doc/html/rfc7523#section-3
|
|
2722
|
+
*/
|
|
2723
|
+
async function rotateClientKeysEndpoint(ctx, opts) {
|
|
2724
|
+
if (!opts.allowClientKeyRotation) throw new APIError("FORBIDDEN", {
|
|
2725
|
+
error: "access_denied",
|
|
2726
|
+
error_description: "Client key rotation is disabled"
|
|
2727
|
+
});
|
|
2728
|
+
const { body } = ctx;
|
|
2729
|
+
assertValidClientJwks(body.jwks);
|
|
2730
|
+
const { client } = await verifyClientAssertion(ctx, opts, body.client_assertion, body.client_assertion_type, void 0, getEndpointUrl({
|
|
2731
|
+
context: ctx.context,
|
|
2732
|
+
request: void 0
|
|
2733
|
+
}, "/oauth2/client/rotate-keys"), {
|
|
2734
|
+
endpointAudienceOnly: true,
|
|
2735
|
+
assertClient: (candidate) => assertRotatable(opts, candidate)
|
|
2736
|
+
});
|
|
2737
|
+
if (!await ctx.context.adapter.incrementOne({
|
|
2738
|
+
model: "oauthClient",
|
|
2739
|
+
where: [{
|
|
2740
|
+
field: "clientId",
|
|
2741
|
+
value: client.clientId
|
|
2742
|
+
}, {
|
|
2743
|
+
field: "jwks",
|
|
2744
|
+
value: client.jwks
|
|
2745
|
+
}],
|
|
2746
|
+
increment: {},
|
|
2747
|
+
set: {
|
|
2748
|
+
jwks: JSON.stringify(body.jwks),
|
|
2749
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
2750
|
+
}
|
|
2751
|
+
})) throw new APIError("CONFLICT", {
|
|
2752
|
+
error: "invalid_request",
|
|
2753
|
+
error_description: "client keys changed while rotating; retry with a fresh client assertion"
|
|
2754
|
+
});
|
|
2755
|
+
return {
|
|
2756
|
+
client_id: client.clientId,
|
|
2757
|
+
jwks: body.jwks
|
|
2758
|
+
};
|
|
2759
|
+
}
|
|
2760
|
+
function assertRotatable(opts, client) {
|
|
2761
|
+
assertNotTrustedClient(opts, client.clientId);
|
|
2762
|
+
if (client.clientDiscoveryId || client.jwksUri || !client.jwks) throw new APIError("BAD_REQUEST", {
|
|
2763
|
+
error: "invalid_client",
|
|
2764
|
+
error_description: "client keys are not stored inline; rotate them where the client publishes them"
|
|
2765
|
+
});
|
|
2766
|
+
}
|
|
2562
2767
|
//#endregion
|
|
2563
2768
|
//#region src/oauthClient/index.ts
|
|
2564
2769
|
const tokenEndpointAuthMethodSchema = z.string().trim().min(1);
|
|
@@ -2580,6 +2785,8 @@ const adminCreateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/creat
|
|
|
2580
2785
|
post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
|
|
2581
2786
|
backchannel_logout_uri: SafeUrlSchema.optional(),
|
|
2582
2787
|
backchannel_logout_session_required: z.boolean().optional(),
|
|
2788
|
+
frontchannel_logout_uri: SafeUrlSchema.optional(),
|
|
2789
|
+
frontchannel_logout_session_required: z.boolean().optional(),
|
|
2583
2790
|
token_endpoint_auth_method: tokenEndpointAuthMethodSchema.optional(),
|
|
2584
2791
|
application_type: z.enum(["web", "native"]).optional(),
|
|
2585
2792
|
jwks: clientJwksSchema.optional(),
|
|
@@ -2753,6 +2960,8 @@ const createOAuthClient = (opts) => createAuthEndpoint("/oauth2/create-client",
|
|
|
2753
2960
|
post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
|
|
2754
2961
|
backchannel_logout_uri: SafeUrlSchema.optional(),
|
|
2755
2962
|
backchannel_logout_session_required: z.boolean().optional(),
|
|
2963
|
+
frontchannel_logout_uri: SafeUrlSchema.optional(),
|
|
2964
|
+
frontchannel_logout_session_required: z.boolean().optional(),
|
|
2756
2965
|
token_endpoint_auth_method: tokenEndpointAuthMethodSchema.optional(),
|
|
2757
2966
|
application_type: z.enum(["web", "native"]).optional(),
|
|
2758
2967
|
jwks: clientJwksSchema.optional(),
|
|
@@ -2942,8 +3151,10 @@ const adminUpdateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/updat
|
|
|
2942
3151
|
software_version: z.string().optional(),
|
|
2943
3152
|
software_statement: z.string().optional(),
|
|
2944
3153
|
post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
|
|
2945
|
-
backchannel_logout_uri: SafeUrlSchema.optional(),
|
|
3154
|
+
backchannel_logout_uri: SafeUrlSchema.nullable().optional(),
|
|
2946
3155
|
backchannel_logout_session_required: z.boolean().optional(),
|
|
3156
|
+
frontchannel_logout_uri: SafeUrlSchema.nullable().optional(),
|
|
3157
|
+
frontchannel_logout_session_required: z.boolean().optional(),
|
|
2947
3158
|
application_type: z.enum(["web", "native"]).optional(),
|
|
2948
3159
|
grant_types: grantTypesSchema.optional(),
|
|
2949
3160
|
client_credentials_scopes: z.array(z.string().trim().min(1)).optional(),
|
|
@@ -2981,8 +3192,10 @@ const updateOAuthClient = (opts) => createAuthEndpoint("/oauth2/update-client",
|
|
|
2981
3192
|
software_version: z.string().optional(),
|
|
2982
3193
|
software_statement: z.string().optional(),
|
|
2983
3194
|
post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
|
|
2984
|
-
backchannel_logout_uri: SafeUrlSchema.optional(),
|
|
3195
|
+
backchannel_logout_uri: SafeUrlSchema.nullable().optional(),
|
|
2985
3196
|
backchannel_logout_session_required: z.boolean().optional(),
|
|
3197
|
+
frontchannel_logout_uri: SafeUrlSchema.nullable().optional(),
|
|
3198
|
+
frontchannel_logout_session_required: z.boolean().optional(),
|
|
2986
3199
|
application_type: z.enum(["web", "native"]).optional(),
|
|
2987
3200
|
grant_types: grantTypesSchema.optional(),
|
|
2988
3201
|
response_types: z.array(z.enum(["code"])).optional()
|
|
@@ -3003,6 +3216,29 @@ const rotateClientSecret = (opts) => createAuthEndpoint("/oauth2/client/rotate-s
|
|
|
3003
3216
|
}, async (ctx) => {
|
|
3004
3217
|
return rotateClientSecretEndpoint(ctx, opts);
|
|
3005
3218
|
});
|
|
3219
|
+
/**
|
|
3220
|
+
* Replaces a `private_key_jwt` client's own signing keys. The client proves it
|
|
3221
|
+
* holds a current key with a client assertion, so no session is needed.
|
|
3222
|
+
*/
|
|
3223
|
+
const rotateClientKeys = (opts) => createOAuthEndpoint("/oauth2/client/rotate-keys", {
|
|
3224
|
+
method: "POST",
|
|
3225
|
+
body: z.object({
|
|
3226
|
+
client_assertion: z.string(),
|
|
3227
|
+
client_assertion_type: z.string(),
|
|
3228
|
+
jwks: clientJwksSchema
|
|
3229
|
+
}),
|
|
3230
|
+
errorCodesByField: {
|
|
3231
|
+
client_assertion: "invalid_client",
|
|
3232
|
+
client_assertion_type: "invalid_client",
|
|
3233
|
+
jwks: "invalid_client_metadata"
|
|
3234
|
+
},
|
|
3235
|
+
metadata: {
|
|
3236
|
+
noStore: true,
|
|
3237
|
+
openapi: { description: "Replaces the signing keys of a private_key_jwt client. The request is authenticated by a client assertion signed with one of the client's current keys and addressed to this endpoint. Keys left out of the new set stop verifying at once." }
|
|
3238
|
+
}
|
|
3239
|
+
}, async (ctx) => {
|
|
3240
|
+
return rotateClientKeysEndpoint(ctx, opts);
|
|
3241
|
+
});
|
|
3006
3242
|
const deleteOAuthClient = (opts) => createAuthEndpoint("/oauth2/delete-client", {
|
|
3007
3243
|
method: "POST",
|
|
3008
3244
|
use: [sessionMiddleware],
|
|
@@ -3449,112 +3685,47 @@ const adminUnlinkClientResource = (opts) => createAuthEndpoint("/admin/oauth2/re
|
|
|
3449
3685
|
//#endregion
|
|
3450
3686
|
//#region src/revoke.ts
|
|
3451
3687
|
/**
|
|
3452
|
-
*
|
|
3453
|
-
* Revocation follows RFC7009
|
|
3688
|
+
* Revocation follows RFC 7009
|
|
3454
3689
|
* https://datatracker.ietf.org/doc/html/rfc7009
|
|
3455
|
-
*
|
|
3456
|
-
*
|
|
3690
|
+
*
|
|
3691
|
+
* A token this server cannot find is already unusable, so it resolves as a
|
|
3692
|
+
* successful no-op (§2.2). Only explicit protocol errors surface.
|
|
3457
3693
|
*/
|
|
3458
3694
|
/**
|
|
3459
|
-
* Revokes a JWT access token against the configured JWKs.
|
|
3460
|
-
*
|
|
3461
3695
|
* A JWT access token is self-contained and never stored, so there is nothing to
|
|
3462
3696
|
* delete. Once the token is confirmed to be a valid JWT for this server, the
|
|
3463
3697
|
* endpoint reports `unsupported_token_type` (RFC 7009 §2.2.1) instead of a
|
|
3464
3698
|
* silent success, so callers can tell that no server-side revocation happened.
|
|
3465
|
-
*
|
|
3466
|
-
*
|
|
3467
|
-
*
|
|
3468
|
-
*
|
|
3699
|
+
* A JWT with an audience rejected by the OAuth resource model is already
|
|
3700
|
+
* inactive and resolves as a successful no-op. Session-bound tokens (carrying
|
|
3701
|
+
* `sid`) are cut off early by the session-liveness check in introspection and
|
|
3702
|
+
* userinfo.
|
|
3469
3703
|
*/
|
|
3470
|
-
async function revokeJwtAccessToken(ctx, opts,
|
|
3471
|
-
|
|
3472
|
-
const jwtPluginOptions = jwtPlugin?.options;
|
|
3473
|
-
try {
|
|
3474
|
-
const verified = await jwtVerify(token, createLocalJWKSet(await getJwks(token, {
|
|
3475
|
-
jwksFetch: jwtPluginOptions?.jwks?.remoteUrl ? jwtPluginOptions.jwks.remoteUrl : async () => {
|
|
3476
|
-
return (await jwtPlugin?.endpoints.getJwks(ctx))?.response;
|
|
3477
|
-
},
|
|
3478
|
-
jwksCacheKey: jwtPlugin
|
|
3479
|
-
})), { issuer: jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL });
|
|
3480
|
-
const userInfoAudience = `${ctx.context.baseURL}/oauth2/userinfo`;
|
|
3481
|
-
if (!verified.payload.azp || !await isAudienceClaimAllowed(ctx, opts, verified.payload.aud, [userInfoAudience])) return null;
|
|
3482
|
-
} catch (error) {
|
|
3483
|
-
if (error instanceof Error) {
|
|
3484
|
-
if (error.name === "TypeError" || error.name === "JWSInvalid") throw new APIError$1("BAD_REQUEST", {
|
|
3485
|
-
error_description: "invalid JWT signature",
|
|
3486
|
-
error: "invalid_request"
|
|
3487
|
-
});
|
|
3488
|
-
else if (error.name === "JWTExpired") return null;
|
|
3489
|
-
else if (error.name === "JWTInvalid") return null;
|
|
3490
|
-
throw error;
|
|
3491
|
-
}
|
|
3492
|
-
throw new Error(error);
|
|
3493
|
-
}
|
|
3704
|
+
async function revokeJwtAccessToken(ctx, opts, payload) {
|
|
3705
|
+
if (!await isAudienceClaimAllowed(ctx, opts, payload.aud, [userInfoResource(ctx.context.baseURL)])) return null;
|
|
3494
3706
|
throw new APIError$1("BAD_REQUEST", {
|
|
3495
3707
|
error_description: "JWT access tokens are self-contained and cannot be revoked server-side",
|
|
3496
3708
|
error: "unsupported_token_type"
|
|
3497
3709
|
});
|
|
3498
3710
|
}
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
*/
|
|
3502
|
-
async function revokeOpaqueAccessToken(ctx, opts, token, clientId) {
|
|
3503
|
-
let tokenValue = token;
|
|
3504
|
-
if (opts.prefix?.opaqueAccessToken) if (tokenValue.startsWith(opts.prefix.opaqueAccessToken)) tokenValue = tokenValue.replace(opts.prefix.opaqueAccessToken, "");
|
|
3505
|
-
else throw new APIError$1("BAD_REQUEST", {
|
|
3506
|
-
error_description: "opaque access token not found",
|
|
3507
|
-
error: "invalid_request"
|
|
3508
|
-
});
|
|
3509
|
-
const accessToken = await ctx.context.adapter.findOne({
|
|
3510
|
-
model: "oauthAccessToken",
|
|
3511
|
-
where: [{
|
|
3512
|
-
field: "token",
|
|
3513
|
-
value: await getStoredToken(opts.storeTokens, tokenValue, "access_token")
|
|
3514
|
-
}]
|
|
3515
|
-
});
|
|
3516
|
-
if (!accessToken) throw new APIError$1("BAD_REQUEST", {
|
|
3517
|
-
error_description: "opaque access token not found",
|
|
3518
|
-
error: "invalid_request"
|
|
3519
|
-
});
|
|
3520
|
-
if (!accessToken.clientId || accessToken.clientId !== clientId) return null;
|
|
3521
|
-
accessToken.id ? await ctx.context.adapter.delete({
|
|
3711
|
+
async function revokeOpaqueAccessToken(ctx, accessToken) {
|
|
3712
|
+
await ctx.context.adapter.delete({
|
|
3522
3713
|
model: "oauthAccessToken",
|
|
3523
|
-
where: [{
|
|
3714
|
+
where: accessToken.id ? [{
|
|
3524
3715
|
field: "id",
|
|
3525
3716
|
value: accessToken.id
|
|
3526
|
-
}]
|
|
3527
|
-
}) : await ctx.context.adapter.delete({
|
|
3528
|
-
model: "oauthAccessToken",
|
|
3529
|
-
where: [{
|
|
3717
|
+
}] : [{
|
|
3530
3718
|
field: "token",
|
|
3531
3719
|
value: accessToken.token
|
|
3532
3720
|
}]
|
|
3533
3721
|
});
|
|
3534
3722
|
}
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
*/
|
|
3538
|
-
async function revokeRefreshToken(ctx, opts, token, clientId) {
|
|
3539
|
-
const refreshToken = await ctx.context.adapter.findOne({
|
|
3540
|
-
model: "oauthRefreshToken",
|
|
3541
|
-
where: [{
|
|
3542
|
-
field: "token",
|
|
3543
|
-
value: await getStoredToken(opts.storeTokens, token, "refresh_token")
|
|
3544
|
-
}]
|
|
3545
|
-
});
|
|
3546
|
-
if (!refreshToken) throw new APIError$1("BAD_REQUEST", {
|
|
3547
|
-
error_description: "token not found",
|
|
3548
|
-
error: "invalid_request"
|
|
3549
|
-
});
|
|
3723
|
+
async function revokeRefreshToken(ctx, refreshToken, clientId) {
|
|
3724
|
+
const { userId } = refreshToken;
|
|
3550
3725
|
if (refreshToken.revoked) {
|
|
3551
|
-
await invalidateRefreshFamily(ctx, clientId,
|
|
3552
|
-
|
|
3553
|
-
error_description: "refresh token revoked",
|
|
3554
|
-
error: "invalid_request"
|
|
3555
|
-
});
|
|
3726
|
+
await invalidateRefreshFamily(ctx, clientId, userId);
|
|
3727
|
+
return;
|
|
3556
3728
|
}
|
|
3557
|
-
if (!refreshToken.clientId || refreshToken.clientId !== clientId) return null;
|
|
3558
3729
|
const iat = Math.floor(Date.now() / 1e3);
|
|
3559
3730
|
if (!await ctx.context.adapter.incrementOne({
|
|
3560
3731
|
model: "oauthRefreshToken",
|
|
@@ -3569,11 +3740,8 @@ async function revokeRefreshToken(ctx, opts, token, clientId) {
|
|
|
3569
3740
|
increment: {},
|
|
3570
3741
|
set: { revoked: /* @__PURE__ */ new Date(iat * 1e3) }
|
|
3571
3742
|
})) {
|
|
3572
|
-
await invalidateRefreshFamily(ctx, clientId,
|
|
3573
|
-
|
|
3574
|
-
error_description: "refresh token revoked",
|
|
3575
|
-
error: "invalid_request"
|
|
3576
|
-
});
|
|
3743
|
+
await invalidateRefreshFamily(ctx, clientId, userId);
|
|
3744
|
+
return;
|
|
3577
3745
|
}
|
|
3578
3746
|
await ctx.context.adapter.deleteMany({
|
|
3579
3747
|
model: "oauthAccessToken",
|
|
@@ -3583,33 +3751,8 @@ async function revokeRefreshToken(ctx, opts, token, clientId) {
|
|
|
3583
3751
|
}]
|
|
3584
3752
|
});
|
|
3585
3753
|
}
|
|
3586
|
-
/**
|
|
3587
|
-
* We don't know the access token format so we try to validate it
|
|
3588
|
-
* as a JWT first, then as an opaque token.
|
|
3589
|
-
*/
|
|
3590
|
-
async function revokeAccessToken(ctx, opts, clientId, token) {
|
|
3591
|
-
try {
|
|
3592
|
-
return await revokeJwtAccessToken(ctx, opts, token);
|
|
3593
|
-
} catch (err) {
|
|
3594
|
-
if (err instanceof APIError$1) {
|
|
3595
|
-
if (err.body?.error === "unsupported_token_type") throw err;
|
|
3596
|
-
} else if (err instanceof Error) throw err;
|
|
3597
|
-
else throw new Error(err);
|
|
3598
|
-
}
|
|
3599
|
-
try {
|
|
3600
|
-
return await revokeOpaqueAccessToken(ctx, opts, token, clientId);
|
|
3601
|
-
} catch (err) {
|
|
3602
|
-
if (err instanceof APIError$1) {} else if (err instanceof Error) throw err;
|
|
3603
|
-
else throw new Error("Unknown error validating access token");
|
|
3604
|
-
}
|
|
3605
|
-
throw new APIError$1("BAD_REQUEST", {
|
|
3606
|
-
error_description: "Invalid access token",
|
|
3607
|
-
error: "invalid_request"
|
|
3608
|
-
});
|
|
3609
|
-
}
|
|
3610
3754
|
async function revokeEndpoint(ctx, opts) {
|
|
3611
3755
|
let { token, token_type_hint } = ctx.body;
|
|
3612
|
-
if (token_type_hint !== "access_token" && token_type_hint !== "refresh_token") token_type_hint = void 0;
|
|
3613
3756
|
const { clientId: client_id, clientSecret: client_secret, preVerified, authMethod } = destructureCredentials(await extractClientCredentials(ctx, opts, `${ctx.context.baseURL}/oauth2/revoke`));
|
|
3614
3757
|
if (!client_id) throw new APIError$1("UNAUTHORIZED", {
|
|
3615
3758
|
error_description: "missing required credentials",
|
|
@@ -3622,38 +3765,23 @@ async function revokeEndpoint(ctx, opts) {
|
|
|
3622
3765
|
});
|
|
3623
3766
|
const client = await validateClientCredentials(ctx, opts, client_id, client_secret, void 0, preVerified, void 0, authMethod);
|
|
3624
3767
|
try {
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
if (
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
if (token_type_hint === void 0 || token_type_hint === "refresh_token") try {
|
|
3634
|
-
return await revokeRefreshToken(ctx, opts, (await decodeRefreshToken(opts, token)).token, client.clientId);
|
|
3635
|
-
} catch (error) {
|
|
3636
|
-
if (error instanceof APIError$1) {
|
|
3637
|
-
if (token_type_hint === "refresh_token") throw error;
|
|
3638
|
-
} else if (error instanceof Error) throw error;
|
|
3639
|
-
else throw new Error(error);
|
|
3768
|
+
const found = await findToken(ctx, opts, token, token_type_hint);
|
|
3769
|
+
if (!found) return null;
|
|
3770
|
+
if ((found.type === "jwt" ? found.payload.azp : found.record.clientId) !== client.clientId) {
|
|
3771
|
+
if (client.tokenEndpointAuthMethod === "none") return null;
|
|
3772
|
+
throw new APIError$1("BAD_REQUEST", {
|
|
3773
|
+
error_description: "client is not authorized to revoke the presented token",
|
|
3774
|
+
error: "invalid_request"
|
|
3775
|
+
});
|
|
3640
3776
|
}
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3777
|
+
if (found.type === "jwt") return await revokeJwtAccessToken(ctx, opts, found.payload);
|
|
3778
|
+
if (found.type === "opaque") await revokeOpaqueAccessToken(ctx, found.record);
|
|
3779
|
+
else await revokeRefreshToken(ctx, found.record, client.clientId);
|
|
3780
|
+
return null;
|
|
3645
3781
|
} catch (error) {
|
|
3646
|
-
if (error instanceof APIError$1)
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
throw error;
|
|
3650
|
-
} else if (error instanceof Error) {
|
|
3651
|
-
logger.error("Introspection error:", error.message, error.stack);
|
|
3652
|
-
throw new APIError$1("INTERNAL_SERVER_ERROR");
|
|
3653
|
-
} else {
|
|
3654
|
-
logger.error("Introspection error:", error);
|
|
3655
|
-
throw new APIError$1("INTERNAL_SERVER_ERROR");
|
|
3656
|
-
}
|
|
3782
|
+
if (error instanceof APIError$1) throw error;
|
|
3783
|
+
logger.error("Revocation error:", error);
|
|
3784
|
+
throw new APIError$1("INTERNAL_SERVER_ERROR");
|
|
3657
3785
|
}
|
|
3658
3786
|
}
|
|
3659
3787
|
//#endregion
|
|
@@ -3692,6 +3820,10 @@ const schema = {
|
|
|
3692
3820
|
type: "string",
|
|
3693
3821
|
required: false
|
|
3694
3822
|
},
|
|
3823
|
+
sectorIdentifier: {
|
|
3824
|
+
type: "string",
|
|
3825
|
+
required: false
|
|
3826
|
+
},
|
|
3695
3827
|
scopes: {
|
|
3696
3828
|
type: "string[]",
|
|
3697
3829
|
required: false
|
|
@@ -3770,6 +3902,14 @@ const schema = {
|
|
|
3770
3902
|
type: "boolean",
|
|
3771
3903
|
required: false
|
|
3772
3904
|
},
|
|
3905
|
+
frontchannelLogoutUri: {
|
|
3906
|
+
type: "string",
|
|
3907
|
+
required: false
|
|
3908
|
+
},
|
|
3909
|
+
frontchannelLogoutSessionRequired: {
|
|
3910
|
+
type: "boolean",
|
|
3911
|
+
required: false
|
|
3912
|
+
},
|
|
3773
3913
|
tokenEndpointAuthMethod: {
|
|
3774
3914
|
type: "string",
|
|
3775
3915
|
required: false
|
|
@@ -4018,6 +4158,14 @@ const schema = {
|
|
|
4018
4158
|
type: "date",
|
|
4019
4159
|
required: false
|
|
4020
4160
|
},
|
|
4161
|
+
acr: {
|
|
4162
|
+
type: "string",
|
|
4163
|
+
required: false
|
|
4164
|
+
},
|
|
4165
|
+
amr: {
|
|
4166
|
+
type: "string[]",
|
|
4167
|
+
required: false
|
|
4168
|
+
},
|
|
4021
4169
|
confirmation: {
|
|
4022
4170
|
type: "json",
|
|
4023
4171
|
required: false
|
|
@@ -4198,7 +4346,7 @@ const rpInitiatedLogoutRequestSchema = z.object({
|
|
|
4198
4346
|
});
|
|
4199
4347
|
const oAuthState = defineRequestState(() => null);
|
|
4200
4348
|
const getOAuthProviderState = oAuthState.get;
|
|
4201
|
-
const
|
|
4349
|
+
const sessionLogoutPlansByContext = /* @__PURE__ */ new WeakMap();
|
|
4202
4350
|
const signedQueryIssuedAtMsKey = "signedQueryIssuedAtMs";
|
|
4203
4351
|
function getServerContextSignedQueryIssuedAt(value) {
|
|
4204
4352
|
const issuedAtMs = typeof value === "number" ? value : typeof value === "string" ? Number(value) : void 0;
|
|
@@ -4222,6 +4370,8 @@ const oauthProvider = (options) => {
|
|
|
4222
4370
|
if (clientRegistrationAllowedScopes) {
|
|
4223
4371
|
for (const sc of clientRegistrationAllowedScopes) if (!scopes.has(sc)) throw new BetterAuthError(`clientRegistrationAllowedScope ${sc} not found in scopes`);
|
|
4224
4372
|
}
|
|
4373
|
+
const acrValuesSupported = options.authenticationContext?.acrValuesSupported;
|
|
4374
|
+
if (acrValuesSupported && (acrValuesSupported.length === 0 || acrValuesSupported.includes(""))) throw new BetterAuthError("authenticationContext.acrValuesSupported must list at least one non-empty acr value");
|
|
4225
4375
|
for (const sc of options.advertisedMetadata?.scopes_supported ?? []) if (!scopes?.has(sc)) throw new BetterAuthError(`advertisedMetadata.scopes_supported ${sc} not found in scopes`);
|
|
4226
4376
|
const configuredResourceIdentifiers = new Set((options.resources ?? []).map((resource) => typeof resource === "string" ? resource : resource.identifier));
|
|
4227
4377
|
for (const [optionName, identifiers] of [["clientRegistrationDefaultResources", options.clientRegistrationDefaultResources], ["clientRegistrationAllowedResources", options.clientRegistrationAllowedResources]]) for (const identifier of identifiers ?? []) if (!configuredResourceIdentifiers.has(identifier)) throw new BetterAuthError(`${optionName} resource ${identifier} not found in resources`);
|
|
@@ -4313,12 +4463,19 @@ const oauthProvider = (options) => {
|
|
|
4313
4463
|
schema: { type: "string" },
|
|
4314
4464
|
description: "OAuth 2.1 response type (e.g., 'code')"
|
|
4315
4465
|
},
|
|
4466
|
+
{
|
|
4467
|
+
name: "user_code",
|
|
4468
|
+
in: "query",
|
|
4469
|
+
required: false,
|
|
4470
|
+
schema: { type: "string" },
|
|
4471
|
+
description: "Device user code to approve instead of an authorization request (requires oauthDeviceAuthorization())"
|
|
4472
|
+
},
|
|
4316
4473
|
{
|
|
4317
4474
|
name: "client_id",
|
|
4318
4475
|
in: "query",
|
|
4319
|
-
required:
|
|
4476
|
+
required: false,
|
|
4320
4477
|
schema: { type: "string" },
|
|
4321
|
-
description: "OAuth 2.1 client ID"
|
|
4478
|
+
description: "OAuth 2.1 client ID, required for authorization requests (not for user_code)"
|
|
4322
4479
|
},
|
|
4323
4480
|
{
|
|
4324
4481
|
name: "redirect_uri",
|
|
@@ -4443,6 +4600,10 @@ const oauthProvider = (options) => {
|
|
|
4443
4600
|
onRequest: handleIssuerMetadataRequest,
|
|
4444
4601
|
init: async (ctx) => {
|
|
4445
4602
|
if (ctx.options.secondaryStorage && ctx.options.session?.storeSessionInDatabase !== true) throw new BetterAuthError("OAuth Provider requires `session.storeSessionInDatabase: true` when using secondaryStorage");
|
|
4603
|
+
if (opts.allowClientKeyRotation) {
|
|
4604
|
+
const gap = ctx.adapter.id === "memory" ? "the memory adapter" : ctx.options.advanced?.database?.generateId === "serial" ? "`advanced.database.generateId: \"serial\"`" : void 0;
|
|
4605
|
+
if (gap) throw new BetterAuthError(`\`allowClientKeyRotation\` requires single-use client assertions, which ${gap} does not enforce`);
|
|
4606
|
+
}
|
|
4446
4607
|
await seedResourcesOnce(ctx, opts).catch((error) => logStartupSeedFailure(ctx, error));
|
|
4447
4608
|
logEnforcePerClientResourcesResolution(opts);
|
|
4448
4609
|
if (!opts.disableJwtPlugin) {
|
|
@@ -4458,25 +4619,26 @@ const oauthProvider = (options) => {
|
|
|
4458
4619
|
return { options: { databaseHooks: { session: { delete: {
|
|
4459
4620
|
async before(session, hookCtx) {
|
|
4460
4621
|
if (!hookCtx) return;
|
|
4461
|
-
const plan = await
|
|
4622
|
+
const plan = await prepareSessionLogoutPlan(hookCtx, opts, {
|
|
4462
4623
|
sessionId: session.id,
|
|
4463
4624
|
userId: session.userId
|
|
4464
4625
|
});
|
|
4465
4626
|
if (!plan) return;
|
|
4466
|
-
const logoutPlanBySessionId =
|
|
4627
|
+
const logoutPlanBySessionId = sessionLogoutPlansByContext.get(hookCtx) ?? /* @__PURE__ */ new Map();
|
|
4467
4628
|
logoutPlanBySessionId.set(session.id, plan);
|
|
4468
|
-
|
|
4629
|
+
sessionLogoutPlansByContext.set(hookCtx, logoutPlanBySessionId);
|
|
4469
4630
|
},
|
|
4470
4631
|
async after(session, hookCtx) {
|
|
4471
4632
|
if (!hookCtx) return;
|
|
4472
|
-
const logoutPlanBySessionId =
|
|
4633
|
+
const logoutPlanBySessionId = sessionLogoutPlansByContext.get(hookCtx);
|
|
4473
4634
|
if (!logoutPlanBySessionId) return;
|
|
4474
4635
|
const plan = logoutPlanBySessionId.get(session.id);
|
|
4475
4636
|
logoutPlanBySessionId.delete(session.id);
|
|
4476
|
-
if (logoutPlanBySessionId.size === 0)
|
|
4637
|
+
if (logoutPlanBySessionId.size === 0) sessionLogoutPlansByContext.delete(hookCtx);
|
|
4477
4638
|
if (!plan) return;
|
|
4478
|
-
|
|
4479
|
-
|
|
4639
|
+
await recordFrontchannelLogoutUris(plan.frontchannelLogoutUris);
|
|
4640
|
+
const logoutTask = applySessionLogoutPlan(hookCtx, plan).catch((error) => {
|
|
4641
|
+
hookCtx.context.logger.error("Logout notification failed after session deletion", error);
|
|
4480
4642
|
});
|
|
4481
4643
|
await hookCtx.context.runInBackgroundOrAwait(logoutTask);
|
|
4482
4644
|
}
|
|
@@ -4620,7 +4782,7 @@ const oauthProvider = (options) => {
|
|
|
4620
4782
|
}),
|
|
4621
4783
|
oauth2Token: createOAuthEndpoint("/oauth2/token", {
|
|
4622
4784
|
method: "POST",
|
|
4623
|
-
|
|
4785
|
+
formParams: { repeatable: ["resource", "audience"] },
|
|
4624
4786
|
body: z.object({
|
|
4625
4787
|
grant_type: z.string().trim().min(1),
|
|
4626
4788
|
client_id: z.string().optional(),
|
|
@@ -4761,15 +4923,11 @@ const oauthProvider = (options) => {
|
|
|
4761
4923
|
}
|
|
4762
4924
|
}
|
|
4763
4925
|
}, async (ctx) => {
|
|
4764
|
-
if (ctx.request) {
|
|
4765
|
-
const repeated = await extractRepeatedResourceFromForm(ctx.request);
|
|
4766
|
-
if (repeated && repeated.length > 1) ctx.body.resource = repeated;
|
|
4767
|
-
}
|
|
4768
4926
|
return tokenEndpoint(ctx, opts);
|
|
4769
4927
|
}),
|
|
4770
4928
|
oauth2Introspect: createOAuthEndpoint("/oauth2/introspect", {
|
|
4771
4929
|
method: "POST",
|
|
4772
|
-
|
|
4930
|
+
formParams: {},
|
|
4773
4931
|
body: z.object({
|
|
4774
4932
|
client_id: z.string().optional(),
|
|
4775
4933
|
client_secret: z.string().optional(),
|
|
@@ -4886,7 +5044,7 @@ const oauthProvider = (options) => {
|
|
|
4886
5044
|
}),
|
|
4887
5045
|
oauth2Revoke: createOAuthEndpoint("/oauth2/revoke", {
|
|
4888
5046
|
method: "POST",
|
|
4889
|
-
|
|
5047
|
+
formParams: {},
|
|
4890
5048
|
body: z.object({
|
|
4891
5049
|
client_id: z.string().optional(),
|
|
4892
5050
|
client_secret: z.string().optional(),
|
|
@@ -5064,7 +5222,7 @@ const oauthProvider = (options) => {
|
|
|
5064
5222
|
openapi: {
|
|
5065
5223
|
description: "RP-Initiated Logout endpoint. Accepts standard logout parameters through GET or an application/x-www-form-urlencoded POST body, plus JSON for Better Auth generated-client calls. Requests without a usable id_token_hint may require explicit OP confirmation.",
|
|
5066
5224
|
responses: { "200": {
|
|
5067
|
-
description: "Logout completed, or an HTML
|
|
5225
|
+
description: "Logout completed, or an HTML page was returned for browser navigation — a confirmation page, or the OIDC front-channel logout page when clients with a registered frontchannel_logout_uri hold tokens on the session. A redirect is used only for an exact registered post_logout_redirect_uri.",
|
|
5068
5226
|
content: {
|
|
5069
5227
|
"application/json": { schema: {
|
|
5070
5228
|
type: "object",
|
|
@@ -5080,7 +5238,10 @@ const oauthProvider = (options) => {
|
|
|
5080
5238
|
}
|
|
5081
5239
|
}
|
|
5082
5240
|
} },
|
|
5083
|
-
"text/html": { schema: {
|
|
5241
|
+
"text/html": { schema: {
|
|
5242
|
+
type: "string",
|
|
5243
|
+
description: "Logout confirmation page, or the OIDC Front-Channel Logout page with one hidden iframe per registered RP"
|
|
5244
|
+
} }
|
|
5084
5245
|
}
|
|
5085
5246
|
} }
|
|
5086
5247
|
}
|
|
@@ -5101,7 +5262,7 @@ const oauthProvider = (options) => {
|
|
|
5101
5262
|
}),
|
|
5102
5263
|
registerOAuthClient: createOAuthEndpoint("/oauth2/register", {
|
|
5103
5264
|
method: "POST",
|
|
5104
|
-
body: clientRegistrationRequestSchema,
|
|
5265
|
+
body: clientRegistrationRequestSchema.loose(),
|
|
5105
5266
|
errorCodesByField: {
|
|
5106
5267
|
redirect_uris: "invalid_redirect_uri",
|
|
5107
5268
|
post_logout_redirect_uris: "invalid_redirect_uri",
|
|
@@ -5205,6 +5366,15 @@ const oauthProvider = (options) => {
|
|
|
5205
5366
|
type: "boolean",
|
|
5206
5367
|
description: "Whether the RP requires a `sid` claim in every Logout Token"
|
|
5207
5368
|
},
|
|
5369
|
+
frontchannel_logout_uri: {
|
|
5370
|
+
type: "string",
|
|
5371
|
+
format: "uri",
|
|
5372
|
+
description: "RP URL rendered in a hidden iframe on the OP's logout page when the end-user's OP session terminates"
|
|
5373
|
+
},
|
|
5374
|
+
frontchannel_logout_session_required: {
|
|
5375
|
+
type: "boolean",
|
|
5376
|
+
description: "Whether the RP requires `iss` and `sid` query parameters on its front-channel logout URI. The OP always sends both."
|
|
5377
|
+
},
|
|
5208
5378
|
token_endpoint_auth_method: {
|
|
5209
5379
|
type: "string",
|
|
5210
5380
|
description: "Requested authentication method for the token endpoint"
|
|
@@ -5257,6 +5427,7 @@ const oauthProvider = (options) => {
|
|
|
5257
5427
|
adminUpdateOAuthClient: adminUpdateOAuthClient(opts),
|
|
5258
5428
|
updateOAuthClient: updateOAuthClient(opts),
|
|
5259
5429
|
rotateClientSecret: rotateClientSecret(opts),
|
|
5430
|
+
rotateClientKeys: rotateClientKeys(opts),
|
|
5260
5431
|
deleteOAuthClient: deleteOAuthClient(opts),
|
|
5261
5432
|
getOAuthConsent: getOAuthConsent(opts),
|
|
5262
5433
|
getOAuthConsents: getOAuthConsents(opts),
|
|
@@ -5297,6 +5468,11 @@ const oauthProvider = (options) => {
|
|
|
5297
5468
|
window: opts.rateLimit?.register?.window ?? 60,
|
|
5298
5469
|
max: opts.rateLimit?.register?.max ?? 5
|
|
5299
5470
|
}] : [],
|
|
5471
|
+
...opts.rateLimit?.rotateClientKeys !== false ? [{
|
|
5472
|
+
pathMatcher: (path) => path === "/oauth2/client/rotate-keys",
|
|
5473
|
+
window: opts.rateLimit?.rotateClientKeys?.window ?? 60,
|
|
5474
|
+
max: opts.rateLimit?.rotateClientKeys?.max ?? 5
|
|
5475
|
+
}] : [],
|
|
5300
5476
|
...opts.rateLimit?.userinfo !== false ? [{
|
|
5301
5477
|
pathMatcher: (path) => path === "/oauth2/userinfo",
|
|
5302
5478
|
window: opts.rateLimit?.userinfo?.window ?? 60,
|
|
@@ -5375,8 +5551,20 @@ const handleRedirect = (ctx, uri) => {
|
|
|
5375
5551
|
};
|
|
5376
5552
|
else throw ctx.redirect(uri);
|
|
5377
5553
|
};
|
|
5378
|
-
|
|
5379
|
-
|
|
5554
|
+
/** Sends an OAuth error to the client's redirect URI, or to the error page without one. */
|
|
5555
|
+
function redirectWithAuthorizationError(ctx, opts, query, error, description) {
|
|
5556
|
+
return handleRedirect(ctx, query.redirect_uri ? formatErrorURL(query.redirect_uri, error, description, query.state, getIssuer(ctx, opts)) : getErrorURL(ctx, error, description));
|
|
5557
|
+
}
|
|
5558
|
+
/**
|
|
5559
|
+
* Returns the URL that reports an `APIError`: `errorPage` when given, otherwise
|
|
5560
|
+
* the provider error page. Rethrows anything else.
|
|
5561
|
+
*/
|
|
5562
|
+
function getAPIErrorURL(ctx, error, errorPage) {
|
|
5563
|
+
if (!(error instanceof APIError$1)) throw error;
|
|
5564
|
+
const body = error.body;
|
|
5565
|
+
const code = body?.error ?? "invalid_request";
|
|
5566
|
+
const description = body?.error_description ?? "invalid request";
|
|
5567
|
+
return errorPage ? formatErrorURL(errorPage, code, description) : getErrorURL(ctx, code, description);
|
|
5380
5568
|
}
|
|
5381
5569
|
/**
|
|
5382
5570
|
* Validates that the issuer URL
|
|
@@ -5538,13 +5726,45 @@ function authorizeRedirectOnError(opts) {
|
|
|
5538
5726
|
};
|
|
5539
5727
|
}
|
|
5540
5728
|
async function authorizeEndpoint(ctx, opts, settings) {
|
|
5541
|
-
if (opts.grantTypes && !opts.grantTypes.includes("authorization_code")) throw new APIError$1("NOT_FOUND");
|
|
5542
5729
|
if (!ctx.request) throw new APIError$1("UNAUTHORIZED", {
|
|
5543
5730
|
error_description: "request not found",
|
|
5544
5731
|
error: "invalid_request"
|
|
5545
5732
|
});
|
|
5546
5733
|
const request = ctx.request;
|
|
5547
5734
|
let query = getAuthorizationRequestParameters(ctx, settings);
|
|
5735
|
+
const interaction = getExtensionInteractionRequest(opts, query);
|
|
5736
|
+
if (interaction) {
|
|
5737
|
+
if (settings?.isAuthorize === true && !await getSessionFromCtx(ctx)) {
|
|
5738
|
+
ctx.query = { [interaction.parameter]: interaction.value };
|
|
5739
|
+
return redirectWithPromptCode(ctx, opts, "login");
|
|
5740
|
+
}
|
|
5741
|
+
const errorPage = interaction.handler.errorPage?.({
|
|
5742
|
+
ctx,
|
|
5743
|
+
value: interaction.value
|
|
5744
|
+
});
|
|
5745
|
+
const toErrorURL = (error) => getAPIErrorURL(ctx, error, errorPage);
|
|
5746
|
+
let interactionRequest;
|
|
5747
|
+
try {
|
|
5748
|
+
interactionRequest = await interaction.handler.load({
|
|
5749
|
+
ctx,
|
|
5750
|
+
opts,
|
|
5751
|
+
value: interaction.value,
|
|
5752
|
+
signedQuery: settings?.isAuthorize === true ? void 0 : query
|
|
5753
|
+
});
|
|
5754
|
+
if (interactionRequest.client.disabled) throw new APIError$1("BAD_REQUEST", {
|
|
5755
|
+
error: "client_disabled",
|
|
5756
|
+
error_description: "client is disabled"
|
|
5757
|
+
});
|
|
5758
|
+
} catch (error) {
|
|
5759
|
+
return handleRedirect(ctx, toErrorURL(error));
|
|
5760
|
+
}
|
|
5761
|
+
return runAuthorizationInteraction(ctx, opts, request.headers, settings, {
|
|
5762
|
+
...interactionRequest,
|
|
5763
|
+
approve: (approval) => interactionRequest.approve(approval).catch(toErrorURL),
|
|
5764
|
+
errorURL: toErrorURL
|
|
5765
|
+
});
|
|
5766
|
+
}
|
|
5767
|
+
if (opts.grantTypes && !opts.grantTypes.includes("authorization_code")) throw new APIError$1("NOT_FOUND");
|
|
5548
5768
|
ctx.query = query;
|
|
5549
5769
|
const requestObject = getStringParameter(query.request);
|
|
5550
5770
|
const requestUri = getStringParameter(query.request_uri);
|
|
@@ -5601,12 +5821,10 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
5601
5821
|
error_description: "response_type is required",
|
|
5602
5822
|
ctx
|
|
5603
5823
|
});
|
|
5604
|
-
|
|
5605
|
-
const promptNone = promptSet?.has("none") ?? false;
|
|
5606
|
-
if (promptSet?.has("select_account") && !opts.selectAccount?.page) return handleRedirect(ctx, getErrorURL(ctx, `unsupported_prompt_select_account`, "unsupported prompt type"));
|
|
5824
|
+
if ((ctx.query?.prompt ? parsePrompt(ctx.query?.prompt) : void 0)?.has("select_account") && !opts.selectAccount?.page) return handleRedirect(ctx, getErrorURL(ctx, `unsupported_prompt_select_account`, "unsupported prompt type"));
|
|
5607
5825
|
if (!(query.response_type === "code")) return handleRedirect(ctx, getErrorURL(ctx, "unsupported_response_type", "unsupported response type"));
|
|
5608
5826
|
const client = await getClient(ctx, opts, query.client_id);
|
|
5609
|
-
if (!client) return handleRedirect(ctx, getErrorURL(ctx, "invalid_client", "
|
|
5827
|
+
if (!client) return handleRedirect(ctx, getErrorURL(ctx, "invalid_client", "client not found"));
|
|
5610
5828
|
if (client.disabled) return handleRedirect(ctx, getErrorURL(ctx, "client_disabled", "client is disabled"));
|
|
5611
5829
|
if (!clientAllowsGrant(client, "authorization_code")) return handleRedirect(ctx, getErrorURL(ctx, "unauthorized_client", "client is not authorized to use the authorization_code grant"));
|
|
5612
5830
|
const registeredUris = client.redirectUris ?? [];
|
|
@@ -5636,8 +5854,7 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
5636
5854
|
const openidRequested = requestedScopes.includes("openid");
|
|
5637
5855
|
if (query.claims !== void 0 && !openidRequested) return handleRedirect(ctx, formatErrorURL(query.redirect_uri, "invalid_request", "openid scope must be requested when using the claims parameter", query.state, getIssuer(ctx, opts)));
|
|
5638
5856
|
if (openidRequested && !isValidOidcClaimsRequest(query.claims)) return handleRedirect(ctx, formatErrorURL(query.redirect_uri, "invalid_request", "claims must be a valid Claims request object", query.state, getIssuer(ctx, opts)));
|
|
5639
|
-
if (openidRequested && !canSatisfyEssentialAcrRequest(query.claims,
|
|
5640
|
-
const requestedUserInfoClaims = getRequestedUserInfoClaims(query.claims, getSupportedClaims(opts));
|
|
5857
|
+
if (openidRequested && !getAcrValuesSupported(opts).some((acr) => canSatisfyEssentialAcrRequest(query.claims, acr))) return handleRedirect(ctx, formatErrorURL(query.redirect_uri, "access_denied", "essential acr requirement cannot be met", query.state, getIssuer(ctx, opts)));
|
|
5641
5858
|
if (query.resource !== void 0) try {
|
|
5642
5859
|
await resolveResourcePolicy(ctx, opts, {
|
|
5643
5860
|
resource: query.resource,
|
|
@@ -5663,12 +5880,35 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
5663
5880
|
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)));
|
|
5664
5881
|
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)));
|
|
5665
5882
|
}
|
|
5666
|
-
|
|
5883
|
+
return runAuthorizationInteraction(ctx, opts, request.headers, settings, {
|
|
5884
|
+
query,
|
|
5885
|
+
client,
|
|
5886
|
+
approve: (approval) => createAuthorizationCodeURL(ctx, opts, {
|
|
5887
|
+
...approval,
|
|
5888
|
+
query: approval.query,
|
|
5889
|
+
clientId: client.clientId,
|
|
5890
|
+
resource: toResourceList(approval.query.resource) ?? []
|
|
5891
|
+
})
|
|
5892
|
+
});
|
|
5893
|
+
}
|
|
5894
|
+
/**
|
|
5895
|
+
* Sends the user to the next required interaction page, or approves the
|
|
5896
|
+
* request once none remain.
|
|
5897
|
+
*/
|
|
5898
|
+
async function runAuthorizationInteraction(ctx, opts, headers, settings, request) {
|
|
5899
|
+
let { query } = request;
|
|
5900
|
+
const { client } = request;
|
|
5901
|
+
ctx.query = query;
|
|
5902
|
+
const scopes = query.scope?.split(" ").filter((scope) => scope) ?? [];
|
|
5903
|
+
const userInfoClaims = getRequestedUserInfoClaims(query.claims, getSupportedClaims(opts));
|
|
5904
|
+
const resources = toResourceList(query.resource) ?? [];
|
|
5905
|
+
const promptSet = query.prompt ? parsePrompt(query.prompt) : void 0;
|
|
5906
|
+
const promptNone = promptSet?.has("none") ?? false;
|
|
5667
5907
|
const session = await getSessionFromCtx(ctx);
|
|
5668
5908
|
const maxAgeSeconds = query.max_age;
|
|
5669
5909
|
const hasSatisfiedMaxAge = session != null && maxAgeSeconds !== void 0 && isWithinMaxAge(new Date(session.session.createdAt), maxAgeSeconds, /* @__PURE__ */ new Date());
|
|
5670
5910
|
if (!session || session != null && maxAgeSeconds !== void 0 && !hasSatisfiedMaxAge || promptSet?.has("login") || promptSet?.has("create")) {
|
|
5671
|
-
if (promptNone) return
|
|
5911
|
+
if (promptNone) return redirectWithAuthorizationError(ctx, opts, query, "login_required", "authentication required");
|
|
5672
5912
|
return redirectWithPromptCode(ctx, opts, promptSet?.has("create") ? "create" : "login");
|
|
5673
5913
|
}
|
|
5674
5914
|
if (hasSatisfiedMaxAge) {
|
|
@@ -5678,89 +5918,79 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
5678
5918
|
if (settings?.isAuthorize && promptSet?.has("select_account")) return redirectWithPromptCode(ctx, opts, "select_account");
|
|
5679
5919
|
if (settings?.isAuthorize && opts.selectAccount) {
|
|
5680
5920
|
if (await opts.selectAccount.shouldRedirect({
|
|
5681
|
-
headers
|
|
5921
|
+
headers,
|
|
5682
5922
|
user: session.user,
|
|
5683
5923
|
session: session.session,
|
|
5684
|
-
scopes
|
|
5924
|
+
scopes
|
|
5685
5925
|
})) {
|
|
5686
|
-
if (promptNone) return
|
|
5926
|
+
if (promptNone) return redirectWithAuthorizationError(ctx, opts, query, "account_selection_required", "End-User account selection is required");
|
|
5687
5927
|
return redirectWithPromptCode(ctx, opts, "select_account");
|
|
5688
5928
|
}
|
|
5689
5929
|
}
|
|
5690
5930
|
if (opts.signup?.shouldRedirect) {
|
|
5691
5931
|
const signupRedirect = await opts.signup.shouldRedirect({
|
|
5692
|
-
headers
|
|
5932
|
+
headers,
|
|
5693
5933
|
user: session.user,
|
|
5694
5934
|
session: session.session,
|
|
5695
|
-
scopes
|
|
5935
|
+
scopes
|
|
5696
5936
|
});
|
|
5697
5937
|
if (signupRedirect) {
|
|
5698
|
-
if (promptNone) return
|
|
5938
|
+
if (promptNone) return redirectWithAuthorizationError(ctx, opts, query, "interaction_required", "End-User interaction is required");
|
|
5699
5939
|
return redirectWithPromptCode(ctx, opts, "create", { page: typeof signupRedirect === "string" ? signupRedirect : void 0 });
|
|
5700
5940
|
}
|
|
5701
5941
|
}
|
|
5702
5942
|
if (!settings?.postLogin && opts.postLogin) {
|
|
5703
5943
|
if (await opts.postLogin.shouldRedirect({
|
|
5704
|
-
headers
|
|
5944
|
+
headers,
|
|
5705
5945
|
user: session.user,
|
|
5706
5946
|
session: session.session,
|
|
5707
|
-
scopes
|
|
5947
|
+
scopes,
|
|
5708
5948
|
isContinuation: settings?.isPostLoginContinuation ?? false
|
|
5709
5949
|
})) {
|
|
5710
|
-
if (promptNone) return
|
|
5950
|
+
if (promptNone) return redirectWithAuthorizationError(ctx, opts, query, "interaction_required", "End-User interaction is required");
|
|
5711
5951
|
return redirectWithPromptCode(ctx, opts, "post_login");
|
|
5712
5952
|
}
|
|
5713
5953
|
}
|
|
5714
|
-
if (promptSet?.has("consent")) return redirectWithPromptCode(ctx, opts, "consent", { sessionId: session.session.id });
|
|
5715
|
-
const referenceId = await opts
|
|
5954
|
+
if (promptSet?.has("consent") || request.requireConsent && !settings?.consentAccepted) return redirectWithPromptCode(ctx, opts, "consent", { sessionId: session.session.id });
|
|
5955
|
+
const referenceId = await getConsentReferenceId(opts, {
|
|
5716
5956
|
user: session.user,
|
|
5717
5957
|
session: session.session,
|
|
5718
|
-
scopes
|
|
5958
|
+
scopes
|
|
5719
5959
|
});
|
|
5720
|
-
|
|
5721
|
-
|
|
5722
|
-
|
|
5723
|
-
|
|
5724
|
-
|
|
5725
|
-
|
|
5726
|
-
|
|
5727
|
-
|
|
5728
|
-
|
|
5729
|
-
|
|
5730
|
-
|
|
5731
|
-
|
|
5732
|
-
|
|
5733
|
-
|
|
5734
|
-
|
|
5735
|
-
|
|
5736
|
-
|
|
5737
|
-
|
|
5738
|
-
|
|
5739
|
-
|
|
5740
|
-
|
|
5741
|
-
|
|
5742
|
-
|
|
5743
|
-
|
|
5744
|
-
|
|
5745
|
-
|
|
5746
|
-
|
|
5747
|
-
|
|
5748
|
-
|
|
5749
|
-
|
|
5750
|
-
|
|
5751
|
-
if (requestedResources.some((requestedResource) => !consentedResources.includes(requestedResource))) {
|
|
5752
|
-
if (promptNone) return redirectWithPromptNoneError(ctx, opts, query, "consent_required", "End-User consent is required");
|
|
5960
|
+
const approve = async () => {
|
|
5961
|
+
let authenticationContext;
|
|
5962
|
+
try {
|
|
5963
|
+
authenticationContext = await resolveAuthenticationContext(opts, {
|
|
5964
|
+
user: session.user,
|
|
5965
|
+
session: session.session,
|
|
5966
|
+
client,
|
|
5967
|
+
scopes,
|
|
5968
|
+
headers,
|
|
5969
|
+
query
|
|
5970
|
+
});
|
|
5971
|
+
} catch (error) {
|
|
5972
|
+
if (!(error instanceof APIError$1)) throw error;
|
|
5973
|
+
if (request.errorURL) return handleRedirect(ctx, request.errorURL(error));
|
|
5974
|
+
const body = error.body;
|
|
5975
|
+
return redirectWithAuthorizationError(ctx, opts, query, body?.error ?? "server_error", body?.error_description ?? "authentication context unavailable");
|
|
5976
|
+
}
|
|
5977
|
+
if (!canSatisfyEssentialAcrRequest(query.claims, authenticationContext.acr ?? "0")) return redirectWithAuthorizationError(ctx, opts, query, "access_denied", "essential acr requirement cannot be met");
|
|
5978
|
+
return handleRedirect(ctx, await request.approve({
|
|
5979
|
+
query,
|
|
5980
|
+
userId: session.user.id,
|
|
5981
|
+
sessionId: session.session.id,
|
|
5982
|
+
authTime: new Date(session.session.createdAt).getTime(),
|
|
5983
|
+
...authenticationContext,
|
|
5984
|
+
referenceId
|
|
5985
|
+
}));
|
|
5986
|
+
};
|
|
5987
|
+
if (client.skipConsent) return approve();
|
|
5988
|
+
const consent = await findConsent(ctx, client.clientId, session.user.id, referenceId);
|
|
5989
|
+
if (!consent || !consentCovers(consent, scopes, resources) || !userInfoClaims.every((claim) => (consent.requestedUserInfoClaims ?? []).includes(claim))) {
|
|
5990
|
+
if (promptNone) return redirectWithAuthorizationError(ctx, opts, query, "consent_required", "End-User consent is required");
|
|
5753
5991
|
return redirectWithPromptCode(ctx, opts, "consent", { sessionId: session.session.id });
|
|
5754
5992
|
}
|
|
5755
|
-
return
|
|
5756
|
-
query,
|
|
5757
|
-
clientId: client.clientId,
|
|
5758
|
-
userId: session.user.id,
|
|
5759
|
-
sessionId: session.session.id,
|
|
5760
|
-
authTime: new Date(session.session.createdAt).getTime(),
|
|
5761
|
-
referenceId,
|
|
5762
|
-
resource: requestedResources
|
|
5763
|
-
});
|
|
5993
|
+
return approve();
|
|
5764
5994
|
}
|
|
5765
5995
|
function serializeAuthorizationQuery(query) {
|
|
5766
5996
|
const params = new URLSearchParams();
|
|
@@ -5772,7 +6002,8 @@ function serializeAuthorizationQuery(query) {
|
|
|
5772
6002
|
}
|
|
5773
6003
|
return params;
|
|
5774
6004
|
}
|
|
5775
|
-
|
|
6005
|
+
/** Stores a single-use authorization code and returns the client redirect URL carrying it. */
|
|
6006
|
+
async function createAuthorizationCodeURL(ctx, opts, verificationValue) {
|
|
5776
6007
|
const code = generateRandomString(32, "a-z", "A-Z", "0-9");
|
|
5777
6008
|
const iat = Math.floor(Date.now() / 1e3);
|
|
5778
6009
|
const exp = iat + (opts.codeExpiresIn ?? 600);
|
|
@@ -5787,6 +6018,8 @@ async function redirectWithAuthorizationCode(ctx, opts, verificationValue) {
|
|
|
5787
6018
|
sessionId: verificationValue?.sessionId,
|
|
5788
6019
|
referenceId: verificationValue.referenceId,
|
|
5789
6020
|
authTime: verificationValue.authTime,
|
|
6021
|
+
acr: verificationValue.acr,
|
|
6022
|
+
amr: verificationValue.amr,
|
|
5790
6023
|
resource: verificationValue.resource
|
|
5791
6024
|
})
|
|
5792
6025
|
};
|
|
@@ -5798,7 +6031,7 @@ async function redirectWithAuthorizationCode(ctx, opts, verificationValue) {
|
|
|
5798
6031
|
redirectUriWithCode.searchParams.set("code", code);
|
|
5799
6032
|
if (verificationValue.query.state) redirectUriWithCode.searchParams.set("state", verificationValue.query.state);
|
|
5800
6033
|
redirectUriWithCode.searchParams.set("iss", getIssuer(ctx, opts));
|
|
5801
|
-
return
|
|
6034
|
+
return redirectUriWithCode.toString();
|
|
5802
6035
|
}
|
|
5803
6036
|
async function redirectWithPromptCode(ctx, opts, type, options) {
|
|
5804
6037
|
const queryParams = await signParams(ctx, opts, { postLoginClearedForSession: type === "consent" && opts.postLogin ? options?.sessionId : void 0 });
|
|
@@ -5826,4 +6059,4 @@ async function signParams(ctx, opts, flags) {
|
|
|
5826
6059
|
return params.toString();
|
|
5827
6060
|
}
|
|
5828
6061
|
//#endregion
|
|
5829
|
-
export {
|
|
6062
|
+
export { assertPublicRoutableHttpsUri as a, consumeClientAssertion as c, authServerMetadata as d, metadataResponse as f, oidcServerMetadata as g, oauthProviderOpenIdConfigMetadata as h, oauthProvider as i, validatePublicClientJwks as l, oauthProviderAuthServerMetadata as m, DEFAULT_OAUTH_SCOPES as n, registerClientMetadataDocument as o, oauthAuthorizationServerMetadata as p, getOAuthProviderState as r, client_assertion_exports as s, getIssuer as t, createOAuthEndpoint as u };
|