Files
SnapOtter/tests/unit/web/login-page-oidc-mfa-redirect.test.tsx
T
SnapOtterandGitHub bbfcbe9c82 fix(auth): give OIDC/SAML logins a real MFA challenge instead of a hard block (#536)
Fixes #533, found while working on #529/#531.

OIDC and SAML logins hard-blocked on the MFA policy with zero check of whether the user actually enrolled TOTP, and no challenge step at all. Once an admin turned on an MFA-required policy, every SSO user was permanently locked out regardless of enrollment status.

- Extract the post-auth MFA decision (challenge / enrollment-required / proceed) into a shared, unit-tested function so OIDC and SAML can't independently diverge again
- An already-enrolled user now gets a real challenge (reusing the existing, auth-method-agnostic MFA completion flow) instead of being blocked
- An unenrolled user under a required policy gets a distinct, correctly mapped error instead of the old generic one
- Fix a real fail-open regression caught in review: a transient DB error during the enrollment-status check could have silently skipped MFA entirely for an enrolled user; now it fails closed and logs
- Strip the one-time challenge token from the URL after consuming it
2026-07-16 18:08:24 +08:00

80 lines
2.7 KiB
TypeScript

// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, useLocation } from "react-router-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
const useAuth = vi.hoisted(() => vi.fn());
vi.mock("@/hooks/use-auth", () => ({ useAuth }));
import { LoginPage } from "@/pages/login-page";
afterEach(() => {
cleanup();
useAuth.mockReset();
});
function LocationProbe({ onChange }: { onChange: (search: string) => void }) {
const location = useLocation();
onChange(location.search);
return null;
}
function renderLoginPage(path: string, onLocationChange?: (search: string) => void) {
useAuth.mockReturnValue({
oidcEnabled: true,
oidcProviderName: "Test IdP",
samlEnabled: false,
samlProviderName: null,
ssoEnforced: false,
});
return render(
<MemoryRouter initialEntries={[path]}>
{onLocationChange && <LocationProbe onChange={onLocationChange} />}
<LoginPage />
</MemoryRouter>,
);
}
describe("LoginPage OIDC/SAML MFA redirect handling", () => {
it("shows the TOTP prompt automatically when redirected back with an mfaToken", () => {
renderLoginPage("/login?mfaToken=abc-123");
expect(screen.getByText(/enter your authentication code/i)).toBeInTheDocument();
expect(screen.getByPlaceholderText("000000")).toBeInTheDocument();
});
it("shows the enrollment-required message for the mfa_enrollment_required error code", () => {
renderLoginPage("/login?error=mfa_enrollment_required");
expect(screen.getByText(/multi-factor authentication/i)).toBeInTheDocument();
});
it("does not show the TOTP prompt for an empty mfaToken param", () => {
renderLoginPage("/login?mfaToken=");
expect(screen.queryByPlaceholderText("000000")).not.toBeInTheDocument();
});
it("prioritizes an mfaToken over a simultaneous error param", () => {
renderLoginPage("/login?mfaToken=abc-123&error=oidc_auth_failed");
expect(screen.getByText(/enter your authentication code/i)).toBeInTheDocument();
expect(screen.queryByText(/authentication error/i)).not.toBeInTheDocument();
});
it("strips mfaToken from the URL after consuming it", async () => {
let currentSearch = "";
renderLoginPage("/login?mfaToken=abc-123", (search) => {
currentSearch = search;
});
await waitFor(() => expect(currentSearch).toBe(""));
});
it("strips the error param from the URL after consuming it", async () => {
let currentSearch = "";
renderLoginPage("/login?error=mfa_enrollment_required", (search) => {
currentSearch = search;
});
await waitFor(() => expect(currentSearch).toBe(""));
});
});