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
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,42 @@ test.describe('severity-1 #smoke', () => {
});
});

test.describe('Repeated sign-in does not trigger rate limit', () => {
test('navigating back and re-entering email does not cause too many requests error', async ({
target,
pages: { page, signin, relier, signinPasswordlessCode },
testAccountTracker,
}) => {
const { email } =
testAccountTracker.generatePasswordlessAccountDetails();

// First attempt: enter the passwordless flow (sends code #1)
await relier.goto('force_passwordless=true');
await relier.clickEmailFirst();
await signin.fillOutEmailFirstForm(email);

await expect(page).toHaveURL(/signin_passwordless_code/);
await expect(signinPasswordlessCode.heading).toBeVisible();

// Second attempt: go back to the RP and re-enter email (sends code #2)
await relier.goto('force_passwordless=true');
await relier.clickEmailFirst();
await signin.fillOutEmailFirstForm(email);

// Should reach the code page without a rate limit error
await expect(page).toHaveURL(/signin_passwordless_code/);
await expect(signinPasswordlessCode.heading).toBeVisible();

// Verify no error banner is displayed (e.g. "too many requests")
await expect(signinPasswordlessCode.errorBanner).not.toBeVisible();

// Complete the flow to confirm it works end-to-end
const code = await target.emailClient.getPasswordlessSignupCode(email);
await signinPasswordlessCode.fillOutCodeForm(code);
expect(await relier.isLoggedIn()).toBe(true);
});
});

test.describe('Error cases', () => {
test('passwordless - invalid code', async ({
target,
Expand Down
4 changes: 2 additions & 2 deletions packages/fxa-auth-server/config/rate-limit-rules.txt
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,8 @@ passkeyDelete : ip_uid : 100 : 15 mi
# Passwordless Authentication OTP Limits
# Controls the rate at which passwordless OTP codes can be sent and verified
#
passwordlessSendOtp : email : 2 : 15 minutes : 15 minutes : block
passwordlessSendOtp : email : 5 : 24 hours : 12 hours : block
passwordlessSendOtp : email : 5 : 15 minutes : 15 minutes : block
passwordlessSendOtp : email : 15 : 24 hours : 15 minutes : block
passwordlessSendOtp : ip : 50 : 24 hours : 12 hours : block
passwordlessSendOtp : ip : 20 : 15 minutes : 30 minutes : block
passwordlessSendOtp : ip : 100 : 24 hours : 15 minutes : ban
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ const FRONTEND_ROUTES = [
'signin_recovery_code',
'signin_recovery_choice',
'signin_recovery_phone',
'signin_passwordless_code',
'oauth/signin_passwordless_code',
'signin_confirmed',
// TODO: FXA-13100 - Uncomment when passkey fallback is fully implemented
// 'signin_passkey_fallback',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ const getReactRouteGroups = (showReactApp, reactRoute) => {
'inline_recovery_key_setup',
'signin_push_code',
'signin_push_code_confirm',
'signin_passwordless_code',
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'oauth/signin_passwordless_code',
]),
fullProdRollout: true,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,23 @@ describe('SigninPasswordlessCode container', () => {
// Even if component re-renders, should not send again
expect(mockAuthClient.passwordlessSendCode).toHaveBeenCalledTimes(1);
});

it('does not re-send code when codeSent is in location state (page refresh)', async () => {
// Simulate page refresh: location state persists via History API
// with codeSent: true from the previous render's history.replaceState
mockLocationState = {
...createMockPasswordlessLocationState(),
codeSent: true,
};
await render();

await waitFor(() => {
expect(screen.getByText('signin passwordless code mock')).toBeInTheDocument();
});

// Should NOT send code because location state has codeSent: true
expect(mockAuthClient.passwordlessSendCode).not.toHaveBeenCalled();
});
});

describe('isSignup flag', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,17 @@ const SigninPasswordlessCodeContainer = ({
integration
);

const [codeSent, setCodeSent] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);

const email = location.state?.email;
const service = location.state?.service;
const isSignup = location.state?.isSignup;

const [codeSent, setCodeSent] = useState(
// If location state already has codeSent (persisted across page refresh
// via the History API), skip sending again.
() => location.state?.codeSent === true
);
const [sendError, setSendError] = useState<string | null>(null);

const cmsInfo = integration.getCmsInfo();
// Use SigninTokenCodePage layout as fallback since SigninPasswordlessCodePage doesn't exist yet
const splitLayout = (cmsInfo as any)?.SigninPasswordlessCodePage?.splitLayout ||
Expand All @@ -49,20 +53,28 @@ const SigninPasswordlessCodeContainer = ({
}
}, [email, navigateWithQuery]);

// Send the initial code when component mounts
// Send the initial code when component mounts, but skip if already sent
// (e.g. after a page refresh). On success, replace the current history
// entry with codeSent: true so the browser-persisted location state
// prevents re-sending on refresh.
useEffect(() => {
if (email && !codeSent) {
const sendCode = async () => {
try {
await authClient.passwordlessSendCode(email, { clientId: integration.getClientId() });
setCodeSent(true);
// Persist codeSent in location state so it survives page refresh
navigateWithQuery(location.pathname + location.search, {
replace: true,
state: { ...location.state, codeSent: true },
});
} catch (error: any) {
setSendError(error.message || 'Failed to send code');
}
};
sendCode();
}
}, [email, service, codeSent, authClient, integration]);
}, [email, service, codeSent, authClient, integration, navigateWithQuery, location.pathname, location.search, location.state]);

if (!email) {
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ export interface PasswordlessLocationState {
service?: string;
// True if user came from signup flow (new account)
isSignup?: boolean;
// Set to true after the initial OTP code has been sent, persisted in
// location state via history.replaceState so it survives page refreshes.
codeSent?: boolean;
}

export interface SigninPasswordlessCodeContainerProps {
Expand Down
Loading