@better-auth/oauth-provider 1.7.0-beta.1 → 1.7.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{client-assertion-CderPEmR.mjs → client-assertion-BYtMWGCE.mjs} +1 -1
- package/dist/client-resource.d.mts +1 -1
- package/dist/client-resource.mjs +2 -2
- package/dist/client.d.mts +1 -1
- package/dist/client.mjs +1 -1
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +312 -121
- package/dist/{oauth-B_qonG53.d.mts → oauth-BxP4Iupj.d.mts} +72 -20
- package/dist/{oauth-CU79t-eG.d.mts → oauth-Ds-ejTJY.d.mts} +15 -3
- package/dist/{utils-Cx_XnD9i.mjs → utils-_Jr_enAe.mjs} +16 -12
- package/dist/{version-DIwdpXrQ.mjs → version-CG1YnCiF.mjs} +1 -1
- package/package.json +5 -5
package/dist/index.mjs
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { n as isPrivateHostname } from "./client-assertion-
|
|
1
|
+
import { n as isPrivateHostname } from "./client-assertion-BYtMWGCE.mjs";
|
|
2
2
|
import { n as mcpHandler } from "./mcp-CYnz-MXn.mjs";
|
|
3
|
-
import { _ as
|
|
4
|
-
import { t as PACKAGE_VERSION } from "./version-
|
|
3
|
+
import { C as validateClientCredentials, S as toClientDiscoveryArray, _ as resolveSubjectIdentifier, a as getJwtPlugin, b as storeClientSecret, c as getStoredToken, d as normalizeTimestampValue, f as parseClientMetadata, g as resolveSessionAuthTime, h as removePromptFromQuery, i as getClient, l as isPKCERequired, m as postLoginClearedParam, n as destructureCredentials, p as parsePrompt, r as extractClientCredentials, s as getSignedQueryIssuedAt, t as decryptStoredClientSecret, u as mergeDiscoveryMetadata, v as searchParamsToQuery, w as verifyOAuthQueryParams, x as storeToken, y as signedQueryIssuedAtParam } from "./utils-_Jr_enAe.mjs";
|
|
4
|
+
import { t as PACKAGE_VERSION } from "./version-CG1YnCiF.mjs";
|
|
5
5
|
import { APIError, createAuthEndpoint, createAuthMiddleware, getOAuthState, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
|
|
6
6
|
import { generateCodeChallenge, getJwks, verifyJwsAccessToken } from "better-auth/oauth2";
|
|
7
7
|
import { APIError as APIError$1 } from "better-call";
|
|
8
8
|
import { ASSERTION_SIGNING_ALGORITHMS } from "@better-auth/core/oauth2";
|
|
9
9
|
import { isBrowserFetchRequest } from "@better-auth/core/utils/fetch-metadata";
|
|
10
|
+
import { isLoopbackHost, isLoopbackIP } from "@better-auth/core/utils/host";
|
|
10
11
|
import { generateRandomString, makeSignature } from "better-auth/crypto";
|
|
11
12
|
import { defineRequestState } from "@better-auth/core/context";
|
|
12
13
|
import { logger } from "@better-auth/core/env";
|
|
@@ -18,7 +19,8 @@ import { resolveSigningKey, signJWT, toExpJWT } from "better-auth/plugins";
|
|
|
18
19
|
import { SignJWT, base64url, compactVerify, createLocalJWKSet, decodeJwt, decodeProtectedHeader } from "jose";
|
|
19
20
|
//#region src/consent.ts
|
|
20
21
|
async function consentEndpoint(ctx, opts) {
|
|
21
|
-
const
|
|
22
|
+
const oauthRequest = await oAuthState.get();
|
|
23
|
+
const _query = oauthRequest?.query;
|
|
22
24
|
if (!_query) throw new APIError("BAD_REQUEST", {
|
|
23
25
|
error_description: "missing oauth query",
|
|
24
26
|
error: "invalid_request"
|
|
@@ -42,6 +44,17 @@ async function consentEndpoint(ctx, opts) {
|
|
|
42
44
|
url: formatErrorURL(query.get("redirect_uri") ?? "", "access_denied", "User denied access", query.get("state") ?? void 0, getIssuer(ctx, opts))
|
|
43
45
|
};
|
|
44
46
|
const session = await getSessionFromCtx(ctx);
|
|
47
|
+
const hasLoginPrompt = parsePrompt(query.get("prompt") ?? "").has("login");
|
|
48
|
+
const hasSatisfiedLoginPrompt = hasLoginPrompt && sessionSatisfiesLoginPrompt(session?.session.createdAt, oauthRequest?.signedQueryIssuedAt);
|
|
49
|
+
if (hasLoginPrompt && !hasSatisfiedLoginPrompt) {
|
|
50
|
+
ctx?.headers?.set("accept", "application/json");
|
|
51
|
+
ctx.query = searchParamsToQuery(query);
|
|
52
|
+
const { url } = await authorizeEndpoint(ctx, opts);
|
|
53
|
+
return {
|
|
54
|
+
redirect: true,
|
|
55
|
+
url
|
|
56
|
+
};
|
|
57
|
+
}
|
|
45
58
|
const referenceId = await opts.postLogin?.consentReferenceId?.({
|
|
46
59
|
user: session?.user,
|
|
47
60
|
session: session?.session,
|
|
@@ -92,14 +105,21 @@ async function consentEndpoint(ctx, opts) {
|
|
|
92
105
|
});
|
|
93
106
|
if (requestedScopes) query.set("scope", consent.scopes.join(" "));
|
|
94
107
|
ctx?.headers?.set("accept", "application/json");
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
108
|
+
let authorizationQuery = removePromptFromQuery(query, "consent");
|
|
109
|
+
if (hasSatisfiedLoginPrompt) authorizationQuery = removePromptFromQuery(authorizationQuery, "login");
|
|
110
|
+
ctx.query = searchParamsToQuery(authorizationQuery);
|
|
111
|
+
const { url } = await authorizeEndpoint(ctx, opts, { postLogin: oauthRequest?.postLoginClearedForSession !== void 0 && oauthRequest.postLoginClearedForSession === session?.session.id });
|
|
98
112
|
return {
|
|
99
113
|
redirect: true,
|
|
100
114
|
url
|
|
101
115
|
};
|
|
102
116
|
}
|
|
117
|
+
function sessionSatisfiesLoginPrompt(sessionCreatedAt, signedQueryIssuedAt) {
|
|
118
|
+
if (!signedQueryIssuedAt) return false;
|
|
119
|
+
const normalized = normalizeTimestampValue(sessionCreatedAt);
|
|
120
|
+
if (!normalized) return false;
|
|
121
|
+
return normalized.getTime() >= signedQueryIssuedAt.getTime();
|
|
122
|
+
}
|
|
103
123
|
//#endregion
|
|
104
124
|
//#region src/continue.ts
|
|
105
125
|
async function continueEndpoint(ctx, opts) {
|
|
@@ -118,7 +138,7 @@ async function selected(ctx, opts) {
|
|
|
118
138
|
error: "invalid_request"
|
|
119
139
|
});
|
|
120
140
|
ctx.headers?.set("accept", "application/json");
|
|
121
|
-
ctx.query =
|
|
141
|
+
ctx.query = searchParamsToQuery(removePromptFromQuery(new URLSearchParams(_query), "select_account"));
|
|
122
142
|
const { url } = await authorizeEndpoint(ctx, opts);
|
|
123
143
|
return {
|
|
124
144
|
redirect: true,
|
|
@@ -133,7 +153,7 @@ async function created(ctx, opts) {
|
|
|
133
153
|
});
|
|
134
154
|
const query = new URLSearchParams(_query);
|
|
135
155
|
ctx.headers?.set("accept", "application/json");
|
|
136
|
-
ctx.query =
|
|
156
|
+
ctx.query = searchParamsToQuery(removePromptFromQuery(query, "create"));
|
|
137
157
|
const { url } = await authorizeEndpoint(ctx, opts);
|
|
138
158
|
return {
|
|
139
159
|
redirect: true,
|
|
@@ -162,9 +182,6 @@ const DANGEROUS_SCHEMES = [
|
|
|
162
182
|
"data:",
|
|
163
183
|
"vbscript:"
|
|
164
184
|
];
|
|
165
|
-
function isLocalhost(hostname) {
|
|
166
|
-
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname.endsWith(".localhost");
|
|
167
|
-
}
|
|
168
185
|
/**
|
|
169
186
|
* Runtime schema for OAuthAuthorizationQuery.
|
|
170
187
|
* Uses passthrough to tolerate fields added by future extensions (PAR, FPA, etc.)
|
|
@@ -203,7 +220,7 @@ const verificationValueSchema = z.object({
|
|
|
203
220
|
/**
|
|
204
221
|
* Reusable URL validation for OAuth redirect URIs.
|
|
205
222
|
* - Blocks dangerous schemes (javascript:, data:, vbscript:)
|
|
206
|
-
* - For http/https: requires HTTPS (HTTP allowed only for localhost)
|
|
223
|
+
* - For http/https: requires HTTPS (HTTP allowed only for loopback hosts: 127.0.0.0/8, [::1], *.localhost per RFC 6761)
|
|
207
224
|
* - Allows custom schemes for mobile apps (e.g., myapp://callback)
|
|
208
225
|
*/
|
|
209
226
|
const SafeUrlSchema = z.url().superRefine((val, ctx) => {
|
|
@@ -223,12 +240,10 @@ const SafeUrlSchema = z.url().superRefine((val, ctx) => {
|
|
|
223
240
|
});
|
|
224
241
|
return;
|
|
225
242
|
}
|
|
226
|
-
if (u.protocol === "http:"
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
});
|
|
231
|
-
}
|
|
243
|
+
if (u.protocol === "http:" && !isLoopbackHost(u.host)) ctx.addIssue({
|
|
244
|
+
code: "custom",
|
|
245
|
+
message: "Redirect URI must use HTTPS (HTTP allowed only for loopback hosts)"
|
|
246
|
+
});
|
|
232
247
|
});
|
|
233
248
|
//#endregion
|
|
234
249
|
//#region src/userinfo.ts
|
|
@@ -259,11 +274,7 @@ function userNormalClaims(user, scopes) {
|
|
|
259
274
|
* Handles the /oauth2/userinfo endpoint
|
|
260
275
|
*/
|
|
261
276
|
async function userInfoEndpoint(ctx, opts) {
|
|
262
|
-
|
|
263
|
-
error_description: "request not found",
|
|
264
|
-
error: "invalid_request"
|
|
265
|
-
});
|
|
266
|
-
const authorization = ctx.request.headers.get("authorization");
|
|
277
|
+
const authorization = ctx.headers?.get("authorization");
|
|
267
278
|
const token = typeof authorization === "string" && authorization?.startsWith("Bearer ") ? authorization?.replace("Bearer ", "") : authorization;
|
|
268
279
|
if (!token?.length) throw new APIError("UNAUTHORIZED", {
|
|
269
280
|
error_description: "authorization header not found",
|
|
@@ -309,8 +320,8 @@ async function userInfoEndpoint(ctx, opts) {
|
|
|
309
320
|
* the grant types
|
|
310
321
|
*/
|
|
311
322
|
async function tokenEndpoint(ctx, opts) {
|
|
312
|
-
const grantType = ctx.body
|
|
313
|
-
if (opts.grantTypes &&
|
|
323
|
+
const grantType = ctx.body.grant_type;
|
|
324
|
+
if (opts.grantTypes && !opts.grantTypes.includes(grantType)) throw new APIError("BAD_REQUEST", {
|
|
314
325
|
error_description: `unsupported grant_type ${grantType}`,
|
|
315
326
|
error: "unsupported_grant_type"
|
|
316
327
|
});
|
|
@@ -318,14 +329,6 @@ async function tokenEndpoint(ctx, opts) {
|
|
|
318
329
|
case "authorization_code": return handleAuthorizationCodeGrant(ctx, opts);
|
|
319
330
|
case "client_credentials": return handleClientCredentialsGrant(ctx, opts);
|
|
320
331
|
case "refresh_token": return handleRefreshTokenGrant(ctx, opts);
|
|
321
|
-
case void 0: throw new APIError("BAD_REQUEST", {
|
|
322
|
-
error_description: "missing required grant_type",
|
|
323
|
-
error: "unsupported_grant_type"
|
|
324
|
-
});
|
|
325
|
-
default: throw new APIError("BAD_REQUEST", {
|
|
326
|
-
error_description: `unsupported grant_type ${grantType}`,
|
|
327
|
-
error: "unsupported_grant_type"
|
|
328
|
-
});
|
|
329
332
|
}
|
|
330
333
|
}
|
|
331
334
|
async function createJwtAccessToken(ctx, opts, user, client, audience, scopes, referenceId, overrides) {
|
|
@@ -955,7 +958,7 @@ async function validateRefreshToken(ctx, opts, token, clientId) {
|
|
|
955
958
|
model: "session",
|
|
956
959
|
where: [{
|
|
957
960
|
field: "id",
|
|
958
|
-
value:
|
|
961
|
+
value: sessionId
|
|
959
962
|
}]
|
|
960
963
|
});
|
|
961
964
|
if (!session || session.expiresAt < /* @__PURE__ */ new Date()) sessionId = void 0;
|
|
@@ -1016,6 +1019,7 @@ async function resolveIntrospectionSub(opts, payload, client) {
|
|
|
1016
1019
|
}
|
|
1017
1020
|
async function introspectEndpoint(ctx, opts) {
|
|
1018
1021
|
let { token, token_type_hint } = ctx.body;
|
|
1022
|
+
if (token_type_hint !== "access_token" && token_type_hint !== "refresh_token") token_type_hint = void 0;
|
|
1019
1023
|
const { clientId: client_id, clientSecret: client_secret, preVerifiedClient } = destructureCredentials(await extractClientCredentials(ctx, opts, `${ctx.context.baseURL}/oauth2/introspect`));
|
|
1020
1024
|
if (!client_id || !client_secret && !preVerifiedClient) throw new APIError$1("UNAUTHORIZED", {
|
|
1021
1025
|
error_description: "missing required credentials",
|
|
@@ -1173,6 +1177,109 @@ async function rpInitiatedLogoutEndpoint(ctx, opts) {
|
|
|
1173
1177
|
}
|
|
1174
1178
|
}
|
|
1175
1179
|
//#endregion
|
|
1180
|
+
//#region src/oauth-endpoint.ts
|
|
1181
|
+
/**
|
|
1182
|
+
* Wraps `createAuthEndpoint` so zod schemas stay the single source of truth
|
|
1183
|
+
* for body/query shape while validation failures serialize as the RFC 6749
|
|
1184
|
+
* §5.2 error envelope `{ error, error_description }`.
|
|
1185
|
+
*
|
|
1186
|
+
* A failing issue is routed by its first path segment via `errorCodesByField`:
|
|
1187
|
+
* - missing required (`invalid_type` + "received undefined") → `.missing`
|
|
1188
|
+
* - unsupported value (`invalid_value`) → `.invalid`
|
|
1189
|
+
* - anything else (wrong type, duplicated params, bad format) → `defaultError`
|
|
1190
|
+
*
|
|
1191
|
+
* For enum fields that need to distinguish missing from unsupported, compose
|
|
1192
|
+
* as `z.string().pipe(z.enum([...]))` so duplicated params fail the outer
|
|
1193
|
+
* `z.string()` as `invalid_type` instead of masquerading as an unsupported
|
|
1194
|
+
* enum value.
|
|
1195
|
+
*/
|
|
1196
|
+
function createOAuthEndpoint(path, options, handler) {
|
|
1197
|
+
const { redirectOnError, onValidationError: userHook, errorCodesByField, defaultError = "invalid_request", ...rest } = options;
|
|
1198
|
+
if (!redirectOnError) return createAuthEndpoint(path, {
|
|
1199
|
+
...rest,
|
|
1200
|
+
onValidationError: async (args) => {
|
|
1201
|
+
if (userHook) await userHook(args);
|
|
1202
|
+
throw new APIError$1("BAD_REQUEST", { ...mapIssuesToOAuthError(args.issues, errorCodesByField, defaultError) });
|
|
1203
|
+
}
|
|
1204
|
+
}, handler);
|
|
1205
|
+
const redirect = redirectOnError;
|
|
1206
|
+
const { body: bodySchema, query: querySchema, ...forwarded } = rest;
|
|
1207
|
+
async function validateSlot(ctx, slot, schema) {
|
|
1208
|
+
if (!schema) return { ok: true };
|
|
1209
|
+
const result = await schema.safeParseAsync(ctx[slot] ?? {});
|
|
1210
|
+
if (result.success) {
|
|
1211
|
+
ctx[slot] = result.data;
|
|
1212
|
+
return { ok: true };
|
|
1213
|
+
}
|
|
1214
|
+
if (userHook) await userHook({
|
|
1215
|
+
message: result.error.message,
|
|
1216
|
+
issues: result.error.issues
|
|
1217
|
+
});
|
|
1218
|
+
return {
|
|
1219
|
+
ok: false,
|
|
1220
|
+
response: redirect({
|
|
1221
|
+
...mapIssuesToOAuthError(result.error.issues, errorCodesByField, defaultError),
|
|
1222
|
+
ctx
|
|
1223
|
+
})
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
return createAuthEndpoint(path, forwarded, async (ctx) => {
|
|
1227
|
+
const body = await validateSlot(ctx, "body", bodySchema);
|
|
1228
|
+
if (!body.ok) return body.response;
|
|
1229
|
+
const query = await validateSlot(ctx, "query", querySchema);
|
|
1230
|
+
if (!query.ok) return query.response;
|
|
1231
|
+
return handler(ctx);
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
function mapIssuesToOAuthError(issues, errorCodesByField, defaultError = "invalid_request") {
|
|
1235
|
+
const issue = issues[0];
|
|
1236
|
+
if (!issue) return {
|
|
1237
|
+
error: defaultError,
|
|
1238
|
+
error_description: "Invalid request."
|
|
1239
|
+
};
|
|
1240
|
+
const first = issue.path?.[0];
|
|
1241
|
+
const fieldKey = typeof first === "string" ? first : void 0;
|
|
1242
|
+
const mapping = fieldKey ? errorCodesByField?.[fieldKey] : void 0;
|
|
1243
|
+
const field = issue.path?.length ? z.core.toDotPath(issue.path) : "";
|
|
1244
|
+
return {
|
|
1245
|
+
error: resolveErrorCode(issue, mapping, defaultError),
|
|
1246
|
+
error_description: describeIssue(issue, field)
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
function resolveErrorCode(issue, mapping, defaultError) {
|
|
1250
|
+
if (typeof mapping === "string") return mapping;
|
|
1251
|
+
if (isMissingValueIssue(issue)) return mapping?.missing ?? defaultError;
|
|
1252
|
+
if (issue.code === "invalid_value") return mapping?.invalid ?? defaultError;
|
|
1253
|
+
return defaultError;
|
|
1254
|
+
}
|
|
1255
|
+
/**
|
|
1256
|
+
* Returns `true` for issues that represent an absent required value. Zod v4
|
|
1257
|
+
* strips `input` from published issues, so the signal is the `invalid_type`
|
|
1258
|
+
* code combined with a message suffix of "received undefined". The suffix is
|
|
1259
|
+
* pinned by a regression test so a zod rephrase fails the test instead of
|
|
1260
|
+
* silently reclassifying missing fields.
|
|
1261
|
+
*
|
|
1262
|
+
* Assumes the default zod error map. Consumers that install a localized map
|
|
1263
|
+
* via `z.setErrorMap()` will break this check, collapsing missing-field
|
|
1264
|
+
* failures to `defaultError`.
|
|
1265
|
+
*/
|
|
1266
|
+
function isMissingValueIssue(issue) {
|
|
1267
|
+
return issue.code === "invalid_type" && issue.message.endsWith("received undefined");
|
|
1268
|
+
}
|
|
1269
|
+
function describeIssue(issue, field) {
|
|
1270
|
+
if (!field) return issue.message;
|
|
1271
|
+
if (issue.code === "invalid_type") {
|
|
1272
|
+
if (issue.message.endsWith("received undefined")) return `${field} is required`;
|
|
1273
|
+
if (issue.message.endsWith("received array")) return `${field} must not appear more than once`;
|
|
1274
|
+
return `${field} must be a ${issue.expected ?? "valid value"}`;
|
|
1275
|
+
}
|
|
1276
|
+
if (issue.code === "invalid_value") {
|
|
1277
|
+
const values = issue.values;
|
|
1278
|
+
if (Array.isArray(values) && values.length > 0) return `${field} must be one of: ${values.join(", ")}`;
|
|
1279
|
+
}
|
|
1280
|
+
return `${field}: ${issue.message}`;
|
|
1281
|
+
}
|
|
1282
|
+
//#endregion
|
|
1176
1283
|
//#region src/middleware/index.ts
|
|
1177
1284
|
const publicSessionMiddleware = (opts) => createAuthMiddleware(async (ctx) => {
|
|
1178
1285
|
if (!opts.allowPublicClientPrelogin) throw new APIError("BAD_REQUEST");
|
|
@@ -1458,14 +1565,8 @@ function schemaToOAuth(input) {
|
|
|
1458
1565
|
//#region src/oauthClient/endpoints.ts
|
|
1459
1566
|
async function getClientEndpoint(ctx, opts) {
|
|
1460
1567
|
const session = await getSessionFromCtx(ctx);
|
|
1568
|
+
await assertClientPrivileges(ctx, session, opts, "read");
|
|
1461
1569
|
if (!session) throw new APIError("UNAUTHORIZED");
|
|
1462
|
-
if (!ctx.headers) throw new APIError("BAD_REQUEST");
|
|
1463
|
-
if (opts.clientPrivileges && !await opts.clientPrivileges({
|
|
1464
|
-
headers: ctx.headers,
|
|
1465
|
-
action: "read",
|
|
1466
|
-
session: session.session,
|
|
1467
|
-
user: session.user
|
|
1468
|
-
})) throw new APIError("UNAUTHORIZED");
|
|
1469
1570
|
const client = await getClient(ctx, opts, ctx.query.client_id);
|
|
1470
1571
|
if (!client) throw new APIError("NOT_FOUND", {
|
|
1471
1572
|
error_description: "client not found",
|
|
@@ -1506,14 +1607,8 @@ async function getClientPublicEndpoint(ctx, opts, clientId) {
|
|
|
1506
1607
|
}
|
|
1507
1608
|
async function getClientsEndpoint(ctx, opts) {
|
|
1508
1609
|
const session = await getSessionFromCtx(ctx);
|
|
1610
|
+
await assertClientPrivileges(ctx, session, opts, "list");
|
|
1509
1611
|
if (!session) throw new APIError("UNAUTHORIZED");
|
|
1510
|
-
if (!ctx.headers) throw new APIError("BAD_REQUEST");
|
|
1511
|
-
if (opts.clientPrivileges && !await opts.clientPrivileges({
|
|
1512
|
-
headers: ctx.headers,
|
|
1513
|
-
action: "list",
|
|
1514
|
-
session: session.session,
|
|
1515
|
-
user: session.user
|
|
1516
|
-
})) throw new APIError("UNAUTHORIZED");
|
|
1517
1612
|
const referenceId = await opts.clientReference?.(session);
|
|
1518
1613
|
if (referenceId) return await ctx.context.adapter.findMany({
|
|
1519
1614
|
model: "oauthClient",
|
|
@@ -1547,14 +1642,8 @@ async function getClientsEndpoint(ctx, opts) {
|
|
|
1547
1642
|
}
|
|
1548
1643
|
async function deleteClientEndpoint(ctx, opts) {
|
|
1549
1644
|
const session = await getSessionFromCtx(ctx);
|
|
1645
|
+
await assertClientPrivileges(ctx, session, opts, "delete");
|
|
1550
1646
|
if (!session) throw new APIError("UNAUTHORIZED");
|
|
1551
|
-
if (!ctx.headers) throw new APIError("BAD_REQUEST");
|
|
1552
|
-
if (opts.clientPrivileges && !await opts.clientPrivileges({
|
|
1553
|
-
headers: ctx.headers,
|
|
1554
|
-
action: "delete",
|
|
1555
|
-
session: session.session,
|
|
1556
|
-
user: session.user
|
|
1557
|
-
})) throw new APIError("UNAUTHORIZED");
|
|
1558
1647
|
const clientId = ctx.body.client_id;
|
|
1559
1648
|
if (opts.cachedTrustedClients?.has(clientId)) throw new APIError("INTERNAL_SERVER_ERROR", {
|
|
1560
1649
|
error_description: "trusted clients must be updated manually",
|
|
@@ -1580,14 +1669,8 @@ async function deleteClientEndpoint(ctx, opts) {
|
|
|
1580
1669
|
}
|
|
1581
1670
|
async function updateClientEndpoint(ctx, opts) {
|
|
1582
1671
|
const session = await getSessionFromCtx(ctx);
|
|
1672
|
+
await assertClientPrivileges(ctx, session, opts, "update");
|
|
1583
1673
|
if (!session) throw new APIError("UNAUTHORIZED");
|
|
1584
|
-
if (!ctx.headers) throw new APIError("BAD_REQUEST");
|
|
1585
|
-
if (opts.clientPrivileges && !await opts.clientPrivileges({
|
|
1586
|
-
headers: ctx.headers,
|
|
1587
|
-
action: "update",
|
|
1588
|
-
session: session.session,
|
|
1589
|
-
user: session.user
|
|
1590
|
-
})) throw new APIError("UNAUTHORIZED");
|
|
1591
1674
|
const clientId = ctx.body.client_id;
|
|
1592
1675
|
if (opts.cachedTrustedClients?.has(clientId)) throw new APIError("INTERNAL_SERVER_ERROR", {
|
|
1593
1676
|
error_description: "trusted clients must be updated manually",
|
|
@@ -1641,14 +1724,8 @@ async function updateClientEndpoint(ctx, opts) {
|
|
|
1641
1724
|
}
|
|
1642
1725
|
async function rotateClientSecretEndpoint(ctx, opts) {
|
|
1643
1726
|
const session = await getSessionFromCtx(ctx);
|
|
1727
|
+
await assertClientPrivileges(ctx, session, opts, "rotate");
|
|
1644
1728
|
if (!session) throw new APIError("UNAUTHORIZED");
|
|
1645
|
-
if (!ctx.headers) throw new APIError("BAD_REQUEST");
|
|
1646
|
-
if (opts.clientPrivileges && !await opts.clientPrivileges({
|
|
1647
|
-
headers: ctx.headers,
|
|
1648
|
-
action: "rotate",
|
|
1649
|
-
session: session.session,
|
|
1650
|
-
user: session.user
|
|
1651
|
-
})) throw new APIError("UNAUTHORIZED");
|
|
1652
1729
|
const clientId = ctx.body.client_id;
|
|
1653
1730
|
if (opts.cachedTrustedClients?.has(clientId)) throw new APIError("INTERNAL_SERVER_ERROR", {
|
|
1654
1731
|
error_description: "trusted clients must be updated manually",
|
|
@@ -1690,6 +1767,16 @@ async function rotateClientSecretEndpoint(ctx, opts) {
|
|
|
1690
1767
|
clientSecret: (opts.prefix?.clientSecret ?? "") + clientSecret
|
|
1691
1768
|
});
|
|
1692
1769
|
}
|
|
1770
|
+
async function assertClientPrivileges(ctx, session, opts, action) {
|
|
1771
|
+
if (!session) throw new APIError("UNAUTHORIZED");
|
|
1772
|
+
if (!ctx.headers) throw new APIError("BAD_REQUEST");
|
|
1773
|
+
if (opts.clientPrivileges && !await opts.clientPrivileges({
|
|
1774
|
+
headers: ctx.headers,
|
|
1775
|
+
action,
|
|
1776
|
+
session: session.session,
|
|
1777
|
+
user: session.user
|
|
1778
|
+
})) throw new APIError("UNAUTHORIZED");
|
|
1779
|
+
}
|
|
1693
1780
|
//#endregion
|
|
1694
1781
|
//#region src/oauthClient/index.ts
|
|
1695
1782
|
const adminCreateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/create-client", {
|
|
@@ -1875,6 +1962,7 @@ const adminCreateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/creat
|
|
|
1875
1962
|
}
|
|
1876
1963
|
}
|
|
1877
1964
|
}, async (ctx) => {
|
|
1965
|
+
await assertClientPrivileges(ctx, await getSessionFromCtx(ctx), opts, "create");
|
|
1878
1966
|
return createOAuthClientEndpoint(ctx, opts, { isRegister: false });
|
|
1879
1967
|
});
|
|
1880
1968
|
const createOAuthClient = (opts) => createAuthEndpoint("/oauth2/create-client", {
|
|
@@ -2047,6 +2135,7 @@ const createOAuthClient = (opts) => createAuthEndpoint("/oauth2/create-client",
|
|
|
2047
2135
|
} }
|
|
2048
2136
|
} }
|
|
2049
2137
|
}, async (ctx) => {
|
|
2138
|
+
await assertClientPrivileges(ctx, await getSessionFromCtx(ctx), opts, "create");
|
|
2050
2139
|
return createOAuthClientEndpoint(ctx, opts, { isRegister: false });
|
|
2051
2140
|
});
|
|
2052
2141
|
const getOAuthClient = (opts) => createAuthEndpoint("/oauth2/get-client", {
|
|
@@ -2459,6 +2548,7 @@ async function revokeAccessToken(ctx, opts, clientId, token) {
|
|
|
2459
2548
|
}
|
|
2460
2549
|
async function revokeEndpoint(ctx, opts) {
|
|
2461
2550
|
let { token, token_type_hint } = ctx.body;
|
|
2551
|
+
if (token_type_hint !== "access_token" && token_type_hint !== "refresh_token") token_type_hint = void 0;
|
|
2462
2552
|
const { clientId: client_id, clientSecret: client_secret, preVerifiedClient } = destructureCredentials(await extractClientCredentials(ctx, opts, `${ctx.context.baseURL}/oauth2/revoke`));
|
|
2463
2553
|
if (!client_id) throw new APIError$1("UNAUTHORIZED", {
|
|
2464
2554
|
error_description: "missing required credentials",
|
|
@@ -2546,7 +2636,8 @@ const schema = {
|
|
|
2546
2636
|
references: {
|
|
2547
2637
|
model: "user",
|
|
2548
2638
|
field: "id"
|
|
2549
|
-
}
|
|
2639
|
+
},
|
|
2640
|
+
index: true
|
|
2550
2641
|
},
|
|
2551
2642
|
createdAt: {
|
|
2552
2643
|
type: "date",
|
|
@@ -2653,7 +2744,8 @@ const schema = {
|
|
|
2653
2744
|
references: {
|
|
2654
2745
|
model: "oauthClient",
|
|
2655
2746
|
field: "clientId"
|
|
2656
|
-
}
|
|
2747
|
+
},
|
|
2748
|
+
index: true
|
|
2657
2749
|
},
|
|
2658
2750
|
sessionId: {
|
|
2659
2751
|
type: "string",
|
|
@@ -2662,7 +2754,8 @@ const schema = {
|
|
|
2662
2754
|
model: "session",
|
|
2663
2755
|
field: "id",
|
|
2664
2756
|
onDelete: "set null"
|
|
2665
|
-
}
|
|
2757
|
+
},
|
|
2758
|
+
index: true
|
|
2666
2759
|
},
|
|
2667
2760
|
userId: {
|
|
2668
2761
|
type: "string",
|
|
@@ -2670,7 +2763,8 @@ const schema = {
|
|
|
2670
2763
|
references: {
|
|
2671
2764
|
model: "user",
|
|
2672
2765
|
field: "id"
|
|
2673
|
-
}
|
|
2766
|
+
},
|
|
2767
|
+
index: true
|
|
2674
2768
|
},
|
|
2675
2769
|
referenceId: {
|
|
2676
2770
|
type: "string",
|
|
@@ -2704,7 +2798,8 @@ const schema = {
|
|
|
2704
2798
|
references: {
|
|
2705
2799
|
model: "oauthClient",
|
|
2706
2800
|
field: "clientId"
|
|
2707
|
-
}
|
|
2801
|
+
},
|
|
2802
|
+
index: true
|
|
2708
2803
|
},
|
|
2709
2804
|
sessionId: {
|
|
2710
2805
|
type: "string",
|
|
@@ -2713,7 +2808,8 @@ const schema = {
|
|
|
2713
2808
|
model: "session",
|
|
2714
2809
|
field: "id",
|
|
2715
2810
|
onDelete: "set null"
|
|
2716
|
-
}
|
|
2811
|
+
},
|
|
2812
|
+
index: true
|
|
2717
2813
|
},
|
|
2718
2814
|
userId: {
|
|
2719
2815
|
type: "string",
|
|
@@ -2721,7 +2817,8 @@ const schema = {
|
|
|
2721
2817
|
references: {
|
|
2722
2818
|
model: "user",
|
|
2723
2819
|
field: "id"
|
|
2724
|
-
}
|
|
2820
|
+
},
|
|
2821
|
+
index: true
|
|
2725
2822
|
},
|
|
2726
2823
|
referenceId: {
|
|
2727
2824
|
type: "string",
|
|
@@ -2733,7 +2830,8 @@ const schema = {
|
|
|
2733
2830
|
references: {
|
|
2734
2831
|
model: "oauthRefreshToken",
|
|
2735
2832
|
field: "id"
|
|
2736
|
-
}
|
|
2833
|
+
},
|
|
2834
|
+
index: true
|
|
2737
2835
|
},
|
|
2738
2836
|
expiresAt: { type: "date" },
|
|
2739
2837
|
createdAt: { type: "date" },
|
|
@@ -2752,7 +2850,8 @@ const schema = {
|
|
|
2752
2850
|
references: {
|
|
2753
2851
|
model: "oauthClient",
|
|
2754
2852
|
field: "clientId"
|
|
2755
|
-
}
|
|
2853
|
+
},
|
|
2854
|
+
index: true
|
|
2756
2855
|
},
|
|
2757
2856
|
userId: {
|
|
2758
2857
|
type: "string",
|
|
@@ -2760,7 +2859,8 @@ const schema = {
|
|
|
2760
2859
|
references: {
|
|
2761
2860
|
model: "user",
|
|
2762
2861
|
field: "id"
|
|
2763
|
-
}
|
|
2862
|
+
},
|
|
2863
|
+
index: true
|
|
2764
2864
|
},
|
|
2765
2865
|
referenceId: {
|
|
2766
2866
|
type: "string",
|
|
@@ -2872,10 +2972,18 @@ const oauthProvider = (options) => {
|
|
|
2872
2972
|
handler: createAuthMiddleware(async (ctx) => {
|
|
2873
2973
|
const query = ctx.body.oauth_query;
|
|
2874
2974
|
if (!await verifyOAuthQueryParams(query, ctx.context.secret)) throw new APIError("BAD_REQUEST", { error: "invalid_signature" });
|
|
2975
|
+
const signedQueryIssuedAt = getSignedQueryIssuedAt(query);
|
|
2875
2976
|
const queryParams = new URLSearchParams(query);
|
|
2977
|
+
const postLoginClearedForSession = queryParams.get("ba_pl") ?? void 0;
|
|
2876
2978
|
queryParams.delete("sig");
|
|
2877
2979
|
queryParams.delete("exp");
|
|
2878
|
-
|
|
2980
|
+
queryParams.delete(signedQueryIssuedAtParam);
|
|
2981
|
+
queryParams.delete(postLoginClearedParam);
|
|
2982
|
+
await oAuthState.set({
|
|
2983
|
+
query: queryParams.toString(),
|
|
2984
|
+
signedQueryIssuedAt: signedQueryIssuedAt ?? void 0,
|
|
2985
|
+
postLoginClearedForSession
|
|
2986
|
+
});
|
|
2879
2987
|
if (ctx.path === "/sign-in/social") {
|
|
2880
2988
|
if (ctx.body.additionalData?.query) return;
|
|
2881
2989
|
if (!ctx.body.additionalData) ctx.body.additionalData = {};
|
|
@@ -2899,7 +3007,7 @@ const oauthProvider = (options) => {
|
|
|
2899
3007
|
const secFetchMode = ctx.request?.headers?.get("sec-fetch-mode")?.toLowerCase();
|
|
2900
3008
|
const acceptHeader = ctx.request?.headers?.get("accept")?.toLowerCase() ?? "";
|
|
2901
3009
|
if (!(secFetchMode === "navigate" || !secFetchMode && (acceptHeader.includes("text/html") || acceptHeader.includes("application/xhtml+xml")))) ctx.headers?.set("accept", "application/json");
|
|
2902
|
-
ctx.query =
|
|
3010
|
+
ctx.query = searchParamsToQuery(removePromptFromQuery(query, "login"));
|
|
2903
3011
|
return await authorizeEndpoint(ctx, opts);
|
|
2904
3012
|
})
|
|
2905
3013
|
}]
|
|
@@ -2927,19 +3035,19 @@ const oauthProvider = (options) => {
|
|
|
2927
3035
|
if (opts.scopes && !opts.scopes.includes("openid")) throw new APIError("NOT_FOUND");
|
|
2928
3036
|
return oidcServerMetadata(ctx, opts);
|
|
2929
3037
|
}),
|
|
2930
|
-
oauth2Authorize:
|
|
3038
|
+
oauth2Authorize: createOAuthEndpoint("/oauth2/authorize", {
|
|
2931
3039
|
method: "GET",
|
|
2932
3040
|
query: z.object({
|
|
2933
|
-
response_type: z.enum(["code"]).optional(),
|
|
3041
|
+
response_type: z.string().pipe(z.enum(["code"])).optional(),
|
|
2934
3042
|
client_id: z.string(),
|
|
2935
3043
|
redirect_uri: SafeUrlSchema.optional(),
|
|
2936
3044
|
scope: z.string().optional(),
|
|
2937
3045
|
state: z.string().optional(),
|
|
2938
3046
|
request_uri: z.string().optional(),
|
|
2939
3047
|
code_challenge: z.string().optional(),
|
|
2940
|
-
code_challenge_method: z.enum(["S256"]).optional(),
|
|
3048
|
+
code_challenge_method: z.string().pipe(z.enum(["S256"])).optional(),
|
|
2941
3049
|
nonce: z.string().optional(),
|
|
2942
|
-
prompt: z.enum([
|
|
3050
|
+
prompt: z.string().pipe(z.enum([
|
|
2943
3051
|
"none",
|
|
2944
3052
|
"consent",
|
|
2945
3053
|
"login",
|
|
@@ -2947,8 +3055,10 @@ const oauthProvider = (options) => {
|
|
|
2947
3055
|
"select_account",
|
|
2948
3056
|
"login consent",
|
|
2949
3057
|
"select_account consent"
|
|
2950
|
-
]).optional()
|
|
3058
|
+
])).optional()
|
|
2951
3059
|
}),
|
|
3060
|
+
redirectOnError: authorizeRedirectOnError(opts),
|
|
3061
|
+
errorCodesByField: { response_type: { invalid: "unsupported_response_type" } },
|
|
2952
3062
|
metadata: { openapi: {
|
|
2953
3063
|
description: "Authorize an OAuth2 request",
|
|
2954
3064
|
parameters: [
|
|
@@ -3107,14 +3217,14 @@ const oauthProvider = (options) => {
|
|
|
3107
3217
|
}, async (ctx) => {
|
|
3108
3218
|
return continueEndpoint(ctx, opts);
|
|
3109
3219
|
}),
|
|
3110
|
-
oauth2Token:
|
|
3220
|
+
oauth2Token: createOAuthEndpoint("/oauth2/token", {
|
|
3111
3221
|
method: "POST",
|
|
3112
3222
|
body: z.object({
|
|
3113
|
-
grant_type: z.enum([
|
|
3223
|
+
grant_type: z.string().pipe(z.enum([
|
|
3114
3224
|
"authorization_code",
|
|
3115
3225
|
"client_credentials",
|
|
3116
3226
|
"refresh_token"
|
|
3117
|
-
]),
|
|
3227
|
+
])),
|
|
3118
3228
|
client_id: z.string().optional(),
|
|
3119
3229
|
client_secret: z.string().optional(),
|
|
3120
3230
|
client_assertion: z.string().optional(),
|
|
@@ -3126,6 +3236,10 @@ const oauthProvider = (options) => {
|
|
|
3126
3236
|
resource: z.string().optional(),
|
|
3127
3237
|
scope: z.string().optional()
|
|
3128
3238
|
}),
|
|
3239
|
+
errorCodesByField: { grant_type: {
|
|
3240
|
+
missing: "invalid_request",
|
|
3241
|
+
invalid: "unsupported_grant_type"
|
|
3242
|
+
} },
|
|
3129
3243
|
metadata: {
|
|
3130
3244
|
allowedMediaTypes: ["application/x-www-form-urlencoded"],
|
|
3131
3245
|
openapi: {
|
|
@@ -3238,7 +3352,7 @@ const oauthProvider = (options) => {
|
|
|
3238
3352
|
}, async (ctx) => {
|
|
3239
3353
|
return tokenEndpoint(ctx, opts);
|
|
3240
3354
|
}),
|
|
3241
|
-
oauth2Introspect:
|
|
3355
|
+
oauth2Introspect: createOAuthEndpoint("/oauth2/introspect", {
|
|
3242
3356
|
method: "POST",
|
|
3243
3357
|
body: z.object({
|
|
3244
3358
|
client_id: z.string().optional(),
|
|
@@ -3246,7 +3360,7 @@ const oauthProvider = (options) => {
|
|
|
3246
3360
|
client_assertion: z.string().optional(),
|
|
3247
3361
|
client_assertion_type: z.string().optional(),
|
|
3248
3362
|
token: z.string(),
|
|
3249
|
-
token_type_hint: z.
|
|
3363
|
+
token_type_hint: z.string().optional()
|
|
3250
3364
|
}),
|
|
3251
3365
|
metadata: {
|
|
3252
3366
|
allowedMediaTypes: ["application/x-www-form-urlencoded"],
|
|
@@ -3271,8 +3385,7 @@ const oauthProvider = (options) => {
|
|
|
3271
3385
|
},
|
|
3272
3386
|
token_type_hint: {
|
|
3273
3387
|
type: "string",
|
|
3274
|
-
|
|
3275
|
-
description: "Hint about the type of the token submitted for introspection"
|
|
3388
|
+
description: "Hint about the token type. Recognized values: `access_token`, `refresh_token`."
|
|
3276
3389
|
},
|
|
3277
3390
|
resource: {
|
|
3278
3391
|
type: "string",
|
|
@@ -3358,7 +3471,7 @@ const oauthProvider = (options) => {
|
|
|
3358
3471
|
}, async (ctx) => {
|
|
3359
3472
|
return introspectEndpoint(ctx, opts);
|
|
3360
3473
|
}),
|
|
3361
|
-
oauth2Revoke:
|
|
3474
|
+
oauth2Revoke: createOAuthEndpoint("/oauth2/revoke", {
|
|
3362
3475
|
method: "POST",
|
|
3363
3476
|
body: z.object({
|
|
3364
3477
|
client_id: z.string().optional(),
|
|
@@ -3366,7 +3479,7 @@ const oauthProvider = (options) => {
|
|
|
3366
3479
|
client_assertion: z.string().optional(),
|
|
3367
3480
|
client_assertion_type: z.string().optional(),
|
|
3368
3481
|
token: z.string(),
|
|
3369
|
-
token_type_hint: z.
|
|
3482
|
+
token_type_hint: z.string().optional()
|
|
3370
3483
|
}),
|
|
3371
3484
|
metadata: {
|
|
3372
3485
|
allowedMediaTypes: ["application/x-www-form-urlencoded"],
|
|
@@ -3391,8 +3504,7 @@ const oauthProvider = (options) => {
|
|
|
3391
3504
|
},
|
|
3392
3505
|
token_type_hint: {
|
|
3393
3506
|
type: "string",
|
|
3394
|
-
|
|
3395
|
-
description: "Hint about the type of the token submitted for revocation"
|
|
3507
|
+
description: "Hint about the token type. Recognized values: `access_token`, `refresh_token`."
|
|
3396
3508
|
}
|
|
3397
3509
|
},
|
|
3398
3510
|
required: ["token"]
|
|
@@ -3513,7 +3625,7 @@ const oauthProvider = (options) => {
|
|
|
3513
3625
|
}, async (ctx) => {
|
|
3514
3626
|
return userInfoEndpoint(ctx, opts);
|
|
3515
3627
|
}),
|
|
3516
|
-
oauth2EndSession:
|
|
3628
|
+
oauth2EndSession: createOAuthEndpoint("/oauth2/end-session", {
|
|
3517
3629
|
method: "GET",
|
|
3518
3630
|
query: z.object({
|
|
3519
3631
|
id_token_hint: z.string(),
|
|
@@ -3544,7 +3656,7 @@ const oauthProvider = (options) => {
|
|
|
3544
3656
|
}, async (ctx) => {
|
|
3545
3657
|
return rpInitiatedLogoutEndpoint(ctx, opts);
|
|
3546
3658
|
}),
|
|
3547
|
-
registerOAuthClient:
|
|
3659
|
+
registerOAuthClient: createOAuthEndpoint("/oauth2/register", {
|
|
3548
3660
|
method: "POST",
|
|
3549
3661
|
body: z.object({
|
|
3550
3662
|
redirect_uris: z.array(SafeUrlSchema).min(1).min(1),
|
|
@@ -3581,6 +3693,12 @@ const oauthProvider = (options) => {
|
|
|
3581
3693
|
subject_type: z.enum(["public", "pairwise"]).optional(),
|
|
3582
3694
|
skip_consent: z.never({ error: "skip_consent cannot be set during dynamic client registration" }).optional()
|
|
3583
3695
|
}),
|
|
3696
|
+
errorCodesByField: {
|
|
3697
|
+
redirect_uris: "invalid_redirect_uri",
|
|
3698
|
+
post_logout_redirect_uris: "invalid_redirect_uri",
|
|
3699
|
+
software_statement: "invalid_software_statement"
|
|
3700
|
+
},
|
|
3701
|
+
defaultError: "invalid_client_metadata",
|
|
3584
3702
|
metadata: { openapi: {
|
|
3585
3703
|
description: "Register an OAuth2 application",
|
|
3586
3704
|
responses: { "200": {
|
|
@@ -3773,17 +3891,37 @@ const oauthProvider = (options) => {
|
|
|
3773
3891
|
//#endregion
|
|
3774
3892
|
//#region src/authorize.ts
|
|
3775
3893
|
/**
|
|
3776
|
-
* Formats an error url
|
|
3894
|
+
* Formats an error url. Per OIDC Core 1.0 §5 / RFC 6749 §4.2.2.1, errors on
|
|
3895
|
+
* implicit and hybrid flows are delivered in the URL fragment, not the query.
|
|
3896
|
+
* Callers on the code flow (default) omit `mode` and get query delivery.
|
|
3777
3897
|
*/
|
|
3778
|
-
function formatErrorURL(url, error, description, state, iss) {
|
|
3898
|
+
function formatErrorURL(url, error, description, state, iss, mode = "query") {
|
|
3779
3899
|
const searchParams = new URLSearchParams({
|
|
3780
3900
|
error,
|
|
3781
3901
|
error_description: description
|
|
3782
3902
|
});
|
|
3783
3903
|
state && searchParams.append("state", state);
|
|
3784
3904
|
iss && searchParams.append("iss", iss);
|
|
3905
|
+
if (mode === "fragment") return `${url}#${searchParams.toString()}`;
|
|
3785
3906
|
return `${url}${url.includes("?") ? "&" : "?"}${searchParams.toString()}`;
|
|
3786
3907
|
}
|
|
3908
|
+
/**
|
|
3909
|
+
* Selects the response mode for an error redirect to the RP. OIDC Core 1.0 §5
|
|
3910
|
+
* defines defaults based on response_type: `code` → query, types containing
|
|
3911
|
+
* `token` / `id_token` → fragment. An explicit `response_mode` overrides.
|
|
3912
|
+
*
|
|
3913
|
+
* When `response_type` is duplicated (array) or absent, we can't trust the
|
|
3914
|
+
* caller's intent, so we default to query — the safer channel for
|
|
3915
|
+
* unrecognized shapes.
|
|
3916
|
+
*/
|
|
3917
|
+
function deriveResponseMode(raw) {
|
|
3918
|
+
const responseMode = typeof raw.response_mode === "string" ? raw.response_mode : void 0;
|
|
3919
|
+
if (responseMode === "fragment") return "fragment";
|
|
3920
|
+
if (responseMode === "query") return "query";
|
|
3921
|
+
const responseType = typeof raw.response_type === "string" ? raw.response_type : void 0;
|
|
3922
|
+
if (responseType && /\b(token|id_token)\b/.test(responseType)) return "fragment";
|
|
3923
|
+
return "query";
|
|
3924
|
+
}
|
|
3787
3925
|
const handleRedirect = (ctx, uri) => {
|
|
3788
3926
|
const fromFetch = isBrowserFetchRequest(ctx.request?.headers);
|
|
3789
3927
|
const acceptJson = ctx.headers?.get("accept")?.includes("application/json");
|
|
@@ -3807,8 +3945,7 @@ function redirectWithPromptNoneError(ctx, opts, query, error, description) {
|
|
|
3807
3945
|
function validateIssuerUrl(issuer) {
|
|
3808
3946
|
try {
|
|
3809
3947
|
const url = new URL(issuer);
|
|
3810
|
-
|
|
3811
|
-
if (url.protocol !== "https:" && !isLocalhost) url.protocol = "https:";
|
|
3948
|
+
if (url.protocol !== "https:" && !isLoopbackHost(url.host)) url.protocol = "https:";
|
|
3812
3949
|
url.search = "";
|
|
3813
3950
|
url.hash = "";
|
|
3814
3951
|
return url.toString().replace(/\/$/, "");
|
|
@@ -3836,6 +3973,64 @@ function getIssuer(ctx, opts) {
|
|
|
3836
3973
|
function getErrorURL(ctx, error, description) {
|
|
3837
3974
|
return formatErrorURL(ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`, error, description);
|
|
3838
3975
|
}
|
|
3976
|
+
/**
|
|
3977
|
+
* Finds the matching entry in a client's registered redirect_uris for a
|
|
3978
|
+
* requested redirect_uri. Honors RFC 8252 §7.3 loopback port variance for
|
|
3979
|
+
* the full 127.0.0.0/8 range and [::1], matching on scheme+host+path+query
|
|
3980
|
+
* and ignoring port. DNS names like "localhost" are excluded per §8.3.
|
|
3981
|
+
*/
|
|
3982
|
+
function findRegisteredRedirectUri(registered, requested) {
|
|
3983
|
+
if (!registered || !requested) return void 0;
|
|
3984
|
+
let req;
|
|
3985
|
+
try {
|
|
3986
|
+
req = new URL(requested);
|
|
3987
|
+
} catch {}
|
|
3988
|
+
return registered.find((url) => {
|
|
3989
|
+
if (url === requested) return true;
|
|
3990
|
+
if (!req) return false;
|
|
3991
|
+
try {
|
|
3992
|
+
const reg = new URL(url);
|
|
3993
|
+
return isLoopbackIP(reg.hostname) && reg.hostname === req.hostname && reg.pathname === req.pathname && reg.protocol === req.protocol && reg.search === req.search;
|
|
3994
|
+
} catch {
|
|
3995
|
+
return false;
|
|
3996
|
+
}
|
|
3997
|
+
});
|
|
3998
|
+
}
|
|
3999
|
+
/**
|
|
4000
|
+
* Loads the client, verifies it's enabled, and returns the requested
|
|
4001
|
+
* redirect_uri when it matches a registered entry. Returns null whenever the
|
|
4002
|
+
* RP cannot be safely reached, so callers can fall back to the server error
|
|
4003
|
+
* page (avoiding open-redirect risk on validation failures).
|
|
4004
|
+
*/
|
|
4005
|
+
async function resolveTrustedRedirectUri(ctx, opts, clientId, redirectUri) {
|
|
4006
|
+
if (!clientId || !redirectUri) return null;
|
|
4007
|
+
let client;
|
|
4008
|
+
try {
|
|
4009
|
+
client = await getClient(ctx, opts, clientId);
|
|
4010
|
+
} catch {
|
|
4011
|
+
return null;
|
|
4012
|
+
}
|
|
4013
|
+
if (!client || client.disabled) return null;
|
|
4014
|
+
return findRegisteredRedirectUri(client.redirectUris, redirectUri) ? redirectUri : null;
|
|
4015
|
+
}
|
|
4016
|
+
/**
|
|
4017
|
+
* `redirectOnError` callback for `/oauth2/authorize`. Per RFC 6749 §4.1.2.1,
|
|
4018
|
+
* authorize errors MUST be delivered to the client's `redirect_uri` with
|
|
4019
|
+
* `error`, `error_description`, `state`, and (RFC 9207) `iss`. The clause
|
|
4020
|
+
* carves out one case: a missing/invalid `redirect_uri` or `client_id` MUST
|
|
4021
|
+
* NOT redirect to the requested URI. We implement the carve-out via
|
|
4022
|
+
* `resolveTrustedRedirectUri`, falling back to the server error page.
|
|
4023
|
+
*
|
|
4024
|
+
* Channel (query vs fragment) follows OIDC Core §5 via `deriveResponseMode`.
|
|
4025
|
+
*/
|
|
4026
|
+
function authorizeRedirectOnError(opts) {
|
|
4027
|
+
return async ({ error, error_description, ctx }) => {
|
|
4028
|
+
const raw = ctx.query ?? {};
|
|
4029
|
+
const trusted = await resolveTrustedRedirectUri(ctx, opts, typeof raw.client_id === "string" ? raw.client_id : void 0, typeof raw.redirect_uri === "string" ? raw.redirect_uri : void 0);
|
|
4030
|
+
if (trusted) return handleRedirect(ctx, formatErrorURL(trusted, error, error_description, typeof raw.state === "string" ? raw.state : void 0, getIssuer(ctx, opts), deriveResponseMode(raw)));
|
|
4031
|
+
return handleRedirect(ctx, getErrorURL(ctx, error, error_description));
|
|
4032
|
+
};
|
|
4033
|
+
}
|
|
3839
4034
|
async function authorizeEndpoint(ctx, opts, settings) {
|
|
3840
4035
|
if (opts.grantTypes && !opts.grantTypes.includes("authorization_code")) throw new APIError$1("NOT_FOUND");
|
|
3841
4036
|
if (!ctx.request) throw new APIError$1("UNAUTHORIZED", {
|
|
@@ -3866,15 +4061,7 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
3866
4061
|
const client = await getClient(ctx, opts, query.client_id);
|
|
3867
4062
|
if (!client) return handleRedirect(ctx, getErrorURL(ctx, "invalid_client", "client_id is required"));
|
|
3868
4063
|
if (client.disabled) return handleRedirect(ctx, getErrorURL(ctx, "client_disabled", "client is disabled"));
|
|
3869
|
-
if (!client.redirectUris
|
|
3870
|
-
if (url === query.redirect_uri) return true;
|
|
3871
|
-
try {
|
|
3872
|
-
const registered = new URL(url);
|
|
3873
|
-
const requested = new URL(query.redirect_uri);
|
|
3874
|
-
if ((registered.hostname === "127.0.0.1" || registered.hostname === "[::1]") && registered.hostname === requested.hostname && registered.pathname === requested.pathname && registered.protocol === requested.protocol && registered.search === requested.search) return true;
|
|
3875
|
-
} catch {}
|
|
3876
|
-
return false;
|
|
3877
|
-
}) || !query.redirect_uri) return handleRedirect(ctx, getErrorURL(ctx, "invalid_redirect", "invalid redirect uri"));
|
|
4064
|
+
if (!findRegisteredRedirectUri(client.redirectUris, query.redirect_uri) || !query.redirect_uri) return handleRedirect(ctx, getErrorURL(ctx, "invalid_redirect", "invalid redirect uri"));
|
|
3878
4065
|
let requestedScopes = query.scope?.split(" ").filter((s) => s);
|
|
3879
4066
|
if (requestedScopes) {
|
|
3880
4067
|
const validScopes = new Set(client.scopes ?? opts.scopes);
|
|
@@ -3921,7 +4108,7 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
3921
4108
|
});
|
|
3922
4109
|
if (signupRedirect) {
|
|
3923
4110
|
if (promptNone) return redirectWithPromptNoneError(ctx, opts, query, "interaction_required", "End-User interaction is required");
|
|
3924
|
-
return redirectWithPromptCode(ctx, opts, "create", typeof signupRedirect === "string" ? signupRedirect : void 0);
|
|
4111
|
+
return redirectWithPromptCode(ctx, opts, "create", { page: typeof signupRedirect === "string" ? signupRedirect : void 0 });
|
|
3925
4112
|
}
|
|
3926
4113
|
}
|
|
3927
4114
|
if (!settings?.postLogin && opts.postLogin) {
|
|
@@ -3935,7 +4122,7 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
3935
4122
|
return redirectWithPromptCode(ctx, opts, "post_login");
|
|
3936
4123
|
}
|
|
3937
4124
|
}
|
|
3938
|
-
if (promptSet?.has("consent")) return redirectWithPromptCode(ctx, opts, "consent");
|
|
4125
|
+
if (promptSet?.has("consent")) return redirectWithPromptCode(ctx, opts, "consent", { sessionId: session.session.id });
|
|
3939
4126
|
const referenceId = await opts.postLogin?.consentReferenceId?.({
|
|
3940
4127
|
user: session.user,
|
|
3941
4128
|
session: session.session,
|
|
@@ -3968,7 +4155,7 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
3968
4155
|
});
|
|
3969
4156
|
if (!consent || !requestedScopes.every((val) => consent.scopes.includes(val))) {
|
|
3970
4157
|
if (promptNone) return redirectWithPromptNoneError(ctx, opts, query, "consent_required", "End-User consent is required");
|
|
3971
|
-
return redirectWithPromptCode(ctx, opts, "consent");
|
|
4158
|
+
return redirectWithPromptCode(ctx, opts, "consent", { sessionId: session.session.id });
|
|
3972
4159
|
}
|
|
3973
4160
|
return redirectWithAuthorizationCode(ctx, opts, {
|
|
3974
4161
|
query,
|
|
@@ -4015,8 +4202,8 @@ async function redirectWithAuthorizationCode(ctx, opts, verificationValue) {
|
|
|
4015
4202
|
redirectUriWithCode.searchParams.set("iss", getIssuer(ctx, opts));
|
|
4016
4203
|
return handleRedirect(ctx, redirectUriWithCode.toString());
|
|
4017
4204
|
}
|
|
4018
|
-
async function redirectWithPromptCode(ctx, opts, type,
|
|
4019
|
-
const queryParams = await signParams(ctx, opts);
|
|
4205
|
+
async function redirectWithPromptCode(ctx, opts, type, options) {
|
|
4206
|
+
const queryParams = await signParams(ctx, opts, { postLoginClearedForSession: type === "consent" && opts.postLogin ? options?.sessionId : void 0 });
|
|
4020
4207
|
let path = opts.loginPage;
|
|
4021
4208
|
if (type === "select_account") path = opts.selectAccount?.page ?? opts.loginPage;
|
|
4022
4209
|
else if (type === "post_login") {
|
|
@@ -4024,12 +4211,16 @@ async function redirectWithPromptCode(ctx, opts, type, page) {
|
|
|
4024
4211
|
path = opts.postLogin?.page;
|
|
4025
4212
|
} else if (type === "consent") path = opts.consentPage;
|
|
4026
4213
|
else if (type === "create") path = opts.signup?.page ?? opts.loginPage;
|
|
4027
|
-
return handleRedirect(ctx, `${page ?? path}?${queryParams}`);
|
|
4214
|
+
return handleRedirect(ctx, `${options?.page ?? path}?${queryParams}`);
|
|
4028
4215
|
}
|
|
4029
|
-
async function signParams(ctx, opts) {
|
|
4030
|
-
const
|
|
4216
|
+
async function signParams(ctx, opts, flags) {
|
|
4217
|
+
const issuedAt = Date.now();
|
|
4218
|
+
const exp = Math.floor(issuedAt / 1e3) + (opts.codeExpiresIn ?? 600);
|
|
4031
4219
|
const params = serializeAuthorizationQuery(ctx.query);
|
|
4032
4220
|
params.set("exp", String(exp));
|
|
4221
|
+
params.set(signedQueryIssuedAtParam, String(issuedAt));
|
|
4222
|
+
params.delete(postLoginClearedParam);
|
|
4223
|
+
if (flags?.postLoginClearedForSession) params.set(postLoginClearedParam, flags.postLoginClearedForSession);
|
|
4033
4224
|
const signature = await makeSignature(params.toString(), ctx.context.secret);
|
|
4034
4225
|
params.append("sig", signature);
|
|
4035
4226
|
return params.toString();
|