Skip to content

Commit d9aa53d

Browse files
committed
feat(auth-machine): flag-gated auth state machine for sign-in, sign-up and reset
Because: - Post-authentication navigation was scattered across leaf handlers, making routing hard to reason about and inconsistent across integration types (plain web, Sync, Firefox-non-sync, OAuth web, OAuth native). This commit: - Adds a hand-rolled funnel state machine (funnelReducer) plus pure routing functions that own the post-auth destination decision, while legacy leaf handlers keep performing the side effects (no double execution). - Gates all behavior behind a tri-state authStateMachine override (?authStateMachine=true|false forces on/off, absent falls back to config). - Routes sign-in, post-signup-confirmation, reset-password (post-OTP decision, recovery-choice, completion handoff), the Settings AAL2 access guard and the InlineTotpSetup post-setup redirect through the machine. - Adds exhaustive Playwright E2E coverage under tests/authMachine/ spanning all integration types for sign-in, sign-up, reset, TOTP, unblock (FXA-12084), the AAL2 guard and the off-switch, plus unit coverage of the routing rules. - Makes the TOTP-setup page-object helper recovery-phone-availability aware so the recovery-method chooser is skipped when it is unavailable.
1 parent 630479e commit d9aa53d

59 files changed

Lines changed: 5475 additions & 141 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/functional-tests/pages/settings/totp.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,10 +193,15 @@ export class TotpPage extends SettingsLayout {
193193
}
194194

195195
async setUpTwoStepAuthWithQrAndBackupCodesChoice(
196-
credentials: Credentials
196+
credentials: Credentials,
197+
recoveryPhoneAvailable = true
197198
): Promise<TotpCredentials> {
198199
const secret = await this.setUp2faAppWithQrCode(credentials);
199-
await this.chooseBackupCodesOption();
200+
// The recovery-method chooser only renders when recovery phone is available
201+
// (auth-server geo + region check); otherwise setup goes straight to backup codes.
202+
if (recoveryPhoneAvailable) {
203+
await this.chooseBackupCodesOption();
204+
}
200205
const recoveryCodes = await this.backupCodesDownloadStep();
201206
await this.confirmBackupCodeStep(recoveryCodes[0]);
202207
return { secret, recoveryCodes };
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { getTotpCode } from '../../lib/totp';
6+
import { expect, test } from '../../lib/fixtures/standard';
7+
import { FirefoxCommand } from '../../lib/channels';
8+
import {
9+
syncDesktopOAuthQueryParams,
10+
syncMobileOAuthQueryParams,
11+
} from '../../lib/query-params';
12+
13+
/**
14+
* Auth state machine — OAuth native (Sync desktop/mobile via oauth_webchannel_v1) sign-in E2E.
15+
*
16+
* Flag delivery: authStateMachine=true is appended to the syncDesktopOAuthQueryParams /
17+
* syncMobileOAuthQueryParams set and passed to signin.goto('/authorization', params),
18+
* matching the pattern used in tests/oauth/syncSignIn.spec.ts for the same fixture.
19+
*
20+
* These tests mirror the coverage in tests/oauth/syncSignIn.spec.ts but with the
21+
* authStateMachine flag on, and additionally assert the fxaOAuthLogin and fxaLogin
22+
* web-channel messages fired by the native path.
23+
*/
24+
25+
// Base params with the machine flag set — derived from syncDesktopOAuthQueryParams.
26+
const desktopParams = (() => {
27+
const p = new URLSearchParams(syncDesktopOAuthQueryParams);
28+
p.set('authStateMachine', 'true');
29+
return p;
30+
})();
31+
32+
const mobileParams = (() => {
33+
const p = new URLSearchParams(syncMobileOAuthQueryParams);
34+
p.set('authStateMachine', 'true');
35+
return p;
36+
})();
37+
38+
test.describe('auth-machine: OAuth native (oauth_webchannel_v1) sign-in', () => {
39+
test('verified Sync-Desktop account reaches connect-another-device and fires fxaOAuthLogin + fxaLogin web-channel messages', async ({
40+
target,
41+
syncOAuthBrowserPages: {
42+
page,
43+
signin,
44+
signinTokenCode,
45+
connectAnotherDevice,
46+
},
47+
testAccountTracker,
48+
}) => {
49+
const credentials = await testAccountTracker.signUpSync();
50+
51+
// Confirm the flag is present in the URL that reaches FxA.
52+
await signin.listenToWebChannelMessages();
53+
await signin.goto('/authorization', desktopParams);
54+
await expect(page).toHaveURL(/authStateMachine=true/);
55+
56+
await signin.fillOutEmailFirstForm(credentials.email);
57+
await signin.fillOutPasswordForm(credentials.password);
58+
59+
// signUpSync uses a restmail address so a session token code is always required.
60+
await page.waitForURL(/signin_token_code/);
61+
const code = await target.emailClient.getVerifyLoginCode(credentials.email);
62+
await signinTokenCode.fillOutCodeForm(code);
63+
64+
await expect(connectAnotherDevice.fxaConnected).toBeVisible();
65+
66+
// Key native-path assertions: both web-channel messages must fire.
67+
await signin.checkWebChannelMessage(FirefoxCommand.OAuthLogin);
68+
await signin.checkWebChannelMessage(FirefoxCommand.Login);
69+
});
70+
71+
test('unverified-session Sync-Desktop account routes to /signin_token_code, then reaches Sync destination + fires web-channel messages', async ({
72+
target,
73+
syncOAuthBrowserPages: {
74+
page,
75+
signin,
76+
signinTokenCode,
77+
connectAnotherDevice,
78+
},
79+
testAccountTracker,
80+
}) => {
81+
// preVerified: 'true' — email verified but every session requires OTP confirmation.
82+
const credentials = await testAccountTracker.signUpSync({
83+
lang: 'en',
84+
service: 'sync',
85+
preVerified: 'true',
86+
});
87+
88+
await signin.listenToWebChannelMessages();
89+
await signin.goto('/authorization', desktopParams);
90+
91+
await signin.fillOutEmailFirstForm(credentials.email);
92+
await signin.fillOutPasswordForm(credentials.password);
93+
94+
await expect(page).toHaveURL(/signin_token_code/);
95+
const code = await target.emailClient.getVerifyLoginCode(credentials.email);
96+
await signinTokenCode.fillOutCodeForm(code);
97+
98+
await expect(connectAnotherDevice.fxaConnected).toBeVisible();
99+
await signin.checkWebChannelMessage(FirefoxCommand.OAuthLogin);
100+
await signin.checkWebChannelMessage(FirefoxCommand.Login);
101+
});
102+
103+
test('unverified-email account routes to /confirm_signup_code, then reaches signup_confirmed_sync', async ({
104+
target,
105+
syncOAuthBrowserPages: {
106+
page,
107+
signin,
108+
confirmSignupCode,
109+
signupConfirmedSync,
110+
},
111+
testAccountTracker,
112+
}) => {
113+
// preVerified: 'false' — email not confirmed; sign-in routes to confirm_signup_code.
114+
// After code entry the destination is signup_confirmed_sync (not connectAnotherDevice),
115+
// matching the syncSignin.spec.ts pattern for new unverified accounts.
116+
const credentials = await testAccountTracker.signUpSync({
117+
lang: 'en',
118+
service: 'sync',
119+
preVerified: 'false',
120+
});
121+
122+
await signin.listenToWebChannelMessages();
123+
await signin.goto('/authorization', desktopParams);
124+
125+
await signin.fillOutEmailFirstForm(credentials.email);
126+
await signin.fillOutPasswordForm(credentials.password);
127+
128+
await expect(page).toHaveURL(/confirm_signup_code/);
129+
const code = await target.emailClient.getVerifyLoginCode(credentials.email);
130+
await confirmSignupCode.fillOutCodeForm(code);
131+
132+
await expect(signupConfirmedSync.bannerConfirmed).toBeVisible();
133+
await signin.checkWebChannelMessage(FirefoxCommand.OAuthLogin);
134+
await signin.checkWebChannelMessage(FirefoxCommand.Login);
135+
});
136+
137+
test('TOTP-enabled Sync-Desktop account routes to /signin_totp_code then reaches Sync destination', async ({
138+
target,
139+
syncOAuthBrowserPages: {
140+
page,
141+
signin,
142+
signinTokenCode,
143+
signinTotpCode,
144+
connectAnotherDevice,
145+
settings,
146+
totp,
147+
},
148+
testAccountTracker,
149+
}) => {
150+
const credentials = await testAccountTracker.signUpSync();
151+
152+
// Enable TOTP via a non-Sync settings session first.
153+
await page.goto(target.contentServerUrl);
154+
await signin.fillOutEmailFirstForm(credentials.email);
155+
await signin.fillOutPasswordForm(credentials.password);
156+
await page.waitForURL(/signin_token_code/);
157+
const setupCode = await target.emailClient.getVerifyLoginCode(
158+
credentials.email
159+
);
160+
await signinTokenCode.fillOutCodeForm(setupCode);
161+
await page.waitForURL(/settings/);
162+
await expect(settings.settingsHeading).toBeVisible();
163+
164+
await settings.totp.addButton.click();
165+
await settings.confirmMfaGuard(credentials.email);
166+
// Read recovery-phone availability so TOTP setup skips the chooser when it's unavailable.
167+
const { available: recoveryPhoneAvailable } =
168+
await target.authClient.recoveryPhoneAvailable(credentials.sessionToken);
169+
const { secret } = await totp.setUpTwoStepAuthWithQrAndBackupCodesChoice(
170+
credentials,
171+
recoveryPhoneAvailable
172+
);
173+
await expect(settings.totp.status).toHaveText('Enabled');
174+
await settings.signOut();
175+
176+
// Now sign in via native OAuth with the machine flag.
177+
await signin.listenToWebChannelMessages();
178+
await signin.goto('/authorization', desktopParams);
179+
await signin.fillOutEmailFirstForm(credentials.email);
180+
await signin.fillOutPasswordForm(credentials.password);
181+
182+
await expect(page).toHaveURL(/signin_totp_code/);
183+
const totpCode = await getTotpCode(secret);
184+
await signinTotpCode.fillOutCodeForm(totpCode);
185+
186+
await expect(connectAnotherDevice.fxaConnected).toBeVisible();
187+
await signin.checkWebChannelMessage(FirefoxCommand.OAuthLogin);
188+
await signin.checkWebChannelMessage(FirefoxCommand.Login);
189+
});
190+
191+
test('verified Sync-Mobile (iOS) account signs in and fires fxaOAuthLogin web-channel message', async ({
192+
target,
193+
syncOAuthBrowserPages: { page, signin, signinTokenCode },
194+
testAccountTracker,
195+
}) => {
196+
// syncMobileOAuthQueryParams (iOS client 1b1a3e44c54fbb58) omits service=sync,
197+
// so the post-auth destination is not connectAnotherDevice — the flow sends
198+
// OAuthLogin and Login web-channel events via the native webchannel path.
199+
const credentials = await testAccountTracker.signUpSync();
200+
201+
await signin.listenToWebChannelMessages();
202+
await signin.goto('/authorization', mobileParams);
203+
await expect(page).toHaveURL(/authStateMachine=true/);
204+
205+
await signin.fillOutEmailFirstForm(credentials.email);
206+
await signin.fillOutPasswordForm(credentials.password);
207+
208+
await page.waitForURL(/signin_token_code/);
209+
const code = await target.emailClient.getVerifyLoginCode(credentials.email);
210+
await signinTokenCode.fillOutCodeForm(code);
211+
212+
// The mobile client fires OAuthLogin (and Login) via web-channel on success.
213+
await signin.checkWebChannelMessage(FirefoxCommand.OAuthLogin);
214+
await signin.checkWebChannelMessage(FirefoxCommand.Login);
215+
});
216+
});
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { getTotpCode } from '../../lib/totp';
6+
import { expect, test } from '../../lib/fixtures/standard';
7+
8+
/**
9+
* Auth state machine — OAuth web (relying-party) sign-in E2E.
10+
*
11+
* Flag delivery: relier.goto('authStateMachine=true') puts authStateMachine=true
12+
* in window.location.search on the 123done page. When the user clicks "Email first",
13+
* 123done's authenticate() reads all current query params and forwards them to
14+
* /api/email_first?authStateMachine=true. The server spreads req.query into the OAuth
15+
* params it sends to the FxA authorization_endpoint, so authStateMachine=true lands on
16+
* the FxA /oauth/... signin URL.
17+
*
18+
* For flows that require session confirmation (signin_token_code) we navigate directly
19+
* to the FxA /authorization endpoint with the scoped-key OAuth params (same client as
20+
* oauth/signinTokenCode.spec.ts) plus authStateMachine=true, since the standard
21+
* 123done client does not request keys_jwk and therefore does not force confirmation.
22+
*/
23+
24+
const MACHINE_QUERY = 'authStateMachine=true';
25+
26+
// Same client/params as oauth/signinTokenCode.spec.ts — keys_jwk forces token-code confirmation.
27+
// Passed to relier.goto() so 123done forwards them via ...req.query to the FxA OAuth URL.
28+
const SCOPED_KEY_RELIER_QUERY =
29+
'client_id=7f368c6886429f19' +
30+
'&code_challenge=aSOwsmuRBE1ZIVtiW6bzKMaf47kCFl7duD6ZWAXdnJo' +
31+
'&code_challenge_method=S256' +
32+
'&keys_jwk=eyJrdHkiOiJFQyIsImtpZCI6Im9DNGFudFBBSFZRX1pmQ09RRUYycTRaQlZYblVNZ2xISGpVRzdtSjZHOEEiLCJjcnYiOiJQLTI1NiIsIngiOiJDeUpUSjVwbUNZb2lQQnVWOTk1UjNvNTFLZVBMaEg1Y3JaQlkwbXNxTDk0IiwieSI6IkJCWDhfcFVZeHpTaldsdXU5MFdPTVZwamIzTlpVRDAyN0xwcC04RW9vckEifQ' +
33+
'&redirect_uri=https%3A%2F%2Fmozilla.github.io%2Fnotes%2Ffxa%2Fandroid-redirect.html' +
34+
'&scope=profile%20https%3A%2F%2Fidentity.mozilla.com%2Fapps%2Fnotes' +
35+
'&authStateMachine=true';
36+
37+
test.describe('auth-machine: OAuth web sign-in', () => {
38+
test('verified account signs in and is redirected back to the RP', async ({
39+
pages: { page, signin, relier },
40+
testAccountTracker,
41+
}) => {
42+
const credentials = await testAccountTracker.signUp();
43+
44+
await relier.goto(MACHINE_QUERY);
45+
await relier.clickEmailFirst();
46+
47+
// Confirm the flag landed on the FxA OAuth signin URL before proceeding.
48+
await expect(page).toHaveURL(/authStateMachine=true/);
49+
50+
await signin.fillOutEmailFirstForm(credentials.email);
51+
await signin.fillOutPasswordForm(credentials.password);
52+
53+
expect(await relier.isLoggedIn()).toBe(true);
54+
});
55+
56+
test('unverified-session account routes to /signin_token_code then back to the redirect URI after code entry', async ({
57+
target,
58+
pages: { page, signin, relier, signinTokenCode },
59+
testAccountTracker,
60+
}) => {
61+
// signUpSync creates a sync-prefixed account. When a client requests keys_jwk
62+
// (scoped keys), the auth server requires session confirmation via token code.
63+
const credentials = await testAccountTracker.signUpSync();
64+
65+
// Use relier.goto() with the notes client params + machine flag so 123done forwards
66+
// them via ...req.query to the FxA OAuth authorization URL (same pattern as
67+
// oauth/signinTokenCode.spec.ts, but with authStateMachine=true added).
68+
await relier.goto(SCOPED_KEY_RELIER_QUERY);
69+
await relier.clickEmailFirst();
70+
await signin.fillOutEmailFirstForm(credentials.email);
71+
await signin.fillOutPasswordForm(credentials.password);
72+
73+
await expect(page).toHaveURL(/signin_token_code/);
74+
const code = await target.emailClient.getVerifyLoginCode(credentials.email);
75+
await signinTokenCode.fillOutCodeForm(code);
76+
77+
// The notes client redirects to github.io — just confirm we left the FxA domain.
78+
await expect(page).toHaveURL(/notes\/fxa/);
79+
});
80+
81+
test('unverified-email account routes to /confirm_signup_code then back to the RP after code entry', async ({
82+
target,
83+
pages: { page, signin, relier, confirmSignupCode },
84+
testAccountTracker,
85+
}) => {
86+
// preVerified: 'false' creates an account whose email has not been confirmed.
87+
const credentials = await testAccountTracker.signUp({
88+
lang: 'en',
89+
preVerified: 'false',
90+
});
91+
92+
await relier.goto(MACHINE_QUERY);
93+
await relier.clickEmailFirst();
94+
await signin.fillOutEmailFirstForm(credentials.email);
95+
await signin.fillOutPasswordForm(credentials.password);
96+
97+
await expect(page).toHaveURL(/confirm_signup_code/);
98+
const code = await target.emailClient.getVerifyLoginCode(credentials.email);
99+
await confirmSignupCode.fillOutCodeForm(code);
100+
101+
expect(await relier.isLoggedIn()).toBe(true);
102+
});
103+
104+
test('TOTP-enabled account routes to /signin_totp_code then back to the RP after code entry', async ({
105+
target,
106+
pages: { page, signin, relier, settings, totp, signinTotpCode },
107+
testAccountTracker,
108+
}) => {
109+
const credentials = await testAccountTracker.signUp();
110+
111+
// Sign in to settings via the standard non-OAuth flow and enable TOTP.
112+
await page.goto(target.contentServerUrl);
113+
await signin.fillOutEmailFirstForm(credentials.email);
114+
await signin.fillOutPasswordForm(credentials.password);
115+
await page.waitForURL(/settings/);
116+
await expect(settings.settingsHeading).toBeVisible();
117+
118+
await settings.totp.addButton.click();
119+
await settings.confirmMfaGuard(credentials.email);
120+
// Read recovery-phone availability so TOTP setup skips the chooser when it's unavailable.
121+
const { available: recoveryPhoneAvailable } =
122+
await target.authClient.recoveryPhoneAvailable(credentials.sessionToken);
123+
const { secret } = await totp.setUpTwoStepAuthWithQrAndBackupCodesChoice(
124+
credentials,
125+
recoveryPhoneAvailable
126+
);
127+
await expect(settings.totp.status).toHaveText('Enabled');
128+
await settings.signOut();
129+
130+
// Sign in via the OAuth RP with the machine flag on.
131+
await relier.goto(MACHINE_QUERY);
132+
await relier.clickEmailFirst();
133+
await signin.fillOutEmailFirstForm(credentials.email);
134+
await signin.fillOutPasswordForm(credentials.password);
135+
136+
await expect(page).toHaveURL(/signin_totp_code/);
137+
const code = await getTotpCode(secret);
138+
await signinTotpCode.fillOutCodeForm(code);
139+
140+
expect(await relier.isLoggedIn()).toBe(true);
141+
});
142+
});

0 commit comments

Comments
 (0)