fix(two-factor): preserve OTP-only verification during sync

This commit is contained in:
Gustavo Valverde
2026-06-26 14:08:24 +01:00
parent 010bd65811
commit 2fa9fbb0eb
3 changed files with 27 additions and 8 deletions
@@ -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);
});
});
+3 -1
View File
@@ -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);