-
+
Back to verification code
{" · "}
diff --git a/resources/js/login/login.js b/resources/js/login/login.js
index 3aa2100c..3d1ac13e 100644
--- a/resources/js/login/login.js
+++ b/resources/js/login/login.js
@@ -587,15 +587,21 @@ class LoginPage extends React.Component {
this.setState({
...this.state,
authFlow: FLOW.RECOVERY,
+ recoveryCode: "",
errors: { ...this.state.errors, recovery: "" },
});
}
onBackToOtp() {
+ // Drop the abandoned recovery code as we leave the mode so a credential the
+ // user chose not to spend is not kept in component state for the rest of the
+ // session. A clean field on re-entry is already guaranteed by onUseRecovery();
+ // this is about not holding the value, not about what the next render shows.
this.setState({
...this.state,
authFlow: FLOW.MFA,
- errors: { ...this.state.errors, twofactor: "" },
+ recoveryCode: "",
+ errors: { ...this.state.errors, twofactor: "", recovery: "" },
});
}
diff --git a/tests/e2e/pages/LoginPage.ts b/tests/e2e/pages/LoginPage.ts
index d05b4be0..d67f5c37 100644
--- a/tests/e2e/pages/LoginPage.ts
+++ b/tests/e2e/pages/LoginPage.ts
@@ -21,6 +21,8 @@ export class LoginPage {
readonly useRecoveryLink: Locator;
// Recovery code step
readonly recoveryForm: Locator;
+ readonly recoveryCodeInput: Locator;
+ readonly backToOtpLink: Locator;
constructor(page: Page) {
this.page = page;
@@ -38,6 +40,8 @@ export class LoginPage {
this.cancelLink = page.locator('[data-testid="cancel-link"]');
this.useRecoveryLink = page.locator('[data-testid="use-recovery-link"]');
this.recoveryForm = page.locator('[data-testid="recovery-form"]');
+ this.recoveryCodeInput = page.locator('#recovery_code');
+ this.backToOtpLink = page.locator('[data-testid="back-to-otp-link"]');
}
async goto() {
diff --git a/tests/e2e/tests/auth/login-mfa-flow.spec.ts b/tests/e2e/tests/auth/login-mfa-flow.spec.ts
index b817dc6e..1c418df4 100644
--- a/tests/e2e/tests/auth/login-mfa-flow.spec.ts
+++ b/tests/e2e/tests/auth/login-mfa-flow.spec.ts
@@ -6,18 +6,27 @@ import type { Page } from '@playwright/test';
// param onto the URL as a query string (in addition to the body), so the real
// request is "
?otp_value=...&method=..." - a glob without the trailing
// wildcard requires an exact end-of-string match and silently never fires.
+// The recovery endpoint is the exception: it posts through postRawRequestFull(),
+// which is body-only, so its URL carries no query string ('**' still matches).
const VERIFY_URL = '**/auth/login/2fa/verify**';
const RESEND_URL = '**/auth/login/2fa/resend**';
const RECOVERY_URL = '**/auth/login/2fa/recovery**';
const CANCEL_URL = '**/auth/login/cancel**';
-// Each TS-* test gets its own MFA-enforced super-admin (mfa-ts-NNN@test.com,
-// seeded by CI via idp:create-super-admin - see pull_request_frontend_tests.yml).
+// Each TS-* test gets its own MFA-enforced super-admin (mfa-ts-NNN@test.com),
+// seeded by CI via idp:create-super-admin in BOTH pull_request_frontend_tests.yml
+// and push_frontend_tests.yml - adding a TS-NNN here means widening the seed loop
+// in both, or the new test logs in as a user that does not exist.
// A real login issues a real OTP challenge and counts against that user's own
// two_factor.rate_limit.max_otp_requests window, so sharing one fixed account
-// across all 8 tests would exhaust the limit well before the suite finishes.
+// across the whole suite would exhaust the limit well before it finishes.
const MFA_USER_PASSWORD = '1Qaz2wsx!';
+// Recovery codes are generated as 8 chars from [A-Z0-9] and shown as XXXX-XXXX
+// (RecoveryCodeService::regenerateCodesForUser), but hashed without the dash.
+const RECOVERY_CODE_AS_DISPLAYED = 'ABCD-1234';
+const RECOVERY_CODE_NORMALIZED = 'ABCD1234';
+
function mfaUserEmailFor(testTitle: string): string {
const match = testTitle.match(/TS-(\d+)/);
if (!match) {
@@ -127,7 +136,7 @@ test.describe('MFA Login Flow', () => {
});
// TS-005 ─────────────────────────────────────────────────────────────────
- test('TS-005: use recovery code — recovery form shown and API called',
+ test('TS-005: use recovery code — recovery form shown and normalized code posted',
async ({ loginPage, page }) => {
await page.route(RECOVERY_URL, route =>
route.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
@@ -137,13 +146,21 @@ test.describe('MFA Login Flow', () => {
await expect(loginPage.recoveryForm).toBeVisible();
await expect(loginPage.twoFactorForm).not.toBeVisible();
- await page.locator('#recovery_code').fill('ABCD-1234-EFGH-5678');
+ // Typed exactly as the code is displayed to the user (XXXX-XXXX); the
+ // separator is presentational only and must never reach the endpoint.
+ await loginPage.recoveryCodeInput.fill(RECOVERY_CODE_AS_DISPLAYED);
+ await expect(loginPage.recoveryCodeInput).toHaveValue(RECOVERY_CODE_NORMALIZED);
- const [response] = await Promise.all([
- page.waitForResponse(RECOVERY_URL),
+ const [request] = await Promise.all([
+ page.waitForRequest(RECOVERY_URL),
loginPage.verifyButton.click(),
]);
- expect(response.status()).toBe(200);
+
+ // The code travels in the body only. verifyRecoveryCode() posts through
+ // postRawRequestFull() precisely so it never reaches the query string,
+ // where access logs would capture it - assert both halves of that.
+ expect(request.postDataJSON()).toMatchObject({ recovery_code: RECOVERY_CODE_NORMALIZED });
+ expect(new URL(request.url()).searchParams.get('recovery_code')).toBeNull();
});
// TS-006 ─────────────────────────────────────────────────────────────────
@@ -210,4 +227,97 @@ test.describe('MFA Login Flow', () => {
await expect(loginPage.errorLabel).toBeVisible();
await expect(loginPage.errorLabel).toContainText('Too many attempts');
});
+
+ // TS-009 ─────────────────────────────────────────────────────────────────
+ test('TS-009: back to verification code — OTP mode restored with a clean recovery field',
+ async ({ loginPage }) => {
+ await loginPage.useRecoveryLink.click();
+ await expect(loginPage.recoveryForm).toBeVisible();
+
+ await loginPage.recoveryCodeInput.fill(RECOVERY_CODE_NORMALIZED);
+ await loginPage.backToOtpLink.click();
+
+ // The OTP flow must be intact — same form, still able to submit a code.
+ await expect(loginPage.twoFactorForm).toBeVisible();
+ await expect(loginPage.recoveryForm).not.toBeVisible();
+ await expect(loginPage.passwordForm).not.toBeVisible();
+
+ // Re-entering recovery mode must not resurrect the abandoned code.
+ await loginPage.useRecoveryLink.click();
+ await expect(loginPage.recoveryCodeInput).toHaveValue('');
+ });
+
+ // TS-010 ─────────────────────────────────────────────────────────────────
+ test('TS-010: invalid/used recovery code — inline error, still in the recovery form',
+ async ({ loginPage, page }) => {
+ // A used code fails exactly like an unknown one: the backend answers
+ // mfa_invalid_recovery for both (AbstractMFAChallengeStrategy).
+ await page.route(RECOVERY_URL, route =>
+ route.fulfill({
+ status: 401,
+ contentType: 'application/json',
+ body: JSON.stringify({ error_code: 'mfa_invalid_recovery' }),
+ })
+ );
+
+ await loginPage.useRecoveryLink.click();
+ await loginPage.recoveryCodeInput.fill(RECOVERY_CODE_NORMALIZED);
+ await loginPage.verifyButton.click();
+
+ await expect(loginPage.errorLabel).toBeVisible();
+ await expect(loginPage.errorLabel).toContainText('Invalid recovery code');
+ // The MFA flow must not be abandoned on a bad code.
+ await expect(loginPage.recoveryForm).toBeVisible();
+ await expect(loginPage.passwordForm).not.toBeVisible();
+ });
+
+ // TS-011 ─────────────────────────────────────────────────────────────────
+ test('TS-011: recovery rate limit — 429 inline error shown',
+ async ({ loginPage, page }) => {
+ await page.route(RECOVERY_URL, route =>
+ route.fulfill({
+ status: 429,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ error_code: 'mfa_rate_limit',
+ error_message: 'Too many attempts. Please try again later.',
+ }),
+ })
+ );
+
+ await loginPage.useRecoveryLink.click();
+ await loginPage.recoveryCodeInput.fill(RECOVERY_CODE_NORMALIZED);
+ await loginPage.verifyButton.click();
+
+ await expect(loginPage.errorLabel).toBeVisible();
+ await expect(loginPage.errorLabel).toContainText('Too many attempts');
+ await expect(loginPage.recoveryForm).toBeVisible();
+ });
+
+ // TS-012 ─────────────────────────────────────────────────────────────────
+ test('TS-012: recovery session expired — back to password form with warning snackbar',
+ async ({ loginPage, page }) => {
+ await page.route(RECOVERY_URL, route =>
+ route.fulfill({
+ status: 401,
+ contentType: 'application/json',
+ body: JSON.stringify({ error_code: 'mfa_session_expired' }),
+ })
+ );
+ // resetToPasswordFlow() fires cancelLogin() in the background; absorb it.
+ await page.route(CANCEL_URL, route =>
+ route.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
+ );
+
+ await loginPage.useRecoveryLink.click();
+ await loginPage.recoveryCodeInput.fill(RECOVERY_CODE_NORMALIZED);
+ await loginPage.verifyButton.click();
+
+ await expect(loginPage.recoveryForm).not.toBeVisible();
+ await expect(loginPage.passwordForm).toBeVisible();
+
+ const snackbar = page.locator('[role="alert"]');
+ await expect(snackbar).toBeVisible();
+ await expect(snackbar).toContainText('session has expired');
+ });
});
diff --git a/tests/js/login/components/recovery-code-form.test.js b/tests/js/login/components/recovery-code-form.test.js
new file mode 100644
index 00000000..c0b8e26f
--- /dev/null
+++ b/tests/js/login/components/recovery-code-form.test.js
@@ -0,0 +1,81 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+import RecoveryCodeForm from '../../../../resources/js/login/components/recovery_code_form';
+
+const baseProps = {
+ recoveryCode: 'ABCD1234',
+ recoveryError: '',
+ disableInput: false,
+ onRecoveryCodeChange: jest.fn(),
+ onVerify: jest.fn(),
+ onBackToOtp: jest.fn(),
+ onCancel: jest.fn(),
+};
+
+describe('RecoveryCodeForm', () => {
+
+ beforeEach(() => jest.clearAllMocks());
+
+ it('does not advertise the field as a one-time-code slot', () => {
+ // "one-time-code" would make the OS offer the e-mailed OTP here — the
+ // wrong credential for this field, and the OTP/recovery confusion risk.
+ render();
+ expect(document.getElementById('recovery_code')).toHaveAttribute('autocomplete', 'off');
+ });
+
+ it('renders the expected-format hint', () => {
+ render();
+ expect(screen.getByText(/8 characters, shown as ABCD-1234/)).toBeInTheDocument();
+ });
+
+ it('VERIFY button is disabled when the code is empty', () => {
+ render();
+ expect(screen.getByTestId('verify-button')).toBeDisabled();
+ });
+
+ it('VERIFY button is disabled while a submit is in flight', () => {
+ render();
+ expect(screen.getByTestId('verify-button')).toBeDisabled();
+ });
+
+ it('error paragraph renders when recoveryError is non-empty', () => {
+ const msg = 'Invalid recovery code. Please try again.';
+ render();
+ expect(screen.getByTestId('error-label')).toHaveTextContent(msg);
+ });
+
+ it('submitting the form calls onVerify without navigating', () => {
+ render();
+ fireEvent.submit(screen.getByTestId('recovery-form'));
+ expect(baseProps.onVerify).toHaveBeenCalledTimes(1);
+ });
+
+ it('"Back to verification code" calls onBackToOtp', () => {
+ render();
+ fireEvent.click(screen.getByTestId('back-to-otp-link'));
+ expect(baseProps.onBackToOtp).toHaveBeenCalledTimes(1);
+ expect(baseProps.onCancel).not.toHaveBeenCalled();
+ });
+
+ it('"Cancel" calls onCancel', () => {
+ render();
+ fireEvent.click(screen.getByTestId('cancel-link'));
+ expect(baseProps.onCancel).toHaveBeenCalledTimes(1);
+ expect(baseProps.onBackToOtp).not.toHaveBeenCalled();
+ });
+
+ it('typing in the field reports the raw value to the parent', () => {
+ // Normalization lives in LoginPage.onRecoveryCodeChange(); the form must
+ // hand over the untouched event so that stays the single source of truth.
+ // Read the value inside the handler: the field is controlled, so React
+ // resets the DOM node back to the (unchanged) prop before the assertion runs.
+ let seen = null;
+ const onRecoveryCodeChange = jest.fn((ev) => { seen = ev.target.value; });
+
+ render();
+ fireEvent.change(document.getElementById('recovery_code'), { target: { value: 'abcd-1234' } });
+
+ expect(onRecoveryCodeChange).toHaveBeenCalledTimes(1);
+ expect(seen).toBe('abcd-1234');
+ });
+});
diff --git a/tests/js/login/login.mfa.test.js b/tests/js/login/login.mfa.test.js
index ca8fdea2..89928a6d 100644
--- a/tests/js/login/login.mfa.test.js
+++ b/tests/js/login/login.mfa.test.js
@@ -1,5 +1,9 @@
import React from 'react';
import { FLOW, HTTP_CODES, MFA_ERROR_CODE } from '../../../resources/js/login/constants';
+import {
+ RECOVERY_CODES_LOW_WARNING_DISMISSED_KEY,
+ DEFAULT_RECOVERY_CODES_LOW_THRESHOLD,
+} from '../../../resources/js/shared/recovery_codes';
// actions.js makes real XHR calls — stub every export so the module loads cleanly.
jest.mock('../../../resources/js/login/actions', () => ({
@@ -18,6 +22,7 @@ delete window.location;
window.location = { href: '', reload: jest.fn() };
import { LoginPage } from '../../../resources/js/login/login';
+import { verifyRecoveryCode, cancelLogin } from '../../../resources/js/login/actions';
// ─── Minimal props that satisfy the LoginPage constructor ────────────────────
@@ -73,6 +78,9 @@ function makeError(status, body = null) {
return { status, response: body ? { body } : null };
}
+/** Let the pending .then()/.catch() callbacks of the mocked action run. */
+const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
+
// ─────────────────────────────────────────────────────────────────────────────
describe('LoginPage', () => {
@@ -138,4 +146,223 @@ describe('LoginPage', () => {
expect(inst.showAlert).not.toHaveBeenCalled();
});
});
+
+ // ─── Recovery-code login flow (CU-86ba2zp4f) ───────────────────────────────
+
+ describe('recovery code flow', () => {
+ let inst;
+
+ beforeEach(() => {
+ inst = makeInstance();
+ inst.state = { ...inst.state, authFlow: FLOW.MFA };
+ window.location.href = '';
+ window.location.reload.mockClear();
+ verifyRecoveryCode.mockReset();
+ cancelLogin.mockReset();
+ sessionStorage.clear();
+ });
+
+ describe('mode switching', () => {
+
+ it('onUseRecovery — switches to the recovery flow with a clean field', () => {
+ inst.state = {
+ ...inst.state,
+ recoveryCode: 'STALE123',
+ errors: { ...inst.state.errors, recovery: 'Invalid recovery code. Please try again.' },
+ };
+
+ inst.onUseRecovery();
+
+ expect(inst.state.authFlow).toBe(FLOW.RECOVERY);
+ expect(inst.state.recoveryCode).toBe('');
+ expect(inst.state.errors.recovery).toBe('');
+ });
+
+ it('onBackToOtp — returns to OTP mode and drops the abandoned recovery attempt', () => {
+ inst.state = {
+ ...inst.state,
+ authFlow: FLOW.RECOVERY,
+ recoveryCode: 'ABCD1234',
+ errors: {
+ ...inst.state.errors,
+ recovery: 'Invalid recovery code. Please try again.',
+ twofactor: 'Invalid or expired verification code. Please try again.',
+ },
+ };
+
+ inst.onBackToOtp();
+
+ expect(inst.state.authFlow).toBe(FLOW.MFA);
+ expect(inst.state.recoveryCode).toBe('');
+ expect(inst.state.errors.recovery).toBe('');
+ expect(inst.state.errors.twofactor).toBe('');
+ });
+
+ it('resetToPasswordFlow — clears the recovery code on the way back to password', async () => {
+ const real = makeInstance();
+ real.resetToPasswordFlow = LoginPage.prototype.resetToPasswordFlow.bind(real);
+ cancelLogin.mockReturnValue(Promise.resolve({}));
+ real.state = { ...real.state, authFlow: FLOW.RECOVERY, recoveryCode: 'ABCD1234' };
+
+ real.resetToPasswordFlow();
+ await flush();
+
+ expect(real.state.authFlow).toBe(FLOW.PASSWORD);
+ expect(real.state.recoveryCode).toBe('');
+ expect(real.state.errors.recovery).toBe('');
+ });
+ });
+
+ describe('onRecoveryCodeChange', () => {
+
+ it('normalizes the displayed dash and lowercase away', () => {
+ // Codes are shown as XXXX-XXXX but hashed without the separator.
+ inst.onRecoveryCodeChange({ target: { value: 'abcd-1234' } });
+ expect(inst.state.recoveryCode).toBe('ABCD1234');
+ });
+
+ it('clears a previous inline error as soon as the user edits the field', () => {
+ inst.state = {
+ ...inst.state,
+ errors: { ...inst.state.errors, recovery: 'Invalid recovery code. Please try again.' },
+ };
+
+ inst.onRecoveryCodeChange({ target: { value: 'A' } });
+
+ expect(inst.state.errors.recovery).toBe('');
+ });
+ });
+
+ describe('onVerifyRecovery', () => {
+
+ it('empty code — inline error, no request issued', () => {
+ inst.state = { ...inst.state, recoveryCode: '' };
+
+ inst.onVerifyRecovery();
+
+ expect(verifyRecoveryCode).not.toHaveBeenCalled();
+ expect(inst.state.errors.recovery).toBe('Recovery code is empty');
+ });
+
+ it('does not re-submit while a request is already in flight', () => {
+ inst.state = { ...inst.state, recoveryCode: 'ABCD1234', disableInput: true };
+
+ inst.onVerifyRecovery();
+
+ expect(verifyRecoveryCode).not.toHaveBeenCalled();
+ });
+
+ it('valid code — posts the normalized code and navigates to redirect_url', async () => {
+ verifyRecoveryCode.mockReturnValue(
+ Promise.resolve({ response: { redirect_url: 'https://idp.test/authorize', recovery_codes_remaining: 7 } }),
+ );
+ inst.state = { ...inst.state, recoveryCode: 'ABCD1234' };
+
+ inst.onVerifyRecovery();
+ expect(verifyRecoveryCode).toHaveBeenCalledWith('ABCD1234', PROPS.token);
+ await flush();
+
+ expect(window.location.href).toBe('https://idp.test/authorize');
+ expect(inst.state.lowRecoveryCodesWarning).toBeNull();
+ });
+
+ it('valid code with few codes left — shows the warning instead of navigating', async () => {
+ verifyRecoveryCode.mockReturnValue(
+ Promise.resolve({
+ response: {
+ redirect_url: 'https://idp.test/authorize',
+ recovery_codes_remaining: DEFAULT_RECOVERY_CODES_LOW_THRESHOLD - 1,
+ },
+ }),
+ );
+ inst.state = { ...inst.state, recoveryCode: 'ABCD1234' };
+
+ inst.onVerifyRecovery();
+ await flush();
+
+ expect(window.location.href).toBe('');
+ expect(inst.state.lowRecoveryCodesWarning).toEqual({
+ remaining: DEFAULT_RECOVERY_CODES_LOW_THRESHOLD - 1,
+ redirectUrl: 'https://idp.test/authorize',
+ });
+ });
+
+ it('low-code warning already dismissed this session — navigates straight through', async () => {
+ sessionStorage.setItem(RECOVERY_CODES_LOW_WARNING_DISMISSED_KEY, '1');
+ verifyRecoveryCode.mockReturnValue(
+ Promise.resolve({ response: { redirect_url: 'https://idp.test/authorize', recovery_codes_remaining: 1 } }),
+ );
+ inst.state = { ...inst.state, recoveryCode: 'ABCD1234' };
+
+ inst.onVerifyRecovery();
+ await flush();
+
+ expect(window.location.href).toBe('https://idp.test/authorize');
+ expect(inst.state.lowRecoveryCodesWarning).toBeNull();
+ });
+
+ it('invalid/used code — inline error and the user stays in the recovery flow', async () => {
+ verifyRecoveryCode.mockReturnValue(
+ Promise.reject(makeError(HTTP_CODES.UNAUTHORIZED, { error_code: 'mfa_invalid_recovery' })),
+ );
+ inst.state = { ...inst.state, authFlow: FLOW.RECOVERY, recoveryCode: 'ABCD1234' };
+
+ inst.onVerifyRecovery();
+ await flush();
+
+ expect(inst.state.errors.recovery).toBe('Invalid recovery code. Please try again.');
+ expect(inst.state.authFlow).toBe(FLOW.RECOVERY);
+ expect(inst.state.disableInput).toBe(false);
+ expect(inst.resetToPasswordFlow).not.toHaveBeenCalled();
+ expect(window.location.href).toBe('');
+ });
+
+ it('mfa_session_expired — returns to the password flow with a warning', async () => {
+ verifyRecoveryCode.mockReturnValue(
+ Promise.reject(
+ makeError(HTTP_CODES.UNAUTHORIZED, { error_code: MFA_ERROR_CODE.MFA_SESSION_EXPIRED }),
+ ),
+ );
+ inst.state = { ...inst.state, authFlow: FLOW.RECOVERY, recoveryCode: 'ABCD1234' };
+
+ inst.onVerifyRecovery();
+ await flush();
+
+ expect(inst.resetToPasswordFlow).toHaveBeenCalledTimes(1);
+ expect(inst.showAlert).toHaveBeenCalledWith(
+ 'Your verification session has expired. Please sign in again.',
+ 'warning',
+ );
+ });
+
+ it('mfa_rate_limit — surfaces the server message inline', async () => {
+ const msg = 'Too many attempts. Please try again later.';
+ verifyRecoveryCode.mockReturnValue(
+ Promise.reject(
+ makeError(HTTP_CODES.TOO_MANY_REQUESTS, { error_code: 'mfa_rate_limit', error_message: msg }),
+ ),
+ );
+ inst.state = { ...inst.state, authFlow: FLOW.RECOVERY, recoveryCode: 'ABCD1234' };
+
+ inst.onVerifyRecovery();
+ await flush();
+
+ expect(inst.state.errors.recovery).toBe(msg);
+ expect(inst.state.authFlow).toBe(FLOW.RECOVERY);
+ expect(inst.state.disableInput).toBe(false);
+ });
+ });
+
+ it('onContinueAfterLowRecoveryCodes — remembers the dismissal and resumes the redirect', () => {
+ inst.state = {
+ ...inst.state,
+ lowRecoveryCodesWarning: { remaining: 1, redirectUrl: 'https://idp.test/authorize' },
+ };
+
+ inst.onContinueAfterLowRecoveryCodes();
+
+ expect(sessionStorage.getItem(RECOVERY_CODES_LOW_WARNING_DISMISSED_KEY)).toBe('1');
+ expect(window.location.href).toBe('https://idp.test/authorize');
+ });
+ });
});