@better-auth/oauth-provider 1.7.0-beta.0 → 1.7.0-beta.2

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/index.mjs CHANGED
@@ -1,12 +1,13 @@
1
- import { n as isPrivateHostname } from "./client-assertion-DZqo-L5j.mjs";
1
+ import { n as isPrivateHostname } from "./client-assertion-CderPEmR.mjs";
2
2
  import { n as mcpHandler } from "./mcp-CYnz-MXn.mjs";
3
- import { _ as storeToken, a as getClient, c as getStoredToken, d as parseClientMetadata, f as parsePrompt, g as storeClientSecret, h as searchParamsToQuery, i as extractClientCredentials, l as isPKCERequired, m as resolveSubjectIdentifier, n as deleteFromPrompt, o as getJwtPlugin, p as resolveSessionAuthTime, r as destructureCredentials, t as decryptStoredClientSecret, u as normalizeTimestampValue, v as validateClientCredentials, y as verifyOAuthQueryParams } from "./utils-CIbcUsZ5.mjs";
4
- import { t as PACKAGE_VERSION } from "./version-BGWhjYBb.mjs";
3
+ import { _ as storeClientSecret, a as getClient, b as validateClientCredentials, c as getStoredToken, d as normalizeTimestampValue, f as parseClientMetadata, g as searchParamsToQuery, h as resolveSubjectIdentifier, i as extractClientCredentials, l as isPKCERequired, m as resolveSessionAuthTime, n as deleteFromPrompt, o as getJwtPlugin, p as parsePrompt, r as destructureCredentials, t as decryptStoredClientSecret, u as mergeDiscoveryMetadata, v as storeToken, x as verifyOAuthQueryParams, y as toClientDiscoveryArray } from "./utils-Cx_XnD9i.mjs";
4
+ import { t as PACKAGE_VERSION } from "./version-CZxZ64qJ.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";
@@ -14,8 +15,8 @@ import { BetterAuthError } from "@better-auth/core/error";
14
15
  import { parseSetCookieHeader } from "better-auth/cookies";
15
16
  import { mergeSchema } from "better-auth/db";
16
17
  import * as z from "zod";
17
- import { signJWT, toExpJWT } from "better-auth/plugins";
18
- import { SignJWT, compactVerify, createLocalJWKSet, decodeJwt } from "jose";
18
+ import { resolveSigningKey, signJWT, toExpJWT } from "better-auth/plugins";
19
+ import { SignJWT, base64url, compactVerify, createLocalJWKSet, decodeJwt, decodeProtectedHeader } from "jose";
19
20
  //#region src/consent.ts
20
21
  async function consentEndpoint(ctx, opts) {
21
22
  const _query = (await oAuthState.get())?.query;
@@ -156,6 +157,76 @@ async function postLogin(ctx, opts) {
156
157
  };
157
158
  }
158
159
  //#endregion
160
+ //#region src/types/zod.ts
161
+ const DANGEROUS_SCHEMES = [
162
+ "javascript:",
163
+ "data:",
164
+ "vbscript:"
165
+ ];
166
+ /**
167
+ * Runtime schema for OAuthAuthorizationQuery.
168
+ * Uses passthrough to tolerate fields added by future extensions (PAR, FPA, etc.)
169
+ */
170
+ const oauthAuthorizationQuerySchema = z.object({
171
+ response_type: z.literal("code").optional(),
172
+ request_uri: z.string().optional(),
173
+ redirect_uri: z.string(),
174
+ scope: z.string().optional(),
175
+ state: z.string().optional(),
176
+ client_id: z.string(),
177
+ prompt: z.string().optional(),
178
+ display: z.string().optional(),
179
+ ui_locales: z.string().optional(),
180
+ max_age: z.coerce.number().optional(),
181
+ acr_values: z.string().optional(),
182
+ login_hint: z.string().optional(),
183
+ id_token_hint: z.string().optional(),
184
+ code_challenge: z.string().optional(),
185
+ code_challenge_method: z.literal("S256").optional(),
186
+ nonce: z.string().optional()
187
+ }).passthrough();
188
+ /**
189
+ * Runtime schema for the authorization code verification value.
190
+ * Validates structure on deserialization from the JSON blob stored in the DB.
191
+ * Uses passthrough so future fields (e.g. from authorization challenge) don't break parsing.
192
+ */
193
+ const verificationValueSchema = z.object({
194
+ type: z.literal("authorization_code"),
195
+ query: oauthAuthorizationQuerySchema,
196
+ sessionId: z.string(),
197
+ userId: z.string(),
198
+ referenceId: z.string().optional(),
199
+ authTime: z.number().optional()
200
+ }).passthrough();
201
+ /**
202
+ * Reusable URL validation for OAuth redirect URIs.
203
+ * - Blocks dangerous schemes (javascript:, data:, vbscript:)
204
+ * - For http/https: requires HTTPS (HTTP allowed only for loopback hosts: 127.0.0.0/8, [::1], *.localhost per RFC 6761)
205
+ * - Allows custom schemes for mobile apps (e.g., myapp://callback)
206
+ */
207
+ const SafeUrlSchema = z.url().superRefine((val, ctx) => {
208
+ if (!URL.canParse(val)) {
209
+ ctx.addIssue({
210
+ code: "custom",
211
+ message: "URL must be parseable",
212
+ fatal: true
213
+ });
214
+ return z.NEVER;
215
+ }
216
+ const u = new URL(val);
217
+ if (DANGEROUS_SCHEMES.includes(u.protocol)) {
218
+ ctx.addIssue({
219
+ code: "custom",
220
+ message: "URL cannot use javascript:, data:, or vbscript: scheme"
221
+ });
222
+ return;
223
+ }
224
+ if (u.protocol === "http:" && !isLoopbackHost(u.host)) ctx.addIssue({
225
+ code: "custom",
226
+ message: "Redirect URI must use HTTPS (HTTP allowed only for loopback hosts)"
227
+ });
228
+ });
229
+ //#endregion
159
230
  //#region src/userinfo.ts
160
231
  /**
161
232
  * Provides shared /userinfo and id_token claims functionality
@@ -184,11 +255,7 @@ function userNormalClaims(user, scopes) {
184
255
  * Handles the /oauth2/userinfo endpoint
185
256
  */
186
257
  async function userInfoEndpoint(ctx, opts) {
187
- if (!ctx.request) throw new APIError("UNAUTHORIZED", {
188
- error_description: "request not found",
189
- error: "invalid_request"
190
- });
191
- const authorization = ctx.request.headers.get("authorization");
258
+ const authorization = ctx.headers?.get("authorization");
192
259
  const token = typeof authorization === "string" && authorization?.startsWith("Bearer ") ? authorization?.replace("Bearer ", "") : authorization;
193
260
  if (!token?.length) throw new APIError("UNAUTHORIZED", {
194
261
  error_description: "authorization header not found",
@@ -234,8 +301,8 @@ async function userInfoEndpoint(ctx, opts) {
234
301
  * the grant types
235
302
  */
236
303
  async function tokenEndpoint(ctx, opts) {
237
- const grantType = ctx.body?.grant_type;
238
- if (opts.grantTypes && grantType && !opts.grantTypes.includes(grantType)) throw new APIError("BAD_REQUEST", {
304
+ const grantType = ctx.body.grant_type;
305
+ if (opts.grantTypes && !opts.grantTypes.includes(grantType)) throw new APIError("BAD_REQUEST", {
239
306
  error_description: `unsupported grant_type ${grantType}`,
240
307
  error: "unsupported_grant_type"
241
308
  });
@@ -243,14 +310,6 @@ async function tokenEndpoint(ctx, opts) {
243
310
  case "authorization_code": return handleAuthorizationCodeGrant(ctx, opts);
244
311
  case "client_credentials": return handleClientCredentialsGrant(ctx, opts);
245
312
  case "refresh_token": return handleRefreshTokenGrant(ctx, opts);
246
- case void 0: throw new APIError("BAD_REQUEST", {
247
- error_description: "missing required grant_type",
248
- error: "unsupported_grant_type"
249
- });
250
- default: throw new APIError("BAD_REQUEST", {
251
- error_description: `unsupported grant_type ${grantType}`,
252
- error: "unsupported_grant_type"
253
- });
254
313
  }
255
314
  }
256
315
  async function createJwtAccessToken(ctx, opts, user, client, audience, scopes, referenceId, overrides) {
@@ -268,7 +327,7 @@ async function createJwtAccessToken(ctx, opts, user, client, audience, scopes, r
268
327
  options: jwtPluginOptions,
269
328
  payload: {
270
329
  ...customClaims,
271
- sub: user.id,
330
+ sub: user?.id,
272
331
  aud: typeof audience === "string" ? audience : audience?.length === 1 ? audience.at(0) : audience,
273
332
  azp: client.clientId,
274
333
  scope: scopes.join(" "),
@@ -280,10 +339,23 @@ async function createJwtAccessToken(ctx, opts, user, client, audience, scopes, r
280
339
  });
281
340
  }
282
341
  /**
342
+ * Computes an OIDC hash (at_hash, c_hash) per OIDC Core §3.1.3.6.
343
+ * Hashes the token, takes the left half, and base64url-encodes it.
344
+ */
345
+ async function computeOidcHash(token, signingAlg) {
346
+ let hashAlg;
347
+ if (signingAlg === "EdDSA") hashAlg = "SHA-512";
348
+ else if (signingAlg.endsWith("384")) hashAlg = "SHA-384";
349
+ else if (signingAlg.endsWith("512")) hashAlg = "SHA-512";
350
+ else hashAlg = "SHA-256";
351
+ const digest = new Uint8Array(await crypto.subtle.digest(hashAlg, new TextEncoder().encode(token)));
352
+ return base64url.encode(digest.slice(0, digest.length / 2));
353
+ }
354
+ /**
283
355
  * Creates a user id token in code_authorization with scope of 'openid'
284
356
  * and hybrid/implicit (not yet implemented) flows
285
357
  */
286
- async function createIdToken(ctx, opts, user, client, scopes, nonce, sessionId, authTime) {
358
+ async function createIdToken(ctx, opts, user, client, scopes, nonce, sessionId, authTime, accessToken) {
287
359
  const iat = Math.floor(Date.now() / 1e3);
288
360
  const exp = iat + (opts.idTokenExpiresIn ?? 36e3);
289
361
  const userClaims = userNormalClaims(user, scopes);
@@ -296,11 +368,15 @@ async function createIdToken(ctx, opts, user, client, scopes, nonce, sessionId,
296
368
  metadata: parseClientMetadata(client.metadata)
297
369
  }) : {};
298
370
  const jwtPluginOptions = opts.disableJwtPlugin ? void 0 : getJwtPlugin(ctx.context).options;
371
+ const resolvedKey = !opts.disableJwtPlugin && !jwtPluginOptions?.jwt?.sign ? await resolveSigningKey(ctx, jwtPluginOptions) : void 0;
372
+ const signingAlg = opts.disableJwtPlugin ? "HS256" : resolvedKey?.alg ?? jwtPluginOptions?.jwks?.keyPairConfig?.alg;
373
+ const atHash = accessToken ? await computeOidcHash(accessToken, signingAlg) : void 0;
299
374
  const payload = {
300
375
  ...userClaims,
301
376
  auth_time: authTimeSec,
302
377
  acr,
303
378
  ...customClaims,
379
+ at_hash: atHash,
304
380
  iss: jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL,
305
381
  sub: resolvedSub,
306
382
  aud: client.clientId,
@@ -310,10 +386,19 @@ async function createIdToken(ctx, opts, user, client, scopes, nonce, sessionId,
310
386
  sid: client.enableEndSession ? sessionId : void 0
311
387
  };
312
388
  if (opts.disableJwtPlugin && !client.clientSecret) return;
313
- return opts.disableJwtPlugin ? new SignJWT(payload).setProtectedHeader({ alg: "HS256" }).sign(new TextEncoder().encode(await decryptStoredClientSecret(ctx, opts.storeClientSecret, client.clientSecret))) : signJWT(ctx, {
389
+ 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, {
314
390
  options: jwtPluginOptions,
315
- payload
391
+ payload,
392
+ resolvedKey: resolvedKey ?? void 0
316
393
  });
394
+ if (idToken && atHash && jwtPluginOptions?.jwt?.sign) {
395
+ const header = decodeProtectedHeader(idToken);
396
+ if (header.alg !== signingAlg) throw new APIError("INTERNAL_SERVER_ERROR", {
397
+ error_description: `ID token signed with "${header.alg}" but at_hash was computed for "${signingAlg}". Ensure jwt.sign uses the algorithm declared in keyPairConfig.alg.`,
398
+ error: "server_error"
399
+ });
400
+ }
401
+ return idToken;
317
402
  }
318
403
  /**
319
404
  * Encodes a refresh token for a client
@@ -402,39 +487,45 @@ async function checkResource(ctx, opts, scopes) {
402
487
  }
403
488
  return audience?.length === 1 ? audience.at(0) : audience;
404
489
  }
405
- async function createUserTokens(ctx, opts, client, scopes, user, referenceId, sessionId, nonce, additional, authTime) {
490
+ async function createUserTokens(ctx, opts, params) {
491
+ const { client, scopes, user, grantType, referenceId, sessionId, nonce, refreshToken: existingRefreshToken, authTime, verificationValue } = params;
406
492
  const iat = Math.floor(Date.now() / 1e3);
407
- const defaultExp = iat + (opts.accessTokenExpiresIn ?? 3600);
493
+ const defaultExp = iat + (user ? opts.accessTokenExpiresIn ?? 3600 : opts.m2mAccessTokenExpiresIn ?? 3600);
408
494
  const exp = opts.scopeExpirations ? scopes.map((sc) => opts.scopeExpirations?.[sc] ? toExpJWT(opts.scopeExpirations[sc], iat) : defaultExp).reduce((prev, curr) => {
409
495
  return prev < curr ? prev : curr;
410
496
  }, defaultExp) : defaultExp;
411
497
  const audience = await checkResource(ctx, opts, scopes);
412
- const isRefreshToken = additional?.refreshToken?.scopes?.includes("offline_access") || scopes.includes("offline_access");
498
+ const isRefreshToken = user && (existingRefreshToken?.scopes?.includes("offline_access") || scopes.includes("offline_access"));
413
499
  const isJwtAccessToken = audience && !opts.disableJwtPlugin;
414
- const isIdToken = scopes.includes("openid");
415
- const earlyRefreshToken = isRefreshToken && !isJwtAccessToken ? await createRefreshToken(ctx, opts, user, referenceId, client, scopes, {
500
+ const isIdToken = user && scopes.includes("openid");
501
+ const customFields = opts.customTokenResponseFields ? await opts.customTokenResponseFields({
502
+ grantType,
503
+ user,
504
+ scopes,
505
+ metadata: parseClientMetadata(client.metadata),
506
+ verificationValue
507
+ }) : void 0;
508
+ const earlyRefreshToken = isRefreshToken && user && !isJwtAccessToken ? await createRefreshToken(ctx, opts, user, referenceId, client, scopes, {
416
509
  iat,
417
510
  exp: iat + (opts.refreshTokenExpiresIn ?? 2592e3),
418
511
  sid: sessionId
419
- }, additional?.refreshToken, authTime) : void 0;
420
- const [accessToken, refreshToken, idToken] = await Promise.all([
421
- isJwtAccessToken ? createJwtAccessToken(ctx, opts, user, client, audience, scopes, referenceId, {
422
- iat,
423
- exp,
424
- sid: sessionId
425
- }) : createOpaqueAccessToken(ctx, opts, user, client, scopes, {
426
- iat,
427
- exp,
428
- sid: sessionId
429
- }, referenceId, earlyRefreshToken?.id),
430
- earlyRefreshToken ? earlyRefreshToken : isRefreshToken ? createRefreshToken(ctx, opts, user, referenceId, client, scopes, {
431
- iat,
432
- exp: iat + (opts.refreshTokenExpiresIn ?? 2592e3),
433
- sid: sessionId
434
- }, additional?.refreshToken, authTime) : void 0,
435
- isIdToken ? createIdToken(ctx, opts, user, client, scopes, nonce, sessionId, authTime) : void 0
436
- ]);
512
+ }, existingRefreshToken, authTime) : void 0;
513
+ const [accessToken, refreshToken] = await Promise.all([isJwtAccessToken ? createJwtAccessToken(ctx, opts, user, client, audience, scopes, referenceId, {
514
+ iat,
515
+ exp,
516
+ sid: sessionId
517
+ }) : createOpaqueAccessToken(ctx, opts, user, client, scopes, {
518
+ iat,
519
+ exp,
520
+ sid: sessionId
521
+ }, referenceId, earlyRefreshToken?.id), earlyRefreshToken ? earlyRefreshToken : isRefreshToken && user ? createRefreshToken(ctx, opts, user, referenceId, client, scopes, {
522
+ iat,
523
+ exp: iat + (opts.refreshTokenExpiresIn ?? 2592e3),
524
+ sid: sessionId
525
+ }, existingRefreshToken, authTime) : void 0]);
526
+ const idToken = isIdToken ? await createIdToken(ctx, opts, user, client, scopes, nonce, sessionId, authTime, accessToken) : void 0;
437
527
  return ctx.json({
528
+ ...customFields,
438
529
  access_token: accessToken,
439
530
  expires_in: exp - iat,
440
531
  expires_at: exp,
@@ -450,7 +541,6 @@ async function createUserTokens(ctx, opts, client, scopes, user, referenceId, se
450
541
  /** Checks verification value */
451
542
  async function checkVerificationValue(ctx, opts, code, client_id, redirect_uri) {
452
543
  const verification = await ctx.context.internalAdapter.findVerificationValue(await storeToken(opts.storeTokens, code, "authorization_code"));
453
- const verificationValue = verification ? JSON.parse(verification?.value) : void 0;
454
544
  if (!verification) throw new APIError("UNAUTHORIZED", {
455
545
  error_description: "Invalid code",
456
546
  error: "invalid_verification"
@@ -460,22 +550,25 @@ async function checkVerificationValue(ctx, opts, code, client_id, redirect_uri)
460
550
  error_description: "code expired",
461
551
  error: "invalid_verification"
462
552
  });
463
- if (!verificationValue) throw new APIError("UNAUTHORIZED", {
464
- error_description: "missing verification value content",
465
- error: "invalid_verification"
466
- });
467
- if (verificationValue.type !== "authorization_code") throw new APIError("UNAUTHORIZED", {
468
- error_description: "incorrect verification type",
553
+ let rawValue;
554
+ try {
555
+ rawValue = JSON.parse(verification.value);
556
+ } catch {
557
+ throw new APIError("UNAUTHORIZED", {
558
+ error_description: "malformed verification value",
559
+ error: "invalid_verification"
560
+ });
561
+ }
562
+ const parsed = verificationValueSchema.safeParse(rawValue);
563
+ if (!parsed.success) throw new APIError("UNAUTHORIZED", {
564
+ error_description: "malformed verification value",
469
565
  error: "invalid_verification"
470
566
  });
567
+ const verificationValue = parsed.data;
471
568
  if (verificationValue.query.client_id !== client_id) throw new APIError("UNAUTHORIZED", {
472
569
  error_description: "invalid client_id",
473
570
  error: "invalid_client"
474
571
  });
475
- if (!verificationValue.userId) throw new APIError("BAD_REQUEST", {
476
- error_description: "missing user_id on challenge",
477
- error: "invalid_user"
478
- });
479
572
  if (verificationValue.query?.redirect_uri && verificationValue.query?.redirect_uri !== redirect_uri) throw new APIError("BAD_REQUEST", {
480
573
  error_description: "redirect_uri mismatch",
481
574
  error: "invalid_request"
@@ -563,7 +656,17 @@ async function handleAuthorizationCodeGrant(ctx, opts) {
563
656
  error: "invalid_request"
564
657
  });
565
658
  const authTime = verificationValue.authTime != null ? normalizeTimestampValue(verificationValue.authTime) : resolveSessionAuthTime(session);
566
- return createUserTokens(ctx, opts, client, verificationValue.query.scope?.split(" ") ?? [], user, verificationValue.referenceId, session.id, verificationValue.query?.nonce, void 0, authTime);
659
+ return createUserTokens(ctx, opts, {
660
+ client,
661
+ scopes: verificationValue.query.scope?.split(" ") ?? [],
662
+ user,
663
+ grantType: "authorization_code",
664
+ referenceId: verificationValue.referenceId,
665
+ sessionId: session.id,
666
+ nonce: verificationValue.query?.nonce,
667
+ authTime,
668
+ verificationValue
669
+ });
567
670
  }
568
671
  /**
569
672
  * Grant that allows direct access to an API using the application's credentials
@@ -601,43 +704,11 @@ async function handleClientCredentialsGrant(ctx, opts) {
601
704
  });
602
705
  }
603
706
  if (!requestedScopes) requestedScopes = client.scopes ?? opts.clientCredentialGrantDefaultScopes ?? opts.scopes ?? [];
604
- const jwtPluginOptions = opts.disableJwtPlugin ? void 0 : getJwtPlugin(ctx.context).options;
605
- const audience = await checkResource(ctx, opts, requestedScopes);
606
- const iat = Math.floor(Date.now() / 1e3);
607
- const defaultExp = iat + (opts.m2mAccessTokenExpiresIn ?? 3600);
608
- const exp = opts.scopeExpirations && requestedScopes ? requestedScopes.map((sc) => opts.scopeExpirations?.[sc] ? toExpJWT(opts.scopeExpirations[sc], iat) : defaultExp).reduce((prev, curr) => {
609
- return prev < curr ? prev : curr;
610
- }, defaultExp) : defaultExp;
611
- const customClaims = opts.customAccessTokenClaims ? await opts.customAccessTokenClaims({
707
+ return createUserTokens(ctx, opts, {
708
+ client,
612
709
  scopes: requestedScopes,
613
- resource: ctx.body.resource,
614
- metadata: parseClientMetadata(client.metadata)
615
- }) : {};
616
- const accessToken = audience && !opts.disableJwtPlugin ? await signJWT(ctx, {
617
- options: jwtPluginOptions,
618
- payload: {
619
- ...customClaims,
620
- aud: audience,
621
- azp: client.clientId,
622
- scope: requestedScopes.join(" "),
623
- iss: jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL,
624
- iat,
625
- exp
626
- }
627
- }) : await createOpaqueAccessToken(ctx, opts, void 0, client, requestedScopes, {
628
- iat,
629
- exp
710
+ grantType: "client_credentials"
630
711
  });
631
- return ctx.json({
632
- access_token: accessToken,
633
- expires_in: exp - iat,
634
- expires_at: exp,
635
- token_type: "Bearer",
636
- scope: requestedScopes.join(" ")
637
- }, { headers: {
638
- "Cache-Control": "no-store",
639
- Pragma: "no-cache"
640
- } });
641
712
  }
642
713
  /**
643
714
  * Obtains new Session Jwt and Refresh Tokens using a refresh token
@@ -708,7 +779,16 @@ async function handleRefreshTokenGrant(ctx, opts) {
708
779
  error: "invalid_request"
709
780
  });
710
781
  const authTime = refreshToken.authTime != null ? normalizeTimestampValue(refreshToken.authTime) : void 0;
711
- return createUserTokens(ctx, opts, client, requestedScopes ?? scopes, user, refreshToken.referenceId, refreshToken.sessionId, void 0, { refreshToken }, authTime);
782
+ return createUserTokens(ctx, opts, {
783
+ client,
784
+ scopes: requestedScopes ?? scopes,
785
+ user,
786
+ grantType: "refresh_token",
787
+ referenceId: refreshToken.referenceId,
788
+ sessionId: refreshToken.sessionId,
789
+ refreshToken,
790
+ authTime
791
+ });
712
792
  }
713
793
  //#endregion
714
794
  //#region src/introspect.ts
@@ -920,6 +1000,7 @@ async function resolveIntrospectionSub(opts, payload, client) {
920
1000
  }
921
1001
  async function introspectEndpoint(ctx, opts) {
922
1002
  let { token, token_type_hint } = ctx.body;
1003
+ if (token_type_hint !== "access_token" && token_type_hint !== "refresh_token") token_type_hint = void 0;
923
1004
  const { clientId: client_id, clientSecret: client_secret, preVerifiedClient } = destructureCredentials(await extractClientCredentials(ctx, opts, `${ctx.context.baseURL}/oauth2/introspect`));
924
1005
  if (!client_id || !client_secret && !preVerifiedClient) throw new APIError$1("UNAUTHORIZED", {
925
1006
  error_description: "missing required credentials",
@@ -1077,6 +1158,109 @@ async function rpInitiatedLogoutEndpoint(ctx, opts) {
1077
1158
  }
1078
1159
  }
1079
1160
  //#endregion
1161
+ //#region src/oauth-endpoint.ts
1162
+ /**
1163
+ * Wraps `createAuthEndpoint` so zod schemas stay the single source of truth
1164
+ * for body/query shape while validation failures serialize as the RFC 6749
1165
+ * §5.2 error envelope `{ error, error_description }`.
1166
+ *
1167
+ * A failing issue is routed by its first path segment via `errorCodesByField`:
1168
+ * - missing required (`invalid_type` + "received undefined") → `.missing`
1169
+ * - unsupported value (`invalid_value`) → `.invalid`
1170
+ * - anything else (wrong type, duplicated params, bad format) → `defaultError`
1171
+ *
1172
+ * For enum fields that need to distinguish missing from unsupported, compose
1173
+ * as `z.string().pipe(z.enum([...]))` so duplicated params fail the outer
1174
+ * `z.string()` as `invalid_type` instead of masquerading as an unsupported
1175
+ * enum value.
1176
+ */
1177
+ function createOAuthEndpoint(path, options, handler) {
1178
+ const { redirectOnError, onValidationError: userHook, errorCodesByField, defaultError = "invalid_request", ...rest } = options;
1179
+ if (!redirectOnError) return createAuthEndpoint(path, {
1180
+ ...rest,
1181
+ onValidationError: async (args) => {
1182
+ if (userHook) await userHook(args);
1183
+ throw new APIError$1("BAD_REQUEST", { ...mapIssuesToOAuthError(args.issues, errorCodesByField, defaultError) });
1184
+ }
1185
+ }, handler);
1186
+ const redirect = redirectOnError;
1187
+ const { body: bodySchema, query: querySchema, ...forwarded } = rest;
1188
+ async function validateSlot(ctx, slot, schema) {
1189
+ if (!schema) return { ok: true };
1190
+ const result = await schema.safeParseAsync(ctx[slot] ?? {});
1191
+ if (result.success) {
1192
+ ctx[slot] = result.data;
1193
+ return { ok: true };
1194
+ }
1195
+ if (userHook) await userHook({
1196
+ message: result.error.message,
1197
+ issues: result.error.issues
1198
+ });
1199
+ return {
1200
+ ok: false,
1201
+ response: redirect({
1202
+ ...mapIssuesToOAuthError(result.error.issues, errorCodesByField, defaultError),
1203
+ ctx
1204
+ })
1205
+ };
1206
+ }
1207
+ return createAuthEndpoint(path, forwarded, async (ctx) => {
1208
+ const body = await validateSlot(ctx, "body", bodySchema);
1209
+ if (!body.ok) return body.response;
1210
+ const query = await validateSlot(ctx, "query", querySchema);
1211
+ if (!query.ok) return query.response;
1212
+ return handler(ctx);
1213
+ });
1214
+ }
1215
+ function mapIssuesToOAuthError(issues, errorCodesByField, defaultError = "invalid_request") {
1216
+ const issue = issues[0];
1217
+ if (!issue) return {
1218
+ error: defaultError,
1219
+ error_description: "Invalid request."
1220
+ };
1221
+ const first = issue.path?.[0];
1222
+ const fieldKey = typeof first === "string" ? first : void 0;
1223
+ const mapping = fieldKey ? errorCodesByField?.[fieldKey] : void 0;
1224
+ const field = issue.path?.length ? z.core.toDotPath(issue.path) : "";
1225
+ return {
1226
+ error: resolveErrorCode(issue, mapping, defaultError),
1227
+ error_description: describeIssue(issue, field)
1228
+ };
1229
+ }
1230
+ function resolveErrorCode(issue, mapping, defaultError) {
1231
+ if (typeof mapping === "string") return mapping;
1232
+ if (isMissingValueIssue(issue)) return mapping?.missing ?? defaultError;
1233
+ if (issue.code === "invalid_value") return mapping?.invalid ?? defaultError;
1234
+ return defaultError;
1235
+ }
1236
+ /**
1237
+ * Returns `true` for issues that represent an absent required value. Zod v4
1238
+ * strips `input` from published issues, so the signal is the `invalid_type`
1239
+ * code combined with a message suffix of "received undefined". The suffix is
1240
+ * pinned by a regression test so a zod rephrase fails the test instead of
1241
+ * silently reclassifying missing fields.
1242
+ *
1243
+ * Assumes the default zod error map. Consumers that install a localized map
1244
+ * via `z.setErrorMap()` will break this check, collapsing missing-field
1245
+ * failures to `defaultError`.
1246
+ */
1247
+ function isMissingValueIssue(issue) {
1248
+ return issue.code === "invalid_type" && issue.message.endsWith("received undefined");
1249
+ }
1250
+ function describeIssue(issue, field) {
1251
+ if (!field) return issue.message;
1252
+ if (issue.code === "invalid_type") {
1253
+ if (issue.message.endsWith("received undefined")) return `${field} is required`;
1254
+ if (issue.message.endsWith("received array")) return `${field} must not appear more than once`;
1255
+ return `${field} must be a ${issue.expected ?? "valid value"}`;
1256
+ }
1257
+ if (issue.code === "invalid_value") {
1258
+ const values = issue.values;
1259
+ if (Array.isArray(values) && values.length > 0) return `${field} must be one of: ${values.join(", ")}`;
1260
+ }
1261
+ return `${field}: ${issue.message}`;
1262
+ }
1263
+ //#endregion
1080
1264
  //#region src/middleware/index.ts
1081
1265
  const publicSessionMiddleware = (opts) => createAuthMiddleware(async (ctx) => {
1082
1266
  if (!opts.allowPublicClientPrelogin) throw new APIError("BAD_REQUEST");
@@ -1085,6 +1269,21 @@ const publicSessionMiddleware = (opts) => createAuthMiddleware(async (ctx) => {
1085
1269
  });
1086
1270
  //#endregion
1087
1271
  //#region src/register.ts
1272
+ /**
1273
+ * Resolves the auth method and type for unauthenticated DCR.
1274
+ * Overrides confidential methods to "none" per RFC 7591 Section 3.2.1.
1275
+ * When overriding, clears type "web" since it is only valid for confidential clients.
1276
+ */
1277
+ function resolveUnauthenticatedAuth(body) {
1278
+ if (body.token_endpoint_auth_method === "none") return {
1279
+ tokenEndpointAuthMethod: "none",
1280
+ type: body.type
1281
+ };
1282
+ return {
1283
+ tokenEndpointAuthMethod: "none",
1284
+ type: body.type === "web" ? void 0 : body.type
1285
+ };
1286
+ }
1088
1287
  async function registerEndpoint(ctx, opts) {
1089
1288
  if (!opts.allowDynamicClientRegistration) throw new APIError("FORBIDDEN", {
1090
1289
  error: "access_denied",
@@ -1096,12 +1295,16 @@ async function registerEndpoint(ctx, opts) {
1096
1295
  error: "invalid_token",
1097
1296
  error_description: "Authentication required for client registration"
1098
1297
  });
1099
- const isPublic = body.token_endpoint_auth_method === "none";
1100
- if (!session && !isPublic) throw new APIError("UNAUTHORIZED", {
1101
- error: "invalid_request",
1102
- error_description: "Authentication required for confidential client registration"
1103
- });
1104
- if (!ctx.body.scope) ctx.body.scope = (opts.clientRegistrationDefaultScopes ?? opts.scopes)?.join(" ");
1298
+ if (!session) {
1299
+ if (body.grant_types?.includes("client_credentials")) throw new APIError("BAD_REQUEST", {
1300
+ error: "invalid_client_metadata",
1301
+ error_description: "client_credentials grant requires authenticated registration"
1302
+ });
1303
+ const resolved = resolveUnauthenticatedAuth(body);
1304
+ body.token_endpoint_auth_method = resolved.tokenEndpointAuthMethod;
1305
+ body.type = resolved.type;
1306
+ }
1307
+ if (!body.scope) body.scope = (opts.clientRegistrationDefaultScopes ?? opts.scopes)?.join(" ");
1105
1308
  return createOAuthClientEndpoint(ctx, opts, { isRegister: true });
1106
1309
  }
1107
1310
  async function checkOAuthClient(client, opts, settings) {
@@ -1340,57 +1543,11 @@ function schemaToOAuth(input) {
1340
1543
  };
1341
1544
  }
1342
1545
  //#endregion
1343
- //#region src/types/zod.ts
1344
- const DANGEROUS_SCHEMES = [
1345
- "javascript:",
1346
- "data:",
1347
- "vbscript:"
1348
- ];
1349
- function isLocalhost(hostname) {
1350
- return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname.endsWith(".localhost");
1351
- }
1352
- /**
1353
- * Reusable URL validation for OAuth redirect URIs.
1354
- * - Blocks dangerous schemes (javascript:, data:, vbscript:)
1355
- * - For http/https: requires HTTPS (HTTP allowed only for localhost)
1356
- * - Allows custom schemes for mobile apps (e.g., myapp://callback)
1357
- */
1358
- const SafeUrlSchema = z.url().superRefine((val, ctx) => {
1359
- if (!URL.canParse(val)) {
1360
- ctx.addIssue({
1361
- code: "custom",
1362
- message: "URL must be parseable",
1363
- fatal: true
1364
- });
1365
- return z.NEVER;
1366
- }
1367
- const u = new URL(val);
1368
- if (DANGEROUS_SCHEMES.includes(u.protocol)) {
1369
- ctx.addIssue({
1370
- code: "custom",
1371
- message: "URL cannot use javascript:, data:, or vbscript: scheme"
1372
- });
1373
- return;
1374
- }
1375
- if (u.protocol === "http:" || u.protocol === "https:") {
1376
- if (u.protocol === "http:" && !isLocalhost(u.hostname)) ctx.addIssue({
1377
- code: "custom",
1378
- message: "Redirect URI must use HTTPS (HTTP allowed only for localhost)"
1379
- });
1380
- }
1381
- });
1382
- //#endregion
1383
1546
  //#region src/oauthClient/endpoints.ts
1384
1547
  async function getClientEndpoint(ctx, opts) {
1385
1548
  const session = await getSessionFromCtx(ctx);
1549
+ await assertClientPrivileges(ctx, session, opts, "read");
1386
1550
  if (!session) throw new APIError("UNAUTHORIZED");
1387
- if (!ctx.headers) throw new APIError("BAD_REQUEST");
1388
- if (opts.clientPrivileges && !await opts.clientPrivileges({
1389
- headers: ctx.headers,
1390
- action: "read",
1391
- session: session.session,
1392
- user: session.user
1393
- })) throw new APIError("UNAUTHORIZED");
1394
1551
  const client = await getClient(ctx, opts, ctx.query.client_id);
1395
1552
  if (!client) throw new APIError("NOT_FOUND", {
1396
1553
  error_description: "client not found",
@@ -1431,14 +1588,8 @@ async function getClientPublicEndpoint(ctx, opts, clientId) {
1431
1588
  }
1432
1589
  async function getClientsEndpoint(ctx, opts) {
1433
1590
  const session = await getSessionFromCtx(ctx);
1591
+ await assertClientPrivileges(ctx, session, opts, "list");
1434
1592
  if (!session) throw new APIError("UNAUTHORIZED");
1435
- if (!ctx.headers) throw new APIError("BAD_REQUEST");
1436
- if (opts.clientPrivileges && !await opts.clientPrivileges({
1437
- headers: ctx.headers,
1438
- action: "list",
1439
- session: session.session,
1440
- user: session.user
1441
- })) throw new APIError("UNAUTHORIZED");
1442
1593
  const referenceId = await opts.clientReference?.(session);
1443
1594
  if (referenceId) return await ctx.context.adapter.findMany({
1444
1595
  model: "oauthClient",
@@ -1472,14 +1623,8 @@ async function getClientsEndpoint(ctx, opts) {
1472
1623
  }
1473
1624
  async function deleteClientEndpoint(ctx, opts) {
1474
1625
  const session = await getSessionFromCtx(ctx);
1626
+ await assertClientPrivileges(ctx, session, opts, "delete");
1475
1627
  if (!session) throw new APIError("UNAUTHORIZED");
1476
- if (!ctx.headers) throw new APIError("BAD_REQUEST");
1477
- if (opts.clientPrivileges && !await opts.clientPrivileges({
1478
- headers: ctx.headers,
1479
- action: "delete",
1480
- session: session.session,
1481
- user: session.user
1482
- })) throw new APIError("UNAUTHORIZED");
1483
1628
  const clientId = ctx.body.client_id;
1484
1629
  if (opts.cachedTrustedClients?.has(clientId)) throw new APIError("INTERNAL_SERVER_ERROR", {
1485
1630
  error_description: "trusted clients must be updated manually",
@@ -1505,14 +1650,8 @@ async function deleteClientEndpoint(ctx, opts) {
1505
1650
  }
1506
1651
  async function updateClientEndpoint(ctx, opts) {
1507
1652
  const session = await getSessionFromCtx(ctx);
1653
+ await assertClientPrivileges(ctx, session, opts, "update");
1508
1654
  if (!session) throw new APIError("UNAUTHORIZED");
1509
- if (!ctx.headers) throw new APIError("BAD_REQUEST");
1510
- if (opts.clientPrivileges && !await opts.clientPrivileges({
1511
- headers: ctx.headers,
1512
- action: "update",
1513
- session: session.session,
1514
- user: session.user
1515
- })) throw new APIError("UNAUTHORIZED");
1516
1655
  const clientId = ctx.body.client_id;
1517
1656
  if (opts.cachedTrustedClients?.has(clientId)) throw new APIError("INTERNAL_SERVER_ERROR", {
1518
1657
  error_description: "trusted clients must be updated manually",
@@ -1543,6 +1682,7 @@ async function updateClientEndpoint(ctx, opts) {
1543
1682
  else {
1544
1683
  schemaUpdates.jwks = null;
1545
1684
  schemaUpdates.jwksUri = null;
1685
+ if (!schemaUpdates.clientSecret) schemaUpdates.clientSecret = await storeClientSecret(ctx, opts, opts.generateClientSecret?.() || generateRandomString(32, "a-z", "A-Z"));
1546
1686
  }
1547
1687
  const updatedClient = await ctx.context.adapter.update({
1548
1688
  model: "oauthClient",
@@ -1565,14 +1705,8 @@ async function updateClientEndpoint(ctx, opts) {
1565
1705
  }
1566
1706
  async function rotateClientSecretEndpoint(ctx, opts) {
1567
1707
  const session = await getSessionFromCtx(ctx);
1708
+ await assertClientPrivileges(ctx, session, opts, "rotate");
1568
1709
  if (!session) throw new APIError("UNAUTHORIZED");
1569
- if (!ctx.headers) throw new APIError("BAD_REQUEST");
1570
- if (opts.clientPrivileges && !await opts.clientPrivileges({
1571
- headers: ctx.headers,
1572
- action: "rotate",
1573
- session: session.session,
1574
- user: session.user
1575
- })) throw new APIError("UNAUTHORIZED");
1576
1710
  const clientId = ctx.body.client_id;
1577
1711
  if (opts.cachedTrustedClients?.has(clientId)) throw new APIError("INTERNAL_SERVER_ERROR", {
1578
1712
  error_description: "trusted clients must be updated manually",
@@ -1614,6 +1748,16 @@ async function rotateClientSecretEndpoint(ctx, opts) {
1614
1748
  clientSecret: (opts.prefix?.clientSecret ?? "") + clientSecret
1615
1749
  });
1616
1750
  }
1751
+ async function assertClientPrivileges(ctx, session, opts, action) {
1752
+ if (!session) throw new APIError("UNAUTHORIZED");
1753
+ if (!ctx.headers) throw new APIError("BAD_REQUEST");
1754
+ if (opts.clientPrivileges && !await opts.clientPrivileges({
1755
+ headers: ctx.headers,
1756
+ action,
1757
+ session: session.session,
1758
+ user: session.user
1759
+ })) throw new APIError("UNAUTHORIZED");
1760
+ }
1617
1761
  //#endregion
1618
1762
  //#region src/oauthClient/index.ts
1619
1763
  const adminCreateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/create-client", {
@@ -1799,6 +1943,7 @@ const adminCreateOAuthClient = (opts) => createAuthEndpoint("/admin/oauth2/creat
1799
1943
  }
1800
1944
  }
1801
1945
  }, async (ctx) => {
1946
+ await assertClientPrivileges(ctx, await getSessionFromCtx(ctx), opts, "create");
1802
1947
  return createOAuthClientEndpoint(ctx, opts, { isRegister: false });
1803
1948
  });
1804
1949
  const createOAuthClient = (opts) => createAuthEndpoint("/oauth2/create-client", {
@@ -1971,6 +2116,7 @@ const createOAuthClient = (opts) => createAuthEndpoint("/oauth2/create-client",
1971
2116
  } }
1972
2117
  } }
1973
2118
  }, async (ctx) => {
2119
+ await assertClientPrivileges(ctx, await getSessionFromCtx(ctx), opts, "create");
1974
2120
  return createOAuthClientEndpoint(ctx, opts, { isRegister: false });
1975
2121
  });
1976
2122
  const getOAuthClient = (opts) => createAuthEndpoint("/oauth2/get-client", {
@@ -2383,6 +2529,7 @@ async function revokeAccessToken(ctx, opts, clientId, token) {
2383
2529
  }
2384
2530
  async function revokeEndpoint(ctx, opts) {
2385
2531
  let { token, token_type_hint } = ctx.body;
2532
+ if (token_type_hint !== "access_token" && token_type_hint !== "refresh_token") token_type_hint = void 0;
2386
2533
  const { clientId: client_id, clientSecret: client_secret, preVerifiedClient } = destructureCredentials(await extractClientCredentials(ctx, opts, `${ctx.context.baseURL}/oauth2/revoke`));
2387
2534
  if (!client_id) throw new APIError$1("UNAUTHORIZED", {
2388
2535
  error_description: "missing required credentials",
@@ -2800,7 +2947,7 @@ const oauthProvider = (options) => {
2800
2947
  queryParams.delete("sig");
2801
2948
  queryParams.delete("exp");
2802
2949
  await oAuthState.set({ query: queryParams.toString() });
2803
- if (ctx.path === "/sign-in/social" || ctx.path === "/sign-in/oauth2") {
2950
+ if (ctx.path === "/sign-in/social") {
2804
2951
  if (ctx.body.additionalData?.query) return;
2805
2952
  if (!ctx.body.additionalData) ctx.body.additionalData = {};
2806
2953
  ctx.body.additionalData.query = queryParams.toString();
@@ -2834,12 +2981,15 @@ const oauthProvider = (options) => {
2834
2981
  metadata: { SERVER_ONLY: true }
2835
2982
  }, async (ctx) => {
2836
2983
  if (opts.scopes && opts.scopes.includes("openid")) return oidcServerMetadata(ctx, opts);
2837
- else return authServerMetadata(ctx, opts.disableJwtPlugin ? void 0 : getJwtPlugin(ctx.context)?.options, {
2838
- scopes_supported: opts.advertisedMetadata?.scopes_supported ?? opts.scopes,
2839
- public_client_supported: opts.allowUnauthenticatedClientRegistration,
2840
- grant_types_supported: opts.grantTypes,
2841
- jwt_disabled: opts.disableJwtPlugin
2842
- });
2984
+ else return {
2985
+ ...authServerMetadata(ctx, opts.disableJwtPlugin ? void 0 : getJwtPlugin(ctx.context)?.options, {
2986
+ scopes_supported: opts.advertisedMetadata?.scopes_supported ?? opts.scopes,
2987
+ public_client_supported: opts.allowUnauthenticatedClientRegistration || toClientDiscoveryArray(opts.clientDiscovery).length > 0,
2988
+ grant_types_supported: opts.grantTypes,
2989
+ jwt_disabled: opts.disableJwtPlugin
2990
+ }),
2991
+ ...mergeDiscoveryMetadata(opts.clientDiscovery)
2992
+ };
2843
2993
  }),
2844
2994
  getOpenIdConfig: createAuthEndpoint("/.well-known/openid-configuration", {
2845
2995
  method: "GET",
@@ -2848,19 +2998,19 @@ const oauthProvider = (options) => {
2848
2998
  if (opts.scopes && !opts.scopes.includes("openid")) throw new APIError("NOT_FOUND");
2849
2999
  return oidcServerMetadata(ctx, opts);
2850
3000
  }),
2851
- oauth2Authorize: createAuthEndpoint("/oauth2/authorize", {
3001
+ oauth2Authorize: createOAuthEndpoint("/oauth2/authorize", {
2852
3002
  method: "GET",
2853
3003
  query: z.object({
2854
- response_type: z.enum(["code"]).optional(),
3004
+ response_type: z.string().pipe(z.enum(["code"])).optional(),
2855
3005
  client_id: z.string(),
2856
3006
  redirect_uri: SafeUrlSchema.optional(),
2857
3007
  scope: z.string().optional(),
2858
3008
  state: z.string().optional(),
2859
3009
  request_uri: z.string().optional(),
2860
3010
  code_challenge: z.string().optional(),
2861
- code_challenge_method: z.enum(["S256"]).optional(),
3011
+ code_challenge_method: z.string().pipe(z.enum(["S256"])).optional(),
2862
3012
  nonce: z.string().optional(),
2863
- prompt: z.enum([
3013
+ prompt: z.string().pipe(z.enum([
2864
3014
  "none",
2865
3015
  "consent",
2866
3016
  "login",
@@ -2868,8 +3018,10 @@ const oauthProvider = (options) => {
2868
3018
  "select_account",
2869
3019
  "login consent",
2870
3020
  "select_account consent"
2871
- ]).optional()
3021
+ ])).optional()
2872
3022
  }),
3023
+ redirectOnError: authorizeRedirectOnError(opts),
3024
+ errorCodesByField: { response_type: { invalid: "unsupported_response_type" } },
2873
3025
  metadata: { openapi: {
2874
3026
  description: "Authorize an OAuth2 request",
2875
3027
  parameters: [
@@ -3028,14 +3180,14 @@ const oauthProvider = (options) => {
3028
3180
  }, async (ctx) => {
3029
3181
  return continueEndpoint(ctx, opts);
3030
3182
  }),
3031
- oauth2Token: createAuthEndpoint("/oauth2/token", {
3183
+ oauth2Token: createOAuthEndpoint("/oauth2/token", {
3032
3184
  method: "POST",
3033
3185
  body: z.object({
3034
- grant_type: z.enum([
3186
+ grant_type: z.string().pipe(z.enum([
3035
3187
  "authorization_code",
3036
3188
  "client_credentials",
3037
3189
  "refresh_token"
3038
- ]),
3190
+ ])),
3039
3191
  client_id: z.string().optional(),
3040
3192
  client_secret: z.string().optional(),
3041
3193
  client_assertion: z.string().optional(),
@@ -3047,6 +3199,10 @@ const oauthProvider = (options) => {
3047
3199
  resource: z.string().optional(),
3048
3200
  scope: z.string().optional()
3049
3201
  }),
3202
+ errorCodesByField: { grant_type: {
3203
+ missing: "invalid_request",
3204
+ invalid: "unsupported_grant_type"
3205
+ } },
3050
3206
  metadata: {
3051
3207
  allowedMediaTypes: ["application/x-www-form-urlencoded"],
3052
3208
  openapi: {
@@ -3159,7 +3315,7 @@ const oauthProvider = (options) => {
3159
3315
  }, async (ctx) => {
3160
3316
  return tokenEndpoint(ctx, opts);
3161
3317
  }),
3162
- oauth2Introspect: createAuthEndpoint("/oauth2/introspect", {
3318
+ oauth2Introspect: createOAuthEndpoint("/oauth2/introspect", {
3163
3319
  method: "POST",
3164
3320
  body: z.object({
3165
3321
  client_id: z.string().optional(),
@@ -3167,7 +3323,7 @@ const oauthProvider = (options) => {
3167
3323
  client_assertion: z.string().optional(),
3168
3324
  client_assertion_type: z.string().optional(),
3169
3325
  token: z.string(),
3170
- token_type_hint: z.enum(["access_token", "refresh_token"]).optional()
3326
+ token_type_hint: z.string().optional()
3171
3327
  }),
3172
3328
  metadata: {
3173
3329
  allowedMediaTypes: ["application/x-www-form-urlencoded"],
@@ -3192,8 +3348,7 @@ const oauthProvider = (options) => {
3192
3348
  },
3193
3349
  token_type_hint: {
3194
3350
  type: "string",
3195
- enum: ["access_token", "refresh_token"],
3196
- description: "Hint about the type of the token submitted for introspection"
3351
+ description: "Hint about the token type. Recognized values: `access_token`, `refresh_token`."
3197
3352
  },
3198
3353
  resource: {
3199
3354
  type: "string",
@@ -3279,7 +3434,7 @@ const oauthProvider = (options) => {
3279
3434
  }, async (ctx) => {
3280
3435
  return introspectEndpoint(ctx, opts);
3281
3436
  }),
3282
- oauth2Revoke: createAuthEndpoint("/oauth2/revoke", {
3437
+ oauth2Revoke: createOAuthEndpoint("/oauth2/revoke", {
3283
3438
  method: "POST",
3284
3439
  body: z.object({
3285
3440
  client_id: z.string().optional(),
@@ -3287,7 +3442,7 @@ const oauthProvider = (options) => {
3287
3442
  client_assertion: z.string().optional(),
3288
3443
  client_assertion_type: z.string().optional(),
3289
3444
  token: z.string(),
3290
- token_type_hint: z.enum(["access_token", "refresh_token"]).optional()
3445
+ token_type_hint: z.string().optional()
3291
3446
  }),
3292
3447
  metadata: {
3293
3448
  allowedMediaTypes: ["application/x-www-form-urlencoded"],
@@ -3312,8 +3467,7 @@ const oauthProvider = (options) => {
3312
3467
  },
3313
3468
  token_type_hint: {
3314
3469
  type: "string",
3315
- enum: ["access_token", "refresh_token"],
3316
- description: "Hint about the type of the token submitted for revocation"
3470
+ description: "Hint about the token type. Recognized values: `access_token`, `refresh_token`."
3317
3471
  }
3318
3472
  },
3319
3473
  required: ["token"]
@@ -3434,7 +3588,7 @@ const oauthProvider = (options) => {
3434
3588
  }, async (ctx) => {
3435
3589
  return userInfoEndpoint(ctx, opts);
3436
3590
  }),
3437
- oauth2EndSession: createAuthEndpoint("/oauth2/end-session", {
3591
+ oauth2EndSession: createOAuthEndpoint("/oauth2/end-session", {
3438
3592
  method: "GET",
3439
3593
  query: z.object({
3440
3594
  id_token_hint: z.string(),
@@ -3465,7 +3619,7 @@ const oauthProvider = (options) => {
3465
3619
  }, async (ctx) => {
3466
3620
  return rpInitiatedLogoutEndpoint(ctx, opts);
3467
3621
  }),
3468
- registerOAuthClient: createAuthEndpoint("/oauth2/register", {
3622
+ registerOAuthClient: createOAuthEndpoint("/oauth2/register", {
3469
3623
  method: "POST",
3470
3624
  body: z.object({
3471
3625
  redirect_uris: z.array(SafeUrlSchema).min(1).min(1),
@@ -3502,6 +3656,12 @@ const oauthProvider = (options) => {
3502
3656
  subject_type: z.enum(["public", "pairwise"]).optional(),
3503
3657
  skip_consent: z.never({ error: "skip_consent cannot be set during dynamic client registration" }).optional()
3504
3658
  }),
3659
+ errorCodesByField: {
3660
+ redirect_uris: "invalid_redirect_uri",
3661
+ post_logout_redirect_uris: "invalid_redirect_uri",
3662
+ software_statement: "invalid_software_statement"
3663
+ },
3664
+ defaultError: "invalid_client_metadata",
3505
3665
  metadata: { openapi: {
3506
3666
  description: "Register an OAuth2 application",
3507
3667
  responses: { "200": {
@@ -3694,17 +3854,37 @@ const oauthProvider = (options) => {
3694
3854
  //#endregion
3695
3855
  //#region src/authorize.ts
3696
3856
  /**
3697
- * Formats an error url
3857
+ * Formats an error url. Per OIDC Core 1.0 §5 / RFC 6749 §4.2.2.1, errors on
3858
+ * implicit and hybrid flows are delivered in the URL fragment, not the query.
3859
+ * Callers on the code flow (default) omit `mode` and get query delivery.
3698
3860
  */
3699
- function formatErrorURL(url, error, description, state, iss) {
3861
+ function formatErrorURL(url, error, description, state, iss, mode = "query") {
3700
3862
  const searchParams = new URLSearchParams({
3701
3863
  error,
3702
3864
  error_description: description
3703
3865
  });
3704
3866
  state && searchParams.append("state", state);
3705
3867
  iss && searchParams.append("iss", iss);
3868
+ if (mode === "fragment") return `${url}#${searchParams.toString()}`;
3706
3869
  return `${url}${url.includes("?") ? "&" : "?"}${searchParams.toString()}`;
3707
3870
  }
3871
+ /**
3872
+ * Selects the response mode for an error redirect to the RP. OIDC Core 1.0 §5
3873
+ * defines defaults based on response_type: `code` → query, types containing
3874
+ * `token` / `id_token` → fragment. An explicit `response_mode` overrides.
3875
+ *
3876
+ * When `response_type` is duplicated (array) or absent, we can't trust the
3877
+ * caller's intent, so we default to query — the safer channel for
3878
+ * unrecognized shapes.
3879
+ */
3880
+ function deriveResponseMode(raw) {
3881
+ const responseMode = typeof raw.response_mode === "string" ? raw.response_mode : void 0;
3882
+ if (responseMode === "fragment") return "fragment";
3883
+ if (responseMode === "query") return "query";
3884
+ const responseType = typeof raw.response_type === "string" ? raw.response_type : void 0;
3885
+ if (responseType && /\b(token|id_token)\b/.test(responseType)) return "fragment";
3886
+ return "query";
3887
+ }
3708
3888
  const handleRedirect = (ctx, uri) => {
3709
3889
  const fromFetch = isBrowserFetchRequest(ctx.request?.headers);
3710
3890
  const acceptJson = ctx.headers?.get("accept")?.includes("application/json");
@@ -3728,8 +3908,7 @@ function redirectWithPromptNoneError(ctx, opts, query, error, description) {
3728
3908
  function validateIssuerUrl(issuer) {
3729
3909
  try {
3730
3910
  const url = new URL(issuer);
3731
- const isLocalhost = url.hostname === "localhost" || url.hostname === "127.0.0.1";
3732
- if (url.protocol !== "https:" && !isLocalhost) url.protocol = "https:";
3911
+ if (url.protocol !== "https:" && !isLoopbackHost(url.host)) url.protocol = "https:";
3733
3912
  url.search = "";
3734
3913
  url.hash = "";
3735
3914
  return url.toString().replace(/\/$/, "");
@@ -3757,6 +3936,64 @@ function getIssuer(ctx, opts) {
3757
3936
  function getErrorURL(ctx, error, description) {
3758
3937
  return formatErrorURL(ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`, error, description);
3759
3938
  }
3939
+ /**
3940
+ * Finds the matching entry in a client's registered redirect_uris for a
3941
+ * requested redirect_uri. Honors RFC 8252 §7.3 loopback port variance for
3942
+ * the full 127.0.0.0/8 range and [::1], matching on scheme+host+path+query
3943
+ * and ignoring port. DNS names like "localhost" are excluded per §8.3.
3944
+ */
3945
+ function findRegisteredRedirectUri(registered, requested) {
3946
+ if (!registered || !requested) return void 0;
3947
+ let req;
3948
+ try {
3949
+ req = new URL(requested);
3950
+ } catch {}
3951
+ return registered.find((url) => {
3952
+ if (url === requested) return true;
3953
+ if (!req) return false;
3954
+ try {
3955
+ const reg = new URL(url);
3956
+ return isLoopbackIP(reg.hostname) && reg.hostname === req.hostname && reg.pathname === req.pathname && reg.protocol === req.protocol && reg.search === req.search;
3957
+ } catch {
3958
+ return false;
3959
+ }
3960
+ });
3961
+ }
3962
+ /**
3963
+ * Loads the client, verifies it's enabled, and returns the requested
3964
+ * redirect_uri when it matches a registered entry. Returns null whenever the
3965
+ * RP cannot be safely reached, so callers can fall back to the server error
3966
+ * page (avoiding open-redirect risk on validation failures).
3967
+ */
3968
+ async function resolveTrustedRedirectUri(ctx, opts, clientId, redirectUri) {
3969
+ if (!clientId || !redirectUri) return null;
3970
+ let client;
3971
+ try {
3972
+ client = await getClient(ctx, opts, clientId);
3973
+ } catch {
3974
+ return null;
3975
+ }
3976
+ if (!client || client.disabled) return null;
3977
+ return findRegisteredRedirectUri(client.redirectUris, redirectUri) ? redirectUri : null;
3978
+ }
3979
+ /**
3980
+ * `redirectOnError` callback for `/oauth2/authorize`. Per RFC 6749 §4.1.2.1,
3981
+ * authorize errors MUST be delivered to the client's `redirect_uri` with
3982
+ * `error`, `error_description`, `state`, and (RFC 9207) `iss`. The clause
3983
+ * carves out one case: a missing/invalid `redirect_uri` or `client_id` MUST
3984
+ * NOT redirect to the requested URI. We implement the carve-out via
3985
+ * `resolveTrustedRedirectUri`, falling back to the server error page.
3986
+ *
3987
+ * Channel (query vs fragment) follows OIDC Core §5 via `deriveResponseMode`.
3988
+ */
3989
+ function authorizeRedirectOnError(opts) {
3990
+ return async ({ error, error_description, ctx }) => {
3991
+ const raw = ctx.query ?? {};
3992
+ 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);
3993
+ if (trusted) return handleRedirect(ctx, formatErrorURL(trusted, error, error_description, typeof raw.state === "string" ? raw.state : void 0, getIssuer(ctx, opts), deriveResponseMode(raw)));
3994
+ return handleRedirect(ctx, getErrorURL(ctx, error, error_description));
3995
+ };
3996
+ }
3760
3997
  async function authorizeEndpoint(ctx, opts, settings) {
3761
3998
  if (opts.grantTypes && !opts.grantTypes.includes("authorization_code")) throw new APIError$1("NOT_FOUND");
3762
3999
  if (!ctx.request) throw new APIError$1("UNAUTHORIZED", {
@@ -3787,15 +4024,7 @@ async function authorizeEndpoint(ctx, opts, settings) {
3787
4024
  const client = await getClient(ctx, opts, query.client_id);
3788
4025
  if (!client) return handleRedirect(ctx, getErrorURL(ctx, "invalid_client", "client_id is required"));
3789
4026
  if (client.disabled) return handleRedirect(ctx, getErrorURL(ctx, "client_disabled", "client is disabled"));
3790
- if (!client.redirectUris?.find((url) => {
3791
- if (url === query.redirect_uri) return true;
3792
- try {
3793
- const registered = new URL(url);
3794
- const requested = new URL(query.redirect_uri);
3795
- 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;
3796
- } catch {}
3797
- return false;
3798
- }) || !query.redirect_uri) return handleRedirect(ctx, getErrorURL(ctx, "invalid_redirect", "invalid redirect uri"));
4027
+ if (!findRegisteredRedirectUri(client.redirectUris, query.redirect_uri) || !query.redirect_uri) return handleRedirect(ctx, getErrorURL(ctx, "invalid_redirect", "invalid redirect uri"));
3799
4028
  let requestedScopes = query.scope?.split(" ").filter((s) => s);
3800
4029
  if (requestedScopes) {
3801
4030
  const validScopes = new Set(client.scopes ?? opts.scopes);
@@ -4004,7 +4233,7 @@ function oidcServerMetadata(ctx, opts) {
4004
4233
  return {
4005
4234
  ...authServerMetadata(ctx, jwtPluginOptions, {
4006
4235
  scopes_supported: opts.advertisedMetadata?.scopes_supported ?? opts.scopes,
4007
- public_client_supported: opts.allowUnauthenticatedClientRegistration,
4236
+ public_client_supported: opts.allowUnauthenticatedClientRegistration || toClientDiscoveryArray(opts.clientDiscovery).length > 0,
4008
4237
  grant_types_supported: opts.grantTypes,
4009
4238
  jwt_disabled: opts.disableJwtPlugin
4010
4239
  }),
@@ -4020,9 +4249,20 @@ function oidcServerMetadata(ctx, opts) {
4020
4249
  "create",
4021
4250
  "select_account",
4022
4251
  "none"
4023
- ]
4252
+ ],
4253
+ ...mergeDiscoveryMetadata(opts.clientDiscovery)
4024
4254
  };
4025
4255
  }
4256
+ const METADATA_CACHE_CONTROL = "public, max-age=15, stale-while-revalidate=15, stale-if-error=86400";
4257
+ function metadataResponse(body, extraHeaders) {
4258
+ const headers = new Headers(extraHeaders);
4259
+ if (!headers.has("Cache-Control")) headers.set("Cache-Control", METADATA_CACHE_CONTROL);
4260
+ headers.set("Content-Type", "application/json");
4261
+ return new Response(JSON.stringify(body), {
4262
+ status: 200,
4263
+ headers
4264
+ });
4265
+ }
4026
4266
  /**
4027
4267
  * Provides an exportable `/.well-known/oauth-authorization-server`.
4028
4268
  *
@@ -4032,16 +4272,11 @@ function oidcServerMetadata(ctx, opts) {
4032
4272
  * @external
4033
4273
  */
4034
4274
  const oauthProviderAuthServerMetadata = (auth, opts) => {
4035
- return async (_request) => {
4036
- const res = await auth.api.getOAuthServerConfig();
4037
- return new Response(JSON.stringify(res), {
4038
- status: 200,
4039
- headers: {
4040
- "Cache-Control": "public, max-age=15, stale-while-revalidate=15, stale-if-error=86400",
4041
- ...opts?.headers,
4042
- "Content-Type": "application/json"
4043
- }
4044
- });
4275
+ return async (request) => {
4276
+ return metadataResponse(await auth.api.getOAuthServerConfig({
4277
+ request,
4278
+ asResponse: false
4279
+ }), opts?.headers);
4045
4280
  };
4046
4281
  };
4047
4282
  /**
@@ -4053,17 +4288,12 @@ const oauthProviderAuthServerMetadata = (auth, opts) => {
4053
4288
  * @external
4054
4289
  */
4055
4290
  const oauthProviderOpenIdConfigMetadata = (auth, opts) => {
4056
- return async (_request) => {
4057
- const res = await auth.api.getOpenIdConfig();
4058
- return new Response(JSON.stringify(res), {
4059
- status: 200,
4060
- headers: {
4061
- "Cache-Control": "public, max-age=15, stale-while-revalidate=15, stale-if-error=86400",
4062
- ...opts?.headers,
4063
- "Content-Type": "application/json"
4064
- }
4065
- });
4291
+ return async (request) => {
4292
+ return metadataResponse(await auth.api.getOpenIdConfig({
4293
+ request,
4294
+ asResponse: false
4295
+ }), opts?.headers);
4066
4296
  };
4067
4297
  };
4068
4298
  //#endregion
4069
- export { authServerMetadata, getOAuthProviderState, mcpHandler, oauthProvider, oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata, oidcServerMetadata };
4299
+ export { authServerMetadata, checkOAuthClient, getOAuthProviderState, mcpHandler, oauthProvider, oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata, oauthToSchema, oidcServerMetadata };