mirror of
https://github.com/better-auth/better-auth.git
synced 2026-07-24 08:58:45 -05:00
fix(two-factor): preserve OTP-only verification during sync
This commit is contained in:
@@ -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<TwoFactorTable>({
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,11 +58,13 @@ export async function fetchRefusingRedirects<T>(
|
||||
options?: BetterFetchOption,
|
||||
) {
|
||||
let redirected = false;
|
||||
const onError = options?.onError;
|
||||
const result = await betterFetch<T>(url, {
|
||||
...options,
|
||||
...NO_FOLLOW_REDIRECT,
|
||||
onError(context) {
|
||||
async onError(context) {
|
||||
if (isRedirectResponse(context.response)) redirected = true;
|
||||
await onError?.(context);
|
||||
},
|
||||
});
|
||||
if (redirected) throw redirectRefused(url);
|
||||
|
||||
Reference in New Issue
Block a user