@better-auth/oauth-provider 1.7.10 → 1.8.0-beta.1
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-dkOG8GqZ.mjs → authorize-Dg976Ug0.mjs} +583 -433
- package/dist/client-resource.d.mts +2 -2
- package/dist/client-resource.mjs +2 -2
- package/dist/client.d.mts +1 -1
- package/dist/client.mjs +1 -1
- package/dist/index-DVydGHlT.d.mts +275 -0
- package/dist/index.d.mts +4 -203
- package/dist/index.mjs +196 -40
- package/dist/internal.d.mts +109 -22
- package/dist/internal.mjs +4 -2
- package/dist/{introspect-BVm_zFQC.mjs → introspect-DdmCPEHN.mjs} +294 -227
- package/dist/{oauth-DXiqbRzK.d.mts → oauth-CzT1PvAY.d.mts} +359 -13
- package/dist/{oauth-Cse_eZ6i.d.mts → oauth-DfH-ghKj.d.mts} +163 -87
- package/dist/{utils-DfMNRh3J.mjs → utils-D-CUrvd1.mjs} +287 -20
- package/dist/{version-aKfgWHzo.mjs → version-BwHks7Wg.mjs} +1 -1
- package/package.json +6 -6
- package/dist/client-jwks-DY2QBrsl.d.mts +0 -25
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime-wcPFST8Q.mjs";
|
|
2
|
-
import { A as
|
|
3
|
-
import { A as
|
|
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-DdmCPEHN.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-D-CUrvd1.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-BwHks7Wg.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
|
-
import { compactVerify, createLocalJWKSet, decodeJwt, decodeProtectedHeader, errors, jwtVerify } from "jose";
|
|
21
20
|
import { getJwks, stripAccessTokenAuthorizationScheme } from "better-auth/oauth2";
|
|
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) {
|
|
@@ -114,7 +128,10 @@ async function consentEndpoint(ctx, opts, authorize) {
|
|
|
114
128
|
authorizationQuery = removeMaxAgeFromQuery(authorizationQuery);
|
|
115
129
|
}
|
|
116
130
|
ctx.query = searchParamsToQuery(authorizationQuery);
|
|
117
|
-
return await authorize(ctx, {
|
|
131
|
+
return await authorize(ctx, {
|
|
132
|
+
postLogin: oauthRequest?.postLoginClearedForSession !== void 0 && oauthRequest.postLoginClearedForSession === session?.session.id,
|
|
133
|
+
consentAccepted: true
|
|
134
|
+
});
|
|
118
135
|
}
|
|
119
136
|
//#endregion
|
|
120
137
|
//#region src/continue.ts
|
|
@@ -170,6 +187,7 @@ const BACKCHANNEL_LOGOUT_EVENT_URI = "http://schemas.openid.net/event/backchanne
|
|
|
170
187
|
const LOGOUT_TOKEN_JWT_TYP = "logout+jwt";
|
|
171
188
|
const LOGOUT_TOKEN_LIFETIME_SECONDS = 120;
|
|
172
189
|
const BACKCHANNEL_DISPATCH_TIMEOUT_MS = 5e3;
|
|
190
|
+
const FRONTCHANNEL_LOGOUT_TIMEOUT_MS = 3e3;
|
|
173
191
|
/**
|
|
174
192
|
* Signs a Back-Channel Logout Token per OIDC Back-Channel Logout 1.0 §2.4.
|
|
175
193
|
*
|
|
@@ -211,11 +229,12 @@ async function signLogoutToken(ctx, options, resolvedKey, claims) {
|
|
|
211
229
|
* Token revocation runs regardless of the JWT plugin (refresh-token revocation
|
|
212
230
|
* has no dependency on signing). Only Logout Token delivery needs the JWT
|
|
213
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.
|
|
214
233
|
*
|
|
215
234
|
* Returns `null` when there is nothing to do, so the caller can skip the
|
|
216
235
|
* background handoff entirely.
|
|
217
236
|
*/
|
|
218
|
-
async function
|
|
237
|
+
async function prepareSessionLogoutPlan(ctx, opts, input) {
|
|
219
238
|
const { sessionId, userId } = input;
|
|
220
239
|
if (!userId) return null;
|
|
221
240
|
const logger = ctx.context.logger;
|
|
@@ -247,24 +266,38 @@ async function prepareBackchannelLogoutPlan(ctx, opts, input) {
|
|
|
247
266
|
const accessToRevokeIds = accessTokens.filter((t) => !t.revoked).map((t) => t.id);
|
|
248
267
|
const refreshToRevokeIds = refreshTokens.filter((t) => !t.revoked && !t.scopes?.includes("offline_access")).map((t) => t.id);
|
|
249
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);
|
|
250
281
|
return {
|
|
251
282
|
accessTokenIds: accessToRevokeIds,
|
|
252
283
|
refreshTokenIds: refreshToRevokeIds,
|
|
253
284
|
sessionId,
|
|
254
|
-
|
|
285
|
+
backchannelTargets,
|
|
286
|
+
frontchannelLogoutUris: clients.flatMap((client) => {
|
|
287
|
+
if (!client.frontchannelLogoutUri || client.disabled) return [];
|
|
255
288
|
try {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
289
|
+
const uri = new URL(client.frontchannelLogoutUri);
|
|
290
|
+
uri.searchParams.set("iss", issuer);
|
|
291
|
+
uri.searchParams.set("sid", sessionId);
|
|
292
|
+
return [uri.toString()];
|
|
260
293
|
} catch (error) {
|
|
261
|
-
logger.warn(`
|
|
262
|
-
return
|
|
294
|
+
logger.warn(`front-channel logout: invalid frontchannel_logout_uri for client ${client.clientId}`, error);
|
|
295
|
+
return [];
|
|
263
296
|
}
|
|
264
|
-
})
|
|
297
|
+
})
|
|
265
298
|
};
|
|
266
299
|
} catch (error) {
|
|
267
|
-
logger.error("
|
|
300
|
+
logger.error("logout planning failed", error);
|
|
268
301
|
return null;
|
|
269
302
|
}
|
|
270
303
|
}
|
|
@@ -273,7 +306,7 @@ async function prepareBackchannelLogoutPlan(ctx, opts, input) {
|
|
|
273
306
|
* `session.delete.after` after the row is consumed and, for transactional
|
|
274
307
|
* callers, only after the transaction commits.
|
|
275
308
|
*/
|
|
276
|
-
async function
|
|
309
|
+
async function applySessionLogoutPlan(ctx, plan) {
|
|
277
310
|
const revokedAt = /* @__PURE__ */ new Date();
|
|
278
311
|
const adapter = await getCurrentAdapter(ctx.context.adapter);
|
|
279
312
|
const revocations = await Promise.allSettled([plan.accessTokenIds.length > 0 ? adapter.updateMany({
|
|
@@ -294,7 +327,7 @@ async function applyBackchannelLogoutPlan(ctx, plan) {
|
|
|
294
327
|
update: { revoked: revokedAt }
|
|
295
328
|
}) : Promise.resolve()]);
|
|
296
329
|
for (const result of revocations) if (result.status === "rejected") ctx.context.logger.error("back-channel logout: token revocation update failed", result.reason);
|
|
297
|
-
if (plan.
|
|
330
|
+
if (plan.backchannelTargets.length > 0) await deliverBackchannelLogoutTokens(ctx, plan);
|
|
298
331
|
}
|
|
299
332
|
/**
|
|
300
333
|
* Asynchronous phase: sign one Logout Token per target client and POST it to
|
|
@@ -315,7 +348,7 @@ async function deliverBackchannelLogoutTokens(ctx, plan) {
|
|
|
315
348
|
const iat = Math.floor(Date.now() / 1e3);
|
|
316
349
|
const exp = iat + LOGOUT_TOKEN_LIFETIME_SECONDS;
|
|
317
350
|
const resolvedKey = jwtPluginOptions?.jwt?.sign ? null : await resolveSigningKey(ctx, jwtPluginOptions);
|
|
318
|
-
await Promise.allSettled(plan.
|
|
351
|
+
await Promise.allSettled(plan.backchannelTargets.map(async ({ client, sub }) => {
|
|
319
352
|
try {
|
|
320
353
|
const jti = generateRandomString(32, "a-z", "A-Z", "0-9");
|
|
321
354
|
const token = await signLogoutToken(ctx, jwtPluginOptions, resolvedKey, {
|
|
@@ -343,6 +376,30 @@ async function deliverBackchannelLogoutTokens(ctx, plan) {
|
|
|
343
376
|
}
|
|
344
377
|
}));
|
|
345
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
|
+
}
|
|
346
403
|
const LOGOUT_CONFIRMATION_TTL_SECONDS = 300;
|
|
347
404
|
const LOGOUT_CONFIRMATION_COOKIE_SUFFIX = ".oauth_logout_confirmation";
|
|
348
405
|
function escapeHtml(value) {
|
|
@@ -363,14 +420,16 @@ function isBrowserNavigation(ctx) {
|
|
|
363
420
|
const accept = headers.get("accept") ?? "";
|
|
364
421
|
return headers.get("sec-fetch-mode") === "navigate" || accept.includes("text/html") || accept.includes("application/xhtml+xml");
|
|
365
422
|
}
|
|
366
|
-
|
|
367
|
-
|
|
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>`, {
|
|
368
426
|
status,
|
|
369
427
|
headers: {
|
|
370
428
|
"cache-control": "no-store",
|
|
371
|
-
"content-security-policy":
|
|
429
|
+
"content-security-policy": page.contentSecurityPolicy ?? LOGOUT_PAGE_CSP,
|
|
372
430
|
"content-type": "text/html; charset=utf-8",
|
|
373
431
|
pragma: "no-cache",
|
|
432
|
+
"referrer-policy": "no-referrer",
|
|
374
433
|
"x-content-type-options": "nosniff"
|
|
375
434
|
}
|
|
376
435
|
});
|
|
@@ -391,6 +450,53 @@ function logoutConfirmationPage(ctx) {
|
|
|
391
450
|
function logoutSuccessPage(note) {
|
|
392
451
|
return logoutPage("Logged out", `<main><p data-oidc-logout-state="logged-out">${note ? `Logged out. ${escapeHtml(note)}` : "Logged out."}</p></main>`);
|
|
393
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
|
+
}
|
|
394
500
|
function logoutErrorPage(description, status) {
|
|
395
501
|
return logoutPage("Logout error", `<main><h1>Logout error</h1><p data-oidc-logout-state="error">${escapeHtml(description)}</p></main>`, status);
|
|
396
502
|
}
|
|
@@ -588,6 +694,22 @@ async function confirmationRequired(ctx, currentSession, confirmation = {}) {
|
|
|
588
694
|
await setLogoutConfirmationState(ctx, currentSession.session.id, confirmation);
|
|
589
695
|
return logoutConfirmationPage(ctx);
|
|
590
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
|
+
}
|
|
591
713
|
async function completeConfirmedLogout(ctx, opts) {
|
|
592
714
|
const state = await getLogoutConfirmationState(ctx);
|
|
593
715
|
const currentSession = await getCurrentBrowserSession(ctx);
|
|
@@ -596,14 +718,13 @@ async function completeConfirmedLogout(ctx, opts) {
|
|
|
596
718
|
if (!currentSession) {
|
|
597
719
|
clearLogoutConfirmationState(ctx);
|
|
598
720
|
if (redirect.uri) return handleRedirect(ctx, redirect.uri);
|
|
599
|
-
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");
|
|
600
722
|
}
|
|
601
723
|
if (state.sessionId && state.sessionId !== currentSession.session.id) return logoutProtocolError(ctx, "BAD_REQUEST", "invalid_request", "The logout confirmation is invalid or expired");
|
|
602
724
|
await deleteLogoutSession(ctx, currentSession.session);
|
|
603
725
|
deleteSessionCookie(ctx);
|
|
604
726
|
clearLogoutConfirmationState(ctx);
|
|
605
|
-
|
|
606
|
-
return isBrowserNavigation(ctx) ? logoutSuccessPage(redirect.invalid ? "The requested post-logout redirect was not registered." : void 0) : null;
|
|
727
|
+
return loggedOutResponse(ctx, redirect);
|
|
607
728
|
}
|
|
608
729
|
/**
|
|
609
730
|
* RP-Initiated Logout (OIDC RP-Initiated Logout 1.0). The RP presents a signed
|
|
@@ -614,7 +735,9 @@ async function completeConfirmedLogout(ctx, opts) {
|
|
|
614
735
|
*
|
|
615
736
|
* Session termination goes through `internalAdapter.deleteSession`, which fires
|
|
616
737
|
* `session.delete.after` so the hook drives revocation and back-channel
|
|
617
|
-
* 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.
|
|
618
741
|
*
|
|
619
742
|
* @see https://openid.net/specs/openid-connect-rpinitiated-1_0.html
|
|
620
743
|
*/
|
|
@@ -658,8 +781,7 @@ async function rpInitiatedLogoutEndpoint(ctx, opts) {
|
|
|
658
781
|
if (sessionToDelete) await deleteLogoutSession(ctx, sessionToDelete);
|
|
659
782
|
if (matchesCurrentSession) deleteSessionCookie(ctx);
|
|
660
783
|
clearLogoutConfirmationState(ctx);
|
|
661
|
-
|
|
662
|
-
return isBrowserNavigation(ctx) ? logoutSuccessPage(redirect.invalid ? "The requested post-logout redirect was not registered." : void 0) : null;
|
|
784
|
+
return loggedOutResponse(ctx, redirect);
|
|
663
785
|
}
|
|
664
786
|
/**
|
|
665
787
|
* Completes the browser confirmation flow for RP-Initiated Logout. This route
|
|
@@ -713,7 +835,9 @@ function authServerMetadata(ctx, opts, overrides) {
|
|
|
713
835
|
authorization_response_iss_parameter_supported: true,
|
|
714
836
|
dpop_signing_alg_values_supported: overrides?.dpop_signing_alg_values_supported ?? [...DPOP_SIGNING_ALGORITHMS],
|
|
715
837
|
backchannel_logout_supported: backchannelSupported,
|
|
716
|
-
backchannel_logout_session_supported: backchannelSupported
|
|
838
|
+
backchannel_logout_session_supported: backchannelSupported,
|
|
839
|
+
frontchannel_logout_supported: true,
|
|
840
|
+
frontchannel_logout_session_supported: true
|
|
717
841
|
};
|
|
718
842
|
}
|
|
719
843
|
/**
|
|
@@ -756,7 +880,7 @@ function oidcServerMetadata(ctx, opts) {
|
|
|
756
880
|
claims_parameter_supported: true,
|
|
757
881
|
userinfo_endpoint: `${baseURL}/oauth2/userinfo`,
|
|
758
882
|
subject_types_supported: opts.pairwiseSecret ? ["public", "pairwise"] : ["public"],
|
|
759
|
-
acr_values_supported:
|
|
883
|
+
acr_values_supported: getAcrValuesSupported(opts),
|
|
760
884
|
id_token_signing_alg_values_supported: (() => {
|
|
761
885
|
if (opts.disableJwtPlugin) return ["HS256"];
|
|
762
886
|
const primary = jwtPluginOptions?.jwks?.keyPairConfig?.alg ?? "EdDSA";
|
|
@@ -830,7 +954,8 @@ const oauthProviderOpenIdConfigMetadata = (auth, opts) => {
|
|
|
830
954
|
*
|
|
831
955
|
* A failing issue is routed by its first path segment via `errorCodesByField`:
|
|
832
956
|
* - missing required (`invalid_type` + "received undefined") → `.missing`
|
|
833
|
-
* - unsupported value (`invalid_value`)
|
|
957
|
+
* - unsupported value (`invalid_value`) or a value a refinement rejected
|
|
958
|
+
* (`custom`, also inside a union branch) → `.invalid`
|
|
834
959
|
* - anything else (wrong type, duplicated params, bad format) → `defaultError`
|
|
835
960
|
*
|
|
836
961
|
* For enum fields that need to distinguish missing from unsupported, compose
|
|
@@ -839,16 +964,19 @@ const oauthProviderOpenIdConfigMetadata = (auth, opts) => {
|
|
|
839
964
|
* enum value.
|
|
840
965
|
*/
|
|
841
966
|
function createOAuthEndpoint(path, options, handler) {
|
|
842
|
-
const { redirectOnError, onValidationError: userHook, errorCodesByField, defaultError = "invalid_request", ...rest } = options;
|
|
843
|
-
if (!redirectOnError
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
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
|
+
}
|
|
851
978
|
const { body: bodySchema, query: querySchema, ...forwarded } = rest;
|
|
979
|
+
if (formParams) forwarded.cloneRequest = true;
|
|
852
980
|
async function validateSlot(ctx, slot, schema) {
|
|
853
981
|
if (!schema) return { ok: true };
|
|
854
982
|
const result = await schema.safeParseAsync(ctx[slot] ?? {});
|
|
@@ -860,15 +988,19 @@ function createOAuthEndpoint(path, options, handler) {
|
|
|
860
988
|
message: result.error.message,
|
|
861
989
|
issues: result.error.issues
|
|
862
990
|
});
|
|
991
|
+
const error = mapIssuesToOAuthError(result.error.issues, errorCodesByField, defaultError);
|
|
992
|
+
if (!redirectOnError) throw new APIError$1("BAD_REQUEST", error);
|
|
863
993
|
return {
|
|
864
994
|
ok: false,
|
|
865
|
-
response:
|
|
866
|
-
...
|
|
995
|
+
response: redirectOnError({
|
|
996
|
+
...error,
|
|
867
997
|
ctx
|
|
868
998
|
})
|
|
869
999
|
};
|
|
870
1000
|
}
|
|
871
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);
|
|
872
1004
|
const body = await validateSlot(ctx, "body", bodySchema);
|
|
873
1005
|
if (!body.ok) return body.response;
|
|
874
1006
|
const query = await validateSlot(ctx, "query", querySchema);
|
|
@@ -886,18 +1018,32 @@ function mapIssuesToOAuthError(issues, errorCodesByField, defaultError = "invali
|
|
|
886
1018
|
const fieldKey = typeof first === "string" ? first : void 0;
|
|
887
1019
|
const mapping = fieldKey ? errorCodesByField?.[fieldKey] : void 0;
|
|
888
1020
|
const field = issue.path?.length ? z.core.toDotPath(issue.path) : "";
|
|
1021
|
+
const invalidValue = findInvalidValueIssue(issue);
|
|
889
1022
|
return {
|
|
890
|
-
error: resolveErrorCode(issue, mapping, defaultError),
|
|
891
|
-
error_description: describeIssue(issue, field)
|
|
1023
|
+
error: resolveErrorCode(issue, invalidValue, mapping, defaultError),
|
|
1024
|
+
error_description: describeIssue(invalidValue ?? issue, field)
|
|
892
1025
|
};
|
|
893
1026
|
}
|
|
894
|
-
function resolveErrorCode(issue, mapping, defaultError) {
|
|
1027
|
+
function resolveErrorCode(issue, invalidValue, mapping, defaultError) {
|
|
895
1028
|
if (typeof mapping === "string") return mapping;
|
|
896
1029
|
if (isMissingValueIssue(issue)) return mapping?.missing ?? defaultError;
|
|
897
|
-
if (
|
|
1030
|
+
if (invalidValue) return mapping?.invalid ?? defaultError;
|
|
898
1031
|
return defaultError;
|
|
899
1032
|
}
|
|
900
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
|
+
/**
|
|
901
1047
|
* Returns `true` for issues that represent an absent required value. Zod v4
|
|
902
1048
|
* strips `input` from published issues, so the signal is the `invalid_type`
|
|
903
1049
|
* code combined with a message suffix of "received undefined". The suffix is
|
|
@@ -1028,137 +1174,6 @@ function assertValidClientJwks(jwks) {
|
|
|
1028
1174
|
});
|
|
1029
1175
|
}
|
|
1030
1176
|
//#endregion
|
|
1031
|
-
//#region src/client-metadata.ts
|
|
1032
|
-
const OAUTH_CLIENT_RECORD_FIELD_NAMES = Object.freeze(Object.keys({
|
|
1033
|
-
clientId: true,
|
|
1034
|
-
clientSecret: true,
|
|
1035
|
-
clientDiscoveryId: true,
|
|
1036
|
-
disabled: true,
|
|
1037
|
-
scopes: true,
|
|
1038
|
-
clientCredentialsScopes: true,
|
|
1039
|
-
userId: true,
|
|
1040
|
-
createdAt: true,
|
|
1041
|
-
updatedAt: true,
|
|
1042
|
-
expiresAt: true,
|
|
1043
|
-
name: true,
|
|
1044
|
-
uri: true,
|
|
1045
|
-
icon: true,
|
|
1046
|
-
contacts: true,
|
|
1047
|
-
tos: true,
|
|
1048
|
-
policy: true,
|
|
1049
|
-
softwareId: true,
|
|
1050
|
-
softwareVersion: true,
|
|
1051
|
-
softwareStatement: true,
|
|
1052
|
-
redirectUris: true,
|
|
1053
|
-
postLogoutRedirectUris: true,
|
|
1054
|
-
backchannelLogoutUri: true,
|
|
1055
|
-
backchannelLogoutSessionRequired: true,
|
|
1056
|
-
tokenEndpointAuthMethod: true,
|
|
1057
|
-
grantTypes: true,
|
|
1058
|
-
responseTypes: true,
|
|
1059
|
-
applicationType: true,
|
|
1060
|
-
jwks: true,
|
|
1061
|
-
jwksUri: true,
|
|
1062
|
-
requirePKCE: true,
|
|
1063
|
-
dpopBoundAccessTokens: true,
|
|
1064
|
-
skipConsent: true,
|
|
1065
|
-
enableEndSession: true,
|
|
1066
|
-
subjectType: true,
|
|
1067
|
-
referenceId: true,
|
|
1068
|
-
metadata: true
|
|
1069
|
-
}));
|
|
1070
|
-
/**
|
|
1071
|
-
* Canonical wire fields emitted by schemaToOAuth. They are mapped explicitly
|
|
1072
|
-
* and therefore must never also survive inside the opaque metadata envelope.
|
|
1073
|
-
*/
|
|
1074
|
-
const OAUTH_CLIENT_WIRE_FIELD_NAMES = [
|
|
1075
|
-
"client_id",
|
|
1076
|
-
"client_secret",
|
|
1077
|
-
"client_secret_expires_at",
|
|
1078
|
-
"scope",
|
|
1079
|
-
"user_id",
|
|
1080
|
-
"client_id_issued_at",
|
|
1081
|
-
"client_name",
|
|
1082
|
-
"client_uri",
|
|
1083
|
-
"logo_uri",
|
|
1084
|
-
"contacts",
|
|
1085
|
-
"tos_uri",
|
|
1086
|
-
"policy_uri",
|
|
1087
|
-
"jwks",
|
|
1088
|
-
"jwks_uri",
|
|
1089
|
-
"software_id",
|
|
1090
|
-
"software_version",
|
|
1091
|
-
"software_statement",
|
|
1092
|
-
"redirect_uris",
|
|
1093
|
-
"post_logout_redirect_uris",
|
|
1094
|
-
"backchannel_logout_uri",
|
|
1095
|
-
"backchannel_logout_session_required",
|
|
1096
|
-
"token_endpoint_auth_method",
|
|
1097
|
-
"grant_types",
|
|
1098
|
-
"response_types",
|
|
1099
|
-
"application_type",
|
|
1100
|
-
"disabled",
|
|
1101
|
-
"skip_consent",
|
|
1102
|
-
"enable_end_session",
|
|
1103
|
-
"require_pkce",
|
|
1104
|
-
"dpop_bound_access_tokens",
|
|
1105
|
-
"subject_type",
|
|
1106
|
-
"reference_id"
|
|
1107
|
-
];
|
|
1108
|
-
const OPAQUE_METADATA_RESERVED_FIELDS = new Set([
|
|
1109
|
-
...OAUTH_CLIENT_RECORD_FIELD_NAMES,
|
|
1110
|
-
...OAUTH_CLIENT_WIRE_FIELD_NAMES,
|
|
1111
|
-
"public",
|
|
1112
|
-
"type",
|
|
1113
|
-
"resources",
|
|
1114
|
-
"client_credentials_scopes"
|
|
1115
|
-
]);
|
|
1116
|
-
const CIMD_FORBIDDEN_SERVER_FIELD_NAMES = new Set([
|
|
1117
|
-
"disabled",
|
|
1118
|
-
"client_secret",
|
|
1119
|
-
"client_secret_expires_at",
|
|
1120
|
-
"client_id_issued_at",
|
|
1121
|
-
"skip_consent",
|
|
1122
|
-
"enable_end_session",
|
|
1123
|
-
"require_pkce",
|
|
1124
|
-
"reference_id",
|
|
1125
|
-
"user_id",
|
|
1126
|
-
"resources",
|
|
1127
|
-
"clientSecret",
|
|
1128
|
-
"clientDiscoveryId",
|
|
1129
|
-
"skipConsent",
|
|
1130
|
-
"enableEndSession",
|
|
1131
|
-
"requirePKCE",
|
|
1132
|
-
"referenceId",
|
|
1133
|
-
"userId",
|
|
1134
|
-
"clientId",
|
|
1135
|
-
"applicationType",
|
|
1136
|
-
"tokenEndpointAuthMethod",
|
|
1137
|
-
"redirectUris",
|
|
1138
|
-
"postLogoutRedirectUris",
|
|
1139
|
-
"grantTypes",
|
|
1140
|
-
"responseTypes",
|
|
1141
|
-
"scopes",
|
|
1142
|
-
"expiresAt",
|
|
1143
|
-
"createdAt",
|
|
1144
|
-
"updatedAt",
|
|
1145
|
-
"softwareId",
|
|
1146
|
-
"softwareVersion",
|
|
1147
|
-
"softwareStatement",
|
|
1148
|
-
"backchannelLogoutUri",
|
|
1149
|
-
"backchannelLogoutSessionRequired",
|
|
1150
|
-
"jwksUri",
|
|
1151
|
-
"dpopBoundAccessTokens",
|
|
1152
|
-
"subjectType"
|
|
1153
|
-
]);
|
|
1154
|
-
function isForbiddenCimdClientMetadataField(field) {
|
|
1155
|
-
return CIMD_FORBIDDEN_SERVER_FIELD_NAMES.has(field);
|
|
1156
|
-
}
|
|
1157
|
-
function stripReservedOAuthClientMetadataExtensions(metadata) {
|
|
1158
|
-
if (!metadata) return void 0;
|
|
1159
|
-
return Object.fromEntries(Object.entries(metadata).filter(([field]) => !OPAQUE_METADATA_RESERVED_FIELDS.has(field)));
|
|
1160
|
-
}
|
|
1161
|
-
//#endregion
|
|
1162
1177
|
//#region src/oauthClient/privileges.ts
|
|
1163
1178
|
/**
|
|
1164
1179
|
* Authorizes a client action against the configured `clientPrivileges` hook.
|
|
@@ -1789,8 +1804,54 @@ function validateClientRedirectUri(redirectUri, applicationType) {
|
|
|
1789
1804
|
}
|
|
1790
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}`);
|
|
1791
1806
|
}
|
|
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;
|
|
1852
|
+
}
|
|
1792
1853
|
async function registerEndpoint(ctx, opts) {
|
|
1793
|
-
const body = ctx.body;
|
|
1854
|
+
const { body, metadata } = splitRegistrationRequest(ctx.body, opts);
|
|
1794
1855
|
if (!opts.allowDynamicClientRegistration) throw new APIError("FORBIDDEN", {
|
|
1795
1856
|
error: "access_denied",
|
|
1796
1857
|
error_description: "Client registration is disabled"
|
|
@@ -1804,13 +1865,13 @@ async function registerEndpoint(ctx, opts) {
|
|
|
1804
1865
|
});
|
|
1805
1866
|
const grantTypes = body.grant_types?.length ? resolveMutuallySupportedGrantTypes(opts, body.grant_types) : body.grant_types;
|
|
1806
1867
|
const droppedAuthorizationCode = body.grant_types?.includes("authorization_code") && !grantTypes?.includes("authorization_code");
|
|
1807
|
-
const
|
|
1868
|
+
const registrationMetadata = {
|
|
1808
1869
|
...body,
|
|
1809
1870
|
grant_types: grantTypes,
|
|
1810
1871
|
response_types: droppedAuthorizationCode ? void 0 : body.response_types
|
|
1811
1872
|
};
|
|
1812
1873
|
if (!session && !isTokenAuthorized) {
|
|
1813
|
-
if (
|
|
1874
|
+
if (registrationMetadata.grant_types?.includes("client_credentials")) throw new APIError("BAD_REQUEST", {
|
|
1814
1875
|
error: "invalid_client_metadata",
|
|
1815
1876
|
error_description: "client_credentials grant requires authenticated registration"
|
|
1816
1877
|
});
|
|
@@ -1821,12 +1882,18 @@ async function registerEndpoint(ctx, opts) {
|
|
|
1821
1882
|
user: session.user,
|
|
1822
1883
|
session: session.session
|
|
1823
1884
|
}) : void 0);
|
|
1885
|
+
const clientCredentialsScopes = normalizeClientCredentialsScopes(tokenAuthorization?.clientCredentialsScopes ?? []);
|
|
1886
|
+
validateClientCredentialsScopes(clientCredentialsScopes, resolveRegistrationGrantTypes(registrationMetadata), registrationMetadata.token_endpoint_auth_method, opts);
|
|
1824
1887
|
const response = await createOAuthClientRegistration(ctx, opts, {
|
|
1825
|
-
metadata
|
|
1888
|
+
metadata: {
|
|
1889
|
+
...registrationMetadata,
|
|
1890
|
+
metadata
|
|
1891
|
+
},
|
|
1826
1892
|
registrationSource: "dynamic",
|
|
1827
1893
|
userId: referenceId ? void 0 : session?.session.userId,
|
|
1828
1894
|
referenceId,
|
|
1829
|
-
requestedResources
|
|
1895
|
+
requestedResources,
|
|
1896
|
+
clientCredentialsScopes
|
|
1830
1897
|
});
|
|
1831
1898
|
ctx.setStatus(201);
|
|
1832
1899
|
return ctx.json(response);
|
|
@@ -1953,32 +2020,40 @@ async function checkOAuthClient(client, opts, settings) {
|
|
|
1953
2020
|
error: "invalid_client_metadata",
|
|
1954
2021
|
error_description: "backchannel_logout_uri requires the jwt plugin (disableJwtPlugin must be false)"
|
|
1955
2022
|
});
|
|
2023
|
+
assertPublicRoutableHttpsUri("backchannel_logout_uri", clientWithDefaults.backchannel_logout_uri);
|
|
2024
|
+
}
|
|
2025
|
+
if (clientWithDefaults.frontchannel_logout_uri !== void 0) {
|
|
1956
2026
|
let url;
|
|
1957
2027
|
try {
|
|
1958
|
-
url = new URL(clientWithDefaults.
|
|
2028
|
+
url = new URL(clientWithDefaults.frontchannel_logout_uri);
|
|
1959
2029
|
} catch {
|
|
1960
2030
|
throw new APIError("BAD_REQUEST", {
|
|
1961
2031
|
error: "invalid_client_metadata",
|
|
1962
|
-
error_description: "
|
|
2032
|
+
error_description: "frontchannel_logout_uri must be an absolute URL"
|
|
1963
2033
|
});
|
|
1964
2034
|
}
|
|
1965
|
-
if (clientWithDefaults.
|
|
2035
|
+
if (clientWithDefaults.frontchannel_logout_uri.includes("#")) throw new APIError("BAD_REQUEST", {
|
|
1966
2036
|
error: "invalid_client_metadata",
|
|
1967
|
-
error_description: "
|
|
1968
|
-
});
|
|
1969
|
-
if (url.protocol !== "https:") throw new APIError("BAD_REQUEST", {
|
|
1970
|
-
error: "invalid_client_metadata",
|
|
1971
|
-
error_description: "backchannel_logout_uri must use https"
|
|
2037
|
+
error_description: "frontchannel_logout_uri must not include a fragment component"
|
|
1972
2038
|
});
|
|
1973
2039
|
if (url.username || url.password) throw new APIError("BAD_REQUEST", {
|
|
1974
2040
|
error: "invalid_client_metadata",
|
|
1975
|
-
error_description: "
|
|
2041
|
+
error_description: "frontchannel_logout_uri must not contain credentials"
|
|
1976
2042
|
});
|
|
1977
|
-
|
|
2043
|
+
const isHttpOrHttps = url.protocol === "https:" || url.protocol === "http:";
|
|
2044
|
+
const sharesRedirectOrigin = (clientWithDefaults.redirect_uris ?? []).some((redirectUri) => {
|
|
2045
|
+
try {
|
|
2046
|
+
return new URL(redirectUri).origin === url.origin;
|
|
2047
|
+
} catch {
|
|
2048
|
+
return false;
|
|
2049
|
+
}
|
|
2050
|
+
});
|
|
2051
|
+
if (!isHttpOrHttps || !sharesRedirectOrigin) throw new APIError("BAD_REQUEST", {
|
|
1978
2052
|
error: "invalid_client_metadata",
|
|
1979
|
-
error_description: "
|
|
2053
|
+
error_description: "frontchannel_logout_uri must use the scheme, host, and port of a registered redirect_uri"
|
|
1980
2054
|
});
|
|
1981
2055
|
}
|
|
2056
|
+
if (!settings?.skipExtensionValidation) await validateExtensionClientMetadata(opts, clientWithDefaults, client.metadata);
|
|
1982
2057
|
}
|
|
1983
2058
|
const CLIENT_REGISTRATION_COLLISION = Symbol("client-registration-collision");
|
|
1984
2059
|
function isClientRegistrationCollision(error) {
|
|
@@ -2005,7 +2080,8 @@ async function persistOAuthClientRegistration(ctx, opts, input) {
|
|
|
2005
2080
|
const isExtensionAuthMethod = isExtensionTokenEndpointAuthMethod(opts, body.token_endpoint_auth_method);
|
|
2006
2081
|
await checkOAuthClient(body, opts, {
|
|
2007
2082
|
registrationSource: input.registrationSource,
|
|
2008
|
-
ctx
|
|
2083
|
+
ctx,
|
|
2084
|
+
skipExtensionValidation: input.registrationSource === "clientMetadataDocument" && Boolean(input.existingClient)
|
|
2009
2085
|
});
|
|
2010
2086
|
const clientId = input.clientId ?? opts.generateClientId?.() ?? generateRandomString(32, "a-z", "A-Z");
|
|
2011
2087
|
const clientSecret = isPublic || isPrivateKeyJwt || isExtensionAuthMethod ? void 0 : opts.generateClientSecret?.() || generateRandomString(32, "a-z", "A-Z");
|
|
@@ -2033,8 +2109,9 @@ async function persistOAuthClientRegistration(ctx, opts, input) {
|
|
|
2033
2109
|
user_id: owner?.referenceId ? void 0 : owner?.userId,
|
|
2034
2110
|
reference_id: owner?.referenceId
|
|
2035
2111
|
});
|
|
2036
|
-
schema.clientCredentialsScopes = input.registrationSource === "clientMetadataDocument" ? input.existingClient?.clientCredentialsScopes ?? [] : input.
|
|
2112
|
+
schema.clientCredentialsScopes = input.registrationSource === "clientMetadataDocument" ? input.existingClient?.clientCredentialsScopes ?? [] : input.clientCredentialsScopes ?? [];
|
|
2037
2113
|
schema.clientDiscoveryId = input.registrationSource === "clientMetadataDocument" ? input.clientDiscoveryId : null;
|
|
2114
|
+
schema.sectorIdentifier = deriveSectorIdentifier(clientId, body.redirect_uris);
|
|
2038
2115
|
if (input.registrationSource === "clientMetadataDocument" && body.application_type === void 0) schema.applicationType = null;
|
|
2039
2116
|
const requestedResources = input.registrationSource === "clientMetadataDocument" ? [] : input.requestedResources ?? [];
|
|
2040
2117
|
if (input.registrationSource === "dynamic") assertDynamicRegistrationResourcesAllowed(opts, requestedResources);
|
|
@@ -2048,6 +2125,19 @@ async function persistOAuthClientRegistration(ctx, opts, input) {
|
|
|
2048
2125
|
let storedClient;
|
|
2049
2126
|
let created = false;
|
|
2050
2127
|
if (existingClient) {
|
|
2128
|
+
let metadata;
|
|
2129
|
+
if (hasExtensionClientMetadata(opts)) {
|
|
2130
|
+
const extensionFields = getExtensionClientMetadataFields(opts);
|
|
2131
|
+
const merged = stripReservedOAuthClientMetadataExtensions(mergeDocumentExtensionMetadata(extensionFields, (await adapter.findOne({
|
|
2132
|
+
model: clientModel,
|
|
2133
|
+
where: [{
|
|
2134
|
+
field: "clientId",
|
|
2135
|
+
value: clientId
|
|
2136
|
+
}]
|
|
2137
|
+
}))?.metadata, body.metadata));
|
|
2138
|
+
await validateExtensionClientMetadata(opts, body, merged);
|
|
2139
|
+
if (extensionFields.size > 0) metadata = merged && Object.keys(merged).length > 0 ? JSON.stringify(merged) : null;
|
|
2140
|
+
}
|
|
2051
2141
|
const updatedClient = await adapter.update({
|
|
2052
2142
|
model: clientModel,
|
|
2053
2143
|
where: [{
|
|
@@ -2059,9 +2149,11 @@ async function persistOAuthClientRegistration(ctx, opts, input) {
|
|
|
2059
2149
|
}],
|
|
2060
2150
|
update: {
|
|
2061
2151
|
...schema,
|
|
2152
|
+
metadata,
|
|
2062
2153
|
disabled: existingClient.disabled,
|
|
2063
2154
|
skipConsent: existingClient.skipConsent,
|
|
2064
2155
|
enableEndSession: existingClient.enableEndSession,
|
|
2156
|
+
sectorIdentifier: existingClient.sectorIdentifier ? schema.sectorIdentifier : null,
|
|
2065
2157
|
createdAt: existingClient.createdAt,
|
|
2066
2158
|
updatedAt: /* @__PURE__ */ new Date(iat * 1e3)
|
|
2067
2159
|
}
|
|
@@ -2166,6 +2258,20 @@ function assertClientDiscoveryOwnership(client, clientDiscoveryId) {
|
|
|
2166
2258
|
});
|
|
2167
2259
|
}
|
|
2168
2260
|
/**
|
|
2261
|
+
* The `metadata` a metadata document registration stores. The document owns the
|
|
2262
|
+
* fields extensions declare, so a refresh replaces them, including removing
|
|
2263
|
+
* ones it no longer declares. Other stored keys, such as ones an admin set, are
|
|
2264
|
+
* kept.
|
|
2265
|
+
*/
|
|
2266
|
+
function mergeDocumentExtensionMetadata(extensionFields, storedMetadata, documentMetadata) {
|
|
2267
|
+
const owned = ([field]) => extensionFields.has(field);
|
|
2268
|
+
const metadata = {
|
|
2269
|
+
...Object.fromEntries(Object.entries(parseClientMetadata(storedMetadata) ?? {}).filter((entry) => !owned(entry))),
|
|
2270
|
+
...Object.fromEntries(Object.entries(documentMetadata ?? {}).filter(owned))
|
|
2271
|
+
};
|
|
2272
|
+
return Object.keys(metadata).length ? metadata : void 0;
|
|
2273
|
+
}
|
|
2274
|
+
/**
|
|
2169
2275
|
* First-party persistence seam for a validated Client ID Metadata Document.
|
|
2170
2276
|
*
|
|
2171
2277
|
* This operation owns both initial creation and atomic replacement. It never
|
|
@@ -2185,18 +2291,20 @@ async function registerClientMetadataDocument(ctx, opts, input) {
|
|
|
2185
2291
|
error: "invalid_client_metadata",
|
|
2186
2292
|
error_description: "Client ID Metadata Document clients cannot use a shared secret"
|
|
2187
2293
|
});
|
|
2188
|
-
const
|
|
2294
|
+
const toRegistrationInput = (existingClient) => ({
|
|
2189
2295
|
...input,
|
|
2296
|
+
existingClient,
|
|
2190
2297
|
metadata: {
|
|
2191
2298
|
...input.metadata,
|
|
2299
|
+
metadata: pickExtensionClientMetadata(input.metadata, opts),
|
|
2192
2300
|
token_endpoint_auth_method: input.metadata.token_endpoint_auth_method ?? "none",
|
|
2193
2301
|
client_secret: void 0,
|
|
2194
2302
|
client_secret_expires_at: void 0
|
|
2195
2303
|
},
|
|
2196
2304
|
registrationSource: "clientMetadataDocument"
|
|
2197
|
-
};
|
|
2305
|
+
});
|
|
2198
2306
|
try {
|
|
2199
|
-
return await persistOAuthClientRegistration(ctx, opts,
|
|
2307
|
+
return await persistOAuthClientRegistration(ctx, opts, toRegistrationInput(input.existingClient));
|
|
2200
2308
|
} catch (error) {
|
|
2201
2309
|
if (!isClientRegistrationCollision(error)) throw error;
|
|
2202
2310
|
const clientModel = opts.schema?.oauthClient?.modelName ?? "oauthClient";
|
|
@@ -2222,10 +2330,7 @@ async function registerClientMetadataDocument(ctx, opts, input) {
|
|
|
2222
2330
|
})) throw error.cause;
|
|
2223
2331
|
}
|
|
2224
2332
|
try {
|
|
2225
|
-
return await persistOAuthClientRegistration(ctx, opts,
|
|
2226
|
-
...registrationInput,
|
|
2227
|
-
existingClient: currentClient
|
|
2228
|
-
});
|
|
2333
|
+
return await persistOAuthClientRegistration(ctx, opts, toRegistrationInput(currentClient));
|
|
2229
2334
|
} catch (retryError) {
|
|
2230
2335
|
if (isClientRegistrationCollision(retryError)) throw retryError.cause;
|
|
2231
2336
|
throw retryError;
|
|
@@ -2274,7 +2379,7 @@ async function createOAuthClientEndpoint(ctx, opts, settings) {
|
|
|
2274
2379
|
return ctx.json(responseBody);
|
|
2275
2380
|
}
|
|
2276
2381
|
function oauthToSchema(input) {
|
|
2277
|
-
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;
|
|
2382
|
+
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;
|
|
2278
2383
|
const expiresAt = _expiresAt ? /* @__PURE__ */ new Date(Number(_expiresAt) * 1e3) : void 0;
|
|
2279
2384
|
const createdAt = _createdAt ? /* @__PURE__ */ new Date(_createdAt * 1e3) : void 0;
|
|
2280
2385
|
const scopes = _scope?.split(" ");
|
|
@@ -2301,6 +2406,8 @@ function oauthToSchema(input) {
|
|
|
2301
2406
|
postLogoutRedirectUris,
|
|
2302
2407
|
backchannelLogoutUri,
|
|
2303
2408
|
backchannelLogoutSessionRequired,
|
|
2409
|
+
frontchannelLogoutUri,
|
|
2410
|
+
frontchannelLogoutSessionRequired,
|
|
2304
2411
|
tokenEndpointAuthMethod,
|
|
2305
2412
|
grantTypes,
|
|
2306
2413
|
responseTypes,
|
|
@@ -2323,7 +2430,7 @@ function oauthToSchema(input) {
|
|
|
2323
2430
|
* @returns
|
|
2324
2431
|
*/
|
|
2325
2432
|
function schemaToOAuth(input) {
|
|
2326
|
-
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;
|
|
2433
|
+
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;
|
|
2327
2434
|
const _expiresAt = expiresAt ? Math.round(new Date(expiresAt).getTime() / 1e3) : void 0;
|
|
2328
2435
|
const _createdAt = createdAt ? Math.round(new Date(createdAt).getTime() / 1e3) : void 0;
|
|
2329
2436
|
const _scopes = scopes?.join(" ");
|
|
@@ -2350,6 +2457,8 @@ function schemaToOAuth(input) {
|
|
|
2350
2457
|
post_logout_redirect_uris: postLogoutRedirectUris ?? void 0,
|
|
2351
2458
|
backchannel_logout_uri: backchannelLogoutUri ?? void 0,
|
|
2352
2459
|
backchannel_logout_session_required: backchannelLogoutSessionRequired ?? void 0,
|
|
2460
|
+
frontchannel_logout_uri: frontchannelLogoutUri ?? void 0,
|
|
2461
|
+
frontchannel_logout_session_required: frontchannelLogoutSessionRequired ?? void 0,
|
|
2353
2462
|
token_endpoint_auth_method: tokenEndpointAuthMethod ?? void 0,
|
|
2354
2463
|
grant_types: grantTypes ?? void 0,
|
|
2355
2464
|
response_types: responseTypes ?? void 0,
|
|
@@ -2477,12 +2586,20 @@ async function updateClientEndpoint(ctx, opts, settings) {
|
|
|
2477
2586
|
error_description: "client not found",
|
|
2478
2587
|
error: "not_found"
|
|
2479
2588
|
});
|
|
2480
|
-
const { client_credentials_scopes: rawClientCredentialsScopes, ...
|
|
2589
|
+
const { client_credentials_scopes: rawClientCredentialsScopes, backchannel_logout_uri: backchannelLogoutUri, frontchannel_logout_uri: frontchannelLogoutUri, ...otherUpdates } = ctx.body.update;
|
|
2590
|
+
const clearsBackchannelLogout = backchannelLogoutUri === null;
|
|
2591
|
+
const clearsFrontchannelLogout = frontchannelLogoutUri === null;
|
|
2592
|
+
const updates = {
|
|
2593
|
+
...otherUpdates,
|
|
2594
|
+
...backchannelLogoutUri != null ? { backchannel_logout_uri: backchannelLogoutUri } : {},
|
|
2595
|
+
...frontchannelLogoutUri != null ? { frontchannel_logout_uri: frontchannelLogoutUri } : {}
|
|
2596
|
+
};
|
|
2597
|
+
const hasUpdates = Object.keys(updates).length > 0 || clearsBackchannelLogout || clearsFrontchannelLogout;
|
|
2481
2598
|
const ownsClient = client.userId ? client.userId === session.user.id : client.referenceId && opts.clientReference ? client.referenceId === await opts.clientReference(session) : false;
|
|
2482
|
-
const isCrossOwnerScopeConfiguration = !ownsClient && settings?.admin && rawClientCredentialsScopes !== void 0 &&
|
|
2599
|
+
const isCrossOwnerScopeConfiguration = !ownsClient && settings?.admin && rawClientCredentialsScopes !== void 0 && !hasUpdates;
|
|
2483
2600
|
if (!ownsClient && !isCrossOwnerScopeConfiguration) throw new APIError("UNAUTHORIZED");
|
|
2484
2601
|
if (isCrossOwnerScopeConfiguration) await assertClientPrivileges(ctx, session, opts, "configure-client-credentials-scopes", clientId);
|
|
2485
|
-
if (
|
|
2602
|
+
if (!hasUpdates && rawClientCredentialsScopes === void 0) {
|
|
2486
2603
|
const res = schemaToOAuth(client);
|
|
2487
2604
|
res.client_secret = void 0;
|
|
2488
2605
|
if (settings?.admin) return {
|
|
@@ -2498,11 +2615,34 @@ async function updateClientEndpoint(ctx, opts, settings) {
|
|
|
2498
2615
|
validateClientCredentialsScopes(clientCredentialsScopes, finalGrantTypes, finalTokenEndpointAuthMethod, opts);
|
|
2499
2616
|
if (clientCredentialsScopes.length > 0 && !isCrossOwnerScopeConfiguration) await assertClientPrivileges(ctx, session, opts, "configure-client-credentials-scopes", clientId);
|
|
2500
2617
|
}
|
|
2501
|
-
|
|
2502
|
-
...schemaToOAuth(
|
|
2503
|
-
|
|
2504
|
-
|
|
2618
|
+
const updatedMetadata = {
|
|
2619
|
+
...schemaToOAuth({
|
|
2620
|
+
...client,
|
|
2621
|
+
metadata: void 0
|
|
2622
|
+
}),
|
|
2623
|
+
...updates,
|
|
2624
|
+
metadata: updates.metadata ?? parseClientMetadata(client.metadata)
|
|
2625
|
+
};
|
|
2626
|
+
if (clearsBackchannelLogout) {
|
|
2627
|
+
updatedMetadata.backchannel_logout_uri = void 0;
|
|
2628
|
+
updatedMetadata.backchannel_logout_session_required = void 0;
|
|
2629
|
+
}
|
|
2630
|
+
if (clearsFrontchannelLogout) {
|
|
2631
|
+
updatedMetadata.frontchannel_logout_uri = void 0;
|
|
2632
|
+
updatedMetadata.frontchannel_logout_session_required = void 0;
|
|
2633
|
+
}
|
|
2634
|
+
await checkOAuthClient(updatedMetadata, opts, { ctx });
|
|
2505
2635
|
const schemaUpdates = { ...oauthToSchema(updates) };
|
|
2636
|
+
if (updates.redirect_uris && client.sectorIdentifier) schemaUpdates.sectorIdentifier = deriveSectorIdentifier(client.clientId, updates.redirect_uris);
|
|
2637
|
+
if (clearsBackchannelLogout) {
|
|
2638
|
+
schemaUpdates.backchannelLogoutUri = null;
|
|
2639
|
+
schemaUpdates.backchannelLogoutSessionRequired = null;
|
|
2640
|
+
}
|
|
2641
|
+
if (clearsFrontchannelLogout) {
|
|
2642
|
+
schemaUpdates.frontchannelLogoutUri = null;
|
|
2643
|
+
schemaUpdates.frontchannelLogoutSessionRequired = null;
|
|
2644
|
+
}
|
|
2645
|
+
if (updates.metadata !== void 0 && schemaUpdates.metadata === void 0) schemaUpdates.metadata = null;
|
|
2506
2646
|
if (!finalGrantTypes.includes("client_credentials") || finalTokenEndpointAuthMethod === "none") schemaUpdates.clientCredentialsScopes = [];
|
|
2507
2647
|
else if (clientCredentialsScopes !== void 0) schemaUpdates.clientCredentialsScopes = clientCredentialsScopes;
|
|
2508
2648
|
if (updates.token_endpoint_auth_method) if (updates.token_endpoint_auth_method === "private_key_jwt") schemaUpdates.clientSecret = null;
|
|
@@ -2648,6 +2788,8 @@ const adminCreateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/creat
|
|
|
2648
2788
|
post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
|
|
2649
2789
|
backchannel_logout_uri: SafeUrlSchema.optional(),
|
|
2650
2790
|
backchannel_logout_session_required: z.boolean().optional(),
|
|
2791
|
+
frontchannel_logout_uri: SafeUrlSchema.optional(),
|
|
2792
|
+
frontchannel_logout_session_required: z.boolean().optional(),
|
|
2651
2793
|
token_endpoint_auth_method: tokenEndpointAuthMethodSchema.optional(),
|
|
2652
2794
|
application_type: z.enum(["web", "native"]).optional(),
|
|
2653
2795
|
jwks: clientJwksSchema.optional(),
|
|
@@ -2821,6 +2963,8 @@ const createOAuthClient = (opts) => createAuthEndpoint("/oauth2/create-client",
|
|
|
2821
2963
|
post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
|
|
2822
2964
|
backchannel_logout_uri: SafeUrlSchema.optional(),
|
|
2823
2965
|
backchannel_logout_session_required: z.boolean().optional(),
|
|
2966
|
+
frontchannel_logout_uri: SafeUrlSchema.optional(),
|
|
2967
|
+
frontchannel_logout_session_required: z.boolean().optional(),
|
|
2824
2968
|
token_endpoint_auth_method: tokenEndpointAuthMethodSchema.optional(),
|
|
2825
2969
|
application_type: z.enum(["web", "native"]).optional(),
|
|
2826
2970
|
jwks: clientJwksSchema.optional(),
|
|
@@ -3010,8 +3154,10 @@ const adminUpdateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/updat
|
|
|
3010
3154
|
software_version: z.string().optional(),
|
|
3011
3155
|
software_statement: z.string().optional(),
|
|
3012
3156
|
post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
|
|
3013
|
-
backchannel_logout_uri: SafeUrlSchema.optional(),
|
|
3157
|
+
backchannel_logout_uri: SafeUrlSchema.nullable().optional(),
|
|
3014
3158
|
backchannel_logout_session_required: z.boolean().optional(),
|
|
3159
|
+
frontchannel_logout_uri: SafeUrlSchema.nullable().optional(),
|
|
3160
|
+
frontchannel_logout_session_required: z.boolean().optional(),
|
|
3015
3161
|
application_type: z.enum(["web", "native"]).optional(),
|
|
3016
3162
|
grant_types: grantTypesSchema.optional(),
|
|
3017
3163
|
client_credentials_scopes: z.array(z.string().trim().min(1)).optional(),
|
|
@@ -3049,8 +3195,10 @@ const updateOAuthClient = (opts) => createAuthEndpoint("/oauth2/update-client",
|
|
|
3049
3195
|
software_version: z.string().optional(),
|
|
3050
3196
|
software_statement: z.string().optional(),
|
|
3051
3197
|
post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
|
|
3052
|
-
backchannel_logout_uri: SafeUrlSchema.optional(),
|
|
3198
|
+
backchannel_logout_uri: SafeUrlSchema.nullable().optional(),
|
|
3053
3199
|
backchannel_logout_session_required: z.boolean().optional(),
|
|
3200
|
+
frontchannel_logout_uri: SafeUrlSchema.nullable().optional(),
|
|
3201
|
+
frontchannel_logout_session_required: z.boolean().optional(),
|
|
3054
3202
|
application_type: z.enum(["web", "native"]).optional(),
|
|
3055
3203
|
grant_types: grantTypesSchema.optional(),
|
|
3056
3204
|
response_types: z.array(z.enum(["code"])).optional()
|
|
@@ -3540,113 +3688,47 @@ const adminUnlinkClientResource = (opts) => createAuthEndpoint("/admin/oauth2/re
|
|
|
3540
3688
|
//#endregion
|
|
3541
3689
|
//#region src/revoke.ts
|
|
3542
3690
|
/**
|
|
3543
|
-
*
|
|
3544
|
-
* Revocation follows RFC7009
|
|
3691
|
+
* Revocation follows RFC 7009
|
|
3545
3692
|
* https://datatracker.ietf.org/doc/html/rfc7009
|
|
3546
|
-
*
|
|
3547
|
-
*
|
|
3693
|
+
*
|
|
3694
|
+
* A token this server cannot find is already unusable, so it resolves as a
|
|
3695
|
+
* successful no-op (§2.2). Only explicit protocol errors surface.
|
|
3548
3696
|
*/
|
|
3549
3697
|
/**
|
|
3550
|
-
* Revokes a JWT access token against the configured JWKs.
|
|
3551
|
-
*
|
|
3552
3698
|
* A JWT access token is self-contained and never stored, so there is nothing to
|
|
3553
3699
|
* delete. Once the token is confirmed to be a valid JWT for this server, the
|
|
3554
3700
|
* endpoint reports `unsupported_token_type` (RFC 7009 §2.2.1) instead of a
|
|
3555
3701
|
* silent success, so callers can tell that no server-side revocation happened.
|
|
3556
|
-
*
|
|
3557
|
-
*
|
|
3558
|
-
*
|
|
3559
|
-
*
|
|
3702
|
+
* A JWT with an audience rejected by the OAuth resource model is already
|
|
3703
|
+
* inactive and resolves as a successful no-op. Session-bound tokens (carrying
|
|
3704
|
+
* `sid`) are cut off early by the session-liveness check in introspection and
|
|
3705
|
+
* userinfo.
|
|
3560
3706
|
*/
|
|
3561
|
-
async function revokeJwtAccessToken(ctx, opts,
|
|
3562
|
-
if (opts.
|
|
3563
|
-
error_description: "JWT access tokens are disabled",
|
|
3564
|
-
error: "invalid_request"
|
|
3565
|
-
});
|
|
3566
|
-
const jwtPlugin = getJwtPlugin(ctx.context);
|
|
3567
|
-
const jwtPluginOptions = jwtPlugin.options;
|
|
3568
|
-
let jwtPayload;
|
|
3569
|
-
try {
|
|
3570
|
-
jwtPayload = (await jwtVerify(token, createLocalJWKSet(await getJwks(token, {
|
|
3571
|
-
jwksFetch: jwtPluginOptions?.jwks?.remoteUrl ? jwtPluginOptions.jwks.remoteUrl : async () => {
|
|
3572
|
-
return (await jwtPlugin.endpoints.getJwks(ctx))?.response;
|
|
3573
|
-
},
|
|
3574
|
-
jwksCacheKey: jwtPlugin
|
|
3575
|
-
})), { issuer: jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL })).payload;
|
|
3576
|
-
} catch (error) {
|
|
3577
|
-
if (error instanceof errors.JWTExpired || error instanceof errors.JWTInvalid) return null;
|
|
3578
|
-
if (error instanceof TypeError || isInvalidJwtError(error)) throw new APIError$1("BAD_REQUEST", {
|
|
3579
|
-
error_description: "invalid JWT signature",
|
|
3580
|
-
error: "invalid_request"
|
|
3581
|
-
});
|
|
3582
|
-
throw error;
|
|
3583
|
-
}
|
|
3584
|
-
const userInfoAudience = `${ctx.context.baseURL}/oauth2/userinfo`;
|
|
3585
|
-
if (!jwtPayload.azp || !await isAudienceClaimAllowed(ctx, opts, jwtPayload.aud, [userInfoAudience])) return null;
|
|
3707
|
+
async function revokeJwtAccessToken(ctx, opts, payload) {
|
|
3708
|
+
if (!await isAudienceClaimAllowed(ctx, opts, payload.aud, [userInfoResource(ctx.context.baseURL)])) return null;
|
|
3586
3709
|
throw new APIError$1("BAD_REQUEST", {
|
|
3587
3710
|
error_description: "JWT access tokens are self-contained and cannot be revoked server-side",
|
|
3588
3711
|
error: "unsupported_token_type"
|
|
3589
3712
|
});
|
|
3590
3713
|
}
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
*/
|
|
3594
|
-
async function revokeOpaqueAccessToken(ctx, opts, token, clientId) {
|
|
3595
|
-
let tokenValue = token;
|
|
3596
|
-
if (opts.prefix?.opaqueAccessToken) if (tokenValue.startsWith(opts.prefix.opaqueAccessToken)) tokenValue = tokenValue.replace(opts.prefix.opaqueAccessToken, "");
|
|
3597
|
-
else throw new APIError$1("BAD_REQUEST", {
|
|
3598
|
-
error_description: "opaque access token not found",
|
|
3599
|
-
error: "invalid_request"
|
|
3600
|
-
});
|
|
3601
|
-
const accessToken = await ctx.context.adapter.findOne({
|
|
3602
|
-
model: "oauthAccessToken",
|
|
3603
|
-
where: [{
|
|
3604
|
-
field: "token",
|
|
3605
|
-
value: await getStoredToken(opts.storeTokens, tokenValue, "access_token")
|
|
3606
|
-
}]
|
|
3607
|
-
});
|
|
3608
|
-
if (!accessToken) throw new APIError$1("BAD_REQUEST", {
|
|
3609
|
-
error_description: "opaque access token not found",
|
|
3610
|
-
error: "invalid_request"
|
|
3611
|
-
});
|
|
3612
|
-
if (!accessToken.clientId || accessToken.clientId !== clientId) return null;
|
|
3613
|
-
accessToken.id ? await ctx.context.adapter.delete({
|
|
3714
|
+
async function revokeOpaqueAccessToken(ctx, accessToken) {
|
|
3715
|
+
await ctx.context.adapter.delete({
|
|
3614
3716
|
model: "oauthAccessToken",
|
|
3615
|
-
where: [{
|
|
3717
|
+
where: accessToken.id ? [{
|
|
3616
3718
|
field: "id",
|
|
3617
3719
|
value: accessToken.id
|
|
3618
|
-
}]
|
|
3619
|
-
}) : await ctx.context.adapter.delete({
|
|
3620
|
-
model: "oauthAccessToken",
|
|
3621
|
-
where: [{
|
|
3720
|
+
}] : [{
|
|
3622
3721
|
field: "token",
|
|
3623
3722
|
value: accessToken.token
|
|
3624
3723
|
}]
|
|
3625
3724
|
});
|
|
3626
3725
|
}
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
*/
|
|
3630
|
-
async function revokeRefreshToken(ctx, opts, token, clientId) {
|
|
3631
|
-
const refreshToken = await ctx.context.adapter.findOne({
|
|
3632
|
-
model: "oauthRefreshToken",
|
|
3633
|
-
where: [{
|
|
3634
|
-
field: "token",
|
|
3635
|
-
value: await getStoredToken(opts.storeTokens, token, "refresh_token")
|
|
3636
|
-
}]
|
|
3637
|
-
});
|
|
3638
|
-
if (!refreshToken) throw new APIError$1("BAD_REQUEST", {
|
|
3639
|
-
error_description: "token not found",
|
|
3640
|
-
error: "invalid_request"
|
|
3641
|
-
});
|
|
3726
|
+
async function revokeRefreshToken(ctx, refreshToken, clientId) {
|
|
3727
|
+
const { userId } = refreshToken;
|
|
3642
3728
|
if (refreshToken.revoked) {
|
|
3643
|
-
await invalidateRefreshFamily(ctx, clientId,
|
|
3644
|
-
|
|
3645
|
-
error_description: "refresh token revoked",
|
|
3646
|
-
error: "invalid_request"
|
|
3647
|
-
});
|
|
3729
|
+
await invalidateRefreshFamily(ctx, clientId, userId);
|
|
3730
|
+
return;
|
|
3648
3731
|
}
|
|
3649
|
-
if (!refreshToken.clientId || refreshToken.clientId !== clientId) return null;
|
|
3650
3732
|
const iat = Math.floor(Date.now() / 1e3);
|
|
3651
3733
|
if (!await ctx.context.adapter.incrementOne({
|
|
3652
3734
|
model: "oauthRefreshToken",
|
|
@@ -3661,11 +3743,8 @@ async function revokeRefreshToken(ctx, opts, token, clientId) {
|
|
|
3661
3743
|
increment: {},
|
|
3662
3744
|
set: { revoked: /* @__PURE__ */ new Date(iat * 1e3) }
|
|
3663
3745
|
})) {
|
|
3664
|
-
await invalidateRefreshFamily(ctx, clientId,
|
|
3665
|
-
|
|
3666
|
-
error_description: "refresh token revoked",
|
|
3667
|
-
error: "invalid_request"
|
|
3668
|
-
});
|
|
3746
|
+
await invalidateRefreshFamily(ctx, clientId, userId);
|
|
3747
|
+
return;
|
|
3669
3748
|
}
|
|
3670
3749
|
await ctx.context.adapter.deleteMany({
|
|
3671
3750
|
model: "oauthAccessToken",
|
|
@@ -3675,33 +3754,8 @@ async function revokeRefreshToken(ctx, opts, token, clientId) {
|
|
|
3675
3754
|
}]
|
|
3676
3755
|
});
|
|
3677
3756
|
}
|
|
3678
|
-
/**
|
|
3679
|
-
* We don't know the access token format so we try to validate it
|
|
3680
|
-
* as a JWT first, then as an opaque token.
|
|
3681
|
-
*/
|
|
3682
|
-
async function revokeAccessToken(ctx, opts, clientId, token) {
|
|
3683
|
-
try {
|
|
3684
|
-
return await revokeJwtAccessToken(ctx, opts, token);
|
|
3685
|
-
} catch (err) {
|
|
3686
|
-
if (err instanceof APIError$1) {
|
|
3687
|
-
if (err.body?.error === "unsupported_token_type") throw err;
|
|
3688
|
-
} else if (err instanceof Error) throw err;
|
|
3689
|
-
else throw new Error(err);
|
|
3690
|
-
}
|
|
3691
|
-
try {
|
|
3692
|
-
return await revokeOpaqueAccessToken(ctx, opts, token, clientId);
|
|
3693
|
-
} catch (err) {
|
|
3694
|
-
if (err instanceof APIError$1) {} else if (err instanceof Error) throw err;
|
|
3695
|
-
else throw new Error("Unknown error validating access token");
|
|
3696
|
-
}
|
|
3697
|
-
throw new APIError$1("BAD_REQUEST", {
|
|
3698
|
-
error_description: "Invalid access token",
|
|
3699
|
-
error: "invalid_request"
|
|
3700
|
-
});
|
|
3701
|
-
}
|
|
3702
3757
|
async function revokeEndpoint(ctx, opts) {
|
|
3703
3758
|
let { token, token_type_hint } = ctx.body;
|
|
3704
|
-
if (token_type_hint !== "access_token" && token_type_hint !== "refresh_token") token_type_hint = void 0;
|
|
3705
3759
|
const { clientId: client_id, clientSecret: client_secret, preVerified, authMethod } = destructureCredentials(await extractClientCredentials(ctx, opts, `${ctx.context.baseURL}/oauth2/revoke`));
|
|
3706
3760
|
if (!client_id) throw new APIError$1("UNAUTHORIZED", {
|
|
3707
3761
|
error_description: "missing required credentials",
|
|
@@ -3714,38 +3768,23 @@ async function revokeEndpoint(ctx, opts) {
|
|
|
3714
3768
|
});
|
|
3715
3769
|
const client = await validateClientCredentials(ctx, opts, client_id, client_secret, void 0, preVerified, void 0, authMethod);
|
|
3716
3770
|
try {
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
if (
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
if (token_type_hint === void 0 || token_type_hint === "refresh_token") try {
|
|
3726
|
-
return await revokeRefreshToken(ctx, opts, (await decodeRefreshToken(opts, token)).token, client.clientId);
|
|
3727
|
-
} catch (error) {
|
|
3728
|
-
if (error instanceof APIError$1) {
|
|
3729
|
-
if (token_type_hint === "refresh_token") throw error;
|
|
3730
|
-
} else if (error instanceof Error) throw error;
|
|
3731
|
-
else throw new Error(error);
|
|
3771
|
+
const found = await findToken(ctx, opts, token, token_type_hint);
|
|
3772
|
+
if (!found) return null;
|
|
3773
|
+
if ((found.type === "jwt" ? found.payload.azp : found.record.clientId) !== client.clientId) {
|
|
3774
|
+
if (client.tokenEndpointAuthMethod === "none") return null;
|
|
3775
|
+
throw new APIError$1("BAD_REQUEST", {
|
|
3776
|
+
error_description: "client is not authorized to revoke the presented token",
|
|
3777
|
+
error: "invalid_request"
|
|
3778
|
+
});
|
|
3732
3779
|
}
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
3780
|
+
if (found.type === "jwt") return await revokeJwtAccessToken(ctx, opts, found.payload);
|
|
3781
|
+
if (found.type === "opaque") await revokeOpaqueAccessToken(ctx, found.record);
|
|
3782
|
+
else await revokeRefreshToken(ctx, found.record, client.clientId);
|
|
3783
|
+
return null;
|
|
3737
3784
|
} catch (error) {
|
|
3738
|
-
if (error instanceof APIError$1)
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
throw error;
|
|
3742
|
-
} else if (error instanceof Error) {
|
|
3743
|
-
logger.error("Introspection error:", error.message, error.stack);
|
|
3744
|
-
throw new APIError$1("INTERNAL_SERVER_ERROR");
|
|
3745
|
-
} else {
|
|
3746
|
-
logger.error("Introspection error:", error);
|
|
3747
|
-
throw new APIError$1("INTERNAL_SERVER_ERROR");
|
|
3748
|
-
}
|
|
3785
|
+
if (error instanceof APIError$1) throw error;
|
|
3786
|
+
logger.error("Revocation error:", error);
|
|
3787
|
+
throw new APIError$1("INTERNAL_SERVER_ERROR");
|
|
3749
3788
|
}
|
|
3750
3789
|
}
|
|
3751
3790
|
//#endregion
|
|
@@ -3784,6 +3823,10 @@ const schema = {
|
|
|
3784
3823
|
type: "string",
|
|
3785
3824
|
required: false
|
|
3786
3825
|
},
|
|
3826
|
+
sectorIdentifier: {
|
|
3827
|
+
type: "string",
|
|
3828
|
+
required: false
|
|
3829
|
+
},
|
|
3787
3830
|
scopes: {
|
|
3788
3831
|
type: "string[]",
|
|
3789
3832
|
required: false
|
|
@@ -3862,6 +3905,14 @@ const schema = {
|
|
|
3862
3905
|
type: "boolean",
|
|
3863
3906
|
required: false
|
|
3864
3907
|
},
|
|
3908
|
+
frontchannelLogoutUri: {
|
|
3909
|
+
type: "string",
|
|
3910
|
+
required: false
|
|
3911
|
+
},
|
|
3912
|
+
frontchannelLogoutSessionRequired: {
|
|
3913
|
+
type: "boolean",
|
|
3914
|
+
required: false
|
|
3915
|
+
},
|
|
3865
3916
|
tokenEndpointAuthMethod: {
|
|
3866
3917
|
type: "string",
|
|
3867
3918
|
required: false
|
|
@@ -4110,6 +4161,14 @@ const schema = {
|
|
|
4110
4161
|
type: "date",
|
|
4111
4162
|
required: false
|
|
4112
4163
|
},
|
|
4164
|
+
acr: {
|
|
4165
|
+
type: "string",
|
|
4166
|
+
required: false
|
|
4167
|
+
},
|
|
4168
|
+
amr: {
|
|
4169
|
+
type: "string[]",
|
|
4170
|
+
required: false
|
|
4171
|
+
},
|
|
4113
4172
|
confirmation: {
|
|
4114
4173
|
type: "json",
|
|
4115
4174
|
required: false
|
|
@@ -4290,7 +4349,7 @@ const rpInitiatedLogoutRequestSchema = z.object({
|
|
|
4290
4349
|
});
|
|
4291
4350
|
const oAuthState = defineRequestState(() => null);
|
|
4292
4351
|
const getOAuthProviderState = oAuthState.get;
|
|
4293
|
-
const
|
|
4352
|
+
const sessionLogoutPlansByContext = /* @__PURE__ */ new WeakMap();
|
|
4294
4353
|
const signedQueryIssuedAtMsKey = "signedQueryIssuedAtMs";
|
|
4295
4354
|
function getServerContextSignedQueryIssuedAt(value) {
|
|
4296
4355
|
const issuedAtMs = typeof value === "number" ? value : typeof value === "string" ? Number(value) : void 0;
|
|
@@ -4314,6 +4373,8 @@ const oauthProvider = (options) => {
|
|
|
4314
4373
|
if (clientRegistrationAllowedScopes) {
|
|
4315
4374
|
for (const sc of clientRegistrationAllowedScopes) if (!scopes.has(sc)) throw new BetterAuthError(`clientRegistrationAllowedScope ${sc} not found in scopes`);
|
|
4316
4375
|
}
|
|
4376
|
+
const acrValuesSupported = options.authenticationContext?.acrValuesSupported;
|
|
4377
|
+
if (acrValuesSupported && (acrValuesSupported.length === 0 || acrValuesSupported.includes(""))) throw new BetterAuthError("authenticationContext.acrValuesSupported must list at least one non-empty acr value");
|
|
4317
4378
|
for (const sc of options.advertisedMetadata?.scopes_supported ?? []) if (!scopes?.has(sc)) throw new BetterAuthError(`advertisedMetadata.scopes_supported ${sc} not found in scopes`);
|
|
4318
4379
|
const configuredResourceIdentifiers = new Set((options.resources ?? []).map((resource) => typeof resource === "string" ? resource : resource.identifier));
|
|
4319
4380
|
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`);
|
|
@@ -4405,12 +4466,19 @@ const oauthProvider = (options) => {
|
|
|
4405
4466
|
schema: { type: "string" },
|
|
4406
4467
|
description: "OAuth 2.1 response type (e.g., 'code')"
|
|
4407
4468
|
},
|
|
4469
|
+
{
|
|
4470
|
+
name: "user_code",
|
|
4471
|
+
in: "query",
|
|
4472
|
+
required: false,
|
|
4473
|
+
schema: { type: "string" },
|
|
4474
|
+
description: "Device user code to approve instead of an authorization request (requires oauthDeviceAuthorization())"
|
|
4475
|
+
},
|
|
4408
4476
|
{
|
|
4409
4477
|
name: "client_id",
|
|
4410
4478
|
in: "query",
|
|
4411
|
-
required:
|
|
4479
|
+
required: false,
|
|
4412
4480
|
schema: { type: "string" },
|
|
4413
|
-
description: "OAuth 2.1 client ID"
|
|
4481
|
+
description: "OAuth 2.1 client ID, required for authorization requests (not for user_code)"
|
|
4414
4482
|
},
|
|
4415
4483
|
{
|
|
4416
4484
|
name: "redirect_uri",
|
|
@@ -4554,25 +4622,26 @@ const oauthProvider = (options) => {
|
|
|
4554
4622
|
return { options: { databaseHooks: { session: { delete: {
|
|
4555
4623
|
async before(session, hookCtx) {
|
|
4556
4624
|
if (!hookCtx) return;
|
|
4557
|
-
const plan = await
|
|
4625
|
+
const plan = await prepareSessionLogoutPlan(hookCtx, opts, {
|
|
4558
4626
|
sessionId: session.id,
|
|
4559
4627
|
userId: session.userId
|
|
4560
4628
|
});
|
|
4561
4629
|
if (!plan) return;
|
|
4562
|
-
const logoutPlanBySessionId =
|
|
4630
|
+
const logoutPlanBySessionId = sessionLogoutPlansByContext.get(hookCtx) ?? /* @__PURE__ */ new Map();
|
|
4563
4631
|
logoutPlanBySessionId.set(session.id, plan);
|
|
4564
|
-
|
|
4632
|
+
sessionLogoutPlansByContext.set(hookCtx, logoutPlanBySessionId);
|
|
4565
4633
|
},
|
|
4566
4634
|
async after(session, hookCtx) {
|
|
4567
4635
|
if (!hookCtx) return;
|
|
4568
|
-
const logoutPlanBySessionId =
|
|
4636
|
+
const logoutPlanBySessionId = sessionLogoutPlansByContext.get(hookCtx);
|
|
4569
4637
|
if (!logoutPlanBySessionId) return;
|
|
4570
4638
|
const plan = logoutPlanBySessionId.get(session.id);
|
|
4571
4639
|
logoutPlanBySessionId.delete(session.id);
|
|
4572
|
-
if (logoutPlanBySessionId.size === 0)
|
|
4640
|
+
if (logoutPlanBySessionId.size === 0) sessionLogoutPlansByContext.delete(hookCtx);
|
|
4573
4641
|
if (!plan) return;
|
|
4574
|
-
|
|
4575
|
-
|
|
4642
|
+
await recordFrontchannelLogoutUris(plan.frontchannelLogoutUris);
|
|
4643
|
+
const logoutTask = applySessionLogoutPlan(hookCtx, plan).catch((error) => {
|
|
4644
|
+
hookCtx.context.logger.error("Logout notification failed after session deletion", error);
|
|
4576
4645
|
});
|
|
4577
4646
|
await hookCtx.context.runInBackgroundOrAwait(logoutTask);
|
|
4578
4647
|
}
|
|
@@ -4716,7 +4785,7 @@ const oauthProvider = (options) => {
|
|
|
4716
4785
|
}),
|
|
4717
4786
|
oauth2Token: createOAuthEndpoint("/oauth2/token", {
|
|
4718
4787
|
method: "POST",
|
|
4719
|
-
|
|
4788
|
+
formParams: { repeatable: ["resource", "audience"] },
|
|
4720
4789
|
body: z.object({
|
|
4721
4790
|
grant_type: z.string().trim().min(1),
|
|
4722
4791
|
client_id: z.string().optional(),
|
|
@@ -4857,15 +4926,11 @@ const oauthProvider = (options) => {
|
|
|
4857
4926
|
}
|
|
4858
4927
|
}
|
|
4859
4928
|
}, async (ctx) => {
|
|
4860
|
-
if (ctx.request) {
|
|
4861
|
-
const repeated = await extractRepeatedResourceFromForm(ctx.request);
|
|
4862
|
-
if (repeated && repeated.length > 1) ctx.body.resource = repeated;
|
|
4863
|
-
}
|
|
4864
4929
|
return tokenEndpoint(ctx, opts);
|
|
4865
4930
|
}),
|
|
4866
4931
|
oauth2Introspect: createOAuthEndpoint("/oauth2/introspect", {
|
|
4867
4932
|
method: "POST",
|
|
4868
|
-
|
|
4933
|
+
formParams: {},
|
|
4869
4934
|
body: z.object({
|
|
4870
4935
|
client_id: z.string().optional(),
|
|
4871
4936
|
client_secret: z.string().optional(),
|
|
@@ -4982,7 +5047,7 @@ const oauthProvider = (options) => {
|
|
|
4982
5047
|
}),
|
|
4983
5048
|
oauth2Revoke: createOAuthEndpoint("/oauth2/revoke", {
|
|
4984
5049
|
method: "POST",
|
|
4985
|
-
|
|
5050
|
+
formParams: {},
|
|
4986
5051
|
body: z.object({
|
|
4987
5052
|
client_id: z.string().optional(),
|
|
4988
5053
|
client_secret: z.string().optional(),
|
|
@@ -5160,7 +5225,7 @@ const oauthProvider = (options) => {
|
|
|
5160
5225
|
openapi: {
|
|
5161
5226
|
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.",
|
|
5162
5227
|
responses: { "200": {
|
|
5163
|
-
description: "Logout completed, or an HTML
|
|
5228
|
+
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.",
|
|
5164
5229
|
content: {
|
|
5165
5230
|
"application/json": { schema: {
|
|
5166
5231
|
type: "object",
|
|
@@ -5176,7 +5241,10 @@ const oauthProvider = (options) => {
|
|
|
5176
5241
|
}
|
|
5177
5242
|
}
|
|
5178
5243
|
} },
|
|
5179
|
-
"text/html": { schema: {
|
|
5244
|
+
"text/html": { schema: {
|
|
5245
|
+
type: "string",
|
|
5246
|
+
description: "Logout confirmation page, or the OIDC Front-Channel Logout page with one hidden iframe per registered RP"
|
|
5247
|
+
} }
|
|
5180
5248
|
}
|
|
5181
5249
|
} }
|
|
5182
5250
|
}
|
|
@@ -5197,7 +5265,7 @@ const oauthProvider = (options) => {
|
|
|
5197
5265
|
}),
|
|
5198
5266
|
registerOAuthClient: createOAuthEndpoint("/oauth2/register", {
|
|
5199
5267
|
method: "POST",
|
|
5200
|
-
body: clientRegistrationRequestSchema,
|
|
5268
|
+
body: clientRegistrationRequestSchema.loose(),
|
|
5201
5269
|
errorCodesByField: {
|
|
5202
5270
|
redirect_uris: "invalid_redirect_uri",
|
|
5203
5271
|
post_logout_redirect_uris: "invalid_redirect_uri",
|
|
@@ -5301,6 +5369,15 @@ const oauthProvider = (options) => {
|
|
|
5301
5369
|
type: "boolean",
|
|
5302
5370
|
description: "Whether the RP requires a `sid` claim in every Logout Token"
|
|
5303
5371
|
},
|
|
5372
|
+
frontchannel_logout_uri: {
|
|
5373
|
+
type: "string",
|
|
5374
|
+
format: "uri",
|
|
5375
|
+
description: "RP URL rendered in a hidden iframe on the OP's logout page when the end-user's OP session terminates"
|
|
5376
|
+
},
|
|
5377
|
+
frontchannel_logout_session_required: {
|
|
5378
|
+
type: "boolean",
|
|
5379
|
+
description: "Whether the RP requires `iss` and `sid` query parameters on its front-channel logout URI. The OP always sends both."
|
|
5380
|
+
},
|
|
5304
5381
|
token_endpoint_auth_method: {
|
|
5305
5382
|
type: "string",
|
|
5306
5383
|
description: "Requested authentication method for the token endpoint"
|
|
@@ -5477,8 +5554,20 @@ const handleRedirect = (ctx, uri) => {
|
|
|
5477
5554
|
};
|
|
5478
5555
|
else throw ctx.redirect(uri);
|
|
5479
5556
|
};
|
|
5480
|
-
|
|
5481
|
-
|
|
5557
|
+
/** Sends an OAuth error to the client's redirect URI, or to the error page without one. */
|
|
5558
|
+
function redirectWithAuthorizationError(ctx, opts, query, error, description) {
|
|
5559
|
+
return handleRedirect(ctx, query.redirect_uri ? formatErrorURL(query.redirect_uri, error, description, query.state, getIssuer(ctx, opts)) : getErrorURL(ctx, error, description));
|
|
5560
|
+
}
|
|
5561
|
+
/**
|
|
5562
|
+
* Returns the URL that reports an `APIError`: `errorPage` when given, otherwise
|
|
5563
|
+
* the provider error page. Rethrows anything else.
|
|
5564
|
+
*/
|
|
5565
|
+
function getAPIErrorURL(ctx, error, errorPage) {
|
|
5566
|
+
if (!(error instanceof APIError$1)) throw error;
|
|
5567
|
+
const body = error.body;
|
|
5568
|
+
const code = body?.error ?? "invalid_request";
|
|
5569
|
+
const description = body?.error_description ?? "invalid request";
|
|
5570
|
+
return errorPage ? formatErrorURL(errorPage, code, description) : getErrorURL(ctx, code, description);
|
|
5482
5571
|
}
|
|
5483
5572
|
/**
|
|
5484
5573
|
* Validates that the issuer URL
|
|
@@ -5640,13 +5729,45 @@ function authorizeRedirectOnError(opts) {
|
|
|
5640
5729
|
};
|
|
5641
5730
|
}
|
|
5642
5731
|
async function authorizeEndpoint(ctx, opts, settings) {
|
|
5643
|
-
if (opts.grantTypes && !opts.grantTypes.includes("authorization_code")) throw new APIError$1("NOT_FOUND");
|
|
5644
5732
|
if (!ctx.request) throw new APIError$1("UNAUTHORIZED", {
|
|
5645
5733
|
error_description: "request not found",
|
|
5646
5734
|
error: "invalid_request"
|
|
5647
5735
|
});
|
|
5648
5736
|
const request = ctx.request;
|
|
5649
5737
|
let query = getAuthorizationRequestParameters(ctx, settings);
|
|
5738
|
+
const interaction = getExtensionInteractionRequest(opts, query);
|
|
5739
|
+
if (interaction) {
|
|
5740
|
+
if (settings?.isAuthorize === true && !await getSessionFromCtx(ctx)) {
|
|
5741
|
+
ctx.query = { [interaction.parameter]: interaction.value };
|
|
5742
|
+
return redirectWithPromptCode(ctx, opts, "login");
|
|
5743
|
+
}
|
|
5744
|
+
const errorPage = interaction.handler.errorPage?.({
|
|
5745
|
+
ctx,
|
|
5746
|
+
value: interaction.value
|
|
5747
|
+
});
|
|
5748
|
+
const toErrorURL = (error) => getAPIErrorURL(ctx, error, errorPage);
|
|
5749
|
+
let interactionRequest;
|
|
5750
|
+
try {
|
|
5751
|
+
interactionRequest = await interaction.handler.load({
|
|
5752
|
+
ctx,
|
|
5753
|
+
opts,
|
|
5754
|
+
value: interaction.value,
|
|
5755
|
+
signedQuery: settings?.isAuthorize === true ? void 0 : query
|
|
5756
|
+
});
|
|
5757
|
+
if (interactionRequest.client.disabled) throw new APIError$1("BAD_REQUEST", {
|
|
5758
|
+
error: "client_disabled",
|
|
5759
|
+
error_description: "client is disabled"
|
|
5760
|
+
});
|
|
5761
|
+
} catch (error) {
|
|
5762
|
+
return handleRedirect(ctx, toErrorURL(error));
|
|
5763
|
+
}
|
|
5764
|
+
return runAuthorizationInteraction(ctx, opts, request.headers, settings, {
|
|
5765
|
+
...interactionRequest,
|
|
5766
|
+
approve: (approval) => interactionRequest.approve(approval).catch(toErrorURL),
|
|
5767
|
+
errorURL: toErrorURL
|
|
5768
|
+
});
|
|
5769
|
+
}
|
|
5770
|
+
if (opts.grantTypes && !opts.grantTypes.includes("authorization_code")) throw new APIError$1("NOT_FOUND");
|
|
5650
5771
|
ctx.query = query;
|
|
5651
5772
|
const requestObject = getStringParameter(query.request);
|
|
5652
5773
|
const requestUri = getStringParameter(query.request_uri);
|
|
@@ -5703,9 +5824,7 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
5703
5824
|
error_description: "response_type is required",
|
|
5704
5825
|
ctx
|
|
5705
5826
|
});
|
|
5706
|
-
|
|
5707
|
-
const promptNone = promptSet?.has("none") ?? false;
|
|
5708
|
-
if (promptSet?.has("select_account") && !opts.selectAccount?.page) return handleRedirect(ctx, getErrorURL(ctx, `unsupported_prompt_select_account`, "unsupported prompt type"));
|
|
5827
|
+
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"));
|
|
5709
5828
|
if (!(query.response_type === "code")) return handleRedirect(ctx, getErrorURL(ctx, "unsupported_response_type", "unsupported response type"));
|
|
5710
5829
|
const client = await getClient(ctx, opts, query.client_id);
|
|
5711
5830
|
if (!client) return handleRedirect(ctx, getErrorURL(ctx, "invalid_client", "client not found"));
|
|
@@ -5738,8 +5857,7 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
5738
5857
|
const openidRequested = requestedScopes.includes("openid");
|
|
5739
5858
|
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)));
|
|
5740
5859
|
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)));
|
|
5741
|
-
if (openidRequested && !canSatisfyEssentialAcrRequest(query.claims,
|
|
5742
|
-
const requestedUserInfoClaims = getRequestedUserInfoClaims(query.claims, getSupportedClaims(opts));
|
|
5860
|
+
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)));
|
|
5743
5861
|
if (query.resource !== void 0) try {
|
|
5744
5862
|
await resolveResourcePolicy(ctx, opts, {
|
|
5745
5863
|
resource: query.resource,
|
|
@@ -5765,12 +5883,35 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
5765
5883
|
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)));
|
|
5766
5884
|
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)));
|
|
5767
5885
|
}
|
|
5768
|
-
|
|
5886
|
+
return runAuthorizationInteraction(ctx, opts, request.headers, settings, {
|
|
5887
|
+
query,
|
|
5888
|
+
client,
|
|
5889
|
+
approve: (approval) => createAuthorizationCodeURL(ctx, opts, {
|
|
5890
|
+
...approval,
|
|
5891
|
+
query: approval.query,
|
|
5892
|
+
clientId: client.clientId,
|
|
5893
|
+
resource: toResourceList(approval.query.resource) ?? []
|
|
5894
|
+
})
|
|
5895
|
+
});
|
|
5896
|
+
}
|
|
5897
|
+
/**
|
|
5898
|
+
* Sends the user to the next required interaction page, or approves the
|
|
5899
|
+
* request once none remain.
|
|
5900
|
+
*/
|
|
5901
|
+
async function runAuthorizationInteraction(ctx, opts, headers, settings, request) {
|
|
5902
|
+
let { query } = request;
|
|
5903
|
+
const { client } = request;
|
|
5904
|
+
ctx.query = query;
|
|
5905
|
+
const scopes = query.scope?.split(" ").filter((scope) => scope) ?? [];
|
|
5906
|
+
const userInfoClaims = getRequestedUserInfoClaims(query.claims, getSupportedClaims(opts));
|
|
5907
|
+
const resources = toResourceList(query.resource) ?? [];
|
|
5908
|
+
const promptSet = query.prompt ? parsePrompt(query.prompt) : void 0;
|
|
5909
|
+
const promptNone = promptSet?.has("none") ?? false;
|
|
5769
5910
|
const session = await getSessionFromCtx(ctx);
|
|
5770
5911
|
const maxAgeSeconds = query.max_age;
|
|
5771
5912
|
const hasSatisfiedMaxAge = session != null && maxAgeSeconds !== void 0 && isWithinMaxAge(new Date(session.session.createdAt), maxAgeSeconds, /* @__PURE__ */ new Date());
|
|
5772
5913
|
if (!session || session != null && maxAgeSeconds !== void 0 && !hasSatisfiedMaxAge || promptSet?.has("login") || promptSet?.has("create")) {
|
|
5773
|
-
if (promptNone) return
|
|
5914
|
+
if (promptNone) return redirectWithAuthorizationError(ctx, opts, query, "login_required", "authentication required");
|
|
5774
5915
|
return redirectWithPromptCode(ctx, opts, promptSet?.has("create") ? "create" : "login");
|
|
5775
5916
|
}
|
|
5776
5917
|
if (hasSatisfiedMaxAge) {
|
|
@@ -5780,73 +5921,79 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
5780
5921
|
if (settings?.isAuthorize && promptSet?.has("select_account")) return redirectWithPromptCode(ctx, opts, "select_account");
|
|
5781
5922
|
if (settings?.isAuthorize && opts.selectAccount) {
|
|
5782
5923
|
if (await opts.selectAccount.shouldRedirect({
|
|
5783
|
-
headers
|
|
5924
|
+
headers,
|
|
5784
5925
|
user: session.user,
|
|
5785
5926
|
session: session.session,
|
|
5786
|
-
scopes
|
|
5927
|
+
scopes
|
|
5787
5928
|
})) {
|
|
5788
|
-
if (promptNone) return
|
|
5929
|
+
if (promptNone) return redirectWithAuthorizationError(ctx, opts, query, "account_selection_required", "End-User account selection is required");
|
|
5789
5930
|
return redirectWithPromptCode(ctx, opts, "select_account");
|
|
5790
5931
|
}
|
|
5791
5932
|
}
|
|
5792
5933
|
if (opts.signup?.shouldRedirect) {
|
|
5793
5934
|
const signupRedirect = await opts.signup.shouldRedirect({
|
|
5794
|
-
headers
|
|
5935
|
+
headers,
|
|
5795
5936
|
user: session.user,
|
|
5796
5937
|
session: session.session,
|
|
5797
|
-
scopes
|
|
5938
|
+
scopes
|
|
5798
5939
|
});
|
|
5799
5940
|
if (signupRedirect) {
|
|
5800
|
-
if (promptNone) return
|
|
5941
|
+
if (promptNone) return redirectWithAuthorizationError(ctx, opts, query, "interaction_required", "End-User interaction is required");
|
|
5801
5942
|
return redirectWithPromptCode(ctx, opts, "create", { page: typeof signupRedirect === "string" ? signupRedirect : void 0 });
|
|
5802
5943
|
}
|
|
5803
5944
|
}
|
|
5804
5945
|
if (!settings?.postLogin && opts.postLogin) {
|
|
5805
5946
|
if (await opts.postLogin.shouldRedirect({
|
|
5806
|
-
headers
|
|
5947
|
+
headers,
|
|
5807
5948
|
user: session.user,
|
|
5808
5949
|
session: session.session,
|
|
5809
|
-
scopes
|
|
5950
|
+
scopes,
|
|
5810
5951
|
isContinuation: settings?.isPostLoginContinuation ?? false
|
|
5811
5952
|
})) {
|
|
5812
|
-
if (promptNone) return
|
|
5953
|
+
if (promptNone) return redirectWithAuthorizationError(ctx, opts, query, "interaction_required", "End-User interaction is required");
|
|
5813
5954
|
return redirectWithPromptCode(ctx, opts, "post_login");
|
|
5814
5955
|
}
|
|
5815
5956
|
}
|
|
5816
|
-
if (promptSet?.has("consent")) return redirectWithPromptCode(ctx, opts, "consent", { sessionId: session.session.id });
|
|
5957
|
+
if (promptSet?.has("consent") || request.requireConsent && !settings?.consentAccepted) return redirectWithPromptCode(ctx, opts, "consent", { sessionId: session.session.id });
|
|
5817
5958
|
const referenceId = await getConsentReferenceId(opts, {
|
|
5818
5959
|
user: session.user,
|
|
5819
5960
|
session: session.session,
|
|
5820
|
-
scopes
|
|
5821
|
-
});
|
|
5822
|
-
if (client.skipConsent) return redirectWithAuthorizationCode(ctx, opts, {
|
|
5823
|
-
query,
|
|
5824
|
-
clientId: client.clientId,
|
|
5825
|
-
userId: session.user.id,
|
|
5826
|
-
sessionId: session.session.id,
|
|
5827
|
-
authTime: new Date(session.session.createdAt).getTime(),
|
|
5828
|
-
referenceId,
|
|
5829
|
-
resource: requestedResources
|
|
5961
|
+
scopes
|
|
5830
5962
|
});
|
|
5963
|
+
const approve = async () => {
|
|
5964
|
+
let authenticationContext;
|
|
5965
|
+
try {
|
|
5966
|
+
authenticationContext = await resolveAuthenticationContext(opts, {
|
|
5967
|
+
user: session.user,
|
|
5968
|
+
session: session.session,
|
|
5969
|
+
client,
|
|
5970
|
+
scopes,
|
|
5971
|
+
headers,
|
|
5972
|
+
query
|
|
5973
|
+
});
|
|
5974
|
+
} catch (error) {
|
|
5975
|
+
if (!(error instanceof APIError$1)) throw error;
|
|
5976
|
+
if (request.errorURL) return handleRedirect(ctx, request.errorURL(error));
|
|
5977
|
+
const body = error.body;
|
|
5978
|
+
return redirectWithAuthorizationError(ctx, opts, query, body?.error ?? "server_error", body?.error_description ?? "authentication context unavailable");
|
|
5979
|
+
}
|
|
5980
|
+
if (!canSatisfyEssentialAcrRequest(query.claims, authenticationContext.acr ?? "0")) return redirectWithAuthorizationError(ctx, opts, query, "access_denied", "essential acr requirement cannot be met");
|
|
5981
|
+
return handleRedirect(ctx, await request.approve({
|
|
5982
|
+
query,
|
|
5983
|
+
userId: session.user.id,
|
|
5984
|
+
sessionId: session.session.id,
|
|
5985
|
+
authTime: new Date(session.session.createdAt).getTime(),
|
|
5986
|
+
...authenticationContext,
|
|
5987
|
+
referenceId
|
|
5988
|
+
}));
|
|
5989
|
+
};
|
|
5990
|
+
if (client.skipConsent) return approve();
|
|
5831
5991
|
const consent = await findConsent(ctx, client.clientId, session.user.id, referenceId);
|
|
5832
|
-
if (!consent || !
|
|
5833
|
-
if (promptNone) return
|
|
5834
|
-
return redirectWithPromptCode(ctx, opts, "consent", { sessionId: session.session.id });
|
|
5835
|
-
}
|
|
5836
|
-
const consentedResources = consent?.resources ?? [];
|
|
5837
|
-
if (requestedResources.some((requestedResource) => !consentedResources.includes(requestedResource))) {
|
|
5838
|
-
if (promptNone) return redirectWithPromptNoneError(ctx, opts, query, "consent_required", "End-User consent is required");
|
|
5992
|
+
if (!consent || !consentCovers(consent, scopes, resources) || !userInfoClaims.every((claim) => (consent.requestedUserInfoClaims ?? []).includes(claim))) {
|
|
5993
|
+
if (promptNone) return redirectWithAuthorizationError(ctx, opts, query, "consent_required", "End-User consent is required");
|
|
5839
5994
|
return redirectWithPromptCode(ctx, opts, "consent", { sessionId: session.session.id });
|
|
5840
5995
|
}
|
|
5841
|
-
return
|
|
5842
|
-
query,
|
|
5843
|
-
clientId: client.clientId,
|
|
5844
|
-
userId: session.user.id,
|
|
5845
|
-
sessionId: session.session.id,
|
|
5846
|
-
authTime: new Date(session.session.createdAt).getTime(),
|
|
5847
|
-
referenceId,
|
|
5848
|
-
resource: requestedResources
|
|
5849
|
-
});
|
|
5996
|
+
return approve();
|
|
5850
5997
|
}
|
|
5851
5998
|
function serializeAuthorizationQuery(query) {
|
|
5852
5999
|
const params = new URLSearchParams();
|
|
@@ -5858,7 +6005,8 @@ function serializeAuthorizationQuery(query) {
|
|
|
5858
6005
|
}
|
|
5859
6006
|
return params;
|
|
5860
6007
|
}
|
|
5861
|
-
|
|
6008
|
+
/** Stores a single-use authorization code and returns the client redirect URL carrying it. */
|
|
6009
|
+
async function createAuthorizationCodeURL(ctx, opts, verificationValue) {
|
|
5862
6010
|
const code = generateRandomString(32, "a-z", "A-Z", "0-9");
|
|
5863
6011
|
const iat = Math.floor(Date.now() / 1e3);
|
|
5864
6012
|
const exp = iat + (opts.codeExpiresIn ?? 600);
|
|
@@ -5873,6 +6021,8 @@ async function redirectWithAuthorizationCode(ctx, opts, verificationValue) {
|
|
|
5873
6021
|
sessionId: verificationValue?.sessionId,
|
|
5874
6022
|
referenceId: verificationValue.referenceId,
|
|
5875
6023
|
authTime: verificationValue.authTime,
|
|
6024
|
+
acr: verificationValue.acr,
|
|
6025
|
+
amr: verificationValue.amr,
|
|
5876
6026
|
resource: verificationValue.resource
|
|
5877
6027
|
})
|
|
5878
6028
|
};
|
|
@@ -5884,7 +6034,7 @@ async function redirectWithAuthorizationCode(ctx, opts, verificationValue) {
|
|
|
5884
6034
|
redirectUriWithCode.searchParams.set("code", code);
|
|
5885
6035
|
if (verificationValue.query.state) redirectUriWithCode.searchParams.set("state", verificationValue.query.state);
|
|
5886
6036
|
redirectUriWithCode.searchParams.set("iss", getIssuer(ctx, opts));
|
|
5887
|
-
return
|
|
6037
|
+
return redirectUriWithCode.toString();
|
|
5888
6038
|
}
|
|
5889
6039
|
async function redirectWithPromptCode(ctx, opts, type, options) {
|
|
5890
6040
|
const queryParams = await signParams(ctx, opts, { postLoginClearedForSession: type === "consent" && opts.postLogin ? options?.sessionId : void 0 });
|
|
@@ -5912,4 +6062,4 @@ async function signParams(ctx, opts, flags) {
|
|
|
5912
6062
|
return params.toString();
|
|
5913
6063
|
}
|
|
5914
6064
|
//#endregion
|
|
5915
|
-
export {
|
|
6065
|
+
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 };
|