better-auth 1.6.9 → 1.6.11

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.
Files changed (71) hide show
  1. package/dist/api/index.d.mts +0 -2
  2. package/dist/api/routes/callback.mjs +6 -5
  3. package/dist/api/routes/email-verification.mjs +2 -2
  4. package/dist/api/routes/error.mjs +1 -1
  5. package/dist/api/routes/sign-in.d.mts +0 -1
  6. package/dist/api/routes/sign-in.mjs +4 -11
  7. package/dist/api/routes/sign-up.mjs +1 -1
  8. package/dist/api/routes/update-user.mjs +1 -1
  9. package/dist/api/to-auth-endpoints.mjs +7 -1
  10. package/dist/client/index.d.mts +2 -2
  11. package/dist/client/plugins/index.d.mts +2 -1
  12. package/dist/cookies/cookie-utils.d.mts +10 -1
  13. package/dist/cookies/cookie-utils.mjs +19 -1
  14. package/dist/cookies/index.d.mts +2 -2
  15. package/dist/cookies/index.mjs +2 -2
  16. package/dist/db/internal-adapter.mjs +103 -7
  17. package/dist/db/with-hooks.d.mts +1 -0
  18. package/dist/db/with-hooks.mjs +58 -1
  19. package/dist/integrations/cookie-plugin-guard.mjs +18 -0
  20. package/dist/integrations/next-js.mjs +6 -0
  21. package/dist/integrations/svelte-kit.mjs +6 -0
  22. package/dist/integrations/tanstack-start-solid.mjs +6 -0
  23. package/dist/integrations/tanstack-start.mjs +6 -0
  24. package/dist/oauth2/link-account.mjs +3 -1
  25. package/dist/package.mjs +1 -1
  26. package/dist/plugins/access/access.d.mts +3 -15
  27. package/dist/plugins/access/index.d.mts +2 -2
  28. package/dist/plugins/access/types.d.mts +11 -4
  29. package/dist/plugins/admin/access/statement.d.mts +29 -93
  30. package/dist/plugins/admin/client.d.mts +5 -0
  31. package/dist/plugins/admin/client.mjs +5 -0
  32. package/dist/plugins/admin/routes.mjs +1 -0
  33. package/dist/plugins/anonymous/client.d.mts +1 -0
  34. package/dist/plugins/anonymous/error-codes.d.mts +1 -0
  35. package/dist/plugins/anonymous/error-codes.mjs +1 -0
  36. package/dist/plugins/anonymous/index.d.mts +1 -0
  37. package/dist/plugins/anonymous/index.mjs +16 -2
  38. package/dist/plugins/bearer/index.mjs +2 -4
  39. package/dist/plugins/captcha/index.mjs +14 -1
  40. package/dist/plugins/device-authorization/error-codes.mjs +1 -0
  41. package/dist/plugins/device-authorization/index.d.mts +1 -0
  42. package/dist/plugins/device-authorization/routes.mjs +34 -3
  43. package/dist/plugins/email-otp/routes.mjs +3 -3
  44. package/dist/plugins/generic-oauth/routes.mjs +3 -3
  45. package/dist/plugins/index.d.mts +2 -2
  46. package/dist/plugins/magic-link/index.d.mts +8 -1
  47. package/dist/plugins/magic-link/index.mjs +5 -17
  48. package/dist/plugins/mcp/authorize.mjs +8 -2
  49. package/dist/plugins/mcp/index.mjs +73 -30
  50. package/dist/plugins/oidc-provider/authorize.mjs +8 -2
  51. package/dist/plugins/oidc-provider/index.mjs +63 -33
  52. package/dist/plugins/one-tap/index.mjs +16 -10
  53. package/dist/plugins/organization/access/statement.d.mts +68 -201
  54. package/dist/plugins/organization/client.d.mts +1 -0
  55. package/dist/plugins/organization/client.mjs +1 -1
  56. package/dist/plugins/organization/error-codes.d.mts +1 -0
  57. package/dist/plugins/organization/error-codes.mjs +1 -0
  58. package/dist/plugins/organization/routes/crud-access-control.d.mts +2 -2
  59. package/dist/plugins/organization/routes/crud-invites.d.mts +8 -1
  60. package/dist/plugins/organization/routes/crud-invites.mjs +5 -3
  61. package/dist/plugins/organization/routes/crud-team.mjs +7 -2
  62. package/dist/plugins/organization/types.d.mts +12 -2
  63. package/dist/plugins/siwe/client.d.mts +4 -0
  64. package/dist/plugins/siwe/client.mjs +5 -1
  65. package/dist/plugins/siwe/index.d.mts +13 -2
  66. package/dist/plugins/siwe/index.mjs +179 -165
  67. package/dist/plugins/username/index.d.mts +11 -0
  68. package/dist/plugins/username/index.mjs +18 -2
  69. package/dist/test-utils/test-instance.d.mts +1 -6
  70. package/dist/test-utils/test-instance.mjs +11 -2
  71. package/package.json +10 -10
@@ -27,6 +27,7 @@ const magicLink = (options) => {
27
27
  allowedAttempts: 1,
28
28
  ...options
29
29
  };
30
+ if (options.allowedAttempts !== void 0 && options.allowedAttempts !== 1) console.warn("[better-auth/magic-link] `allowedAttempts` is ignored: tokens are consumed atomically on the first verification call (GHSA-hc7v-rggr-4hvx). Any value other than `1` has no effect; remove the option to silence this warning.");
30
31
  async function storeToken(ctx, token) {
31
32
  if (opts.storeToken === "hashed") return await defaultKeyHasher(token);
32
33
  if (typeof opts.storeToken === "object" && "type" in opts.storeToken && opts.storeToken.type === "custom-hasher") return await opts.storeToken.hash(token);
@@ -59,8 +60,7 @@ const magicLink = (options) => {
59
60
  identifier: storedToken,
60
61
  value: JSON.stringify({
61
62
  email,
62
- name: ctx.body.name,
63
- attempt: 0
63
+ name: ctx.body.name
64
64
  }),
65
65
  expiresAt: new Date(Date.now() + (opts.expiresIn || 300) * 1e3)
66
66
  });
@@ -119,22 +119,10 @@ const magicLink = (options) => {
119
119
  }
120
120
  const newUserCallbackURL = new URL(ctx.query.newUserCallbackURL ? decodeURIComponent(ctx.query.newUserCallbackURL) : callbackURL, ctx.context.baseURL).toString();
121
121
  const storedToken = await storeToken(ctx, token);
122
- const tokenValue = await ctx.context.internalAdapter.findVerificationValue(storedToken);
122
+ const tokenValue = await ctx.context.internalAdapter.consumeVerificationValue(storedToken);
123
123
  if (!tokenValue) redirectWithError("INVALID_TOKEN");
124
- if (tokenValue.expiresAt < /* @__PURE__ */ new Date()) {
125
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(storedToken);
126
- redirectWithError("EXPIRED_TOKEN");
127
- }
128
- const { email, name, attempt = 0 } = JSON.parse(tokenValue.value);
129
- if (attempt >= opts.allowedAttempts) {
130
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(storedToken);
131
- redirectWithError("ATTEMPTS_EXCEEDED");
132
- }
133
- await ctx.context.internalAdapter.updateVerificationByIdentifier(storedToken, { value: JSON.stringify({
134
- email,
135
- name,
136
- attempt: attempt + 1
137
- }) });
124
+ if (tokenValue.expiresAt < /* @__PURE__ */ new Date()) redirectWithError("EXPIRED_TOKEN");
125
+ const { email, name } = JSON.parse(tokenValue.value);
138
126
  let isNewUser = false;
139
127
  let user = await ctx.context.internalAdapter.findUserByEmail(email).then((res) => res?.user);
140
128
  if (!user) if (!opts.disableSignUp) {
@@ -72,8 +72,14 @@ async function authorizeMCPOAuth(ctx, options) {
72
72
  });
73
73
  if (invalidScopes.length) throw ctx.redirect(redirectErrorURL(query.redirect_uri, "invalid_scope", `The following scopes are invalid: ${invalidScopes.join(", ")}`));
74
74
  if ((!query.code_challenge || !query.code_challenge_method) && options.requirePKCE) throw ctx.redirect(redirectErrorURL(query.redirect_uri, "invalid_request", "pkce is required"));
75
- if (!query.code_challenge_method) query.code_challenge_method = "plain";
76
- if (!["s256", options.allowPlainCodeChallengeMethod ? "plain" : "s256"].includes(query.code_challenge_method?.toLowerCase() || "")) throw ctx.redirect(redirectErrorURL(query.redirect_uri, "invalid_request", "invalid code_challenge method"));
75
+ if (query.code_challenge_method && !query.code_challenge) throw ctx.redirect(redirectErrorURL(query.redirect_uri, "invalid_request", "code_challenge_method requires code_challenge"));
76
+ if (query.code_challenge) {
77
+ const allowedCodeChallengeMethods = options.allowPlainCodeChallengeMethod ? ["s256", "plain"] : ["s256"];
78
+ let codeChallengeMethod = query.code_challenge_method?.toLowerCase();
79
+ if (!codeChallengeMethod && options.allowPlainCodeChallengeMethod) codeChallengeMethod = "plain";
80
+ if (!codeChallengeMethod || !allowedCodeChallengeMethods.includes(codeChallengeMethod)) throw ctx.redirect(redirectErrorURL(query.redirect_uri, "invalid_request", "invalid code_challenge method"));
81
+ query.code_challenge_method = codeChallengeMethod;
82
+ }
77
83
  const code = generateRandomString(32, "a-z", "A-Z", "0-9");
78
84
  const codeExpiresInMs = opts.codeExpiresIn * 1e3;
79
85
  const expiresAt = new Date(Date.now() + codeExpiresInMs);
@@ -1,5 +1,6 @@
1
1
  import { getBaseURL, isDynamicBaseURLConfig, resolveBaseURL } from "../../utils/url.mjs";
2
2
  import { parseSetCookieHeader } from "../../cookies/cookie-utils.mjs";
3
+ import { constantTimeEqual } from "../../crypto/buffer.mjs";
3
4
  import { generateRandomString } from "../../crypto/random.mjs";
4
5
  import { expireCookie } from "../../cookies/index.mjs";
5
6
  import { resolveDynamicTrustedProxyHeaders } from "../../context/helpers.mjs";
@@ -45,7 +46,7 @@ const getMCPProviderMetadata = (ctx, options) => {
45
46
  grant_types_supported: ["authorization_code", "refresh_token"],
46
47
  acr_values_supported: ["urn:mace:incommon:iap:silver", "urn:mace:incommon:iap:bronze"],
47
48
  subject_types_supported: ["public"],
48
- id_token_signing_alg_values_supported: ["RS256", "none"],
49
+ id_token_signing_alg_values_supported: ["RS256"],
49
50
  token_endpoint_auth_methods_supported: [
50
51
  "client_secret_basic",
51
52
  "client_secret_post",
@@ -81,7 +82,7 @@ const getMCPProtectedResourceMetadata = (ctx, options) => {
81
82
  "offline_access"
82
83
  ],
83
84
  bearer_methods_supported: ["header"],
84
- resource_signing_alg_values_supported: ["RS256", "none"]
85
+ resource_signing_alg_values_supported: ["RS256"]
85
86
  };
86
87
  };
87
88
  const registerMcpClientBodySchema = z.object({
@@ -122,7 +123,7 @@ const mcp = (options) => {
122
123
  defaultScope: "openid",
123
124
  accessTokenExpiresIn: 3600,
124
125
  refreshTokenExpiresIn: 604800,
125
- allowPlainCodeChallengeMethod: true,
126
+ allowPlainCodeChallengeMethod: false,
126
127
  ...options.oidcConfig,
127
128
  loginPage: options.loginPage,
128
129
  scopes: [
@@ -225,10 +226,6 @@ const mcp = (options) => {
225
226
  allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"]
226
227
  }
227
228
  }, async (ctx) => {
228
- ctx.setHeader("Access-Control-Allow-Origin", "*");
229
- ctx.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
230
- ctx.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
231
- ctx.setHeader("Access-Control-Max-Age", "86400");
232
229
  let { body } = ctx;
233
230
  if (!body) throw ctx.error("BAD_REQUEST", {
234
231
  error_description: "request body not found",
@@ -241,25 +238,43 @@ const mcp = (options) => {
241
238
  });
242
239
  let { client_id, client_secret } = body;
243
240
  const authorization = ctx.request?.headers.get("authorization") || null;
244
- if (authorization && !client_id && !client_secret && authorization.startsWith("Basic ")) try {
245
- const encoded = authorization.replace("Basic ", "");
246
- const decoded = new TextDecoder().decode(base64.decode(encoded));
247
- if (!decoded.includes(":")) throw new APIError("UNAUTHORIZED", {
241
+ if (authorization && !client_secret && authorization.startsWith("Basic ")) {
242
+ let decoded;
243
+ try {
244
+ const encoded = authorization.replace("Basic ", "");
245
+ decoded = new TextDecoder().decode(base64.decode(encoded));
246
+ } catch {
247
+ throw new APIError("UNAUTHORIZED", {
248
+ error_description: "invalid authorization header format",
249
+ error: "invalid_client"
250
+ });
251
+ }
252
+ const colonIndex = decoded.indexOf(":");
253
+ if (colonIndex === -1) throw new APIError("UNAUTHORIZED", {
248
254
  error_description: "invalid authorization header format",
249
255
  error: "invalid_client"
250
256
  });
251
- const [id, secret] = decoded.split(":");
257
+ let id;
258
+ let secret;
259
+ try {
260
+ id = decodeURIComponent(decoded.slice(0, colonIndex));
261
+ secret = decodeURIComponent(decoded.slice(colonIndex + 1));
262
+ } catch {
263
+ throw new APIError("UNAUTHORIZED", {
264
+ error_description: "invalid authorization header format",
265
+ error: "invalid_client"
266
+ });
267
+ }
252
268
  if (!id || !secret) throw new APIError("UNAUTHORIZED", {
253
269
  error_description: "invalid authorization header format",
254
270
  error: "invalid_client"
255
271
  });
256
- client_id = id;
257
- client_secret = secret;
258
- } catch {
259
- throw new APIError("UNAUTHORIZED", {
260
- error_description: "invalid authorization header format",
272
+ if (client_id && client_id.toString() !== id) throw new APIError("UNAUTHORIZED", {
273
+ error_description: "client_id in body does not match Authorization header",
261
274
  error: "invalid_client"
262
275
  });
276
+ client_id = id;
277
+ client_secret = secret;
263
278
  }
264
279
  const { grant_type, code, redirect_uri, refresh_token, code_verifier } = body;
265
280
  if (grant_type === "refresh_token") {
@@ -286,6 +301,38 @@ const mcp = (options) => {
286
301
  error_description: "refresh token expired",
287
302
  error: "invalid_grant"
288
303
  });
304
+ const refreshClient = await ctx.context.adapter.findOne({
305
+ model: modelName.oauthClient,
306
+ where: [{
307
+ field: "clientId",
308
+ value: client_id.toString()
309
+ }]
310
+ }).then((res) => {
311
+ if (!res) return null;
312
+ return {
313
+ ...res,
314
+ redirectUrls: res.redirectUrls.split(","),
315
+ metadata: res.metadata ? JSON.parse(res.metadata) : {}
316
+ };
317
+ });
318
+ if (!refreshClient) throw new APIError("UNAUTHORIZED", {
319
+ error_description: "invalid client_id",
320
+ error: "invalid_client"
321
+ });
322
+ if (refreshClient.disabled) throw new APIError("UNAUTHORIZED", {
323
+ error_description: "client is disabled",
324
+ error: "invalid_client"
325
+ });
326
+ if (refreshClient.type !== "public") {
327
+ if (!refreshClient.clientSecret || !client_secret) throw new APIError("UNAUTHORIZED", {
328
+ error_description: "client_secret is required for confidential clients",
329
+ error: "invalid_client"
330
+ });
331
+ if (!constantTimeEqual(refreshClient.clientSecret, client_secret.toString())) throw new APIError("UNAUTHORIZED", {
332
+ error_description: "invalid client_secret",
333
+ error: "invalid_client"
334
+ });
335
+ }
289
336
  const accessToken = generateRandomString(32, "a-z", "A-Z");
290
337
  const newRefreshToken = generateRandomString(32, "a-z", "A-Z");
291
338
  const accessTokenExpiresAt = new Date(Date.now() + opts.accessTokenExpiresIn * 1e3);
@@ -320,11 +367,7 @@ const mcp = (options) => {
320
367
  error_description: "code verifier is missing",
321
368
  error: "invalid_request"
322
369
  });
323
- /**
324
- * We need to check if the code is valid before we can proceed
325
- * with the rest of the request.
326
- */
327
- const verificationValue = await ctx.context.internalAdapter.findVerificationValue(code.toString());
370
+ const verificationValue = await ctx.context.internalAdapter.consumeVerificationValue(code.toString());
328
371
  if (!verificationValue) throw new APIError("UNAUTHORIZED", {
329
372
  error_description: "invalid code",
330
373
  error: "invalid_grant"
@@ -333,7 +376,6 @@ const mcp = (options) => {
333
376
  error_description: "code expired",
334
377
  error: "invalid_grant"
335
378
  });
336
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(code.toString());
337
379
  if (!client_id) throw new APIError("UNAUTHORIZED", {
338
380
  error_description: "client_id is required",
339
381
  error: "invalid_client"
@@ -378,11 +420,11 @@ const mcp = (options) => {
378
420
  error: "invalid_request"
379
421
  });
380
422
  } else {
381
- if (!client_secret) throw new APIError("UNAUTHORIZED", {
423
+ if (!client.clientSecret || !client_secret) throw new APIError("UNAUTHORIZED", {
382
424
  error_description: "client_secret is required for confidential clients",
383
425
  error: "invalid_client"
384
426
  });
385
- if (!(client.clientSecret === client_secret.toString())) throw new APIError("UNAUTHORIZED", {
427
+ if (!constantTimeEqual(client.clientSecret, client_secret.toString())) throw new APIError("UNAUTHORIZED", {
386
428
  error_description: "invalid client_secret",
387
429
  error: "invalid_client"
388
430
  });
@@ -400,12 +442,13 @@ const mcp = (options) => {
400
442
  error_description: "code verifier is missing",
401
443
  error: "invalid_request"
402
444
  });
403
- if ((value.codeChallengeMethod === "plain" ? code_verifier : await createHash("SHA-256", "base64urlnopad").digest(code_verifier)) !== value.codeChallenge) throw new APIError("UNAUTHORIZED", {
404
- error_description: "code verification failed",
405
- error: "invalid_request"
406
- });
445
+ if (value.codeChallenge) {
446
+ if ((value.codeChallengeMethod === "plain" ? code_verifier : await createHash("SHA-256", "base64urlnopad").digest(code_verifier)) !== value.codeChallenge) throw new APIError("UNAUTHORIZED", {
447
+ error_description: "code verification failed",
448
+ error: "invalid_request"
449
+ });
450
+ }
407
451
  const requestedScopes = value.scope;
408
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(code.toString());
409
452
  const accessToken = generateRandomString(32, "a-z", "A-Z");
410
453
  const refreshToken = generateRandomString(32, "A-Z", "a-z");
411
454
  const accessTokenExpiresAt = new Date(Date.now() + opts.accessTokenExpiresIn * 1e3);
@@ -93,8 +93,14 @@ async function authorize(ctx, options) {
93
93
  });
94
94
  if (invalidScopes.length) return handleRedirect(formatErrorURL(query.redirect_uri, "invalid_scope", `The following scopes are invalid: ${invalidScopes.join(", ")}`));
95
95
  if ((!query.code_challenge || !query.code_challenge_method) && options.requirePKCE) return handleRedirect(formatErrorURL(query.redirect_uri, "invalid_request", "pkce is required"));
96
- if (!query.code_challenge_method) query.code_challenge_method = "plain";
97
- if (!["s256", options.allowPlainCodeChallengeMethod ? "plain" : "s256"].includes(query.code_challenge_method?.toLowerCase() || "")) return handleRedirect(formatErrorURL(query.redirect_uri, "invalid_request", "invalid code_challenge method"));
96
+ if (query.code_challenge_method && !query.code_challenge) return handleRedirect(formatErrorURL(query.redirect_uri, "invalid_request", "code_challenge_method requires code_challenge"));
97
+ if (query.code_challenge) {
98
+ const allowedCodeChallengeMethods = options.allowPlainCodeChallengeMethod ? ["s256", "plain"] : ["s256"];
99
+ let codeChallengeMethod = query.code_challenge_method?.toLowerCase();
100
+ if (!codeChallengeMethod && options.allowPlainCodeChallengeMethod) codeChallengeMethod = "plain";
101
+ if (!codeChallengeMethod || !allowedCodeChallengeMethods.includes(codeChallengeMethod)) return handleRedirect(formatErrorURL(query.redirect_uri, "invalid_request", "invalid code_challenge method"));
102
+ query.code_challenge_method = codeChallengeMethod;
103
+ }
98
104
  const code = generateRandomString(32, "a-z", "A-Z", "0-9");
99
105
  const codeExpiresInMs = opts.codeExpiresIn * 1e3;
100
106
  const expiresAt = new Date(Date.now() + codeExpiresInMs);
@@ -1,5 +1,6 @@
1
1
  import { mergeSchema } from "../../db/schema.mjs";
2
2
  import { parseSetCookieHeader } from "../../cookies/cookie-utils.mjs";
3
+ import { constantTimeEqual } from "../../crypto/buffer.mjs";
3
4
  import { generateRandomString } from "../../crypto/random.mjs";
4
5
  import { symmetricDecrypt, symmetricEncrypt } from "../../crypto/index.mjs";
5
6
  import { expireCookie } from "../../cookies/index.mjs";
@@ -52,11 +53,7 @@ const getMetadata = (ctx, options) => {
52
53
  const jwtPlugin = ctx.context.getPlugin("jwt");
53
54
  const issuer = jwtPlugin && jwtPlugin.options?.jwt && jwtPlugin.options.jwt.issuer ? jwtPlugin.options.jwt.issuer : ctx.context.options.baseURL;
54
55
  const baseURL = ctx.context.baseURL;
55
- const supportedAlgs = options?.useJWTPlugin ? [
56
- "RS256",
57
- "EdDSA",
58
- "none"
59
- ] : ["HS256", "none"];
56
+ const supportedAlgs = options?.useJWTPlugin ? ["RS256", "EdDSA"] : ["HS256"];
60
57
  return {
61
58
  issuer,
62
59
  authorization_endpoint: `${baseURL}/oauth2/authorize`,
@@ -161,7 +158,7 @@ const oidcProvider = (options) => {
161
158
  defaultScope: "openid",
162
159
  accessTokenExpiresIn: DEFAULT_ACCESS_TOKEN_EXPIRES_IN,
163
160
  refreshTokenExpiresIn: DEFAULT_REFRESH_TOKEN_EXPIRES_IN,
164
- allowPlainCodeChallengeMethod: true,
161
+ allowPlainCodeChallengeMethod: false,
165
162
  storeClientSecret: "plain",
166
163
  ...options,
167
164
  scopes: [
@@ -190,14 +187,14 @@ const oidcProvider = (options) => {
190
187
  * Verify stored client secret against provided client secret
191
188
  */
192
189
  async function verifyStoredClientSecret(ctx, storedClientSecret, clientSecret) {
193
- if (opts.storeClientSecret === "encrypted") return await symmetricDecrypt({
190
+ if (opts.storeClientSecret === "encrypted") return constantTimeEqual(await symmetricDecrypt({
194
191
  key: ctx.context.secretConfig,
195
192
  data: storedClientSecret
196
- }) === clientSecret;
197
- if (opts.storeClientSecret === "hashed") return await defaultClientSecretHasher(clientSecret) === storedClientSecret;
198
- if (typeof opts.storeClientSecret === "object" && "hash" in opts.storeClientSecret) return await opts.storeClientSecret.hash(clientSecret) === storedClientSecret;
199
- if (typeof opts.storeClientSecret === "object" && "decrypt" in opts.storeClientSecret) return await opts.storeClientSecret.decrypt(storedClientSecret) === clientSecret;
200
- return clientSecret === storedClientSecret;
193
+ }), clientSecret);
194
+ if (opts.storeClientSecret === "hashed") return constantTimeEqual(await defaultClientSecretHasher(clientSecret), storedClientSecret);
195
+ if (typeof opts.storeClientSecret === "object" && "hash" in opts.storeClientSecret) return constantTimeEqual(await opts.storeClientSecret.hash(clientSecret), storedClientSecret);
196
+ if (typeof opts.storeClientSecret === "object" && "decrypt" in opts.storeClientSecret) return constantTimeEqual(await opts.storeClientSecret.decrypt(storedClientSecret), clientSecret);
197
+ return constantTimeEqual(clientSecret, storedClientSecret);
201
198
  }
202
199
  return {
203
200
  id: "oidc-provider",
@@ -378,25 +375,43 @@ const oidcProvider = (options) => {
378
375
  });
379
376
  let { client_id, client_secret } = body;
380
377
  const authorization = ctx.request?.headers.get("authorization") || null;
381
- if (authorization && !client_id && !client_secret && authorization.startsWith("Basic ")) try {
382
- const encoded = authorization.replace("Basic ", "");
383
- const decoded = new TextDecoder().decode(base64.decode(encoded));
384
- if (!decoded.includes(":")) throw new APIError("UNAUTHORIZED", {
378
+ if (authorization && !client_secret && authorization.startsWith("Basic ")) {
379
+ let decoded;
380
+ try {
381
+ const encoded = authorization.replace("Basic ", "");
382
+ decoded = new TextDecoder().decode(base64.decode(encoded));
383
+ } catch {
384
+ throw new APIError("UNAUTHORIZED", {
385
+ error_description: "invalid authorization header format",
386
+ error: "invalid_client"
387
+ });
388
+ }
389
+ const colonIndex = decoded.indexOf(":");
390
+ if (colonIndex === -1) throw new APIError("UNAUTHORIZED", {
385
391
  error_description: "invalid authorization header format",
386
392
  error: "invalid_client"
387
393
  });
388
- const [id, secret] = decoded.split(":");
394
+ let id;
395
+ let secret;
396
+ try {
397
+ id = decodeURIComponent(decoded.slice(0, colonIndex));
398
+ secret = decodeURIComponent(decoded.slice(colonIndex + 1));
399
+ } catch {
400
+ throw new APIError("UNAUTHORIZED", {
401
+ error_description: "invalid authorization header format",
402
+ error: "invalid_client"
403
+ });
404
+ }
389
405
  if (!id || !secret) throw new APIError("UNAUTHORIZED", {
390
406
  error_description: "invalid authorization header format",
391
407
  error: "invalid_client"
392
408
  });
393
- client_id = id;
394
- client_secret = secret;
395
- } catch {
396
- throw new APIError("UNAUTHORIZED", {
397
- error_description: "invalid authorization header format",
409
+ if (client_id && client_id.toString() !== id) throw new APIError("UNAUTHORIZED", {
410
+ error_description: "client_id in body does not match Authorization header",
398
411
  error: "invalid_client"
399
412
  });
413
+ client_id = id;
414
+ client_secret = secret;
400
415
  }
401
416
  const now = Date.now();
402
417
  const iat = Math.floor(now / 1e3);
@@ -428,6 +443,25 @@ const oidcProvider = (options) => {
428
443
  error_description: "refresh token expired",
429
444
  error: "invalid_grant"
430
445
  });
446
+ const refreshClient = await getClient(client_id.toString(), trustedClients);
447
+ if (!refreshClient) throw new APIError("UNAUTHORIZED", {
448
+ error_description: "invalid client_id",
449
+ error: "invalid_client"
450
+ });
451
+ if (refreshClient.disabled) throw new APIError("UNAUTHORIZED", {
452
+ error_description: "client is disabled",
453
+ error: "invalid_client"
454
+ });
455
+ if (refreshClient.type !== "public") {
456
+ if (!refreshClient.clientSecret || !client_secret) throw new APIError("UNAUTHORIZED", {
457
+ error_description: "client_secret is required for confidential clients",
458
+ error: "invalid_client"
459
+ });
460
+ if (!await verifyStoredClientSecret(ctx, refreshClient.clientSecret, client_secret.toString())) throw new APIError("UNAUTHORIZED", {
461
+ error_description: "invalid client_secret",
462
+ error: "invalid_client"
463
+ });
464
+ }
431
465
  const accessToken = generateRandomString(32, "a-z", "A-Z");
432
466
  const newRefreshToken = generateRandomString(32, "a-z", "A-Z");
433
467
  await ctx.context.adapter.create({
@@ -460,11 +494,7 @@ const oidcProvider = (options) => {
460
494
  error_description: "code verifier is missing",
461
495
  error: "invalid_request"
462
496
  });
463
- /**
464
- * We need to check if the code is valid before we can proceed
465
- * with the rest of the request.
466
- */
467
- const verificationValue = await ctx.context.internalAdapter.findVerificationValue(code.toString());
497
+ const verificationValue = await ctx.context.internalAdapter.consumeVerificationValue(code.toString());
468
498
  if (!verificationValue) throw new APIError("UNAUTHORIZED", {
469
499
  error_description: "invalid code",
470
500
  error: "invalid_grant"
@@ -473,7 +503,6 @@ const oidcProvider = (options) => {
473
503
  error_description: "code expired",
474
504
  error: "invalid_grant"
475
505
  });
476
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(code.toString());
477
506
  if (!client_id) throw new APIError("UNAUTHORIZED", {
478
507
  error_description: "client_id is required",
479
508
  error: "invalid_client"
@@ -527,12 +556,13 @@ const oidcProvider = (options) => {
527
556
  error: "invalid_client"
528
557
  });
529
558
  }
530
- if ((value.codeChallengeMethod === "plain" ? code_verifier : await createHash("SHA-256", "base64urlnopad").digest(code_verifier)) !== value.codeChallenge) throw new APIError("UNAUTHORIZED", {
531
- error_description: "code verification failed",
532
- error: "invalid_request"
533
- });
559
+ if (value.codeChallenge) {
560
+ if ((value.codeChallengeMethod === "plain" ? code_verifier : await createHash("SHA-256", "base64urlnopad").digest(code_verifier)) !== value.codeChallenge) throw new APIError("UNAUTHORIZED", {
561
+ error_description: "code verification failed",
562
+ error: "invalid_request"
563
+ });
564
+ }
534
565
  const requestedScopes = value.scope;
535
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(code.toString());
536
566
  const accessToken = generateRandomString(32, "a-z", "A-Z");
537
567
  const refreshToken = generateRandomString(32, "A-Z", "a-z");
538
568
  await ctx.context.adapter.create({
@@ -45,8 +45,9 @@ const oneTap = (options) => ({
45
45
  } catch {
46
46
  throw new APIError("BAD_REQUEST", { message: "invalid id token" });
47
47
  }
48
- const { email, email_verified, name, picture, sub } = payload;
49
- if (!email) return ctx.json({ error: "Email not available in token" });
48
+ const { email: rawEmail, email_verified, name, picture, sub } = payload;
49
+ if (!rawEmail) return ctx.json({ error: "Email not available in token" });
50
+ const email = rawEmail.toLowerCase();
50
51
  const user = await ctx.context.internalAdapter.findUserByEmail(email);
51
52
  if (!user) {
52
53
  if (options?.disableSignup) throw new APIError("BAD_GATEWAY", { message: "User not found" });
@@ -70,14 +71,19 @@ const oneTap = (options) => ({
70
71
  user: parseUserOutput(ctx.context.options, newUser.user)
71
72
  });
72
73
  }
73
- if (!await ctx.context.internalAdapter.findAccount(sub)) if ((ctx.context.options.account?.accountLinking)?.enabled !== false && (ctx.context.trustedProviders.includes("google") || email_verified)) await ctx.context.internalAdapter.linkAccount({
74
- userId: user.user.id,
75
- providerId: "google",
76
- accountId: sub,
77
- scope: "openid,profile,email",
78
- idToken
79
- });
80
- else throw new APIError("UNAUTHORIZED", { message: "Google sub doesn't match" });
74
+ if (!await ctx.context.internalAdapter.findAccount(sub)) {
75
+ const accountLinking = ctx.context.options.account?.accountLinking;
76
+ const providerEmailVerified = typeof email_verified === "boolean" ? email_verified : toBoolean(email_verified);
77
+ const requireLocalEmailVerified = accountLinking?.requireLocalEmailVerified ?? true;
78
+ if (accountLinking?.enabled !== false && accountLinking?.disableImplicitLinking !== true && (!requireLocalEmailVerified || user.user.emailVerified) && (ctx.context.trustedProviders.includes("google") || providerEmailVerified)) await ctx.context.internalAdapter.linkAccount({
79
+ userId: user.user.id,
80
+ providerId: "google",
81
+ accountId: sub,
82
+ scope: "openid,profile,email",
83
+ idToken
84
+ });
85
+ else throw new APIError("UNAUTHORIZED", { message: "Google identity cannot be linked: implicit account-linking is disabled, the local email is not verified, or the Google email_verified claim is false and Google is not a trusted provider" });
86
+ }
81
87
  const session = await ctx.context.internalAdapter.createSession(user.user.id);
82
88
  await setSessionCookie(ctx, {
83
89
  user: user.user,