@better-auth/oauth-provider 1.7.0-beta.4 → 1.7.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{client-assertion-DLMKVgoj.mjs → client-assertion-DmT1B6_6.mjs} +39 -48
- package/dist/client-resource.d.mts +15 -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 +4 -2
- package/dist/index.mjs +600 -279
- package/dist/{oauth-q7dn10NU.d.mts → oauth-BXrYl5x6.d.mts} +93 -0
- package/dist/{oauth-Vt3lTNHX.d.mts → oauth-DU6NeviY.d.mts} +106 -34
- package/dist/{utils-DKBWQ8fe.mjs → utils-D2dLqo7f.mjs} +33 -3
- package/dist/{version-nFnRm-a3.mjs → version-B1ZiRmxj.mjs} +1 -1
- package/package.json +7 -7
package/dist/index.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { n as isPrivateHostname } from "./client-assertion-
|
|
1
|
+
import { n as isPrivateHostname } from "./client-assertion-DmT1B6_6.mjs";
|
|
2
2
|
import { n as mcpHandler } from "./mcp-CYnz-MXn.mjs";
|
|
3
|
-
import { C as
|
|
4
|
-
import { t as PACKAGE_VERSION } from "./version-
|
|
5
|
-
import { APIError, createAuthEndpoint, createAuthMiddleware, getOAuthState, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
|
|
3
|
+
import { A as verifyOAuthQueryParams, C as signedQueryIssuedAtParam, D as toClientDiscoveryArray, E as toAudienceClaim, O as toResourceList, S as searchParamsToQuery, T as storeToken, _ as postLoginClearedParam, a as extractClientCredentials, b as resolveSessionAuthTime, d as isPKCERequired, f as isSessionFreshForSignedQuery, g as parsePrompt, h as parseClientMetadata, i as destructureCredentials, k as validateClientCredentials, l as getSignedQueryIssuedAt, m as normalizeTimestampValue, n as clientAllowsGrant, o as getClient, p as mergeDiscoveryMetadata, r as decryptStoredClientSecret, s as getJwtPlugin, t as checkResource, u as getStoredToken, v as removeMaxAgeFromQuery, w as storeClientSecret, x as resolveSubjectIdentifier, y as removePromptFromQuery } from "./utils-D2dLqo7f.mjs";
|
|
4
|
+
import { t as PACKAGE_VERSION } from "./version-B1ZiRmxj.mjs";
|
|
5
|
+
import { APIError, addOAuthServerContext, createAuthEndpoint, createAuthMiddleware, dispatchAuthEndpoint, getOAuthState, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
|
|
6
6
|
import { generateCodeChallenge, getJwks, verifyJwsAccessToken } from "better-auth/oauth2";
|
|
7
7
|
import { APIError as APIError$1 } from "better-call";
|
|
8
8
|
import { PRIVATE_KEY_JWT_SIGNING_ALGORITHMS } from "@better-auth/core/oauth2";
|
|
@@ -19,7 +19,7 @@ import { resolveSigningKey, signJWT, toExpJWT } from "better-auth/plugins";
|
|
|
19
19
|
import { SignJWT, base64url, compactVerify, createLocalJWKSet, decodeJwt, decodeProtectedHeader } from "jose";
|
|
20
20
|
import { SafeUrlSchema } from "@better-auth/core/utils/redirect-uri";
|
|
21
21
|
//#region src/consent.ts
|
|
22
|
-
async function consentEndpoint(ctx, opts) {
|
|
22
|
+
async function consentEndpoint(ctx, opts, authorize) {
|
|
23
23
|
const oauthRequest = await oAuthState.get();
|
|
24
24
|
const _query = oauthRequest?.query;
|
|
25
25
|
if (!_query) throw new APIError("BAD_REQUEST", {
|
|
@@ -46,15 +46,11 @@ async function consentEndpoint(ctx, opts) {
|
|
|
46
46
|
};
|
|
47
47
|
const session = await getSessionFromCtx(ctx);
|
|
48
48
|
const hasLoginPrompt = parsePrompt(query.get("prompt") ?? "").has("login");
|
|
49
|
-
const hasSatisfiedLoginPrompt = hasLoginPrompt &&
|
|
49
|
+
const hasSatisfiedLoginPrompt = hasLoginPrompt && isSessionFreshForSignedQuery(session?.session.createdAt, oauthRequest?.signedQueryIssuedAt);
|
|
50
50
|
if (hasLoginPrompt && !hasSatisfiedLoginPrompt) {
|
|
51
51
|
ctx?.headers?.set("accept", "application/json");
|
|
52
52
|
ctx.query = searchParamsToQuery(query);
|
|
53
|
-
|
|
54
|
-
return {
|
|
55
|
-
redirect: true,
|
|
56
|
-
url
|
|
57
|
-
};
|
|
53
|
+
return await authorize(ctx);
|
|
58
54
|
}
|
|
59
55
|
const referenceId = await opts.postLogin?.consentReferenceId?.({
|
|
60
56
|
user: session?.user,
|
|
@@ -110,32 +106,25 @@ async function consentEndpoint(ctx, opts) {
|
|
|
110
106
|
if (requestedScopes) query.set("scope", consent.scopes.join(" "));
|
|
111
107
|
ctx?.headers?.set("accept", "application/json");
|
|
112
108
|
let authorizationQuery = removePromptFromQuery(query, "consent");
|
|
113
|
-
if (hasSatisfiedLoginPrompt)
|
|
109
|
+
if (hasSatisfiedLoginPrompt) {
|
|
110
|
+
authorizationQuery = removePromptFromQuery(authorizationQuery, "login");
|
|
111
|
+
authorizationQuery = removeMaxAgeFromQuery(authorizationQuery);
|
|
112
|
+
}
|
|
114
113
|
ctx.query = searchParamsToQuery(authorizationQuery);
|
|
115
|
-
|
|
116
|
-
return {
|
|
117
|
-
redirect: true,
|
|
118
|
-
url
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
function sessionSatisfiesLoginPrompt(sessionCreatedAt, signedQueryIssuedAt) {
|
|
122
|
-
if (!signedQueryIssuedAt) return false;
|
|
123
|
-
const normalized = normalizeTimestampValue(sessionCreatedAt);
|
|
124
|
-
if (!normalized) return false;
|
|
125
|
-
return normalized.getTime() >= signedQueryIssuedAt.getTime();
|
|
114
|
+
return await authorize(ctx, { postLogin: oauthRequest?.postLoginClearedForSession !== void 0 && oauthRequest.postLoginClearedForSession === session?.session.id });
|
|
126
115
|
}
|
|
127
116
|
//#endregion
|
|
128
117
|
//#region src/continue.ts
|
|
129
|
-
async function continueEndpoint(ctx,
|
|
130
|
-
if (ctx.body.selected === true) return await selected(ctx,
|
|
131
|
-
else if (ctx.body.created === true) return await created(ctx,
|
|
132
|
-
else if (ctx.body.postLogin === true) return await postLogin(ctx,
|
|
118
|
+
async function continueEndpoint(ctx, authorize) {
|
|
119
|
+
if (ctx.body.selected === true) return await selected(ctx, authorize);
|
|
120
|
+
else if (ctx.body.created === true) return await created(ctx, authorize);
|
|
121
|
+
else if (ctx.body.postLogin === true) return await postLogin(ctx, authorize);
|
|
133
122
|
else throw new APIError("BAD_REQUEST", {
|
|
134
123
|
error_description: "Missing parameters",
|
|
135
124
|
error: "invalid_request"
|
|
136
125
|
});
|
|
137
126
|
}
|
|
138
|
-
async function selected(ctx,
|
|
127
|
+
async function selected(ctx, authorize) {
|
|
139
128
|
const _query = (await oAuthState.get())?.query;
|
|
140
129
|
if (!_query) throw new APIError("BAD_REQUEST", {
|
|
141
130
|
error_description: "missing oauth query",
|
|
@@ -143,13 +132,9 @@ async function selected(ctx, opts) {
|
|
|
143
132
|
});
|
|
144
133
|
ctx.headers?.set("accept", "application/json");
|
|
145
134
|
ctx.query = searchParamsToQuery(removePromptFromQuery(new URLSearchParams(_query), "select_account"));
|
|
146
|
-
|
|
147
|
-
return {
|
|
148
|
-
redirect: true,
|
|
149
|
-
url
|
|
150
|
-
};
|
|
135
|
+
return await authorize(ctx);
|
|
151
136
|
}
|
|
152
|
-
async function created(ctx,
|
|
137
|
+
async function created(ctx, authorize) {
|
|
153
138
|
const _query = (await oAuthState.get())?.query;
|
|
154
139
|
if (!_query) throw new APIError("BAD_REQUEST", {
|
|
155
140
|
error_description: "missing oauth query",
|
|
@@ -158,14 +143,11 @@ async function created(ctx, opts) {
|
|
|
158
143
|
const query = new URLSearchParams(_query);
|
|
159
144
|
ctx.headers?.set("accept", "application/json");
|
|
160
145
|
ctx.query = searchParamsToQuery(removePromptFromQuery(query, "create"));
|
|
161
|
-
|
|
162
|
-
return {
|
|
163
|
-
redirect: true,
|
|
164
|
-
url
|
|
165
|
-
};
|
|
146
|
+
return await authorize(ctx);
|
|
166
147
|
}
|
|
167
|
-
async function postLogin(ctx,
|
|
168
|
-
const
|
|
148
|
+
async function postLogin(ctx, authorize) {
|
|
149
|
+
const state = await oAuthState.get();
|
|
150
|
+
const _query = state?.query;
|
|
169
151
|
if (!_query) throw new APIError("BAD_REQUEST", {
|
|
170
152
|
error_description: "missing oauth query",
|
|
171
153
|
error: "invalid_request"
|
|
@@ -173,51 +155,11 @@ async function postLogin(ctx, opts) {
|
|
|
173
155
|
const query = new URLSearchParams(_query);
|
|
174
156
|
ctx.headers?.set("accept", "application/json");
|
|
175
157
|
ctx.query = searchParamsToQuery(query);
|
|
176
|
-
const
|
|
177
|
-
return {
|
|
178
|
-
redirect: true,
|
|
179
|
-
url
|
|
180
|
-
};
|
|
158
|
+
const session = await getSessionFromCtx(ctx);
|
|
159
|
+
return await authorize(ctx, { postLogin: state?.postLoginClearedForSession !== void 0 && state.postLoginClearedForSession === session?.session.id });
|
|
181
160
|
}
|
|
182
161
|
//#endregion
|
|
183
162
|
//#region src/types/zod.ts
|
|
184
|
-
/**
|
|
185
|
-
* Runtime schema for OAuthAuthorizationQuery.
|
|
186
|
-
* Uses passthrough to tolerate fields added by future extensions (PAR, FPA, etc.)
|
|
187
|
-
*/
|
|
188
|
-
const oauthAuthorizationQuerySchema = z.object({
|
|
189
|
-
response_type: z.literal("code").optional(),
|
|
190
|
-
request_uri: z.string().optional(),
|
|
191
|
-
redirect_uri: z.string(),
|
|
192
|
-
scope: z.string().optional(),
|
|
193
|
-
state: z.string().optional(),
|
|
194
|
-
client_id: z.string(),
|
|
195
|
-
prompt: z.string().optional(),
|
|
196
|
-
display: z.string().optional(),
|
|
197
|
-
ui_locales: z.string().optional(),
|
|
198
|
-
max_age: z.coerce.number().optional(),
|
|
199
|
-
acr_values: z.string().optional(),
|
|
200
|
-
login_hint: z.string().optional(),
|
|
201
|
-
id_token_hint: z.string().optional(),
|
|
202
|
-
code_challenge: z.string().optional(),
|
|
203
|
-
code_challenge_method: z.literal("S256").optional(),
|
|
204
|
-
nonce: z.string().optional(),
|
|
205
|
-
resource: z.union([z.string(), z.array(z.string())]).optional()
|
|
206
|
-
}).passthrough();
|
|
207
|
-
/**
|
|
208
|
-
* Runtime schema for the authorization code verification value.
|
|
209
|
-
* Validates structure on deserialization from the JSON blob stored in the DB.
|
|
210
|
-
* Uses passthrough so future fields (e.g. from authorization challenge) don't break parsing.
|
|
211
|
-
*/
|
|
212
|
-
const verificationValueSchema = z.object({
|
|
213
|
-
type: z.literal("authorization_code"),
|
|
214
|
-
query: oauthAuthorizationQuerySchema,
|
|
215
|
-
sessionId: z.string(),
|
|
216
|
-
userId: z.string(),
|
|
217
|
-
referenceId: z.string().optional(),
|
|
218
|
-
authTime: z.number().optional(),
|
|
219
|
-
resource: z.array(z.string()).optional()
|
|
220
|
-
}).passthrough();
|
|
221
163
|
const DANGEROUS_SCHEMES = [
|
|
222
164
|
"javascript:",
|
|
223
165
|
"data:",
|
|
@@ -251,6 +193,88 @@ const ResourceUriSchema = z.string().superRefine((val, ctx) => {
|
|
|
251
193
|
message: "resource cannot use javascript:, data:, or vbscript: scheme"
|
|
252
194
|
});
|
|
253
195
|
});
|
|
196
|
+
const authorizationPromptTokenSchema = z.enum([
|
|
197
|
+
"none",
|
|
198
|
+
"consent",
|
|
199
|
+
"login",
|
|
200
|
+
"create",
|
|
201
|
+
"select_account"
|
|
202
|
+
]);
|
|
203
|
+
const authorizationPromptSchema = z.string().superRefine((value, ctx) => {
|
|
204
|
+
const promptTokens = value.split(" ").map((token) => token.trim()).filter(Boolean);
|
|
205
|
+
const promptSet = /* @__PURE__ */ new Set();
|
|
206
|
+
if (!promptTokens.length) {
|
|
207
|
+
ctx.addIssue({
|
|
208
|
+
code: "custom",
|
|
209
|
+
message: "prompt must include at least one value"
|
|
210
|
+
});
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
for (const token of promptTokens) {
|
|
214
|
+
const result = authorizationPromptTokenSchema.safeParse(token);
|
|
215
|
+
if (!result.success) {
|
|
216
|
+
ctx.addIssue({
|
|
217
|
+
code: "custom",
|
|
218
|
+
message: `unsupported prompt value: ${token}`
|
|
219
|
+
});
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
promptSet.add(result.data);
|
|
223
|
+
}
|
|
224
|
+
if (promptSet.has("none") && promptSet.size > 1) ctx.addIssue({
|
|
225
|
+
code: "custom",
|
|
226
|
+
message: "prompt=none cannot be combined with other prompt values"
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
const maxAgeSchema = z.union([z.number(), z.string().trim().min(1)]).transform((value, ctx) => {
|
|
230
|
+
const maxAge = typeof value === "number" ? value : Number(value);
|
|
231
|
+
if (!Number.isInteger(maxAge) || maxAge < 0) {
|
|
232
|
+
ctx.addIssue({
|
|
233
|
+
code: "custom",
|
|
234
|
+
message: "max_age must be a non-negative integer"
|
|
235
|
+
});
|
|
236
|
+
return z.NEVER;
|
|
237
|
+
}
|
|
238
|
+
return maxAge;
|
|
239
|
+
});
|
|
240
|
+
/**
|
|
241
|
+
* Runtime schema for OAuthAuthorizationQuery.
|
|
242
|
+
* Uses passthrough to tolerate fields added by future extensions (PAR, FPA, etc.)
|
|
243
|
+
*/
|
|
244
|
+
const authorizationQuerySchema = z.object({
|
|
245
|
+
response_type: z.string().pipe(z.enum(["code"])).optional(),
|
|
246
|
+
request_uri: z.string().optional(),
|
|
247
|
+
redirect_uri: SafeUrlSchema.optional(),
|
|
248
|
+
scope: z.string().optional(),
|
|
249
|
+
state: z.string().optional(),
|
|
250
|
+
client_id: z.string(),
|
|
251
|
+
prompt: authorizationPromptSchema.optional(),
|
|
252
|
+
display: z.string().optional(),
|
|
253
|
+
ui_locales: z.string().optional(),
|
|
254
|
+
max_age: maxAgeSchema.optional(),
|
|
255
|
+
acr_values: z.string().optional(),
|
|
256
|
+
login_hint: z.string().optional(),
|
|
257
|
+
id_token_hint: z.string().optional(),
|
|
258
|
+
code_challenge: z.string().optional(),
|
|
259
|
+
code_challenge_method: z.string().pipe(z.enum(["S256"])).optional(),
|
|
260
|
+
nonce: z.string().optional(),
|
|
261
|
+
resource: z.union([ResourceUriSchema, z.array(ResourceUriSchema).min(1)]).optional()
|
|
262
|
+
}).passthrough();
|
|
263
|
+
const storedAuthorizationQuerySchema = authorizationQuerySchema.extend({ redirect_uri: SafeUrlSchema });
|
|
264
|
+
/**
|
|
265
|
+
* Runtime schema for the authorization code verification value.
|
|
266
|
+
* Validates structure on deserialization from the JSON blob stored in the DB.
|
|
267
|
+
* Uses passthrough so future fields (e.g. from authorization challenge) don't break parsing.
|
|
268
|
+
*/
|
|
269
|
+
const verificationValueSchema = z.object({
|
|
270
|
+
type: z.literal("authorization_code"),
|
|
271
|
+
query: storedAuthorizationQuerySchema,
|
|
272
|
+
sessionId: z.string(),
|
|
273
|
+
userId: z.string(),
|
|
274
|
+
referenceId: z.string().optional(),
|
|
275
|
+
authTime: z.number().optional(),
|
|
276
|
+
resource: z.array(z.string()).optional()
|
|
277
|
+
}).passthrough();
|
|
254
278
|
//#endregion
|
|
255
279
|
//#region src/userinfo.ts
|
|
256
280
|
/**
|
|
@@ -287,6 +311,10 @@ async function userInfoEndpoint(ctx, opts) {
|
|
|
287
311
|
error: "invalid_request"
|
|
288
312
|
});
|
|
289
313
|
const jwt = await validateAccessToken(ctx, opts, token);
|
|
314
|
+
if (!jwt.active) throw new APIError("UNAUTHORIZED", {
|
|
315
|
+
error_description: "the access token is invalid or has been revoked",
|
|
316
|
+
error: "invalid_token"
|
|
317
|
+
});
|
|
290
318
|
const scopes = jwt.scope?.split(" ");
|
|
291
319
|
if (!scopes?.includes("openid")) throw new APIError("BAD_REQUEST", {
|
|
292
320
|
error_description: "Missing required scope",
|
|
@@ -396,6 +424,7 @@ async function createIdToken(ctx, opts, user, client, scopes, nonce, sessionId,
|
|
|
396
424
|
const resolvedKey = !opts.disableJwtPlugin && !jwtPluginOptions?.jwt?.sign ? await resolveSigningKey(ctx, jwtPluginOptions) : void 0;
|
|
397
425
|
const signingAlg = opts.disableJwtPlugin ? "HS256" : resolvedKey?.alg ?? jwtPluginOptions?.jwks?.keyPairConfig?.alg;
|
|
398
426
|
const atHash = accessToken ? await computeOidcHash(accessToken, signingAlg) : void 0;
|
|
427
|
+
const emitSid = Boolean(client.enableEndSession || client.backchannelLogoutUri);
|
|
399
428
|
const payload = {
|
|
400
429
|
...userClaims,
|
|
401
430
|
auth_time: authTimeSec,
|
|
@@ -408,7 +437,7 @@ async function createIdToken(ctx, opts, user, client, scopes, nonce, sessionId,
|
|
|
408
437
|
nonce,
|
|
409
438
|
iat,
|
|
410
439
|
exp,
|
|
411
|
-
sid:
|
|
440
|
+
sid: emitSid ? sessionId : void 0
|
|
412
441
|
};
|
|
413
442
|
if (opts.disableJwtPlugin && !client.clientSecret) return;
|
|
414
443
|
const idToken = opts.disableJwtPlugin ? await new SignJWT(payload).setProtectedHeader({ alg: "HS256" }).sign(new TextEncoder().encode(await decryptStoredClientSecret(ctx, opts.storeClientSecret, client.clientSecret))) : await signJWT(ctx, {
|
|
@@ -572,7 +601,7 @@ async function createUserTokens(ctx, opts, params) {
|
|
|
572
601
|
error: "invalid_target"
|
|
573
602
|
});
|
|
574
603
|
const audience = resourceResult.audience;
|
|
575
|
-
const isRefreshToken = user && (existingRefreshToken?.scopes?.includes("offline_access") || scopes.includes("offline_access"));
|
|
604
|
+
const isRefreshToken = user && clientAllowsGrant(client, "refresh_token") && (existingRefreshToken?.scopes?.includes("offline_access") || scopes.includes("offline_access"));
|
|
576
605
|
const isJwtAccessToken = audience && !opts.disableJwtPlugin;
|
|
577
606
|
const isIdToken = user && scopes.includes("openid");
|
|
578
607
|
const customFields = opts.customTokenResponseFields ? await opts.customTokenResponseFields({
|
|
@@ -695,7 +724,7 @@ async function handleAuthorizationCodeGrant(ctx, opts) {
|
|
|
695
724
|
error: "invalid_scope"
|
|
696
725
|
});
|
|
697
726
|
/** Verify Client */
|
|
698
|
-
const client = await validateClientCredentials(ctx, opts, client_id, client_secret, scopes, preVerifiedClient);
|
|
727
|
+
const client = await validateClientCredentials(ctx, opts, client_id, client_secret, scopes, preVerifiedClient, "authorization_code");
|
|
699
728
|
if (isPKCERequired(client, (verificationValue.query?.scope)?.split(" ") || [])) {
|
|
700
729
|
if (!isAuthCodeWithPkce) throw new APIError("BAD_REQUEST", {
|
|
701
730
|
error_description: "PKCE is required for this client",
|
|
@@ -776,7 +805,7 @@ async function handleClientCredentialsGrant(ctx, opts) {
|
|
|
776
805
|
error_description: "Missing a required client_secret",
|
|
777
806
|
error: "invalid_grant"
|
|
778
807
|
});
|
|
779
|
-
const client = await validateClientCredentials(ctx, opts, client_id, client_secret, void 0, preVerifiedClient);
|
|
808
|
+
const client = await validateClientCredentials(ctx, opts, client_id, client_secret, void 0, preVerifiedClient, "client_credentials");
|
|
780
809
|
let requestedScopes = scope?.split(" ");
|
|
781
810
|
if (requestedScopes) {
|
|
782
811
|
const validScopes = new Set(client.scopes ?? opts.scopes);
|
|
@@ -860,7 +889,7 @@ async function handleRefreshTokenGrant(ctx, opts) {
|
|
|
860
889
|
error: "invalid_scope"
|
|
861
890
|
});
|
|
862
891
|
}
|
|
863
|
-
const client = await validateClientCredentials(ctx, opts, client_id, client_secret, requestedScopes ?? scopes, preVerifiedClient);
|
|
892
|
+
const client = await validateClientCredentials(ctx, opts, client_id, client_secret, requestedScopes ?? scopes, preVerifiedClient, "refresh_token");
|
|
864
893
|
const user = await ctx.context.internalAdapter.findUserById(refreshToken.userId);
|
|
865
894
|
if (!user) throw new APIError("BAD_REQUEST", {
|
|
866
895
|
error_description: "user not found",
|
|
@@ -919,12 +948,10 @@ async function validateJwtAccessToken(ctx, opts, token, clientId) {
|
|
|
919
948
|
}
|
|
920
949
|
throw new Error(error);
|
|
921
950
|
}
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
if (clientId && jwtPayload.azp !== clientId) return { active: false };
|
|
927
|
-
}
|
|
951
|
+
if (!jwtPayload.azp) return { active: false };
|
|
952
|
+
const client = await getClient(ctx, opts, jwtPayload.azp);
|
|
953
|
+
if (!client || client?.disabled) return { active: false };
|
|
954
|
+
if (clientId && jwtPayload.azp !== clientId) return { active: false };
|
|
928
955
|
const sessionId = jwtPayload.sid;
|
|
929
956
|
if (sessionId) {
|
|
930
957
|
const session = await ctx.context.adapter.findOne({
|
|
@@ -934,9 +961,9 @@ async function validateJwtAccessToken(ctx, opts, token, clientId) {
|
|
|
934
961
|
value: sessionId
|
|
935
962
|
}]
|
|
936
963
|
});
|
|
937
|
-
if (!session || session.expiresAt < /* @__PURE__ */ new Date())
|
|
964
|
+
if (!session || session.expiresAt < /* @__PURE__ */ new Date()) return { active: false };
|
|
938
965
|
}
|
|
939
|
-
|
|
966
|
+
jwtPayload.client_id = jwtPayload.azp;
|
|
940
967
|
jwtPayload.active = true;
|
|
941
968
|
return jwtPayload;
|
|
942
969
|
}
|
|
@@ -964,13 +991,14 @@ async function validateOpaqueAccessToken(ctx, opts, token, clientId) {
|
|
|
964
991
|
error: "invalid_token"
|
|
965
992
|
});
|
|
966
993
|
if (!accessToken.expiresAt || accessToken.expiresAt < /* @__PURE__ */ new Date()) return { active: false };
|
|
994
|
+
if (accessToken.revoked) return { active: false };
|
|
967
995
|
let client;
|
|
968
996
|
if (accessToken.clientId) {
|
|
969
997
|
client = await getClient(ctx, opts, accessToken.clientId);
|
|
970
998
|
if (!client || client?.disabled) return { active: false };
|
|
971
999
|
if (clientId && accessToken.clientId !== clientId) return { active: false };
|
|
972
1000
|
}
|
|
973
|
-
|
|
1001
|
+
const sessionId = accessToken.sessionId ?? void 0;
|
|
974
1002
|
if (sessionId) {
|
|
975
1003
|
const session = await ctx.context.adapter.findOne({
|
|
976
1004
|
model: "session",
|
|
@@ -979,7 +1007,7 @@ async function validateOpaqueAccessToken(ctx, opts, token, clientId) {
|
|
|
979
1007
|
value: sessionId
|
|
980
1008
|
}]
|
|
981
1009
|
});
|
|
982
|
-
if (!session || session.expiresAt < /* @__PURE__ */ new Date())
|
|
1010
|
+
if (!session || session.expiresAt < /* @__PURE__ */ new Date()) return { active: false };
|
|
983
1011
|
}
|
|
984
1012
|
let user;
|
|
985
1013
|
if (accessToken.userId) user = await ctx.context.internalAdapter.findUserById(accessToken?.userId);
|
|
@@ -1145,9 +1173,173 @@ async function introspectEndpoint(ctx, opts) {
|
|
|
1145
1173
|
}
|
|
1146
1174
|
//#endregion
|
|
1147
1175
|
//#region src/logout.ts
|
|
1176
|
+
const BACKCHANNEL_LOGOUT_EVENT_URI = "http://schemas.openid.net/event/backchannel-logout";
|
|
1177
|
+
const LOGOUT_TOKEN_JWT_TYP = "logout+jwt";
|
|
1178
|
+
const LOGOUT_TOKEN_LIFETIME_SECONDS = 120;
|
|
1179
|
+
const BACKCHANNEL_DISPATCH_TIMEOUT_MS = 5e3;
|
|
1148
1180
|
/**
|
|
1149
|
-
*
|
|
1150
|
-
*
|
|
1181
|
+
* Signs a Back-Channel Logout Token per OIDC Back-Channel Logout 1.0 §2.4.
|
|
1182
|
+
*
|
|
1183
|
+
* The token reuses the ID Token signing key so any RP that validates ID Tokens
|
|
1184
|
+
* from this OP can validate Logout Tokens without extra configuration. The
|
|
1185
|
+
* caller resolves that key once and passes it in so a fan-out to many RPs does
|
|
1186
|
+
* not re-read it per target.
|
|
1187
|
+
*
|
|
1188
|
+
* §2.4 mandates `iss`, `aud`, `iat`, `exp`, `jti`, `events`, and at least one
|
|
1189
|
+
* of `sub` / `sid` (we send both). A `nonce` claim MUST NOT be present, and
|
|
1190
|
+
* `alg: none` is forbidden (§2.6).
|
|
1191
|
+
*/
|
|
1192
|
+
async function signLogoutToken(ctx, options, resolvedKey, claims) {
|
|
1193
|
+
return signJWT(ctx, {
|
|
1194
|
+
options,
|
|
1195
|
+
payload: {
|
|
1196
|
+
...claims,
|
|
1197
|
+
events: { [BACKCHANNEL_LOGOUT_EVENT_URI]: {} }
|
|
1198
|
+
},
|
|
1199
|
+
header: { typ: LOGOUT_TOKEN_JWT_TYP },
|
|
1200
|
+
resolvedKey: resolvedKey ?? void 0
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* Synchronous phase: enumerate tokens for the session being terminated, revoke
|
|
1205
|
+
* them, and return a plan for the asynchronous delivery phase. Runs inline in
|
|
1206
|
+
* the `session.delete.before` hook so the DB state is consistent before the
|
|
1207
|
+
* session row disappears.
|
|
1208
|
+
*
|
|
1209
|
+
* Revocation is the stored backstop, not the primary enforcement: introspection
|
|
1210
|
+
* and `/userinfo` already treat a token whose session has ended as inactive
|
|
1211
|
+
* (see `validateOpaqueAccessToken` / `validateJwtAccessToken`), so a missed
|
|
1212
|
+
* `revoked` write cannot keep a session-bound token alive on its own. Access
|
|
1213
|
+
* tokens bound to the session are revoked as OP hardening. Refresh tokens
|
|
1214
|
+
* follow OIDC Back-Channel Logout 1.0 §2.7: those without `offline_access` are
|
|
1215
|
+
* revoked; `offline_access` refresh tokens survive so long-lived API access can
|
|
1216
|
+
* outlive the browser session.
|
|
1217
|
+
*
|
|
1218
|
+
* Revocation runs regardless of the JWT plugin (refresh-token revocation has no
|
|
1219
|
+
* dependency on signing). Only the Logout Token delivery plan needs the JWT
|
|
1220
|
+
* plugin, so when it is disabled we still revoke but never build a plan.
|
|
1221
|
+
*
|
|
1222
|
+
* Returns `null` when there is nothing to do, so the caller can skip the
|
|
1223
|
+
* background handoff entirely.
|
|
1224
|
+
*/
|
|
1225
|
+
async function revokeAndPlanBackchannelLogout(ctx, opts, input) {
|
|
1226
|
+
const { sessionId, userId } = input;
|
|
1227
|
+
if (!userId) return null;
|
|
1228
|
+
const logger = ctx.context.logger;
|
|
1229
|
+
try {
|
|
1230
|
+
const where = [{
|
|
1231
|
+
field: "sessionId",
|
|
1232
|
+
value: sessionId
|
|
1233
|
+
}];
|
|
1234
|
+
const [accessTokens, refreshTokens] = await Promise.all([ctx.context.adapter.findMany({
|
|
1235
|
+
model: "oauthAccessToken",
|
|
1236
|
+
where
|
|
1237
|
+
}), ctx.context.adapter.findMany({
|
|
1238
|
+
model: "oauthRefreshToken",
|
|
1239
|
+
where
|
|
1240
|
+
})]);
|
|
1241
|
+
const affectedClientIds = /* @__PURE__ */ new Set();
|
|
1242
|
+
for (const t of accessTokens) affectedClientIds.add(t.clientId);
|
|
1243
|
+
for (const t of refreshTokens) affectedClientIds.add(t.clientId);
|
|
1244
|
+
if (affectedClientIds.size === 0) return null;
|
|
1245
|
+
const clients = await ctx.context.adapter.findMany({
|
|
1246
|
+
model: "oauthClient",
|
|
1247
|
+
where: [{
|
|
1248
|
+
field: "clientId",
|
|
1249
|
+
operator: "in",
|
|
1250
|
+
value: Array.from(affectedClientIds)
|
|
1251
|
+
}]
|
|
1252
|
+
});
|
|
1253
|
+
const revokedAt = /* @__PURE__ */ new Date();
|
|
1254
|
+
const accessToRevokeIds = accessTokens.filter((t) => !t.revoked).map((t) => t.id);
|
|
1255
|
+
const refreshToRevokeIds = refreshTokens.filter((t) => !t.revoked && !t.scopes?.includes("offline_access")).map((t) => t.id);
|
|
1256
|
+
const revocations = await Promise.allSettled([accessToRevokeIds.length > 0 ? ctx.context.adapter.updateMany({
|
|
1257
|
+
model: "oauthAccessToken",
|
|
1258
|
+
where: [{
|
|
1259
|
+
field: "id",
|
|
1260
|
+
operator: "in",
|
|
1261
|
+
value: accessToRevokeIds
|
|
1262
|
+
}],
|
|
1263
|
+
update: { revoked: revokedAt }
|
|
1264
|
+
}) : Promise.resolve(), refreshToRevokeIds.length > 0 ? ctx.context.adapter.updateMany({
|
|
1265
|
+
model: "oauthRefreshToken",
|
|
1266
|
+
where: [{
|
|
1267
|
+
field: "id",
|
|
1268
|
+
operator: "in",
|
|
1269
|
+
value: refreshToRevokeIds
|
|
1270
|
+
}],
|
|
1271
|
+
update: { revoked: revokedAt }
|
|
1272
|
+
}) : Promise.resolve()]);
|
|
1273
|
+
for (const result of revocations) if (result.status === "rejected") logger.error("back-channel logout: token revocation update failed", result.reason);
|
|
1274
|
+
const eligibleClients = opts.disableJwtPlugin ? [] : clients.filter((c) => Boolean(c.backchannelLogoutUri) && !c.disabled);
|
|
1275
|
+
if (eligibleClients.length === 0) return null;
|
|
1276
|
+
return {
|
|
1277
|
+
sessionId,
|
|
1278
|
+
targets: await Promise.all(eligibleClients.map(async (client) => ({
|
|
1279
|
+
client,
|
|
1280
|
+
sub: await resolveSubjectIdentifier(userId, client, opts)
|
|
1281
|
+
})))
|
|
1282
|
+
};
|
|
1283
|
+
} catch (error) {
|
|
1284
|
+
logger.error("back-channel logout revocation failed", error);
|
|
1285
|
+
return null;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* Asynchronous phase: sign one Logout Token per target client and POST it to
|
|
1290
|
+
* the registered `backchannel_logout_uri`. The caller hands this to
|
|
1291
|
+
* `runInBackgroundOrAwait`, so when a background handler is configured (Vercel
|
|
1292
|
+
* `waitUntil`, Cloudflare `ctx.waitUntil`) it runs after the response; without
|
|
1293
|
+
* one it completes inline so delivery is not lost on request teardown.
|
|
1294
|
+
*
|
|
1295
|
+
* Spec §2.5: "the OP SHOULD NOT retransmit", so each RP gets a single attempt
|
|
1296
|
+
* within `BACKCHANNEL_DISPATCH_TIMEOUT_MS`. Every per-client failure (fetch
|
|
1297
|
+
* error, non-2xx response, signing error, subject resolution error) is
|
|
1298
|
+
* logged; none of them can reject the outer promise.
|
|
1299
|
+
*/
|
|
1300
|
+
async function deliverBackchannelLogoutTokens(ctx, plan) {
|
|
1301
|
+
const logger = ctx.context.logger;
|
|
1302
|
+
const jwtPluginOptions = getJwtPlugin(ctx.context)?.options;
|
|
1303
|
+
const iss = jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL;
|
|
1304
|
+
const iat = Math.floor(Date.now() / 1e3);
|
|
1305
|
+
const exp = iat + LOGOUT_TOKEN_LIFETIME_SECONDS;
|
|
1306
|
+
const resolvedKey = jwtPluginOptions?.jwt?.sign ? null : await resolveSigningKey(ctx, jwtPluginOptions);
|
|
1307
|
+
await Promise.allSettled(plan.targets.map(async ({ client, sub }) => {
|
|
1308
|
+
try {
|
|
1309
|
+
const jti = generateRandomString(32, "a-z", "A-Z", "0-9");
|
|
1310
|
+
const token = await signLogoutToken(ctx, jwtPluginOptions, resolvedKey, {
|
|
1311
|
+
iss,
|
|
1312
|
+
aud: client.clientId,
|
|
1313
|
+
sub,
|
|
1314
|
+
sid: plan.sessionId,
|
|
1315
|
+
iat,
|
|
1316
|
+
exp,
|
|
1317
|
+
jti
|
|
1318
|
+
});
|
|
1319
|
+
const response = await fetch(client.backchannelLogoutUri, {
|
|
1320
|
+
method: "POST",
|
|
1321
|
+
headers: {
|
|
1322
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
1323
|
+
Accept: "application/json"
|
|
1324
|
+
},
|
|
1325
|
+
body: new URLSearchParams({ logout_token: token }),
|
|
1326
|
+
signal: AbortSignal.timeout(BACKCHANNEL_DISPATCH_TIMEOUT_MS),
|
|
1327
|
+
redirect: "error"
|
|
1328
|
+
});
|
|
1329
|
+
if (response.status !== 200 && response.status !== 204) logger.warn(`back-channel logout to client ${client.clientId} returned ${response.status}`);
|
|
1330
|
+
} catch (error) {
|
|
1331
|
+
logger.warn(`back-channel logout to client ${client.clientId} failed`, error);
|
|
1332
|
+
}
|
|
1333
|
+
}));
|
|
1334
|
+
}
|
|
1335
|
+
/**
|
|
1336
|
+
* RP-Initiated Logout (OIDC RP-Initiated Logout 1.0). The RP presents a signed
|
|
1337
|
+
* `id_token_hint`; after verification, the OP terminates the matching session
|
|
1338
|
+
* and optionally redirects to `post_logout_redirect_uri`.
|
|
1339
|
+
*
|
|
1340
|
+
* Session termination goes through `internalAdapter.deleteSession`, which fires
|
|
1341
|
+
* `session.delete.before` so the hook drives revocation and back-channel
|
|
1342
|
+
* notifications to every RP with tokens on the session.
|
|
1151
1343
|
*
|
|
1152
1344
|
* @see https://openid.net/specs/openid-connect-rpinitiated-1_0.html
|
|
1153
1345
|
*/
|
|
@@ -1195,12 +1387,10 @@ async function rpInitiatedLogoutEndpoint(ctx, opts) {
|
|
|
1195
1387
|
});
|
|
1196
1388
|
const secret = await decryptStoredClientSecret(ctx, opts.storeClientSecret, clientSecret);
|
|
1197
1389
|
const { payload } = await compactVerify(id_token_hint, new TextEncoder().encode(secret));
|
|
1198
|
-
|
|
1199
|
-
idTokenPayload = JSON.parse(idToken);
|
|
1390
|
+
idTokenPayload = JSON.parse(new TextDecoder().decode(payload));
|
|
1200
1391
|
} else {
|
|
1201
1392
|
const { payload } = await compactVerify(id_token_hint, createLocalJWKSet(await getJwks(id_token_hint, { jwksFetch: jwksUrl })));
|
|
1202
|
-
|
|
1203
|
-
idTokenPayload = JSON.parse(idToken);
|
|
1393
|
+
idTokenPayload = JSON.parse(new TextDecoder().decode(payload));
|
|
1204
1394
|
}
|
|
1205
1395
|
if (!idTokenPayload) throw new APIError$1("INTERNAL_SERVER_ERROR", {
|
|
1206
1396
|
error_description: "missing payload",
|
|
@@ -1232,18 +1422,13 @@ async function rpInitiatedLogoutEndpoint(ctx, opts) {
|
|
|
1232
1422
|
value: sessionId
|
|
1233
1423
|
}]
|
|
1234
1424
|
});
|
|
1235
|
-
session?.token
|
|
1425
|
+
if (session?.token) await ctx.context.internalAdapter.deleteSession(session.token);
|
|
1426
|
+
else if (session) await ctx.context.adapter.delete({
|
|
1236
1427
|
model: "session",
|
|
1237
1428
|
where: [{
|
|
1238
1429
|
field: "id",
|
|
1239
1430
|
value: session.id
|
|
1240
1431
|
}]
|
|
1241
|
-
}) : await ctx.context.adapter.delete({
|
|
1242
|
-
model: "session",
|
|
1243
|
-
where: [{
|
|
1244
|
-
field: "id",
|
|
1245
|
-
value: sessionId
|
|
1246
|
-
}]
|
|
1247
1432
|
});
|
|
1248
1433
|
} catch {}
|
|
1249
1434
|
if (post_logout_redirect_uri) {
|
|
@@ -1518,6 +1703,38 @@ async function checkOAuthClient(client, opts, settings) {
|
|
|
1518
1703
|
error: "invalid_client_metadata",
|
|
1519
1704
|
error_description: "jwks and jwks_uri are only allowed with private_key_jwt authentication"
|
|
1520
1705
|
});
|
|
1706
|
+
if (client.backchannel_logout_uri !== void 0) {
|
|
1707
|
+
if (opts.disableJwtPlugin) throw new APIError("BAD_REQUEST", {
|
|
1708
|
+
error: "invalid_client_metadata",
|
|
1709
|
+
error_description: "backchannel_logout_uri requires the jwt plugin (disableJwtPlugin must be false)"
|
|
1710
|
+
});
|
|
1711
|
+
let url;
|
|
1712
|
+
try {
|
|
1713
|
+
url = new URL(client.backchannel_logout_uri);
|
|
1714
|
+
} catch {
|
|
1715
|
+
throw new APIError("BAD_REQUEST", {
|
|
1716
|
+
error: "invalid_client_metadata",
|
|
1717
|
+
error_description: "backchannel_logout_uri must be an absolute URL"
|
|
1718
|
+
});
|
|
1719
|
+
}
|
|
1720
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") throw new APIError("BAD_REQUEST", {
|
|
1721
|
+
error: "invalid_client_metadata",
|
|
1722
|
+
error_description: "backchannel_logout_uri must use http or https"
|
|
1723
|
+
});
|
|
1724
|
+
if (client.backchannel_logout_uri.includes("#")) throw new APIError("BAD_REQUEST", {
|
|
1725
|
+
error: "invalid_client_metadata",
|
|
1726
|
+
error_description: "backchannel_logout_uri must not include a fragment component"
|
|
1727
|
+
});
|
|
1728
|
+
const loopback = isLoopbackHost(url.hostname);
|
|
1729
|
+
if (!isPublic && url.protocol !== "https:" && !loopback) throw new APIError("BAD_REQUEST", {
|
|
1730
|
+
error: "invalid_client_metadata",
|
|
1731
|
+
error_description: "backchannel_logout_uri must use https for confidential clients"
|
|
1732
|
+
});
|
|
1733
|
+
if (isPrivateHostname(url.hostname) && !loopback) throw new APIError("BAD_REQUEST", {
|
|
1734
|
+
error: "invalid_client_metadata",
|
|
1735
|
+
error_description: "backchannel_logout_uri must not point to a private or reserved address"
|
|
1736
|
+
});
|
|
1737
|
+
}
|
|
1521
1738
|
}
|
|
1522
1739
|
async function createOAuthClientEndpoint(ctx, opts, settings) {
|
|
1523
1740
|
const body = ctx.body;
|
|
@@ -1576,7 +1793,7 @@ async function createOAuthClientEndpoint(ctx, opts, settings) {
|
|
|
1576
1793
|
* @returns
|
|
1577
1794
|
*/
|
|
1578
1795
|
function oauthToSchema(input) {
|
|
1579
|
-
const { client_id: clientId, client_secret: clientSecret, client_secret_expires_at: _expiresAt, scope: _scope, user_id: userId, client_id_issued_at: _createdAt, client_name: name, client_uri: uri, logo_uri: icon, contacts, tos_uri: tos, policy_uri: policy, jwks: inputJwks, jwks_uri: jwksUri, software_id: softwareId, software_version: softwareVersion, software_statement: softwareStatement, redirect_uris: redirectUris, post_logout_redirect_uris: postLogoutRedirectUris, token_endpoint_auth_method: tokenEndpointAuthMethod, grant_types: grantTypes, response_types: responseTypes, public: _public, type, disabled, skip_consent: skipConsent, enable_end_session: enableEndSession, require_pkce: requirePKCE, subject_type: subjectType, reference_id: referenceId, metadata: inputMetadata, ...rest } = input;
|
|
1796
|
+
const { client_id: clientId, client_secret: clientSecret, client_secret_expires_at: _expiresAt, scope: _scope, user_id: userId, client_id_issued_at: _createdAt, client_name: name, client_uri: uri, logo_uri: icon, contacts, tos_uri: tos, policy_uri: policy, jwks: inputJwks, jwks_uri: jwksUri, software_id: softwareId, software_version: softwareVersion, software_statement: softwareStatement, redirect_uris: redirectUris, post_logout_redirect_uris: postLogoutRedirectUris, backchannel_logout_uri: backchannelLogoutUri, backchannel_logout_session_required: backchannelLogoutSessionRequired, token_endpoint_auth_method: tokenEndpointAuthMethod, grant_types: grantTypes, response_types: responseTypes, public: _public, type, disabled, skip_consent: skipConsent, enable_end_session: enableEndSession, require_pkce: requirePKCE, subject_type: subjectType, reference_id: referenceId, metadata: inputMetadata, ...rest } = input;
|
|
1580
1797
|
const expiresAt = _expiresAt ? /* @__PURE__ */ new Date(_expiresAt * 1e3) : void 0;
|
|
1581
1798
|
const createdAt = _createdAt ? /* @__PURE__ */ new Date(_createdAt * 1e3) : void 0;
|
|
1582
1799
|
const scopes = _scope?.split(" ");
|
|
@@ -1604,6 +1821,8 @@ function oauthToSchema(input) {
|
|
|
1604
1821
|
softwareStatement,
|
|
1605
1822
|
redirectUris,
|
|
1606
1823
|
postLogoutRedirectUris,
|
|
1824
|
+
backchannelLogoutUri,
|
|
1825
|
+
backchannelLogoutSessionRequired,
|
|
1607
1826
|
tokenEndpointAuthMethod,
|
|
1608
1827
|
grantTypes,
|
|
1609
1828
|
responseTypes,
|
|
@@ -1626,7 +1845,7 @@ function oauthToSchema(input) {
|
|
|
1626
1845
|
* @returns
|
|
1627
1846
|
*/
|
|
1628
1847
|
function schemaToOAuth(input) {
|
|
1629
|
-
const { clientId, clientSecret, disabled, scopes, userId, createdAt, updatedAt: _updatedAt, expiresAt, name, uri, icon, contacts, tos, policy, softwareId, softwareVersion, softwareStatement, redirectUris, postLogoutRedirectUris, tokenEndpointAuthMethod, grantTypes, responseTypes, public: _public, type, jwks, jwksUri, skipConsent, enableEndSession, requirePKCE, subjectType, referenceId, metadata } = input;
|
|
1848
|
+
const { clientId, clientSecret, disabled, scopes, userId, createdAt, updatedAt: _updatedAt, expiresAt, name, uri, icon, contacts, tos, policy, softwareId, softwareVersion, softwareStatement, redirectUris, postLogoutRedirectUris, backchannelLogoutUri, backchannelLogoutSessionRequired, tokenEndpointAuthMethod, grantTypes, responseTypes, public: _public, type, jwks, jwksUri, skipConsent, enableEndSession, requirePKCE, subjectType, referenceId, metadata } = input;
|
|
1630
1849
|
const _expiresAt = expiresAt ? Math.round(new Date(expiresAt).getTime() / 1e3) : void 0;
|
|
1631
1850
|
const _createdAt = createdAt ? Math.round(new Date(createdAt).getTime() / 1e3) : void 0;
|
|
1632
1851
|
const _scopes = scopes?.join(" ");
|
|
@@ -1651,6 +1870,8 @@ function schemaToOAuth(input) {
|
|
|
1651
1870
|
software_statement: softwareStatement ?? void 0,
|
|
1652
1871
|
redirect_uris: redirectUris ?? [],
|
|
1653
1872
|
post_logout_redirect_uris: postLogoutRedirectUris ?? void 0,
|
|
1873
|
+
backchannel_logout_uri: backchannelLogoutUri ?? void 0,
|
|
1874
|
+
backchannel_logout_session_required: backchannelLogoutSessionRequired ?? void 0,
|
|
1654
1875
|
token_endpoint_auth_method: tokenEndpointAuthMethod ?? void 0,
|
|
1655
1876
|
grant_types: grantTypes ?? void 0,
|
|
1656
1877
|
response_types: responseTypes ?? void 0,
|
|
@@ -1887,6 +2108,8 @@ const adminCreateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/creat
|
|
|
1887
2108
|
software_version: z.string().optional(),
|
|
1888
2109
|
software_statement: z.string().optional(),
|
|
1889
2110
|
post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
|
|
2111
|
+
backchannel_logout_uri: SafeUrlSchema.optional(),
|
|
2112
|
+
backchannel_logout_session_required: z.boolean().optional(),
|
|
1890
2113
|
token_endpoint_auth_method: z.enum([
|
|
1891
2114
|
"none",
|
|
1892
2115
|
"client_secret_basic",
|
|
@@ -2073,6 +2296,8 @@ const createOAuthClient = (opts) => createAuthEndpoint("/oauth2/create-client",
|
|
|
2073
2296
|
software_version: z.string().optional(),
|
|
2074
2297
|
software_statement: z.string().optional(),
|
|
2075
2298
|
post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
|
|
2299
|
+
backchannel_logout_uri: SafeUrlSchema.optional(),
|
|
2300
|
+
backchannel_logout_session_required: z.boolean().optional(),
|
|
2076
2301
|
token_endpoint_auth_method: z.enum([
|
|
2077
2302
|
"none",
|
|
2078
2303
|
"client_secret_basic",
|
|
@@ -2282,6 +2507,8 @@ const adminUpdateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/updat
|
|
|
2282
2507
|
software_version: z.string().optional(),
|
|
2283
2508
|
software_statement: z.string().optional(),
|
|
2284
2509
|
post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
|
|
2510
|
+
backchannel_logout_uri: SafeUrlSchema.optional(),
|
|
2511
|
+
backchannel_logout_session_required: z.boolean().optional(),
|
|
2285
2512
|
grant_types: z.array(z.enum([
|
|
2286
2513
|
"authorization_code",
|
|
2287
2514
|
"client_credentials",
|
|
@@ -2324,6 +2551,8 @@ const updateOAuthClient = (opts) => createAuthEndpoint("/oauth2/update-client",
|
|
|
2324
2551
|
software_version: z.string().optional(),
|
|
2325
2552
|
software_statement: z.string().optional(),
|
|
2326
2553
|
post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
|
|
2554
|
+
backchannel_logout_uri: SafeUrlSchema.optional(),
|
|
2555
|
+
backchannel_logout_session_required: z.boolean().optional(),
|
|
2327
2556
|
grant_types: z.array(z.enum([
|
|
2328
2557
|
"authorization_code",
|
|
2329
2558
|
"client_credentials",
|
|
@@ -2503,7 +2732,14 @@ const deleteOAuthConsent = (opts) => createAuthEndpoint("/oauth2/delete-consent"
|
|
|
2503
2732
|
*/
|
|
2504
2733
|
/**
|
|
2505
2734
|
* Revokes a JWT access token against the configured JWKs.
|
|
2506
|
-
*
|
|
2735
|
+
*
|
|
2736
|
+
* A JWT access token is self-contained and never stored, so there is nothing to
|
|
2737
|
+
* delete. Once the token is confirmed to be a valid JWT for this server, the
|
|
2738
|
+
* endpoint reports `unsupported_token_type` (RFC 7009 §2.2.1) instead of a
|
|
2739
|
+
* silent success, so callers can tell that no server-side revocation happened.
|
|
2740
|
+
* An expired or wrong-audience JWT is already inactive and still resolves as a
|
|
2741
|
+
* successful no-op. Session-bound tokens (carrying `sid`) are cut off early by
|
|
2742
|
+
* the session-liveness check in introspection and userinfo.
|
|
2507
2743
|
*/
|
|
2508
2744
|
async function revokeJwtAccessToken(ctx, opts, token) {
|
|
2509
2745
|
const jwtPlugin = opts.disableJwtPlugin ? void 0 : getJwtPlugin(ctx.context);
|
|
@@ -2530,6 +2766,10 @@ async function revokeJwtAccessToken(ctx, opts, token) {
|
|
|
2530
2766
|
}
|
|
2531
2767
|
throw new Error(error);
|
|
2532
2768
|
}
|
|
2769
|
+
throw new APIError$1("BAD_REQUEST", {
|
|
2770
|
+
error_description: "JWT access tokens are self-contained and cannot be revoked server-side",
|
|
2771
|
+
error: "unsupported_token_type"
|
|
2772
|
+
});
|
|
2533
2773
|
}
|
|
2534
2774
|
/**
|
|
2535
2775
|
* Searches for an opaque access token in the database and validates it
|
|
@@ -2625,7 +2865,9 @@ async function revokeAccessToken(ctx, opts, clientId, token) {
|
|
|
2625
2865
|
try {
|
|
2626
2866
|
return await revokeJwtAccessToken(ctx, opts, token);
|
|
2627
2867
|
} catch (err) {
|
|
2628
|
-
if (err instanceof APIError$1) {
|
|
2868
|
+
if (err instanceof APIError$1) {
|
|
2869
|
+
if (err.body?.error === "unsupported_token_type") throw err;
|
|
2870
|
+
} else if (err instanceof Error) throw err;
|
|
2629
2871
|
else throw new Error(err);
|
|
2630
2872
|
}
|
|
2631
2873
|
try {
|
|
@@ -2658,7 +2900,7 @@ async function revokeEndpoint(ctx, opts) {
|
|
|
2658
2900
|
return await revokeAccessToken(ctx, opts, client.clientId, token);
|
|
2659
2901
|
} catch (error) {
|
|
2660
2902
|
if (error instanceof APIError$1) {
|
|
2661
|
-
if (token_type_hint === "access_token") throw error;
|
|
2903
|
+
if (token_type_hint === "access_token" || error.body?.error === "unsupported_token_type") throw error;
|
|
2662
2904
|
} else if (error instanceof Error) throw error;
|
|
2663
2905
|
else throw new Error(error);
|
|
2664
2906
|
}
|
|
@@ -2676,6 +2918,7 @@ async function revokeEndpoint(ctx, opts) {
|
|
|
2676
2918
|
});
|
|
2677
2919
|
} catch (error) {
|
|
2678
2920
|
if (error instanceof APIError$1) {
|
|
2921
|
+
if (error.body?.error === "unsupported_token_type") throw error;
|
|
2679
2922
|
if (error.name === "BAD_REQUEST") return null;
|
|
2680
2923
|
throw error;
|
|
2681
2924
|
} else if (error instanceof Error) {
|
|
@@ -2784,6 +3027,14 @@ const schema = {
|
|
|
2784
3027
|
type: "string[]",
|
|
2785
3028
|
required: false
|
|
2786
3029
|
},
|
|
3030
|
+
backchannelLogoutUri: {
|
|
3031
|
+
type: "string",
|
|
3032
|
+
required: false
|
|
3033
|
+
},
|
|
3034
|
+
backchannelLogoutSessionRequired: {
|
|
3035
|
+
type: "boolean",
|
|
3036
|
+
required: false
|
|
3037
|
+
},
|
|
2787
3038
|
tokenEndpointAuthMethod: {
|
|
2788
3039
|
type: "string",
|
|
2789
3040
|
required: false
|
|
@@ -2937,6 +3188,10 @@ const schema = {
|
|
|
2937
3188
|
},
|
|
2938
3189
|
expiresAt: { type: "date" },
|
|
2939
3190
|
createdAt: { type: "date" },
|
|
3191
|
+
revoked: {
|
|
3192
|
+
type: "date",
|
|
3193
|
+
required: false
|
|
3194
|
+
},
|
|
2940
3195
|
scopes: {
|
|
2941
3196
|
type: "string[]",
|
|
2942
3197
|
required: true
|
|
@@ -2979,12 +3234,25 @@ const schema = {
|
|
|
2979
3234
|
createdAt: { type: "date" },
|
|
2980
3235
|
updatedAt: { type: "date" }
|
|
2981
3236
|
}
|
|
3237
|
+
},
|
|
3238
|
+
oauthClientAssertion: {
|
|
3239
|
+
modelName: "oauthClientAssertion",
|
|
3240
|
+
fields: { expiresAt: {
|
|
3241
|
+
type: "date",
|
|
3242
|
+
required: true
|
|
3243
|
+
} }
|
|
2982
3244
|
}
|
|
2983
3245
|
};
|
|
2984
3246
|
//#endregion
|
|
2985
3247
|
//#region src/oauth.ts
|
|
2986
3248
|
const oAuthState = defineRequestState(() => null);
|
|
2987
3249
|
const getOAuthProviderState = oAuthState.get;
|
|
3250
|
+
const signedQueryIssuedAtMsKey = "signedQueryIssuedAtMs";
|
|
3251
|
+
function getServerContextSignedQueryIssuedAt(value) {
|
|
3252
|
+
const issuedAtMs = typeof value === "number" ? value : typeof value === "string" ? Number(value) : void 0;
|
|
3253
|
+
if (!issuedAtMs || !Number.isFinite(issuedAtMs) || issuedAtMs <= 0) return;
|
|
3254
|
+
return new Date(issuedAtMs);
|
|
3255
|
+
}
|
|
2988
3256
|
/**
|
|
2989
3257
|
* oAuth 2.1 provider plugin for Better Auth.
|
|
2990
3258
|
*
|
|
@@ -3093,6 +3361,146 @@ const oauthProvider = (options) => {
|
|
|
3093
3361
|
}
|
|
3094
3362
|
if (isOpenIdConfigRequest) return { response: createMetadataResponse(oidcServerMetadata(endpointCtx, opts)) };
|
|
3095
3363
|
};
|
|
3364
|
+
const oauth2AuthorizeEndpoint = createOAuthEndpoint("/oauth2/authorize", {
|
|
3365
|
+
method: "GET",
|
|
3366
|
+
query: authorizationQuerySchema,
|
|
3367
|
+
redirectOnError: authorizeRedirectOnError(opts),
|
|
3368
|
+
errorCodesByField: {
|
|
3369
|
+
response_type: { invalid: "unsupported_response_type" },
|
|
3370
|
+
resource: { invalid: "invalid_target" }
|
|
3371
|
+
},
|
|
3372
|
+
metadata: { openapi: {
|
|
3373
|
+
description: "Authorize an OAuth2 request",
|
|
3374
|
+
parameters: [
|
|
3375
|
+
{
|
|
3376
|
+
name: "response_type",
|
|
3377
|
+
in: "query",
|
|
3378
|
+
required: false,
|
|
3379
|
+
schema: { type: "string" },
|
|
3380
|
+
description: "OAuth2 response type (e.g., 'code')"
|
|
3381
|
+
},
|
|
3382
|
+
{
|
|
3383
|
+
name: "client_id",
|
|
3384
|
+
in: "query",
|
|
3385
|
+
required: true,
|
|
3386
|
+
schema: { type: "string" },
|
|
3387
|
+
description: "OAuth2 client ID"
|
|
3388
|
+
},
|
|
3389
|
+
{
|
|
3390
|
+
name: "redirect_uri",
|
|
3391
|
+
in: "query",
|
|
3392
|
+
required: false,
|
|
3393
|
+
schema: {
|
|
3394
|
+
type: "string",
|
|
3395
|
+
format: "uri"
|
|
3396
|
+
},
|
|
3397
|
+
description: "OAuth2 redirect URI"
|
|
3398
|
+
},
|
|
3399
|
+
{
|
|
3400
|
+
name: "scope",
|
|
3401
|
+
in: "query",
|
|
3402
|
+
required: false,
|
|
3403
|
+
schema: { type: "string" },
|
|
3404
|
+
description: "OAuth2 scopes (space-separated)"
|
|
3405
|
+
},
|
|
3406
|
+
{
|
|
3407
|
+
name: "state",
|
|
3408
|
+
in: "query",
|
|
3409
|
+
required: false,
|
|
3410
|
+
schema: { type: "string" },
|
|
3411
|
+
description: "OAuth2 state parameter"
|
|
3412
|
+
},
|
|
3413
|
+
{
|
|
3414
|
+
name: "request_uri",
|
|
3415
|
+
in: "query",
|
|
3416
|
+
required: false,
|
|
3417
|
+
schema: { type: "string" },
|
|
3418
|
+
description: "Pushed Authorization Request URI referencing stored parameters"
|
|
3419
|
+
},
|
|
3420
|
+
{
|
|
3421
|
+
name: "code_challenge",
|
|
3422
|
+
in: "query",
|
|
3423
|
+
required: false,
|
|
3424
|
+
schema: { type: "string" },
|
|
3425
|
+
description: "PKCE code challenge"
|
|
3426
|
+
},
|
|
3427
|
+
{
|
|
3428
|
+
name: "code_challenge_method",
|
|
3429
|
+
in: "query",
|
|
3430
|
+
required: false,
|
|
3431
|
+
schema: { type: "string" },
|
|
3432
|
+
description: "PKCE code challenge method"
|
|
3433
|
+
},
|
|
3434
|
+
{
|
|
3435
|
+
name: "nonce",
|
|
3436
|
+
in: "query",
|
|
3437
|
+
required: false,
|
|
3438
|
+
schema: { type: "string" },
|
|
3439
|
+
description: "OpenID Connect nonce"
|
|
3440
|
+
},
|
|
3441
|
+
{
|
|
3442
|
+
name: "max_age",
|
|
3443
|
+
in: "query",
|
|
3444
|
+
required: false,
|
|
3445
|
+
schema: {
|
|
3446
|
+
type: "integer",
|
|
3447
|
+
minimum: 0
|
|
3448
|
+
},
|
|
3449
|
+
description: "Maximum authentication age in seconds; forces re-authentication when exceeded"
|
|
3450
|
+
},
|
|
3451
|
+
{
|
|
3452
|
+
name: "resource",
|
|
3453
|
+
in: "query",
|
|
3454
|
+
required: false,
|
|
3455
|
+
schema: {
|
|
3456
|
+
type: "array",
|
|
3457
|
+
items: { type: "string" }
|
|
3458
|
+
},
|
|
3459
|
+
description: "Requested token resource(s) (ie audience) to obtain a JWT formatted access token. May be supplied multiple times as repeated 'resource' query parameters (RFC 8707) or as an array of strings."
|
|
3460
|
+
},
|
|
3461
|
+
{
|
|
3462
|
+
name: "prompt",
|
|
3463
|
+
in: "query",
|
|
3464
|
+
required: false,
|
|
3465
|
+
schema: { type: "string" },
|
|
3466
|
+
description: "OAuth2 prompt parameter"
|
|
3467
|
+
}
|
|
3468
|
+
],
|
|
3469
|
+
responses: {
|
|
3470
|
+
"302": {
|
|
3471
|
+
description: "Redirect to client with code or error",
|
|
3472
|
+
headers: { Location: {
|
|
3473
|
+
description: "Redirect URI with code or error",
|
|
3474
|
+
schema: {
|
|
3475
|
+
type: "string",
|
|
3476
|
+
format: "uri"
|
|
3477
|
+
}
|
|
3478
|
+
} }
|
|
3479
|
+
},
|
|
3480
|
+
"400": {
|
|
3481
|
+
description: "Invalid request",
|
|
3482
|
+
content: { "application/json": { schema: {
|
|
3483
|
+
type: "object",
|
|
3484
|
+
properties: {
|
|
3485
|
+
error: { type: "string" },
|
|
3486
|
+
error_description: { type: "string" },
|
|
3487
|
+
state: { type: "string" }
|
|
3488
|
+
},
|
|
3489
|
+
required: ["error"]
|
|
3490
|
+
} } }
|
|
3491
|
+
}
|
|
3492
|
+
}
|
|
3493
|
+
} }
|
|
3494
|
+
}, async (ctx) => {
|
|
3495
|
+
return authorizeEndpoint(ctx, opts, ctx.authorizeSettings ?? { isAuthorize: true });
|
|
3496
|
+
});
|
|
3497
|
+
const runOAuth2Authorize = (ctx, settings) => dispatchAuthEndpoint(oauth2AuthorizeEndpoint, {
|
|
3498
|
+
...ctx,
|
|
3499
|
+
asResponse: false,
|
|
3500
|
+
returnHeaders: false,
|
|
3501
|
+
returnStatus: false,
|
|
3502
|
+
authorizeSettings: settings ?? {}
|
|
3503
|
+
});
|
|
3096
3504
|
return {
|
|
3097
3505
|
id: "oauth-provider",
|
|
3098
3506
|
version: PACKAGE_VERSION,
|
|
@@ -3108,12 +3516,20 @@ const oauthProvider = (options) => {
|
|
|
3108
3516
|
try {
|
|
3109
3517
|
issuerPath = new URL(issuer).pathname;
|
|
3110
3518
|
} catch (error) {
|
|
3111
|
-
if (isDynamicBaseURLInit
|
|
3112
|
-
throw error;
|
|
3519
|
+
if (!isDynamicBaseURLInit || issuer !== "") throw error;
|
|
3113
3520
|
}
|
|
3114
|
-
if (!opts.silenceWarnings?.oauthAuthServerConfig && !(ctx.options.basePath === "/" && issuerPath === "/")) logger.warn(`Please ensure '/.well-known/oauth-authorization-server${issuerPath === "/" ? "" : issuerPath}' exists. Upon completion, clear with silenceWarnings.oauthAuthServerConfig.`);
|
|
3115
|
-
if (!opts.silenceWarnings?.openidConfig && ctx.options.basePath !== issuerPath && opts.scopes?.includes("openid")) logger.warn(`Please ensure '${issuerPath}${issuerPath.endsWith("/") ? "" : "/"}.well-known/openid-configuration' exists. Upon completion, clear with silenceWarnings.openidConfig.`);
|
|
3521
|
+
if (issuerPath !== void 0 && !opts.silenceWarnings?.oauthAuthServerConfig && !(ctx.options.basePath === "/" && issuerPath === "/")) logger.warn(`Please ensure '/.well-known/oauth-authorization-server${issuerPath === "/" ? "" : issuerPath}' exists. Upon completion, clear with silenceWarnings.oauthAuthServerConfig.`);
|
|
3522
|
+
if (issuerPath !== void 0 && !opts.silenceWarnings?.openidConfig && ctx.options.basePath !== issuerPath && opts.scopes?.includes("openid")) logger.warn(`Please ensure '${issuerPath}${issuerPath.endsWith("/") ? "" : "/"}.well-known/openid-configuration' exists. Upon completion, clear with silenceWarnings.openidConfig.`);
|
|
3116
3523
|
}
|
|
3524
|
+
return { options: { databaseHooks: { session: { delete: { async before(session, hookCtx) {
|
|
3525
|
+
if (!hookCtx) return;
|
|
3526
|
+
const plan = await revokeAndPlanBackchannelLogout(hookCtx, opts, {
|
|
3527
|
+
sessionId: session.id,
|
|
3528
|
+
userId: session.userId
|
|
3529
|
+
});
|
|
3530
|
+
if (!plan) return;
|
|
3531
|
+
await hookCtx.context.runInBackgroundOrAwait(deliverBackchannelLogoutTokens(hookCtx, plan));
|
|
3532
|
+
} } } } } };
|
|
3117
3533
|
},
|
|
3118
3534
|
hooks: {
|
|
3119
3535
|
before: [{
|
|
@@ -3135,11 +3551,10 @@ const oauthProvider = (options) => {
|
|
|
3135
3551
|
signedQueryIssuedAt: signedQueryIssuedAt ?? void 0,
|
|
3136
3552
|
postLoginClearedForSession
|
|
3137
3553
|
});
|
|
3138
|
-
if (ctx.path === "/sign-in/social") {
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
}
|
|
3554
|
+
if (ctx.path === "/sign-in/social") await addOAuthServerContext({
|
|
3555
|
+
query: queryParams.toString(),
|
|
3556
|
+
...signedQueryIssuedAt ? { [signedQueryIssuedAtMsKey]: signedQueryIssuedAt.getTime() } : {}
|
|
3557
|
+
});
|
|
3143
3558
|
})
|
|
3144
3559
|
}],
|
|
3145
3560
|
after: [{
|
|
@@ -3149,7 +3564,9 @@ const oauthProvider = (options) => {
|
|
|
3149
3564
|
handler: createAuthMiddleware(async (ctx) => {
|
|
3150
3565
|
const sessionToken = parseSetCookieHeader(ctx.context.responseHeaders?.get("set-cookie") || "").get(ctx.context.authCookies.sessionToken.name)?.value.split(".")[0];
|
|
3151
3566
|
if (!sessionToken) return;
|
|
3152
|
-
const
|
|
3567
|
+
const oauthRequest = await oAuthState.get();
|
|
3568
|
+
const serverContext = (await getOAuthState())?.serverContext;
|
|
3569
|
+
const _query = oauthRequest?.query ?? serverContext?.query;
|
|
3153
3570
|
if (!_query) return;
|
|
3154
3571
|
const query = new URLSearchParams(_query);
|
|
3155
3572
|
const session = await ctx.context.internalAdapter.findSession(sessionToken);
|
|
@@ -3158,8 +3575,11 @@ const oauthProvider = (options) => {
|
|
|
3158
3575
|
const secFetchMode = ctx.request?.headers?.get("sec-fetch-mode")?.toLowerCase();
|
|
3159
3576
|
const acceptHeader = ctx.request?.headers?.get("accept")?.toLowerCase() ?? "";
|
|
3160
3577
|
if (!(secFetchMode === "navigate" || !secFetchMode && (acceptHeader.includes("text/html") || acceptHeader.includes("application/xhtml+xml")))) ctx.headers?.set("accept", "application/json");
|
|
3161
|
-
|
|
3162
|
-
|
|
3578
|
+
const signedQueryIssuedAt = oauthRequest?.signedQueryIssuedAt ?? getServerContextSignedQueryIssuedAt(serverContext?.[signedQueryIssuedAtMsKey]);
|
|
3579
|
+
let authorizationQuery = removePromptFromQuery(query, "login");
|
|
3580
|
+
if (isSessionFreshForSignedQuery(session.session.createdAt, signedQueryIssuedAt)) authorizationQuery = removeMaxAgeFromQuery(authorizationQuery);
|
|
3581
|
+
ctx.query = searchParamsToQuery(authorizationQuery);
|
|
3582
|
+
return await runOAuth2Authorize(ctx);
|
|
3163
3583
|
})
|
|
3164
3584
|
}]
|
|
3165
3585
|
},
|
|
@@ -3187,149 +3607,7 @@ const oauthProvider = (options) => {
|
|
|
3187
3607
|
if (opts.scopes && !opts.scopes.includes("openid")) throw new APIError("NOT_FOUND");
|
|
3188
3608
|
return oidcServerMetadata(ctx, opts);
|
|
3189
3609
|
}),
|
|
3190
|
-
oauth2Authorize:
|
|
3191
|
-
method: "GET",
|
|
3192
|
-
query: z.object({
|
|
3193
|
-
response_type: z.string().pipe(z.enum(["code"])).optional(),
|
|
3194
|
-
client_id: z.string(),
|
|
3195
|
-
redirect_uri: SafeUrlSchema.optional(),
|
|
3196
|
-
scope: z.string().optional(),
|
|
3197
|
-
state: z.string().optional(),
|
|
3198
|
-
request_uri: z.string().optional(),
|
|
3199
|
-
code_challenge: z.string().optional(),
|
|
3200
|
-
code_challenge_method: z.string().pipe(z.enum(["S256"])).optional(),
|
|
3201
|
-
nonce: z.string().optional(),
|
|
3202
|
-
resource: z.union([ResourceUriSchema, z.array(ResourceUriSchema).min(1)]).optional(),
|
|
3203
|
-
prompt: z.string().pipe(z.enum([
|
|
3204
|
-
"none",
|
|
3205
|
-
"consent",
|
|
3206
|
-
"login",
|
|
3207
|
-
"create",
|
|
3208
|
-
"select_account",
|
|
3209
|
-
"login consent",
|
|
3210
|
-
"select_account consent"
|
|
3211
|
-
])).optional()
|
|
3212
|
-
}),
|
|
3213
|
-
redirectOnError: authorizeRedirectOnError(opts),
|
|
3214
|
-
errorCodesByField: {
|
|
3215
|
-
response_type: { invalid: "unsupported_response_type" },
|
|
3216
|
-
resource: { invalid: "invalid_target" }
|
|
3217
|
-
},
|
|
3218
|
-
metadata: { openapi: {
|
|
3219
|
-
description: "Authorize an OAuth2 request",
|
|
3220
|
-
parameters: [
|
|
3221
|
-
{
|
|
3222
|
-
name: "response_type",
|
|
3223
|
-
in: "query",
|
|
3224
|
-
required: false,
|
|
3225
|
-
schema: { type: "string" },
|
|
3226
|
-
description: "OAuth2 response type (e.g., 'code')"
|
|
3227
|
-
},
|
|
3228
|
-
{
|
|
3229
|
-
name: "client_id",
|
|
3230
|
-
in: "query",
|
|
3231
|
-
required: true,
|
|
3232
|
-
schema: { type: "string" },
|
|
3233
|
-
description: "OAuth2 client ID"
|
|
3234
|
-
},
|
|
3235
|
-
{
|
|
3236
|
-
name: "redirect_uri",
|
|
3237
|
-
in: "query",
|
|
3238
|
-
required: false,
|
|
3239
|
-
schema: {
|
|
3240
|
-
type: "string",
|
|
3241
|
-
format: "uri"
|
|
3242
|
-
},
|
|
3243
|
-
description: "OAuth2 redirect URI"
|
|
3244
|
-
},
|
|
3245
|
-
{
|
|
3246
|
-
name: "scope",
|
|
3247
|
-
in: "query",
|
|
3248
|
-
required: false,
|
|
3249
|
-
schema: { type: "string" },
|
|
3250
|
-
description: "OAuth2 scopes (space-separated)"
|
|
3251
|
-
},
|
|
3252
|
-
{
|
|
3253
|
-
name: "state",
|
|
3254
|
-
in: "query",
|
|
3255
|
-
required: false,
|
|
3256
|
-
schema: { type: "string" },
|
|
3257
|
-
description: "OAuth2 state parameter"
|
|
3258
|
-
},
|
|
3259
|
-
{
|
|
3260
|
-
name: "request_uri",
|
|
3261
|
-
in: "query",
|
|
3262
|
-
required: false,
|
|
3263
|
-
schema: { type: "string" },
|
|
3264
|
-
description: "Pushed Authorization Request URI referencing stored parameters"
|
|
3265
|
-
},
|
|
3266
|
-
{
|
|
3267
|
-
name: "code_challenge",
|
|
3268
|
-
in: "query",
|
|
3269
|
-
required: false,
|
|
3270
|
-
schema: { type: "string" },
|
|
3271
|
-
description: "PKCE code challenge"
|
|
3272
|
-
},
|
|
3273
|
-
{
|
|
3274
|
-
name: "code_challenge_method",
|
|
3275
|
-
in: "query",
|
|
3276
|
-
required: false,
|
|
3277
|
-
schema: { type: "string" },
|
|
3278
|
-
description: "PKCE code challenge method"
|
|
3279
|
-
},
|
|
3280
|
-
{
|
|
3281
|
-
name: "nonce",
|
|
3282
|
-
in: "query",
|
|
3283
|
-
required: false,
|
|
3284
|
-
schema: { type: "string" },
|
|
3285
|
-
description: "OpenID Connect nonce"
|
|
3286
|
-
},
|
|
3287
|
-
{
|
|
3288
|
-
name: "resource",
|
|
3289
|
-
in: "query",
|
|
3290
|
-
required: false,
|
|
3291
|
-
schema: {
|
|
3292
|
-
type: "array",
|
|
3293
|
-
items: { type: "string" }
|
|
3294
|
-
},
|
|
3295
|
-
description: "Requested token resource(s) (ie audience) to obtain a JWT formatted access token. May be supplied multiple times as repeated 'resource' query parameters (RFC 8707) or as an array of strings."
|
|
3296
|
-
},
|
|
3297
|
-
{
|
|
3298
|
-
name: "prompt",
|
|
3299
|
-
in: "query",
|
|
3300
|
-
required: false,
|
|
3301
|
-
schema: { type: "string" },
|
|
3302
|
-
description: "OAuth2 prompt parameter"
|
|
3303
|
-
}
|
|
3304
|
-
],
|
|
3305
|
-
responses: {
|
|
3306
|
-
"302": {
|
|
3307
|
-
description: "Redirect to client with code or error",
|
|
3308
|
-
headers: { Location: {
|
|
3309
|
-
description: "Redirect URI with code or error",
|
|
3310
|
-
schema: {
|
|
3311
|
-
type: "string",
|
|
3312
|
-
format: "uri"
|
|
3313
|
-
}
|
|
3314
|
-
} }
|
|
3315
|
-
},
|
|
3316
|
-
"400": {
|
|
3317
|
-
description: "Invalid request",
|
|
3318
|
-
content: { "application/json": { schema: {
|
|
3319
|
-
type: "object",
|
|
3320
|
-
properties: {
|
|
3321
|
-
error: { type: "string" },
|
|
3322
|
-
error_description: { type: "string" },
|
|
3323
|
-
state: { type: "string" }
|
|
3324
|
-
},
|
|
3325
|
-
required: ["error"]
|
|
3326
|
-
} } }
|
|
3327
|
-
}
|
|
3328
|
-
}
|
|
3329
|
-
} }
|
|
3330
|
-
}, async (ctx) => {
|
|
3331
|
-
return authorizeEndpoint(ctx, opts, { isAuthorize: true });
|
|
3332
|
-
}),
|
|
3610
|
+
oauth2Authorize: oauth2AuthorizeEndpoint,
|
|
3333
3611
|
oauth2Consent: createAuthEndpoint("/oauth2/consent", {
|
|
3334
3612
|
method: "POST",
|
|
3335
3613
|
body: z.object({
|
|
@@ -3354,7 +3632,7 @@ const oauthProvider = (options) => {
|
|
|
3354
3632
|
} }
|
|
3355
3633
|
} }
|
|
3356
3634
|
}, async (ctx) => {
|
|
3357
|
-
return consentEndpoint(ctx, opts);
|
|
3635
|
+
return consentEndpoint(ctx, opts, runOAuth2Authorize);
|
|
3358
3636
|
}),
|
|
3359
3637
|
oauth2Continue: createAuthEndpoint("/oauth2/continue", {
|
|
3360
3638
|
method: "POST",
|
|
@@ -3381,7 +3659,7 @@ const oauthProvider = (options) => {
|
|
|
3381
3659
|
} }
|
|
3382
3660
|
} }
|
|
3383
3661
|
}, async (ctx) => {
|
|
3384
|
-
return continueEndpoint(ctx,
|
|
3662
|
+
return continueEndpoint(ctx, runOAuth2Authorize);
|
|
3385
3663
|
}),
|
|
3386
3664
|
oauth2Token: createOAuthEndpoint("/oauth2/token", {
|
|
3387
3665
|
method: "POST",
|
|
@@ -3709,7 +3987,7 @@ const oauthProvider = (options) => {
|
|
|
3709
3987
|
return revokeEndpoint(ctx, opts);
|
|
3710
3988
|
}),
|
|
3711
3989
|
oauth2UserInfo: createAuthEndpoint("/oauth2/userinfo", {
|
|
3712
|
-
method: "GET",
|
|
3990
|
+
method: ["GET", "POST"],
|
|
3713
3991
|
metadata: { openapi: {
|
|
3714
3992
|
description: "Get OpenID Connect user information (UserInfo endpoint)",
|
|
3715
3993
|
security: [{ bearerAuth: [] }, { OAuth2: [
|
|
@@ -3843,6 +4121,8 @@ const oauthProvider = (options) => {
|
|
|
3843
4121
|
software_version: z.string().optional(),
|
|
3844
4122
|
software_statement: z.string().optional(),
|
|
3845
4123
|
post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(),
|
|
4124
|
+
backchannel_logout_uri: SafeUrlSchema.optional(),
|
|
4125
|
+
backchannel_logout_session_required: z.boolean().optional(),
|
|
3846
4126
|
token_endpoint_auth_method: z.enum([
|
|
3847
4127
|
"none",
|
|
3848
4128
|
"client_secret_basic",
|
|
@@ -3955,6 +4235,15 @@ const oauthProvider = (options) => {
|
|
|
3955
4235
|
},
|
|
3956
4236
|
description: "List of allowed logout redirect uris"
|
|
3957
4237
|
},
|
|
4238
|
+
backchannel_logout_uri: {
|
|
4239
|
+
type: "string",
|
|
4240
|
+
format: "uri",
|
|
4241
|
+
description: "RP URL to receive signed Logout Tokens when the end-user's OP session terminates"
|
|
4242
|
+
},
|
|
4243
|
+
backchannel_logout_session_required: {
|
|
4244
|
+
type: "boolean",
|
|
4245
|
+
description: "Whether the RP requires a `sid` claim in every Logout Token"
|
|
4246
|
+
},
|
|
3958
4247
|
token_endpoint_auth_method: {
|
|
3959
4248
|
type: "string",
|
|
3960
4249
|
description: "Requested authentication method for the token endpoint",
|
|
@@ -4063,6 +4352,19 @@ const oauthProvider = (options) => {
|
|
|
4063
4352
|
//#endregion
|
|
4064
4353
|
//#region src/authorize.ts
|
|
4065
4354
|
/**
|
|
4355
|
+
* Whether a past authentication is still fresh enough for an OIDC `max_age`
|
|
4356
|
+
* request: true when no more than `maxAge` seconds have elapsed since the user
|
|
4357
|
+
* last authenticated. The caller supplies `now`, keeping this pure.
|
|
4358
|
+
*/
|
|
4359
|
+
function isWithinMaxAge(sessionCreatedAt, maxAgeSeconds, now) {
|
|
4360
|
+
if (maxAgeSeconds === 0) return false;
|
|
4361
|
+
return now.getTime() - sessionCreatedAt.getTime() <= maxAgeSeconds * 1e3;
|
|
4362
|
+
}
|
|
4363
|
+
function removeMaxAgeFromAuthorizationQuery(query) {
|
|
4364
|
+
const { max_age: _maxAge, ...queryWithoutMaxAge } = query;
|
|
4365
|
+
return queryWithoutMaxAge;
|
|
4366
|
+
}
|
|
4367
|
+
/**
|
|
4066
4368
|
* Formats an error url. Per OIDC Core 1.0 §5 / RFC 6749 §4.2.2.1, errors on
|
|
4067
4369
|
* implicit and hybrid flows are delivered in the URL fragment, not the query.
|
|
4068
4370
|
* Callers on the code flow (default) omit `mode` and get query delivery.
|
|
@@ -4209,6 +4511,7 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
4209
4511
|
error_description: "request not found",
|
|
4210
4512
|
error: "invalid_request"
|
|
4211
4513
|
});
|
|
4514
|
+
const request = ctx.request;
|
|
4212
4515
|
let query = ctx.query;
|
|
4213
4516
|
if (query.request_uri) {
|
|
4214
4517
|
if (!opts.requestUriResolver) return handleRedirect(ctx, getErrorURL(ctx, "invalid_request_uri", "request_uri not supported"));
|
|
@@ -4223,6 +4526,14 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
4223
4526
|
if (urlClientId) query.client_id = urlClientId;
|
|
4224
4527
|
}
|
|
4225
4528
|
ctx.query = query;
|
|
4529
|
+
const parsedQuery = authorizationQuerySchema.safeParse(query);
|
|
4530
|
+
if (!parsedQuery.success) return authorizeRedirectOnError(opts)({
|
|
4531
|
+
error: "invalid_request",
|
|
4532
|
+
error_description: "invalid authorization request",
|
|
4533
|
+
ctx
|
|
4534
|
+
});
|
|
4535
|
+
query = parsedQuery.data;
|
|
4536
|
+
ctx.query = query;
|
|
4226
4537
|
await oAuthState.set({ query: serializeAuthorizationQuery(query).toString() });
|
|
4227
4538
|
if (!query.client_id) return handleRedirect(ctx, getErrorURL(ctx, "invalid_client", "client_id is required"));
|
|
4228
4539
|
if (!query.response_type) return handleRedirect(ctx, getErrorURL(ctx, "invalid_request", "response_type is required"));
|
|
@@ -4233,6 +4544,7 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
4233
4544
|
const client = await getClient(ctx, opts, query.client_id);
|
|
4234
4545
|
if (!client) return handleRedirect(ctx, getErrorURL(ctx, "invalid_client", "client_id is required"));
|
|
4235
4546
|
if (client.disabled) return handleRedirect(ctx, getErrorURL(ctx, "client_disabled", "client is disabled"));
|
|
4547
|
+
if (!clientAllowsGrant(client, "authorization_code")) return handleRedirect(ctx, getErrorURL(ctx, "unauthorized_client", "client is not authorized to use the authorization_code grant"));
|
|
4236
4548
|
if (!findRegisteredRedirectUri(client.redirectUris, query.redirect_uri) || !query.redirect_uri) return handleRedirect(ctx, getErrorURL(ctx, "invalid_redirect", "invalid redirect uri"));
|
|
4237
4549
|
let requestedScopes = query.scope?.split(" ").filter((s) => s);
|
|
4238
4550
|
if (requestedScopes) {
|
|
@@ -4258,14 +4570,20 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
4258
4570
|
if (!(await checkResource(ctx, opts, resource, requestedScopes)).success) return handleRedirect(ctx, formatErrorURL(query.redirect_uri, "invalid_target", "requested resource invalid", query.state, getIssuer(ctx, opts)));
|
|
4259
4571
|
const requestedResources = toResourceList(resource) ?? [];
|
|
4260
4572
|
const session = await getSessionFromCtx(ctx);
|
|
4261
|
-
|
|
4573
|
+
const maxAgeSeconds = query.max_age;
|
|
4574
|
+
const hasSatisfiedMaxAge = session != null && maxAgeSeconds !== void 0 && isWithinMaxAge(new Date(session.session.createdAt), maxAgeSeconds, /* @__PURE__ */ new Date());
|
|
4575
|
+
if (!session || session != null && maxAgeSeconds !== void 0 && !hasSatisfiedMaxAge || promptSet?.has("login") || promptSet?.has("create")) {
|
|
4262
4576
|
if (promptNone) return redirectWithPromptNoneError(ctx, opts, query, "login_required", "authentication required");
|
|
4263
4577
|
return redirectWithPromptCode(ctx, opts, promptSet?.has("create") ? "create" : "login");
|
|
4264
4578
|
}
|
|
4579
|
+
if (hasSatisfiedMaxAge) {
|
|
4580
|
+
query = removeMaxAgeFromAuthorizationQuery(query);
|
|
4581
|
+
ctx.query = query;
|
|
4582
|
+
}
|
|
4265
4583
|
if (settings?.isAuthorize && promptSet?.has("select_account")) return redirectWithPromptCode(ctx, opts, "select_account");
|
|
4266
4584
|
if (settings?.isAuthorize && opts.selectAccount) {
|
|
4267
4585
|
if (await opts.selectAccount.shouldRedirect({
|
|
4268
|
-
headers:
|
|
4586
|
+
headers: request.headers,
|
|
4269
4587
|
user: session.user,
|
|
4270
4588
|
session: session.session,
|
|
4271
4589
|
scopes: requestedScopes
|
|
@@ -4276,7 +4594,7 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
4276
4594
|
}
|
|
4277
4595
|
if (opts.signup?.shouldRedirect) {
|
|
4278
4596
|
const signupRedirect = await opts.signup.shouldRedirect({
|
|
4279
|
-
headers:
|
|
4597
|
+
headers: request.headers,
|
|
4280
4598
|
user: session.user,
|
|
4281
4599
|
session: session.session,
|
|
4282
4600
|
scopes: requestedScopes
|
|
@@ -4288,7 +4606,7 @@ async function authorizeEndpoint(ctx, opts, settings) {
|
|
|
4288
4606
|
}
|
|
4289
4607
|
if (!settings?.postLogin && opts.postLogin) {
|
|
4290
4608
|
if (await opts.postLogin.shouldRedirect({
|
|
4291
|
-
headers:
|
|
4609
|
+
headers: request.headers,
|
|
4292
4610
|
user: session.user,
|
|
4293
4611
|
session: session.session,
|
|
4294
4612
|
scopes: requestedScopes
|
|
@@ -4412,6 +4730,7 @@ async function signParams(ctx, opts, flags) {
|
|
|
4412
4730
|
//#region src/metadata.ts
|
|
4413
4731
|
function authServerMetadata(ctx, opts, overrides) {
|
|
4414
4732
|
const baseURL = ctx.context.baseURL;
|
|
4733
|
+
const backchannelSupported = !overrides?.jwt_disabled;
|
|
4415
4734
|
return {
|
|
4416
4735
|
scopes_supported: overrides?.scopes_supported,
|
|
4417
4736
|
issuer: validateIssuerUrl(opts?.jwt?.issuer ?? baseURL),
|
|
@@ -4448,7 +4767,9 @@ function authServerMetadata(ctx, opts, overrides) {
|
|
|
4448
4767
|
],
|
|
4449
4768
|
revocation_endpoint_auth_signing_alg_values_supported: [...PRIVATE_KEY_JWT_SIGNING_ALGORITHMS],
|
|
4450
4769
|
code_challenge_methods_supported: ["S256"],
|
|
4451
|
-
authorization_response_iss_parameter_supported: true
|
|
4770
|
+
authorization_response_iss_parameter_supported: true,
|
|
4771
|
+
backchannel_logout_supported: backchannelSupported,
|
|
4772
|
+
backchannel_logout_session_supported: backchannelSupported
|
|
4452
4773
|
};
|
|
4453
4774
|
}
|
|
4454
4775
|
function oidcServerMetadata(ctx, opts) {
|