Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/src/features/auth/AuthLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ export function AuthLayout({ children }: AuthLayoutProps) {
>
{copy.footerCompany}
</a>{' '}
· <span className="opacity-70">© {new Date().getFullYear()}</span>
· <span>© {new Date().getFullYear()}</span>
</div>
</div>
</div>
Expand Down
136 changes: 119 additions & 17 deletions frontend/src/features/auth/AuthScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { landingForBusinessRole } from '../../shared/navModel';
import { useUIStore } from '../../store/uiStore';
import { authCopy, providerLabel, type OAuthErrorCode } from './authStrings';
import { challengeMFA, setupMFA, verifyMFA } from './authService';
import { classifyChallengeRefusal, minutesToWait } from './challengeRefusal';
import { OtpField, sanitiseCode } from '../../shared/ds';
import { AuthLayout } from './AuthLayout';
import { cascade, usePrefersReducedMotion } from './motion';
Expand All @@ -35,12 +36,21 @@ const OAUTH_PROVIDERS: { id: 'google' | 'github' | 'azure'; label: string }[] =

export function AuthScreen({ initialView = 'login' }: { initialView?: View }) {
const [view, setView] = useState<View>(initialView);
// Why the login form is showing, when another screen sent the user to it:
// a challenge that cannot take another code ends on the password (#872).
const [notice, setNotice] = useState('');
return (
<AuthLayout>
{view === 'login' ? (
<LoginForm onRegister={() => setView('register')} />
<LoginForm notice={notice} onRegister={() => setView('register')} />
) : (
<RegisterForm onLogin={() => setView('login')} />
<RegisterForm
onLogin={() => setView('login')}
onRestart={(message) => {
setNotice(message);
setView('login');
}}
/>
)}
</AuthLayout>
);
Expand Down Expand Up @@ -89,7 +99,7 @@ function LoginForm({ onRegister }: { onRegister: () => void }) {
const [password, setPassword] = useState('');
const [show, setShow] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [error, setError] = useState(notice);
// Bumped on every failure so the shake fires again even for an identical
// message — otherwise retyping the same wrong password gives no feedback.
const [errorNonce, setErrorNonce] = useState(0);
Expand Down Expand Up @@ -179,10 +189,16 @@ function LoginForm({ onRegister }: { onRegister: () => void }) {
};

if (mfa) {
// The address and password stay filled in: signing in again is one click,
// and the banner says why it is needed.
const restart = (message: string) => {
setMfa(null);
fail(message);
};
return mfa.enrolling ? (
<MFAEnrollment token={mfa.token} />
<MFAEnrollment token={mfa.token} onRestart={restart} />
) : (
<MFAChallenge token={mfa.token} onCancel={() => setMfa(null)} />
<MFAChallenge token={mfa.token} onCancel={() => setMfa(null)} onRestart={restart} />
);
}

Expand Down Expand Up @@ -320,7 +336,19 @@ function LoginForm({ onRegister }: { onRegister: () => void }) {
// MFA — challenge
// ---------------------------------------------------------------------------

function MFAChallenge({ token, onCancel }: { token: string; onCancel: () => void }) {
/** Disables the field this long when the server gave no wait (per-address limit). */
const UNKNOWN_LOCK_MS = 60_000;

function MFAChallenge({
token,
onCancel,
onRestart,
}: {
token: string;
onCancel: () => void;
/** The sign-in attempt is over; only the password can start another one. */
onRestart: (message: string) => void;
}) {
const navigate = useNavigate();
const lang = useUIStore((s) => s.lang);
const copy = authCopy(lang);
Expand All @@ -331,9 +359,36 @@ function MFAChallenge({ token, onCancel }: { token: string; onCancel: () => void
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [nonce, setNonce] = useState(0);
// Set while the account refuses codes (#872). The field is disabled until
// then: a code typed during the lock is refused without being read.
const [lockedUntil, setLockedUntil] = useState<number | null>(null);
const codeInput = useRef<HTMLInputElement>(null);
const locked = lockedUntil !== null;

useEffect(() => {
if (lockedUntil === null) return;
const timer = window.setTimeout(
() => {
setLockedUntil(null);
setError('');
},
Math.max(0, lockedUntil - Date.now()),
);
return () => window.clearTimeout(timer);
}, [lockedUntil]);

// Focus returns to the field once it is enabled again. It cannot be done in
// the timer above: the field is still disabled until React re-renders, and a
// disabled input ignores focus().
const wasLocked = useRef(false);
useEffect(() => {
if (wasLocked.current && !locked) codeInput.current?.focus();
wasLocked.current = locked;
}, [locked]);

const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (locked) return;
setBusy(true);
setError('');
try {
Expand All @@ -344,8 +399,26 @@ function MFAChallenge({ token, onCancel }: { token: string; onCancel: () => void
await adoptSession(result.token_pair.access_token);
}
navigate(landingForBusinessRole(useAuthStore.getState().user?.business_role));
} catch {
setError(copy.mfaInvalid);
} catch (err) {
const refusal = classifyChallengeRefusal(err);
switch (refusal.kind) {
case 'restart':
onRestart(refusal.reason === 'exhausted' ? copy.mfaExhausted : copy.mfaExpired);
return;
case 'locked':
setLockedUntil(
Date.now() +
(refusal.retryAfterSeconds === null ? UNKNOWN_LOCK_MS : refusal.retryAfterSeconds * 1000),
);
setError(
refusal.retryAfterSeconds === null
? copy.mfaLockedUnknown
: copy.mfaLocked(minutesToWait(refusal.retryAfterSeconds)),
);
break;
default:
setError(copy.mfaInvalid);
}
setNonce((n) => n + 1);
} finally {
setBusy(false);
Expand Down Expand Up @@ -381,8 +454,10 @@ function MFAChallenge({ token, onCancel }: { token: string; onCancel: () => void
pasted with spaces, or with the words around it, used to be
rejected while the user could plainly read the digits. */}
<input
ref={codeInput}
id="mfa-code"
data-testid="mfa-code"
disabled={locked}
inputMode="numeric"
autoComplete="one-time-code"
autoCapitalize="off"
Expand All @@ -402,9 +477,9 @@ function MFAChallenge({ token, onCancel }: { token: string; onCancel: () => void
<button
type="submit"
data-testid="mfa-submit"
disabled={busy || !code.trim()}
disabled={busy || locked || !code.trim()}
className={primaryBtn}
style={{ ...primaryStyle, opacity: busy || !code.trim() ? 0.6 : 1 }}
style={{ ...primaryStyle, opacity: busy || locked || !code.trim() ? 0.6 : 1 }}
>
{busy ? copy.signingIn : copy.mfaSubmit}
</button>
Expand All @@ -431,7 +506,14 @@ function MFAChallenge({ token, onCancel }: { token: string; onCancel: () => void
* completes the login in the same step, so the user is not asked for the
* password they typed a minute ago.
*/
function MFAEnrollment({ token }: { token: string }) {
function MFAEnrollment({
token,
onRestart,
}: {
token: string;
/** The enrolment token is spent or expired; only the password gets a new one. */
onRestart: (message: string) => void;
}) {
const navigate = useNavigate();
const lang = useUIStore((s) => s.lang);
const copy = authCopy(lang);
Expand Down Expand Up @@ -462,13 +544,19 @@ function MFAEnrollment({ token }: { token: string }) {
setQr(r.qr_code);
setBackupCodes(r.backup_codes ?? []);
})
.catch(() => {
.catch((err: unknown) => {
// An enrolment token that has expired cannot be retried; send the user
// back to the password for a new one (#872).
if (classifyChallengeRefusal(err).kind === 'restart') {
onRestart(copy.mfaExpired);
return;
}
// A setup failure is not a registration failure: the account is already
// created. Say what actually went wrong, and allow another attempt.
requested.current = null;
setError(copy.mfaSetupFailed);
});
}, [token, copy.mfaSetupFailed]);
}, [token, copy.mfaSetupFailed, copy.mfaExpired, onRestart]);

const qrSrc = qr.startsWith('data:') ? qr : `data:image/jpeg;base64,${qr}`;

Expand All @@ -488,7 +576,13 @@ function MFAEnrollment({ token }: { token: string }) {
await adoptSession(result.token_pair.access_token);
}
navigate(landingForBusinessRole(useAuthStore.getState().user?.business_role));
} catch {
} catch (err) {
// The enrolment token lives 15 minutes. Past that, every code is refused
// whatever it is, so "incorrect code" would be a lie (#872).
if (classifyChallengeRefusal(err).kind === 'restart') {
onRestart(copy.mfaExpired);
return;
}
setError(copy.mfaInvalid);
setNonce((n) => n + 1);
} finally {
Expand Down Expand Up @@ -583,7 +677,13 @@ function MFAEnrollment({ token }: { token: string }) {
// Register
// ---------------------------------------------------------------------------

function RegisterForm({ onLogin }: { onLogin: () => void }) {
function RegisterForm({
onLogin,
onRestart,
}: {
onLogin: () => void;
onRestart: (message: string) => void;
}) {
const navigate = useNavigate();
const lang = useUIStore((s) => s.lang);
const copy = authCopy(lang);
Expand Down Expand Up @@ -686,11 +786,13 @@ function RegisterForm({ onLogin }: { onLogin: () => void }) {

// Hand off to the same enrolment/challenge screens the login form uses, rather
// than a second implementation that would drift from it.
// The account exists by now, so starting over means signing in, not
// registering again.
if (mfa) {
return mfa.enrolling ? (
<MFAEnrollment token={mfa.token} />
<MFAEnrollment token={mfa.token} onRestart={onRestart} />
) : (
<MFAChallenge token={mfa.token} onCancel={() => setMfa(null)} />
<MFAChallenge token={mfa.token} onCancel={() => setMfa(null)} onRestart={onRestart} />
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright (c) 2026 OpenDefender Contributors
// SPDX-License-Identifier: AGPL-3.0-only
//
// #872 — the MFA calls that carry their own token must say so, or the response
// interceptor treats their 401s as a lost session and reloads /login.

import { beforeEach, describe, expect, it, vi } from 'vitest';

const post = vi.fn();
vi.mock('../../../lib/api', () => ({
api: { post: (...a: unknown[]) => post(...a) },
}));

import { challengeMFA, setupMFA, verifyMFA } from '../authService';

function configOfLastCall(): { ownCredential?: boolean; headers?: Record<string, string> } | undefined {
return post.mock.calls.at(-1)?.[2] as ReturnType<typeof configOfLastCall>;
}

beforeEach(() => {
post.mockReset();
post.mockResolvedValue({ data: {} });
});

describe('MFA requests and the session (#872)', () => {
it('marks the challenge as carrying its own credential', async () => {
await challengeMFA('123456', 'challenge-token');
expect(configOfLastCall()).toEqual({
headers: { Authorization: 'Bearer challenge-token' },
ownCredential: true,
});
});

it('marks mandated enrolment the same way', async () => {
await setupMFA('enrol-token');
expect(configOfLastCall()?.ownCredential).toBe(true);
await verifyMFA('123456', 'enrol-token');
expect(configOfLastCall()?.ownCredential).toBe(true);
});

it('leaves enrolment from Settings on the session', async () => {
await setupMFA();
expect(configOfLastCall()).toBeUndefined();
await verifyMFA('123456');
expect(configOfLastCall()).toBeUndefined();
});
});
71 changes: 71 additions & 0 deletions frontend/src/features/auth/__tests__/challengeRefusal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Copyright (c) 2026 OpenDefender Contributors
// SPDX-License-Identifier: AGPL-3.0-only

import { AxiosError, AxiosHeaders, type AxiosResponse } from 'axios';
import { describe, expect, it } from 'vitest';

import { classifyChallengeRefusal, minutesToWait } from '../challengeRefusal';

function refused(status: number, data: unknown, headers: Record<string, string> = {}): AxiosError {
const response = {
status,
statusText: '',
data,
headers,
config: { headers: new AxiosHeaders() },
} as AxiosResponse;
return new AxiosError('refused', String(status), response.config, null, response);
}

describe('classifyChallengeRefusal (#872)', () => {
it('reads a wrong code as a wrong code', () => {
expect(classifyChallengeRefusal(refused(400, { error: 'invalid MFA code' }))).toEqual({
kind: 'invalid',
});
});

it('sends a spent or revoked token back to the password', () => {
for (const code of ['MFA_CHALLENGE_EXHAUSTED', 'TOKEN_REVOKED']) {
expect(classifyChallengeRefusal(refused(401, { code }))).toEqual({
kind: 'restart',
reason: 'exhausted',
});
}
});

it('sends an expired or missing token back to the password', () => {
for (const code of ['TOKEN_EXPIRED', 'TOKEN_INVALID', 'UNAUTHORIZED', 'SOMETHING_NEW']) {
expect(classifyChallengeRefusal(refused(401, { code }))).toEqual({
kind: 'restart',
reason: 'expired',
});
}
});

it('reads the wait from the body first, then from Retry-After', () => {
expect(classifyChallengeRefusal(refused(429, { code: 'MFA_LOCKED', retry_after: 900 }))).toEqual({
kind: 'locked',
retryAfterSeconds: 900,
});
expect(
classifyChallengeRefusal(refused(429, { code: 'MFA_LOCKED' }, { 'retry-after': '120' })),
).toEqual({ kind: 'locked', retryAfterSeconds: 120 });
});

it('treats the per-address limit as a wait of unknown length', () => {
expect(classifyChallengeRefusal(refused(429, { error: true, msg: 'Rate limit exceeded' }))).toEqual({
kind: 'locked',
retryAfterSeconds: null,
});
});

it('falls back to "invalid" when no response came back', () => {
expect(classifyChallengeRefusal(new Error('network'))).toEqual({ kind: 'invalid' });
});

it('rounds the wait up to whole minutes and never says zero', () => {
expect(minutesToWait(900)).toBe(15);
expect(minutesToWait(61)).toBe(2);
expect(minutesToWait(1)).toBe(1);
});
});
Loading
Loading