From 2fa9fbb0eb6bc33dd2cbfdd1a7227c6a9e04b398 Mon Sep 17 00:00:00 2001 From: Gustavo Valverde Date: Fri, 26 Jun 2026 14:08:24 +0100 Subject: [PATCH] fix(two-factor): preserve OTP-only verification during sync --- .../src/plugins/two-factor/otp/index.ts | 12 +++++------- .../oauth2/reject-redirects.runtime.test.ts | 19 +++++++++++++++++++ packages/core/src/oauth2/reject-redirects.ts | 4 +++- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/packages/better-auth/src/plugins/two-factor/otp/index.ts b/packages/better-auth/src/plugins/two-factor/otp/index.ts index ad326bcc10..6587929733 100644 --- a/packages/better-auth/src/plugins/two-factor/otp/index.ts +++ b/packages/better-auth/src/plugins/two-factor/otp/index.ts @@ -319,20 +319,18 @@ export const otp2fa = (options?: OTPOptions | undefined) => { const twoFactorTable = "twoFactor"; // Account-level lockout shares one counter across all factors, so OTP // failures count toward and are blocked by the same lock as TOTP and - // backup codes. Fail closed on a sign-in if the record is missing. + // backup codes whenever the account has a twoFactor row. OTP-only + // accounts can be enabled without a TOTP/backup-code row; those accounts + // still use the per-challenge OTP attempt cap below. let twoFactor: TwoFactorTable | null = null; if (isSignIn) { twoFactor = await ctx.context.adapter.findOne({ model: twoFactorTable, where: [{ field: "userId", value: session.user.id }], }); - if (!twoFactor) { - throw APIError.from( - "BAD_REQUEST", - TWO_FACTOR_ERROR_CODES.TWO_FACTOR_NOT_ENABLED, - ); + if (twoFactor) { + await assertTwoFactorNotLocked(ctx, twoFactorTable, twoFactor); } - await assertTwoFactorNotLocked(ctx, twoFactorTable, twoFactor); } // Consume the OTP row atomically as the race gate. The first concurrent // submission wins the row; every other racer receives null and is diff --git a/packages/core/src/oauth2/reject-redirects.runtime.test.ts b/packages/core/src/oauth2/reject-redirects.runtime.test.ts index 4659ef795d..a107342166 100644 --- a/packages/core/src/oauth2/reject-redirects.runtime.test.ts +++ b/packages/core/src/oauth2/reject-redirects.runtime.test.ts @@ -1,5 +1,6 @@ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; import { refreshAccessToken } from "./refresh-access-token"; +import { fetchRefusingRedirects } from "./reject-redirects"; /** * Exercises the real `betterFetch` (only `globalThis.fetch` is mocked) to prove @@ -40,4 +41,22 @@ describe("server-side OAuth fetch refuses redirects via real betterFetch", () => const init = mockedFetch.mock.calls[0]?.[1] as RequestInit | undefined; expect(init?.redirect).toBe("manual"); }); + + it("preserves caller onError handlers for non-redirect errors", async () => { + const onError = vi.fn(); + mockedFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ error: "invalid_client" }), { + status: 401, + headers: { "content-type": "application/json" }, + }), + ); + + const result = await fetchRefusingRedirects("https://idp.example/token", { + onError, + }); + + expect(result.error).toBeDefined(); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0]?.[0].response.status).toBe(401); + }); }); diff --git a/packages/core/src/oauth2/reject-redirects.ts b/packages/core/src/oauth2/reject-redirects.ts index 2f938ec1f0..114dad3fad 100644 --- a/packages/core/src/oauth2/reject-redirects.ts +++ b/packages/core/src/oauth2/reject-redirects.ts @@ -58,11 +58,13 @@ export async function fetchRefusingRedirects( options?: BetterFetchOption, ) { let redirected = false; + const onError = options?.onError; const result = await betterFetch(url, { ...options, ...NO_FOLLOW_REDIRECT, - onError(context) { + async onError(context) { if (isRedirectResponse(context.response)) redirected = true; + await onError?.(context); }, }); if (redirected) throw redirectRefused(url);