better-auth 1.6.1 → 1.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/api/index.d.mts +0 -2
  2. package/dist/api/routes/account.mjs +1 -1
  3. package/dist/api/routes/callback.mjs +2 -2
  4. package/dist/api/routes/email-verification.mjs +1 -1
  5. package/dist/api/routes/password.mjs +1 -1
  6. package/dist/api/routes/session.d.mts +0 -1
  7. package/dist/api/routes/session.mjs +3 -4
  8. package/dist/api/routes/sign-in.mjs +1 -1
  9. package/dist/api/to-auth-endpoints.mjs +27 -3
  10. package/dist/auth/base.mjs +5 -24
  11. package/dist/client/plugins/index.d.mts +2 -2
  12. package/dist/client/query.mjs +4 -4
  13. package/dist/context/create-context.mjs +2 -2
  14. package/dist/context/helpers.mjs +61 -3
  15. package/dist/cookies/index.mjs +3 -3
  16. package/dist/crypto/index.mjs +2 -2
  17. package/dist/db/index.mjs +1 -1
  18. package/dist/db/internal-adapter.mjs +1 -1
  19. package/dist/index.d.mts +2 -2
  20. package/dist/index.mjs +2 -2
  21. package/dist/integrations/next-js.mjs +21 -12
  22. package/dist/oauth2/state.d.mts +1 -0
  23. package/dist/package.mjs +1 -1
  24. package/dist/plugins/admin/admin.d.mts +1 -3
  25. package/dist/plugins/admin/routes.mjs +2 -8
  26. package/dist/plugins/device-authorization/routes.mjs +1 -1
  27. package/dist/plugins/email-otp/routes.mjs +1 -1
  28. package/dist/plugins/index.d.mts +2 -2
  29. package/dist/plugins/jwt/utils.mjs +1 -1
  30. package/dist/plugins/mcp/index.mjs +20 -8
  31. package/dist/plugins/oauth-proxy/index.mjs +5 -1
  32. package/dist/plugins/oidc-provider/index.mjs +2 -2
  33. package/dist/plugins/organization/organization.d.mts +1 -3
  34. package/dist/plugins/organization/organization.mjs +1 -7
  35. package/dist/plugins/organization/routes/crud-invites.mjs +1 -1
  36. package/dist/plugins/organization/routes/crud-org.mjs +1 -1
  37. package/dist/plugins/organization/routes/crud-team.mjs +1 -1
  38. package/dist/plugins/phone-number/routes.mjs +1 -1
  39. package/dist/plugins/two-factor/backup-codes/index.d.mts +2 -1
  40. package/dist/plugins/two-factor/backup-codes/index.mjs +12 -17
  41. package/dist/plugins/two-factor/client.d.mts +12 -2
  42. package/dist/plugins/two-factor/client.mjs +1 -1
  43. package/dist/plugins/two-factor/index.d.mts +9 -2
  44. package/dist/plugins/two-factor/index.mjs +35 -3
  45. package/dist/plugins/two-factor/otp/index.mjs +1 -1
  46. package/dist/plugins/two-factor/schema.d.mts +6 -0
  47. package/dist/plugins/two-factor/schema.mjs +6 -0
  48. package/dist/plugins/two-factor/totp/index.mjs +19 -10
  49. package/dist/plugins/two-factor/types.d.mts +1 -1
  50. package/dist/state.d.mts +6 -0
  51. package/dist/state.mjs +18 -2
  52. package/dist/test-utils/test-instance.d.mts +0 -6
  53. package/dist/test-utils/test-instance.mjs +7 -1
  54. package/dist/utils/index.d.mts +1 -1
  55. package/dist/utils/url.d.mts +22 -15
  56. package/dist/utils/url.mjs +54 -28
  57. package/package.json +9 -9
@@ -1,7 +1,8 @@
1
1
  import { getBaseURL, isDynamicBaseURLConfig, resolveBaseURL } from "../../utils/url.mjs";
2
- import { generateRandomString } from "../../crypto/random.mjs";
3
2
  import { parseSetCookieHeader } from "../../cookies/cookie-utils.mjs";
3
+ import { generateRandomString } from "../../crypto/random.mjs";
4
4
  import { expireCookie } from "../../cookies/index.mjs";
5
+ import { resolveDynamicTrustedProxyHeaders } from "../../context/helpers.mjs";
5
6
  import { getSessionFromCtx } from "../../api/routes/session.mjs";
6
7
  import { HIDE_METADATA } from "../../utils/hide-metadata.mjs";
7
8
  import { APIError } from "../../api/index.mjs";
@@ -15,9 +16,9 @@ import { safeJSONParse } from "@better-auth/core/utils/json";
15
16
  import { createAuthEndpoint, createAuthMiddleware } from "@better-auth/core/api";
16
17
  import * as z from "zod";
17
18
  import { base64 } from "@better-auth/utils/base64";
18
- import { createHash } from "@better-auth/utils/hash";
19
- import { getWebcryptoSubtle } from "@better-auth/utils";
20
19
  import { SignJWT } from "jose";
20
+ import { getWebcryptoSubtle } from "@better-auth/utils";
21
+ import { createHash } from "@better-auth/utils/hash";
21
22
  //#region src/plugins/mcp/index.ts
22
23
  const getMCPProviderMetadata = (ctx, options) => {
23
24
  const issuer = typeof ctx.context.options.baseURL === "string" ? ctx.context.options.baseURL : "";
@@ -662,10 +663,15 @@ const mcp = (options) => {
662
663
  const withMcpAuth = (auth, handler) => {
663
664
  return async (req) => {
664
665
  const basePath = auth.options.basePath || "/api/auth";
665
- const baseURL = isDynamicBaseURLConfig(auth.options.baseURL) ? resolveBaseURL(auth.options.baseURL, basePath, req) : getBaseURL(typeof auth.options.baseURL === "string" ? auth.options.baseURL : void 0, basePath);
666
+ const trustedProxyHeaders = resolveDynamicTrustedProxyHeaders(auth.options);
667
+ const baseURL = isDynamicBaseURLConfig(auth.options.baseURL) ? resolveBaseURL(auth.options.baseURL, basePath, req, void 0, trustedProxyHeaders) : getBaseURL(typeof auth.options.baseURL === "string" ? auth.options.baseURL : void 0, basePath);
666
668
  if (!baseURL && !isProduction) logger.warn("Unable to get the baseURL, please check your config!");
667
- const session = await auth.api.getMcpSession({ headers: req.headers });
668
- const wwwAuthenticateValue = `Bearer resource_metadata="${baseURL}/.well-known/oauth-protected-resource"`;
669
+ const session = await auth.api.getMcpSession({
670
+ request: req,
671
+ headers: req.headers,
672
+ asResponse: false
673
+ });
674
+ const wwwAuthenticateValue = baseURL ? `Bearer resource_metadata="${baseURL}/.well-known/oauth-protected-resource"` : "Bearer";
669
675
  if (!session) return Response.json({
670
676
  jsonrpc: "2.0",
671
677
  error: {
@@ -686,7 +692,10 @@ const withMcpAuth = (auth, handler) => {
686
692
  };
687
693
  const oAuthDiscoveryMetadata = (auth) => {
688
694
  return async (request) => {
689
- const res = await auth.api.getMcpOAuthConfig();
695
+ const res = await auth.api.getMcpOAuthConfig({
696
+ request,
697
+ asResponse: false
698
+ });
690
699
  return new Response(JSON.stringify(res), {
691
700
  status: 200,
692
701
  headers: {
@@ -701,7 +710,10 @@ const oAuthDiscoveryMetadata = (auth) => {
701
710
  };
702
711
  const oAuthProtectedResourceMetadata = (auth) => {
703
712
  return async (request) => {
704
- const res = await auth.api.getMCPProtectedResource();
713
+ const res = await auth.api.getMCPProtectedResource({
714
+ request,
715
+ asResponse: false
716
+ });
705
717
  return new Response(JSON.stringify(res), {
706
718
  status: 200,
707
719
  headers: {
@@ -1,7 +1,7 @@
1
1
  import { getOrigin } from "../../utils/url.mjs";
2
2
  import { originCheck } from "../../api/middlewares/origin-check.mjs";
3
- import { symmetricDecrypt, symmetricEncrypt } from "../../crypto/index.mjs";
4
3
  import { parseSetCookieHeader } from "../../cookies/cookie-utils.mjs";
4
+ import { symmetricDecrypt, symmetricEncrypt } from "../../crypto/index.mjs";
5
5
  import { setSessionCookie } from "../../cookies/index.mjs";
6
6
  import { parseGenericState } from "../../state.mjs";
7
7
  import { handleOAuthUserInfo } from "../../oauth2/link-account.mjs";
@@ -164,6 +164,10 @@ const oAuthProxy = (opts) => {
164
164
  return;
165
165
  }
166
166
  const errorURL = stateData.errorURL || ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
167
+ if (stateData.oauthState !== void 0 && stateData.oauthState !== statePackage.state) {
168
+ ctx.context.logger.error("OAuth proxy state binding mismatch");
169
+ throw redirectOnError(ctx, errorURL, "state_mismatch");
170
+ }
167
171
  if (error) throw redirectOnError(ctx, errorURL, error);
168
172
  if (!code) {
169
173
  ctx.context.logger.error("OAuth callback missing authorization code");
@@ -1,7 +1,7 @@
1
1
  import { mergeSchema } from "../../db/schema.mjs";
2
+ import { parseSetCookieHeader } from "../../cookies/cookie-utils.mjs";
2
3
  import { generateRandomString } from "../../crypto/random.mjs";
3
4
  import { symmetricDecrypt, symmetricEncrypt } from "../../crypto/index.mjs";
4
- import { parseSetCookieHeader } from "../../cookies/cookie-utils.mjs";
5
5
  import { expireCookie } from "../../cookies/index.mjs";
6
6
  import { getSessionFromCtx, sessionMiddleware } from "../../api/routes/session.mjs";
7
7
  import { HIDE_METADATA } from "../../utils/hide-metadata.mjs";
@@ -18,8 +18,8 @@ import { createAuthEndpoint, createAuthMiddleware } from "@better-auth/core/api"
18
18
  import { deprecate } from "@better-auth/core/utils/deprecate";
19
19
  import * as z from "zod";
20
20
  import { base64 } from "@better-auth/utils/base64";
21
- import { createHash } from "@better-auth/utils/hash";
22
21
  import { SignJWT, jwtVerify } from "jose";
22
+ import { createHash } from "@better-auth/utils/hash";
23
23
  //#region src/plugins/oidc-provider/index.ts
24
24
  /**
25
25
  * Get a client by ID, checking trusted clients first, then database
@@ -99,11 +99,9 @@ declare const createHasPermission: <O extends OrganizationOptions>(options: O) =
99
99
  requireHeaders: true;
100
100
  body: z.ZodIntersection<z.ZodObject<{
101
101
  organizationId: z.ZodOptional<z.ZodString>;
102
- }, z.core.$strip>, z.ZodUnion<readonly [z.ZodObject<{
102
+ }, z.core.$strip>, z.ZodXor<readonly [z.ZodObject<{
103
103
  permission: z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString>>;
104
- permissions: z.ZodUndefined;
105
104
  }, z.core.$strip>, z.ZodObject<{
106
- permission: z.ZodUndefined;
107
105
  permissions: z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString>>;
108
106
  }, z.core.$strip>]>>;
109
107
  use: ((inputContext: better_call0.MiddlewareInputContext<{
@@ -18,13 +18,7 @@ import * as z from "zod";
18
18
  function parseRoles(roles) {
19
19
  return Array.isArray(roles) ? roles.join(",") : roles;
20
20
  }
21
- const createHasPermissionBodySchema = z.object({ organizationId: z.string().optional() }).and(z.union([z.object({
22
- permission: z.record(z.string(), z.array(z.string())),
23
- permissions: z.undefined()
24
- }), z.object({
25
- permission: z.undefined(),
26
- permissions: z.record(z.string(), z.array(z.string()))
27
- })]));
21
+ const createHasPermissionBodySchema = z.object({ organizationId: z.string().optional() }).and(z.xor([z.object({ permission: z.record(z.string(), z.array(z.string())) }), z.object({ permissions: z.record(z.string(), z.array(z.string())) })]));
28
22
  const createHasPermission = (options) => {
29
23
  return createAuthEndpoint("/organization/has-permission", {
30
24
  method: "POST",
@@ -1,6 +1,6 @@
1
1
  import { getDate } from "../../../utils/date.mjs";
2
- import { toZodSchema } from "../../../db/to-zod.mjs";
3
2
  import { setSessionCookie } from "../../../cookies/index.mjs";
3
+ import { toZodSchema } from "../../../db/to-zod.mjs";
4
4
  import { getSessionFromCtx } from "../../../api/routes/session.mjs";
5
5
  import { defaultRoles } from "../access/statement.mjs";
6
6
  import { ORGANIZATION_ERROR_CODES } from "../error-codes.mjs";
@@ -1,5 +1,5 @@
1
- import { toZodSchema } from "../../../db/to-zod.mjs";
2
1
  import { setSessionCookie } from "../../../cookies/index.mjs";
2
+ import { toZodSchema } from "../../../db/to-zod.mjs";
3
3
  import { getSessionFromCtx, requestOnlySessionMiddleware } from "../../../api/routes/session.mjs";
4
4
  import { ORGANIZATION_ERROR_CODES } from "../error-codes.mjs";
5
5
  import { getOrgAdapter } from "../adapter.mjs";
@@ -1,5 +1,5 @@
1
- import { toZodSchema } from "../../../db/to-zod.mjs";
2
1
  import { setSessionCookie } from "../../../cookies/index.mjs";
2
+ import { toZodSchema } from "../../../db/to-zod.mjs";
3
3
  import { getSessionFromCtx } from "../../../api/routes/session.mjs";
4
4
  import { ORGANIZATION_ERROR_CODES } from "../error-codes.mjs";
5
5
  import { getOrgAdapter } from "../adapter.mjs";
@@ -1,5 +1,5 @@
1
- import { getDate } from "../../utils/date.mjs";
2
1
  import { parseUserInput, parseUserOutput } from "../../db/schema.mjs";
2
+ import { getDate } from "../../utils/date.mjs";
3
3
  import { generateRandomString } from "../../crypto/random.mjs";
4
4
  import { setSessionCookie } from "../../cookies/index.mjs";
5
5
  import { getSessionFromCtx } from "../../api/routes/session.mjs";
@@ -36,6 +36,7 @@ interface BackupCodeOptions {
36
36
  */
37
37
  allowPasswordless?: boolean | undefined;
38
38
  }
39
+ declare function encodeBackupCodes(codes: string[], secret: string | SecretConfig, options?: BackupCodeOptions | undefined): Promise<string>;
39
40
  declare function generateBackupCodes(secret: string | SecretConfig, options?: BackupCodeOptions | undefined): Promise<{
40
41
  backupCodes: string[];
41
42
  encryptedBackupCodes: string;
@@ -286,4 +287,4 @@ declare const backupCode2fa: (opts: BackupCodeOptions) => {
286
287
  };
287
288
  };
288
289
  //#endregion
289
- export { BackupCodeOptions, backupCode2fa, generateBackupCodes, getBackupCodes, verifyBackupCode };
290
+ export { BackupCodeOptions, backupCode2fa, encodeBackupCodes, generateBackupCodes, getBackupCodes, verifyBackupCode };
@@ -14,22 +14,20 @@ import * as z from "zod";
14
14
  function generateBackupCodesFn(options) {
15
15
  return Array.from({ length: options?.amount ?? 10 }).fill(null).map(() => generateRandomString(options?.length ?? 10, "a-z", "0-9", "A-Z")).map((code) => `${code.slice(0, 5)}-${code.slice(5)}`);
16
16
  }
17
+ async function encodeBackupCodes(codes, secret, options) {
18
+ const json = JSON.stringify(codes);
19
+ if (options?.storeBackupCodes === "encrypted") return symmetricEncrypt({
20
+ data: json,
21
+ key: secret
22
+ });
23
+ if (typeof options?.storeBackupCodes === "object" && "encrypt" in options?.storeBackupCodes) return options.storeBackupCodes.encrypt(json);
24
+ return json;
25
+ }
17
26
  async function generateBackupCodes(secret, options) {
18
27
  const backupCodes = options?.customBackupCodesGenerate ? options.customBackupCodesGenerate() : generateBackupCodesFn(options);
19
- if (options?.storeBackupCodes === "encrypted") return {
20
- backupCodes,
21
- encryptedBackupCodes: await symmetricEncrypt({
22
- data: JSON.stringify(backupCodes),
23
- key: secret
24
- })
25
- };
26
- if (typeof options?.storeBackupCodes === "object" && "encrypt" in options?.storeBackupCodes) return {
27
- backupCodes,
28
- encryptedBackupCodes: await options?.storeBackupCodes.encrypt(JSON.stringify(backupCodes))
29
- };
30
28
  return {
31
29
  backupCodes,
32
- encryptedBackupCodes: JSON.stringify(backupCodes)
30
+ encryptedBackupCodes: await encodeBackupCodes(backupCodes, secret, options)
33
31
  };
34
32
  }
35
33
  async function verifyBackupCode(data, key, options) {
@@ -177,11 +175,8 @@ const backupCode2fa = (opts) => {
177
175
  backupCodes: twoFactor.backupCodes,
178
176
  code: ctx.body.code
179
177
  }, ctx.context.secretConfig, opts);
180
- if (!validate.status) throw APIError.from("UNAUTHORIZED", TWO_FACTOR_ERROR_CODES.INVALID_BACKUP_CODE);
181
- const updatedBackupCodes = await symmetricEncrypt({
182
- key: ctx.context.secretConfig,
183
- data: JSON.stringify(validate.updated)
184
- });
178
+ if (!validate.status || !validate.updated) throw APIError.from("UNAUTHORIZED", TWO_FACTOR_ERROR_CODES.INVALID_BACKUP_CODE);
179
+ const updatedBackupCodes = await encodeBackupCodes(validate.updated, ctx.context.secretConfig, opts);
185
180
  if (!await ctx.context.adapter.update({
186
181
  model: twoFactorTable,
187
182
  update: { backupCodes: updatedBackupCodes },
@@ -1,4 +1,4 @@
1
- import { BackupCodeOptions, backupCode2fa, generateBackupCodes, getBackupCodes, verifyBackupCode } from "./backup-codes/index.mjs";
1
+ import { BackupCodeOptions, backupCode2fa, encodeBackupCodes, generateBackupCodes, getBackupCodes, verifyBackupCode } from "./backup-codes/index.mjs";
2
2
  import { OTPOptions, otp2fa } from "./otp/index.mjs";
3
3
  import { TOTPOptions, totp2fa } from "./totp/index.mjs";
4
4
  import { TwoFactorOptions, TwoFactorProvider, TwoFactorTable, UserWithTwoFactor } from "./types.mjs";
@@ -19,8 +19,18 @@ declare const twoFactorClient: (options?: {
19
19
  /**
20
20
  * a redirect function to call if a user needs to verify
21
21
  * their two factor
22
+ *
23
+ * @param context.twoFactorMethods - The list of
24
+ * enabled two factor providers (e.g. ["totp", "otp"]).
25
+ * Use this to determine which 2FA UI to show.
22
26
  */
23
- onTwoFactorRedirect?: () => void | Promise<void>;
27
+ onTwoFactorRedirect?: (context: {
28
+ /**
29
+ * The list of enabled two factor providers
30
+ * for the user (e.g. ["totp", "otp"]).
31
+ */
32
+ twoFactorMethods?: string[];
33
+ }) => void | Promise<void>;
24
34
  } | undefined) => {
25
35
  id: "two-factor";
26
36
  version: string;
@@ -26,7 +26,7 @@ const twoFactorClient = (options) => {
26
26
  hooks: { async onSuccess(context) {
27
27
  if (context.data?.twoFactorRedirect) {
28
28
  if (options?.onTwoFactorRedirect) {
29
- await options.onTwoFactorRedirect();
29
+ await options.onTwoFactorRedirect({ twoFactorMethods: context.data.twoFactorMethods });
30
30
  return;
31
31
  }
32
32
  if (options?.twoFactorPage && typeof window !== "undefined") window.location.href = options.twoFactorPage;
@@ -1,4 +1,4 @@
1
- import { BackupCodeOptions, backupCode2fa, generateBackupCodes, getBackupCodes, verifyBackupCode } from "./backup-codes/index.mjs";
1
+ import { BackupCodeOptions, backupCode2fa, encodeBackupCodes, generateBackupCodes, getBackupCodes, verifyBackupCode } from "./backup-codes/index.mjs";
2
2
  import { OTPOptions, otp2fa } from "./otp/index.mjs";
3
3
  import { TOTPOptions, totp2fa } from "./totp/index.mjs";
4
4
  import { TwoFactorOptions, TwoFactorProvider, TwoFactorTable, UserWithTwoFactor } from "./types.mjs";
@@ -618,6 +618,7 @@ declare const twoFactor: <O extends TwoFactorOptions>(options?: O) => {
618
618
  matcher(context: _better_auth_core0.HookEndpointContext): boolean;
619
619
  handler: (inputContext: better_call0.MiddlewareInputContext<better_call0.MiddlewareOptions>) => Promise<{
620
620
  twoFactorRedirect: boolean;
621
+ twoFactorMethods: string[];
621
622
  } | undefined>;
622
623
  }[];
623
624
  };
@@ -655,6 +656,12 @@ declare const twoFactor: <O extends TwoFactorOptions>(options?: O) => {
655
656
  };
656
657
  index: true;
657
658
  };
659
+ verified: {
660
+ type: "boolean";
661
+ required: false;
662
+ defaultValue: true;
663
+ input: false;
664
+ };
658
665
  };
659
666
  };
660
667
  };
@@ -676,4 +683,4 @@ declare const twoFactor: <O extends TwoFactorOptions>(options?: O) => {
676
683
  };
677
684
  };
678
685
  //#endregion
679
- export { BackupCodeOptions, OTPOptions, TOTPOptions, TWO_FACTOR_ERROR_CODES, TwoFactorOptions, TwoFactorProvider, TwoFactorTable, UserWithTwoFactor, backupCode2fa, generateBackupCodes, getBackupCodes, otp2fa, totp2fa, twoFactor, twoFactorClient, verifyBackupCode };
686
+ export { BackupCodeOptions, OTPOptions, TOTPOptions, TWO_FACTOR_ERROR_CODES, TwoFactorOptions, TwoFactorProvider, TwoFactorTable, UserWithTwoFactor, backupCode2fa, encodeBackupCodes, generateBackupCodes, getBackupCodes, otp2fa, totp2fa, twoFactor, twoFactorClient, verifyBackupCode };
@@ -103,6 +103,13 @@ const twoFactor = (options) => {
103
103
  });
104
104
  await ctx.context.internalAdapter.deleteSession(ctx.context.session.session.token);
105
105
  }
106
+ const existingTwoFactor = await ctx.context.adapter.findOne({
107
+ model: opts.twoFactorTable,
108
+ where: [{
109
+ field: "userId",
110
+ value: user.id
111
+ }]
112
+ });
106
113
  await ctx.context.adapter.deleteMany({
107
114
  model: opts.twoFactorTable,
108
115
  where: [{
@@ -115,7 +122,8 @@ const twoFactor = (options) => {
115
122
  data: {
116
123
  secret: encryptedSecret,
117
124
  backupCodes: backupCodes.encryptedBackupCodes,
118
- userId: user.id
125
+ userId: user.id,
126
+ verified: existingTwoFactor != null && existingTwoFactor.verified !== false || !!options?.skipVerificationOnEnable
119
127
  }
120
128
  });
121
129
  const totpURI = createOTP(secret, {
@@ -181,12 +189,13 @@ const twoFactor = (options) => {
181
189
  options,
182
190
  hooks: { after: [{
183
191
  matcher(context) {
184
- return context.path === "/sign-in/email" || context.path === "/sign-in/username" || context.path === "/sign-in/phone-number";
192
+ return context.context.newSession != null && !context.path?.startsWith("/two-factor/");
185
193
  },
186
194
  handler: createAuthMiddleware(async (ctx) => {
187
195
  const data = ctx.context.newSession;
188
196
  if (!data) return;
189
197
  if (!data?.user.twoFactorEnabled) return;
198
+ if (ctx.context.session) return;
190
199
  const trustDeviceCookieAttrs = ctx.context.createAuthCookie(TRUST_DEVICE_COOKIE_NAME, { maxAge: trustDeviceMaxAge });
191
200
  const trustDeviceCookie = await ctx.getSignedCookie(trustDeviceCookieAttrs.name, ctx.context.secret);
192
201
  if (trustDeviceCookie) {
@@ -225,7 +234,30 @@ const twoFactor = (options) => {
225
234
  expiresAt: new Date(Date.now() + maxAge * 1e3)
226
235
  });
227
236
  await ctx.setSignedCookie(twoFactorCookie.name, identifier, ctx.context.secret, twoFactorCookie.attributes);
228
- return ctx.json({ twoFactorRedirect: true });
237
+ const twoFactorMethods = [];
238
+ /**
239
+ * totp requires per-user setup, so we check
240
+ * that the user actually has a secret stored.
241
+ */
242
+ if (!options?.totpOptions?.disable) {
243
+ const userTotpSecret = await ctx.context.adapter.findOne({
244
+ model: opts.twoFactorTable,
245
+ where: [{
246
+ field: "userId",
247
+ value: data.user.id
248
+ }]
249
+ });
250
+ if (userTotpSecret && userTotpSecret.verified !== false) twoFactorMethods.push("totp");
251
+ }
252
+ /**
253
+ * otp is server-level — if sendOTP is configured,
254
+ * any user with 2fa enabled can receive a code.
255
+ */
256
+ if (options?.otpOptions?.sendOTP) twoFactorMethods.push("otp");
257
+ return ctx.json({
258
+ twoFactorRedirect: true,
259
+ twoFactorMethods
260
+ });
229
261
  })
230
262
  }] },
231
263
  schema: mergeSchema(schema, {
@@ -175,11 +175,11 @@ const otp2fa = (options) => {
175
175
  if (!session.session) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.FAILED_TO_CREATE_SESSION);
176
176
  const updatedUser = await ctx.context.internalAdapter.updateUser(session.user.id, { twoFactorEnabled: true });
177
177
  const newSession = await ctx.context.internalAdapter.createSession(session.user.id, false, session.session);
178
- await ctx.context.internalAdapter.deleteSession(session.session.token);
179
178
  await setSessionCookie(ctx, {
180
179
  session: newSession,
181
180
  user: updatedUser
182
181
  });
182
+ await ctx.context.internalAdapter.deleteSession(session.session.token);
183
183
  return ctx.json({
184
184
  token: newSession.token,
185
185
  user: parseUserOutput(ctx.context.options, updatedUser)
@@ -33,6 +33,12 @@ declare const schema: {
33
33
  };
34
34
  index: true;
35
35
  };
36
+ verified: {
37
+ type: "boolean";
38
+ required: false;
39
+ defaultValue: true;
40
+ input: false;
41
+ };
36
42
  };
37
43
  };
38
44
  };
@@ -27,6 +27,12 @@ const schema = {
27
27
  field: "id"
28
28
  },
29
29
  index: true
30
+ },
31
+ verified: {
32
+ type: "boolean",
33
+ required: false,
34
+ defaultValue: true,
35
+ input: false
30
36
  }
31
37
  } }
32
38
  };
@@ -124,6 +124,7 @@ const totp2fa = (options) => {
124
124
  }
125
125
  const { session, valid, invalid } = await verifyTwoFactor(ctx);
126
126
  const user = session.user;
127
+ const isSignIn = !session.session;
127
128
  const twoFactor = await ctx.context.adapter.findOne({
128
129
  model: twoFactorTable,
129
130
  where: [{
@@ -132,6 +133,7 @@ const totp2fa = (options) => {
132
133
  }]
133
134
  });
134
135
  if (!twoFactor) throw APIError.from("BAD_REQUEST", TWO_FACTOR_ERROR_CODES.TOTP_NOT_ENABLED);
136
+ if (isSignIn && twoFactor.verified === false) throw APIError.from("BAD_REQUEST", TWO_FACTOR_ERROR_CODES.TOTP_NOT_ENABLED);
135
137
  if (!await createOTP(await symmetricDecrypt({
136
138
  key: ctx.context.secretConfig,
137
139
  data: twoFactor.secret
@@ -139,16 +141,23 @@ const totp2fa = (options) => {
139
141
  period: opts.period,
140
142
  digits: opts.digits
141
143
  }).verify(ctx.body.code)) return invalid("INVALID_CODE");
142
- if (!user.twoFactorEnabled) {
143
- if (!session.session) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.FAILED_TO_CREATE_SESSION);
144
- const updatedUser = await ctx.context.internalAdapter.updateUser(user.id, { twoFactorEnabled: true });
145
- const newSession = await ctx.context.internalAdapter.createSession(user.id, false, session.session).catch((e) => {
146
- throw e;
147
- });
148
- await ctx.context.internalAdapter.deleteSession(session.session.token);
149
- await setSessionCookie(ctx, {
150
- session: newSession,
151
- user: updatedUser
144
+ if (twoFactor.verified !== true) {
145
+ if (!user.twoFactorEnabled) {
146
+ const activeSession = session.session;
147
+ const updatedUser = await ctx.context.internalAdapter.updateUser(user.id, { twoFactorEnabled: true });
148
+ await setSessionCookie(ctx, {
149
+ session: await ctx.context.internalAdapter.createSession(user.id, false, activeSession),
150
+ user: updatedUser
151
+ });
152
+ await ctx.context.internalAdapter.deleteSession(activeSession.token);
153
+ }
154
+ await ctx.context.adapter.update({
155
+ model: twoFactorTable,
156
+ update: { verified: true },
157
+ where: [{
158
+ field: "id",
159
+ value: twoFactor.id
160
+ }]
152
161
  });
153
162
  }
154
163
  return valid(ctx);
@@ -80,7 +80,7 @@ interface TwoFactorTable {
80
80
  userId: string;
81
81
  secret: string;
82
82
  backupCodes: string;
83
- enabled: boolean;
83
+ verified: boolean;
84
84
  }
85
85
  //#endregion
86
86
  export { TwoFactorOptions, TwoFactorProvider, TwoFactorTable, UserWithTwoFactor };
package/dist/state.d.mts CHANGED
@@ -9,6 +9,11 @@ declare const stateDataSchema: z.ZodObject<{
9
9
  errorURL: z.ZodOptional<z.ZodString>;
10
10
  newUserURL: z.ZodOptional<z.ZodString>;
11
11
  expiresAt: z.ZodNumber;
12
+ /**
13
+ * CSRF nonce returned to the OAuth provider. When using cookie state storage,
14
+ * this must match the callback `state` query parameter.
15
+ */
16
+ oauthState: z.ZodOptional<z.ZodString>;
12
17
  link: z.ZodOptional<z.ZodObject<{
13
18
  email: z.ZodString;
14
19
  userId: z.ZodCoercedString<unknown>;
@@ -32,6 +37,7 @@ declare function parseGenericState(c: GenericEndpointContext, state: string, set
32
37
  expiresAt: number;
33
38
  errorURL?: string | undefined;
34
39
  newUserURL?: string | undefined;
40
+ oauthState?: string | undefined;
35
41
  link?: {
36
42
  email: string;
37
43
  userId: string;
package/dist/state.mjs CHANGED
@@ -10,6 +10,7 @@ const stateDataSchema = z.looseObject({
10
10
  errorURL: z.string().optional(),
11
11
  newUserURL: z.string().optional(),
12
12
  expiresAt: z.number(),
13
+ oauthState: z.string().optional(),
13
14
  link: z.object({
14
15
  email: z.string(),
15
16
  userId: z.coerce.string()
@@ -28,9 +29,13 @@ var StateError = class extends BetterAuthError {
28
29
  async function generateGenericState(c, stateData, settings) {
29
30
  const state = generateRandomString(32);
30
31
  if (c.context.oauthConfig.storeStateStrategy === "cookie") {
32
+ const payload = {
33
+ ...stateData,
34
+ oauthState: state
35
+ };
31
36
  const encryptedData = await symmetricEncrypt({
32
37
  key: c.context.secretConfig,
33
- data: JSON.stringify(stateData)
38
+ data: JSON.stringify(payload)
34
39
  });
35
40
  const stateCookie = c.context.createAuthCookie(settings?.cookieName ?? "oauth_state", { maxAge: 600 });
36
41
  c.setCookie(stateCookie.name, encryptedData, stateCookie.attributes);
@@ -44,7 +49,10 @@ async function generateGenericState(c, stateData, settings) {
44
49
  const expiresAt = /* @__PURE__ */ new Date();
45
50
  expiresAt.setMinutes(expiresAt.getMinutes() + 10);
46
51
  if (!await c.context.internalAdapter.createVerificationValue({
47
- value: JSON.stringify(stateData),
52
+ value: JSON.stringify({
53
+ ...stateData,
54
+ oauthState: state
55
+ }),
48
56
  identifier: state,
49
57
  expiresAt
50
58
  })) throw new StateError("Unable to create verification. Make sure the database adapter is properly working and there is a verification table in the database", { code: "state_generation_error" });
@@ -76,6 +84,10 @@ async function parseGenericState(c, state, settings) {
76
84
  cause: error
77
85
  });
78
86
  }
87
+ if (!parsedData.oauthState || parsedData.oauthState !== state) throw new StateError("State mismatch: OAuth state parameter does not match stored state", {
88
+ code: "state_security_mismatch",
89
+ details: { state }
90
+ });
79
91
  expireCookie(c, stateCookie);
80
92
  } else {
81
93
  const data = await c.context.internalAdapter.findVerificationValue(state);
@@ -84,6 +96,10 @@ async function parseGenericState(c, state, settings) {
84
96
  details: { state }
85
97
  });
86
98
  parsedData = stateDataSchema.parse(JSON.parse(data.value));
99
+ if (parsedData.oauthState !== void 0 && parsedData.oauthState !== state) throw new StateError("State mismatch: OAuth state parameter does not match stored state", {
100
+ code: "state_security_mismatch",
101
+ details: { state }
102
+ });
87
103
  const stateCookie = c.context.createAuthCookie(settings?.cookieName ?? "state");
88
104
  const stateCookieValue = await c.getSignedCookie(stateCookie.name, c.context.secret);
89
105
  if (!(settings?.skipStateCookieCheck ?? c.context.oauthConfig.skipStateCookieCheck) && (!stateCookieValue || stateCookieValue !== state)) throw new StateError("State mismatch: State not persisted correctly", {
@@ -261,7 +261,6 @@ declare function getTestInstance<O extends Partial<BetterAuthOptions>, C extends
261
261
  "application/json": {
262
262
  schema: {
263
263
  type: "object";
264
- nullable: boolean;
265
264
  properties: {
266
265
  session: {
267
266
  $ref: string;
@@ -2265,7 +2264,6 @@ declare function getTestInstance<O extends Partial<BetterAuthOptions>, C extends
2265
2264
  "application/json": {
2266
2265
  schema: {
2267
2266
  type: "object";
2268
- nullable: boolean;
2269
2267
  properties: {
2270
2268
  session: {
2271
2269
  $ref: string;
@@ -4272,7 +4270,6 @@ declare function getTestInstance<O extends Partial<BetterAuthOptions>, C extends
4272
4270
  "application/json": {
4273
4271
  schema: {
4274
4272
  type: "object";
4275
- nullable: boolean;
4276
4273
  properties: {
4277
4274
  session: {
4278
4275
  $ref: string;
@@ -6276,7 +6273,6 @@ declare function getTestInstance<O extends Partial<BetterAuthOptions>, C extends
6276
6273
  "application/json": {
6277
6274
  schema: {
6278
6275
  type: "object";
6279
- nullable: boolean;
6280
6276
  properties: {
6281
6277
  session: {
6282
6278
  $ref: string;
@@ -8354,7 +8350,6 @@ declare function getTestInstance<O extends Partial<BetterAuthOptions>, C extends
8354
8350
  "application/json": {
8355
8351
  schema: {
8356
8352
  type: "object";
8357
- nullable: boolean;
8358
8353
  properties: {
8359
8354
  session: {
8360
8355
  $ref: string;
@@ -10358,7 +10353,6 @@ declare function getTestInstance<O extends Partial<BetterAuthOptions>, C extends
10358
10353
  "application/json": {
10359
10354
  schema: {
10360
10355
  type: "object";
10361
- nullable: boolean;
10362
10356
  properties: {
10363
10357
  session: {
10364
10358
  $ref: string;
@@ -80,7 +80,13 @@ async function getTestInstance(options, config) {
80
80
  };
81
81
  async function createTestUser() {
82
82
  if (config?.disableTestUser) return;
83
- await auth.api.signUpEmail({ body: testUser });
83
+ const dynamicBaseURL = isDynamicBaseURLConfig(auth.options.baseURL) ? auth.options.baseURL : void 0;
84
+ const host = (dynamicBaseURL?.allowedHosts.find((h) => !h.includes("*") && !h.includes("?")) ?? dynamicBaseURL?.allowedHosts[0])?.replace(/^https?:\/\//, "").split(/[/#]/)[0]?.replace(/\*/g, "test").replace(/\?/g, "x");
85
+ const headers = host ? new Headers({ host }) : void 0;
86
+ await auth.api.signUpEmail({
87
+ body: testUser,
88
+ headers
89
+ });
84
90
  }
85
91
  if (testWith !== "mongodb") {
86
92
  const { runMigrations } = await getMigrations({
@@ -1,4 +1,4 @@
1
1
  import { generateState, parseState } from "../oauth2/state.mjs";
2
2
  import { StateData, generateGenericState, parseGenericState } from "../state.mjs";
3
3
  import { HIDE_METADATA } from "./hide-metadata.mjs";
4
- import { getBaseURL, getHost, getHostFromRequest, getOrigin, getProtocol, getProtocolFromRequest, isDynamicBaseURLConfig, matchesHostPattern, resolveBaseURL, resolveDynamicBaseURL } from "./url.mjs";
4
+ import { getBaseURL, getHost, getHostFromSource, getOrigin, getProtocol, getProtocolFromSource, isDynamicBaseURLConfig, isRequestLike, matchesHostPattern, resolveBaseURL, resolveDynamicBaseURL } from "./url.mjs";