From d9aa53d89bc4750d219f0ff9e4a55b98379bbb9d Mon Sep 17 00:00:00 2001 From: Vijay Budhram Date: Mon, 22 Jun 2026 08:50:56 -0400 Subject: [PATCH] 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. --- .../functional-tests/pages/settings/totp.ts | 9 +- .../authMachine/oauthNativeSignin.spec.ts | 216 +++++ .../tests/authMachine/oauthWebSignin.spec.ts | 142 +++ .../tests/authMachine/offSwitch.spec.ts | 41 + .../tests/authMachine/passkeySignin.spec.ts | 63 ++ .../tests/authMachine/resetPassword.spec.ts | 203 +++++ .../authMachine/settingsAalGuard.spec.ts | 76 ++ .../tests/authMachine/signin.spec.ts | 21 + .../tests/authMachine/signup.spec.ts | 181 ++++ .../tests/authMachine/syncSignin.spec.ts | 169 ++++ .../tests/authMachine/unblock.spec.ts | 83 ++ .../tests/authMachine/verify.spec.ts | 58 ++ .../scripts/generate-statechart.ts | 12 + .../src/components/Settings/index.test.tsx | 146 +++- .../src/components/Settings/index.tsx | 60 +- .../src/lib/auth-machine/chart.test.ts | 14 + .../src/lib/auth-machine/chart.ts | 64 ++ .../src/lib/auth-machine/context.test.ts | 38 + .../src/lib/auth-machine/context.ts | 85 ++ .../fxa-settings/src/lib/auth-machine/deps.ts | 83 ++ .../src/lib/auth-machine/effects.test.ts | 61 ++ .../src/lib/auth-machine/effects.ts | 95 ++ .../src/lib/auth-machine/flag.test.ts | 57 ++ .../fxa-settings/src/lib/auth-machine/flag.ts | 18 + .../funnel.authenticating.test.ts | 70 ++ .../funnel.signin-decider.test.ts | 80 ++ .../src/lib/auth-machine/funnel.ts | 203 +++++ .../lib/auth-machine/funnel.verifying.test.ts | 168 ++++ .../src/lib/auth-machine/guards.test.ts | 81 ++ .../src/lib/auth-machine/guards.ts | 43 + .../src/lib/auth-machine/inline.test.ts | 74 ++ .../src/lib/auth-machine/inline.ts | 44 + .../src/lib/auth-machine/mocks.ts | 31 + .../src/lib/auth-machine/reset.test.ts | 167 ++++ .../src/lib/auth-machine/reset.ts | 99 +++ .../lib/auth-machine/route-adapter.test.ts | 29 + .../src/lib/auth-machine/route-adapter.ts | 31 + .../src/lib/auth-machine/session.test.ts | 87 ++ .../src/lib/auth-machine/session.ts | 36 + .../src/lib/auth-machine/signup.test.ts | 75 ++ .../src/lib/auth-machine/signup.ts | 44 + .../src/lib/auth-machine/types.test.ts | 14 + .../src/lib/auth-machine/types.ts | 120 +++ .../lib/auth-machine/useAuthMachine.test.tsx | 48 + .../src/lib/auth-machine/useAuthMachine.ts | 62 ++ packages/fxa-settings/src/lib/config.ts | 8 +- .../src/models/pages/signin/query-params.ts | 7 +- .../src/pages/InlineTotpSetup/container.tsx | 44 +- .../CompleteResetPassword/container.test.tsx | 63 +- .../CompleteResetPassword/container.tsx | 55 +- .../ConfirmResetPassword/container.tsx | 30 +- .../container.test.tsx | 59 +- .../ResetPasswordRecoveryChoice/container.tsx | 76 +- .../Signin/container.authmachine.test.tsx | 314 +++++++ .../src/pages/Signin/container.tsx | 151 +++- .../fxa-settings/src/pages/Signin/index.tsx | 38 + .../src/pages/Signin/utils.test.ts | 826 +++++++++++++++++- .../fxa-settings/src/pages/Signin/utils.ts | 250 +++++- .../pages/Signup/ConfirmSignupCode/index.tsx | 94 +- 59 files changed, 5475 insertions(+), 141 deletions(-) create mode 100644 packages/functional-tests/tests/authMachine/oauthNativeSignin.spec.ts create mode 100644 packages/functional-tests/tests/authMachine/oauthWebSignin.spec.ts create mode 100644 packages/functional-tests/tests/authMachine/offSwitch.spec.ts create mode 100644 packages/functional-tests/tests/authMachine/passkeySignin.spec.ts create mode 100644 packages/functional-tests/tests/authMachine/resetPassword.spec.ts create mode 100644 packages/functional-tests/tests/authMachine/settingsAalGuard.spec.ts create mode 100644 packages/functional-tests/tests/authMachine/signin.spec.ts create mode 100644 packages/functional-tests/tests/authMachine/signup.spec.ts create mode 100644 packages/functional-tests/tests/authMachine/syncSignin.spec.ts create mode 100644 packages/functional-tests/tests/authMachine/unblock.spec.ts create mode 100644 packages/functional-tests/tests/authMachine/verify.spec.ts create mode 100644 packages/fxa-settings/scripts/generate-statechart.ts create mode 100644 packages/fxa-settings/src/lib/auth-machine/chart.test.ts create mode 100644 packages/fxa-settings/src/lib/auth-machine/chart.ts create mode 100644 packages/fxa-settings/src/lib/auth-machine/context.test.ts create mode 100644 packages/fxa-settings/src/lib/auth-machine/context.ts create mode 100644 packages/fxa-settings/src/lib/auth-machine/deps.ts create mode 100644 packages/fxa-settings/src/lib/auth-machine/effects.test.ts create mode 100644 packages/fxa-settings/src/lib/auth-machine/effects.ts create mode 100644 packages/fxa-settings/src/lib/auth-machine/flag.test.ts create mode 100644 packages/fxa-settings/src/lib/auth-machine/flag.ts create mode 100644 packages/fxa-settings/src/lib/auth-machine/funnel.authenticating.test.ts create mode 100644 packages/fxa-settings/src/lib/auth-machine/funnel.signin-decider.test.ts create mode 100644 packages/fxa-settings/src/lib/auth-machine/funnel.ts create mode 100644 packages/fxa-settings/src/lib/auth-machine/funnel.verifying.test.ts create mode 100644 packages/fxa-settings/src/lib/auth-machine/guards.test.ts create mode 100644 packages/fxa-settings/src/lib/auth-machine/guards.ts create mode 100644 packages/fxa-settings/src/lib/auth-machine/inline.test.ts create mode 100644 packages/fxa-settings/src/lib/auth-machine/inline.ts create mode 100644 packages/fxa-settings/src/lib/auth-machine/mocks.ts create mode 100644 packages/fxa-settings/src/lib/auth-machine/reset.test.ts create mode 100644 packages/fxa-settings/src/lib/auth-machine/reset.ts create mode 100644 packages/fxa-settings/src/lib/auth-machine/route-adapter.test.ts create mode 100644 packages/fxa-settings/src/lib/auth-machine/route-adapter.ts create mode 100644 packages/fxa-settings/src/lib/auth-machine/session.test.ts create mode 100644 packages/fxa-settings/src/lib/auth-machine/session.ts create mode 100644 packages/fxa-settings/src/lib/auth-machine/signup.test.ts create mode 100644 packages/fxa-settings/src/lib/auth-machine/signup.ts create mode 100644 packages/fxa-settings/src/lib/auth-machine/types.test.ts create mode 100644 packages/fxa-settings/src/lib/auth-machine/types.ts create mode 100644 packages/fxa-settings/src/lib/auth-machine/useAuthMachine.test.tsx create mode 100644 packages/fxa-settings/src/lib/auth-machine/useAuthMachine.ts create mode 100644 packages/fxa-settings/src/pages/Signin/container.authmachine.test.tsx diff --git a/packages/functional-tests/pages/settings/totp.ts b/packages/functional-tests/pages/settings/totp.ts index 2938174da0a..04842b243af 100644 --- a/packages/functional-tests/pages/settings/totp.ts +++ b/packages/functional-tests/pages/settings/totp.ts @@ -193,10 +193,15 @@ export class TotpPage extends SettingsLayout { } async setUpTwoStepAuthWithQrAndBackupCodesChoice( - credentials: Credentials + credentials: Credentials, + recoveryPhoneAvailable = true ): Promise { const secret = await this.setUp2faAppWithQrCode(credentials); - await this.chooseBackupCodesOption(); + // The recovery-method chooser only renders when recovery phone is available + // (auth-server geo + region check); otherwise setup goes straight to backup codes. + if (recoveryPhoneAvailable) { + await this.chooseBackupCodesOption(); + } const recoveryCodes = await this.backupCodesDownloadStep(); await this.confirmBackupCodeStep(recoveryCodes[0]); return { secret, recoveryCodes }; diff --git a/packages/functional-tests/tests/authMachine/oauthNativeSignin.spec.ts b/packages/functional-tests/tests/authMachine/oauthNativeSignin.spec.ts new file mode 100644 index 00000000000..9dde1059c5a --- /dev/null +++ b/packages/functional-tests/tests/authMachine/oauthNativeSignin.spec.ts @@ -0,0 +1,216 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { getTotpCode } from '../../lib/totp'; +import { expect, test } from '../../lib/fixtures/standard'; +import { FirefoxCommand } from '../../lib/channels'; +import { + syncDesktopOAuthQueryParams, + syncMobileOAuthQueryParams, +} from '../../lib/query-params'; + +/** + * Auth state machine — OAuth native (Sync desktop/mobile via oauth_webchannel_v1) sign-in E2E. + * + * Flag delivery: authStateMachine=true is appended to the syncDesktopOAuthQueryParams / + * syncMobileOAuthQueryParams set and passed to signin.goto('/authorization', params), + * matching the pattern used in tests/oauth/syncSignIn.spec.ts for the same fixture. + * + * These tests mirror the coverage in tests/oauth/syncSignIn.spec.ts but with the + * authStateMachine flag on, and additionally assert the fxaOAuthLogin and fxaLogin + * web-channel messages fired by the native path. + */ + +// Base params with the machine flag set — derived from syncDesktopOAuthQueryParams. +const desktopParams = (() => { + const p = new URLSearchParams(syncDesktopOAuthQueryParams); + p.set('authStateMachine', 'true'); + return p; +})(); + +const mobileParams = (() => { + const p = new URLSearchParams(syncMobileOAuthQueryParams); + p.set('authStateMachine', 'true'); + return p; +})(); + +test.describe('auth-machine: OAuth native (oauth_webchannel_v1) sign-in', () => { + test('verified Sync-Desktop account reaches connect-another-device and fires fxaOAuthLogin + fxaLogin web-channel messages', async ({ + target, + syncOAuthBrowserPages: { + page, + signin, + signinTokenCode, + connectAnotherDevice, + }, + testAccountTracker, + }) => { + const credentials = await testAccountTracker.signUpSync(); + + // Confirm the flag is present in the URL that reaches FxA. + await signin.listenToWebChannelMessages(); + await signin.goto('/authorization', desktopParams); + await expect(page).toHaveURL(/authStateMachine=true/); + + await signin.fillOutEmailFirstForm(credentials.email); + await signin.fillOutPasswordForm(credentials.password); + + // signUpSync uses a restmail address so a session token code is always required. + await page.waitForURL(/signin_token_code/); + const code = await target.emailClient.getVerifyLoginCode(credentials.email); + await signinTokenCode.fillOutCodeForm(code); + + await expect(connectAnotherDevice.fxaConnected).toBeVisible(); + + // Key native-path assertions: both web-channel messages must fire. + await signin.checkWebChannelMessage(FirefoxCommand.OAuthLogin); + await signin.checkWebChannelMessage(FirefoxCommand.Login); + }); + + test('unverified-session Sync-Desktop account routes to /signin_token_code, then reaches Sync destination + fires web-channel messages', async ({ + target, + syncOAuthBrowserPages: { + page, + signin, + signinTokenCode, + connectAnotherDevice, + }, + testAccountTracker, + }) => { + // preVerified: 'true' — email verified but every session requires OTP confirmation. + const credentials = await testAccountTracker.signUpSync({ + lang: 'en', + service: 'sync', + preVerified: 'true', + }); + + await signin.listenToWebChannelMessages(); + await signin.goto('/authorization', desktopParams); + + await signin.fillOutEmailFirstForm(credentials.email); + await signin.fillOutPasswordForm(credentials.password); + + await expect(page).toHaveURL(/signin_token_code/); + const code = await target.emailClient.getVerifyLoginCode(credentials.email); + await signinTokenCode.fillOutCodeForm(code); + + await expect(connectAnotherDevice.fxaConnected).toBeVisible(); + await signin.checkWebChannelMessage(FirefoxCommand.OAuthLogin); + await signin.checkWebChannelMessage(FirefoxCommand.Login); + }); + + test('unverified-email account routes to /confirm_signup_code, then reaches signup_confirmed_sync', async ({ + target, + syncOAuthBrowserPages: { + page, + signin, + confirmSignupCode, + signupConfirmedSync, + }, + testAccountTracker, + }) => { + // preVerified: 'false' — email not confirmed; sign-in routes to confirm_signup_code. + // After code entry the destination is signup_confirmed_sync (not connectAnotherDevice), + // matching the syncSignin.spec.ts pattern for new unverified accounts. + const credentials = await testAccountTracker.signUpSync({ + lang: 'en', + service: 'sync', + preVerified: 'false', + }); + + await signin.listenToWebChannelMessages(); + await signin.goto('/authorization', desktopParams); + + await signin.fillOutEmailFirstForm(credentials.email); + await signin.fillOutPasswordForm(credentials.password); + + await expect(page).toHaveURL(/confirm_signup_code/); + const code = await target.emailClient.getVerifyLoginCode(credentials.email); + await confirmSignupCode.fillOutCodeForm(code); + + await expect(signupConfirmedSync.bannerConfirmed).toBeVisible(); + await signin.checkWebChannelMessage(FirefoxCommand.OAuthLogin); + await signin.checkWebChannelMessage(FirefoxCommand.Login); + }); + + test('TOTP-enabled Sync-Desktop account routes to /signin_totp_code then reaches Sync destination', async ({ + target, + syncOAuthBrowserPages: { + page, + signin, + signinTokenCode, + signinTotpCode, + connectAnotherDevice, + settings, + totp, + }, + testAccountTracker, + }) => { + const credentials = await testAccountTracker.signUpSync(); + + // Enable TOTP via a non-Sync settings session first. + await page.goto(target.contentServerUrl); + await signin.fillOutEmailFirstForm(credentials.email); + await signin.fillOutPasswordForm(credentials.password); + await page.waitForURL(/signin_token_code/); + const setupCode = await target.emailClient.getVerifyLoginCode( + credentials.email + ); + await signinTokenCode.fillOutCodeForm(setupCode); + await page.waitForURL(/settings/); + await expect(settings.settingsHeading).toBeVisible(); + + await settings.totp.addButton.click(); + await settings.confirmMfaGuard(credentials.email); + // Read recovery-phone availability so TOTP setup skips the chooser when it's unavailable. + const { available: recoveryPhoneAvailable } = + await target.authClient.recoveryPhoneAvailable(credentials.sessionToken); + const { secret } = await totp.setUpTwoStepAuthWithQrAndBackupCodesChoice( + credentials, + recoveryPhoneAvailable + ); + await expect(settings.totp.status).toHaveText('Enabled'); + await settings.signOut(); + + // Now sign in via native OAuth with the machine flag. + await signin.listenToWebChannelMessages(); + await signin.goto('/authorization', desktopParams); + await signin.fillOutEmailFirstForm(credentials.email); + await signin.fillOutPasswordForm(credentials.password); + + await expect(page).toHaveURL(/signin_totp_code/); + const totpCode = await getTotpCode(secret); + await signinTotpCode.fillOutCodeForm(totpCode); + + await expect(connectAnotherDevice.fxaConnected).toBeVisible(); + await signin.checkWebChannelMessage(FirefoxCommand.OAuthLogin); + await signin.checkWebChannelMessage(FirefoxCommand.Login); + }); + + test('verified Sync-Mobile (iOS) account signs in and fires fxaOAuthLogin web-channel message', async ({ + target, + syncOAuthBrowserPages: { page, signin, signinTokenCode }, + testAccountTracker, + }) => { + // syncMobileOAuthQueryParams (iOS client 1b1a3e44c54fbb58) omits service=sync, + // so the post-auth destination is not connectAnotherDevice — the flow sends + // OAuthLogin and Login web-channel events via the native webchannel path. + const credentials = await testAccountTracker.signUpSync(); + + await signin.listenToWebChannelMessages(); + await signin.goto('/authorization', mobileParams); + await expect(page).toHaveURL(/authStateMachine=true/); + + await signin.fillOutEmailFirstForm(credentials.email); + await signin.fillOutPasswordForm(credentials.password); + + await page.waitForURL(/signin_token_code/); + const code = await target.emailClient.getVerifyLoginCode(credentials.email); + await signinTokenCode.fillOutCodeForm(code); + + // The mobile client fires OAuthLogin (and Login) via web-channel on success. + await signin.checkWebChannelMessage(FirefoxCommand.OAuthLogin); + await signin.checkWebChannelMessage(FirefoxCommand.Login); + }); +}); diff --git a/packages/functional-tests/tests/authMachine/oauthWebSignin.spec.ts b/packages/functional-tests/tests/authMachine/oauthWebSignin.spec.ts new file mode 100644 index 00000000000..81521e1b36b --- /dev/null +++ b/packages/functional-tests/tests/authMachine/oauthWebSignin.spec.ts @@ -0,0 +1,142 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { getTotpCode } from '../../lib/totp'; +import { expect, test } from '../../lib/fixtures/standard'; + +/** + * Auth state machine — OAuth web (relying-party) sign-in E2E. + * + * Flag delivery: relier.goto('authStateMachine=true') puts authStateMachine=true + * in window.location.search on the 123done page. When the user clicks "Email first", + * 123done's authenticate() reads all current query params and forwards them to + * /api/email_first?authStateMachine=true. The server spreads req.query into the OAuth + * params it sends to the FxA authorization_endpoint, so authStateMachine=true lands on + * the FxA /oauth/... signin URL. + * + * For flows that require session confirmation (signin_token_code) we navigate directly + * to the FxA /authorization endpoint with the scoped-key OAuth params (same client as + * oauth/signinTokenCode.spec.ts) plus authStateMachine=true, since the standard + * 123done client does not request keys_jwk and therefore does not force confirmation. + */ + +const MACHINE_QUERY = 'authStateMachine=true'; + +// Same client/params as oauth/signinTokenCode.spec.ts — keys_jwk forces token-code confirmation. +// Passed to relier.goto() so 123done forwards them via ...req.query to the FxA OAuth URL. +const SCOPED_KEY_RELIER_QUERY = + 'client_id=7f368c6886429f19' + + '&code_challenge=aSOwsmuRBE1ZIVtiW6bzKMaf47kCFl7duD6ZWAXdnJo' + + '&code_challenge_method=S256' + + '&keys_jwk=eyJrdHkiOiJFQyIsImtpZCI6Im9DNGFudFBBSFZRX1pmQ09RRUYycTRaQlZYblVNZ2xISGpVRzdtSjZHOEEiLCJjcnYiOiJQLTI1NiIsIngiOiJDeUpUSjVwbUNZb2lQQnVWOTk1UjNvNTFLZVBMaEg1Y3JaQlkwbXNxTDk0IiwieSI6IkJCWDhfcFVZeHpTaldsdXU5MFdPTVZwamIzTlpVRDAyN0xwcC04RW9vckEifQ' + + '&redirect_uri=https%3A%2F%2Fmozilla.github.io%2Fnotes%2Ffxa%2Fandroid-redirect.html' + + '&scope=profile%20https%3A%2F%2Fidentity.mozilla.com%2Fapps%2Fnotes' + + '&authStateMachine=true'; + +test.describe('auth-machine: OAuth web sign-in', () => { + test('verified account signs in and is redirected back to the RP', async ({ + pages: { page, signin, relier }, + testAccountTracker, + }) => { + const credentials = await testAccountTracker.signUp(); + + await relier.goto(MACHINE_QUERY); + await relier.clickEmailFirst(); + + // Confirm the flag landed on the FxA OAuth signin URL before proceeding. + await expect(page).toHaveURL(/authStateMachine=true/); + + await signin.fillOutEmailFirstForm(credentials.email); + await signin.fillOutPasswordForm(credentials.password); + + expect(await relier.isLoggedIn()).toBe(true); + }); + + test('unverified-session account routes to /signin_token_code then back to the redirect URI after code entry', async ({ + target, + pages: { page, signin, relier, signinTokenCode }, + testAccountTracker, + }) => { + // signUpSync creates a sync-prefixed account. When a client requests keys_jwk + // (scoped keys), the auth server requires session confirmation via token code. + const credentials = await testAccountTracker.signUpSync(); + + // Use relier.goto() with the notes client params + machine flag so 123done forwards + // them via ...req.query to the FxA OAuth authorization URL (same pattern as + // oauth/signinTokenCode.spec.ts, but with authStateMachine=true added). + await relier.goto(SCOPED_KEY_RELIER_QUERY); + await relier.clickEmailFirst(); + await signin.fillOutEmailFirstForm(credentials.email); + await signin.fillOutPasswordForm(credentials.password); + + await expect(page).toHaveURL(/signin_token_code/); + const code = await target.emailClient.getVerifyLoginCode(credentials.email); + await signinTokenCode.fillOutCodeForm(code); + + // The notes client redirects to github.io — just confirm we left the FxA domain. + await expect(page).toHaveURL(/notes\/fxa/); + }); + + test('unverified-email account routes to /confirm_signup_code then back to the RP after code entry', async ({ + target, + pages: { page, signin, relier, confirmSignupCode }, + testAccountTracker, + }) => { + // preVerified: 'false' creates an account whose email has not been confirmed. + const credentials = await testAccountTracker.signUp({ + lang: 'en', + preVerified: 'false', + }); + + await relier.goto(MACHINE_QUERY); + await relier.clickEmailFirst(); + await signin.fillOutEmailFirstForm(credentials.email); + await signin.fillOutPasswordForm(credentials.password); + + await expect(page).toHaveURL(/confirm_signup_code/); + const code = await target.emailClient.getVerifyLoginCode(credentials.email); + await confirmSignupCode.fillOutCodeForm(code); + + expect(await relier.isLoggedIn()).toBe(true); + }); + + test('TOTP-enabled account routes to /signin_totp_code then back to the RP after code entry', async ({ + target, + pages: { page, signin, relier, settings, totp, signinTotpCode }, + testAccountTracker, + }) => { + const credentials = await testAccountTracker.signUp(); + + // Sign in to settings via the standard non-OAuth flow and enable TOTP. + await page.goto(target.contentServerUrl); + await signin.fillOutEmailFirstForm(credentials.email); + await signin.fillOutPasswordForm(credentials.password); + await page.waitForURL(/settings/); + await expect(settings.settingsHeading).toBeVisible(); + + await settings.totp.addButton.click(); + await settings.confirmMfaGuard(credentials.email); + // Read recovery-phone availability so TOTP setup skips the chooser when it's unavailable. + const { available: recoveryPhoneAvailable } = + await target.authClient.recoveryPhoneAvailable(credentials.sessionToken); + const { secret } = await totp.setUpTwoStepAuthWithQrAndBackupCodesChoice( + credentials, + recoveryPhoneAvailable + ); + await expect(settings.totp.status).toHaveText('Enabled'); + await settings.signOut(); + + // Sign in via the OAuth RP with the machine flag on. + await relier.goto(MACHINE_QUERY); + await relier.clickEmailFirst(); + await signin.fillOutEmailFirstForm(credentials.email); + await signin.fillOutPasswordForm(credentials.password); + + await expect(page).toHaveURL(/signin_totp_code/); + const code = await getTotpCode(secret); + await signinTotpCode.fillOutCodeForm(code); + + expect(await relier.isLoggedIn()).toBe(true); + }); +}); diff --git a/packages/functional-tests/tests/authMachine/offSwitch.spec.ts b/packages/functional-tests/tests/authMachine/offSwitch.spec.ts new file mode 100644 index 00000000000..e35ff89b31a --- /dev/null +++ b/packages/functional-tests/tests/authMachine/offSwitch.spec.ts @@ -0,0 +1,41 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { expect, test } from '../../lib/fixtures/standard'; + +/** + * Auth state machine — tri-state flag off-switch E2E. + * + * The flag is tri-state: ?authStateMachine=true forces the machine on, + * ?authStateMachine=false forces it off (overriding config), absent → config. + * Config defaults OFF on this stack, so the meaningful E2E checks are that + * both flag values let a plain email+password sign-in complete normally. + */ +test.describe('auth-machine: flag off-switch', () => { + test('flag=false: plain sign-in still completes to settings (machine disabled)', async ({ + target, + page, + pages: { signin, settings }, + testAccountTracker, + }) => { + const credentials = await testAccountTracker.signUp(); + await page.goto(`${target.contentServerUrl}?authStateMachine=false`); + await signin.fillOutEmailFirstForm(credentials.email); + await signin.fillOutPasswordForm(credentials.password); + await expect(settings.settingsHeading).toBeVisible(); + }); + + test('flag=true: plain sign-in completes to settings (machine enabled)', async ({ + target, + page, + pages: { signin, settings }, + testAccountTracker, + }) => { + const credentials = await testAccountTracker.signUp(); + await page.goto(`${target.contentServerUrl}?authStateMachine=true`); + await signin.fillOutEmailFirstForm(credentials.email); + await signin.fillOutPasswordForm(credentials.password); + await expect(settings.settingsHeading).toBeVisible(); + }); +}); diff --git a/packages/functional-tests/tests/authMachine/passkeySignin.spec.ts b/packages/functional-tests/tests/authMachine/passkeySignin.spec.ts new file mode 100644 index 00000000000..5ddc48df031 --- /dev/null +++ b/packages/functional-tests/tests/authMachine/passkeySignin.spec.ts @@ -0,0 +1,63 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { expect, test } from '../../lib/fixtures/standard'; + +/** + * Auth state machine — passkey sign-in E2E. + * + * The passkey sign-in flow delegates to the same handleNavigation seam as the + * password path (lib/passkeys/signin-flow.ts), and machineOwnsNavigation has no + * passkey exclusion — so with the flag on, the machine owns the passkey post-auth + * routing decision. A passkey assertion is AAL2 and yields a verified session, so + * the machine must finalize it to settings (routeAfterVerify → finalizing.handoff). + * + * Requires the passkey feature flags; the test skips when they are unavailable. + */ +test.describe('auth-machine: passkey sign-in', () => { + test.beforeEach(async ({ pages: { configPage } }) => { + const config = await configPage.getConfig(); + test.skip( + !config.featureFlags?.passkeysEnabled || + !config.featureFlags?.passkeyRegistrationEnabled || + !config.featureFlags?.passkeyAuthenticationEnabled, + 'Passkey feature flags are not enabled' + ); + }); + + test('verified passkey sign-in is finalized to settings (machine on)', async ({ + target, + pages: { page, settings, settingsPasskeyAdd, signin }, + testAccountTracker, + }) => { + // Register a passkey on a fresh account (installs the WebAuthn polyfill). + const credentials = await testAccountTracker.signUp(); + await page.goto(target.contentServerUrl); + await signin.fillOutEmailFirstForm(credentials.email); + await signin.fillOutPasswordForm(credentials.password); + await page.waitForURL(/settings/); + await expect(settings.settingsHeading).toBeVisible(); + + await settingsPasskeyAdd.registerNewPasskey(settings, credentials.email); + + // Sign out but keep the polyfill credential discoverable. + await page.context().clearCookies(); + await page.evaluate(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + + // Sign in with the passkey, machine flag on. The machine must own the + // post-auth navigation and finalize the AAL2 passkey session to settings. + await page.goto(`${target.contentServerUrl}?authStateMachine=true`); + await expect(page).toHaveURL(/authStateMachine=true/); + + await settingsPasskeyAdd.passkeyAuth.assertion(async () => { + await signin.passkeySigninButton.click(); + await page.waitForURL(/settings/); + }); + + await expect(settings.settingsHeading).toBeVisible(); + }); +}); diff --git a/packages/functional-tests/tests/authMachine/resetPassword.spec.ts b/packages/functional-tests/tests/authMachine/resetPassword.spec.ts new file mode 100644 index 00000000000..3fb2d3a785d --- /dev/null +++ b/packages/functional-tests/tests/authMachine/resetPassword.spec.ts @@ -0,0 +1,203 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { expect, test } from '../../lib/fixtures/standard'; +import { getTotpCode } from '../../lib/totp'; + +const AUTH_MACHINE_QUERY = 'authStateMachine=true'; + +/** Auth state machine — reset-password E2E. */ +test.describe('auth-machine: reset-password', () => { + test('plain account reset (no recovery key, no TOTP) lands at settings', async ({ + target, + page, + pages: { resetPassword, settings }, + testAccountTracker, + }) => { + const credentials = await testAccountTracker.signUp(); + const newPassword = testAccountTracker.generatePassword(); + + await page.goto( + `${target.contentServerUrl}/reset_password?${AUTH_MACHINE_QUERY}` + ); + await page.waitForURL(/reset_password/); + + await resetPassword.fillOutEmailForm(credentials.email); + + const code = await target.emailClient.getResetPasswordCode( + credentials.email + ); + await resetPassword.fillOutResetPasswordCodeForm(code); + + // Machine routes to /complete_reset_password after OTP for plain account + await resetPassword.fillOutNewPasswordForm(newPassword); + + // Machine handoff to /settings on success + await expect(settings.settingsHeading).toBeVisible(); + + credentials.password = newPassword; + }); + + test('account with TOTP routes to confirm_totp_reset_password after OTP', async ({ + target, + page, + pages: { signin, resetPassword, settings, totp }, + testAccountTracker, + }) => { + const credentials = await testAccountTracker.signUp(); + const newPassword = testAccountTracker.generatePassword(); + + // Sign in and set up TOTP + await signin.goto(); + await signin.fillOutEmailFirstForm(credentials.email); + await signin.fillOutPasswordForm(credentials.password); + await expect(settings.settingsHeading).toBeVisible(); + + await settings.totp.addButton.click(); + await settings.confirmMfaGuard(credentials.email); + // Read recovery-phone availability so TOTP setup skips the chooser when it's unavailable. + const { available: recoveryPhoneAvailable } = + await target.authClient.recoveryPhoneAvailable(credentials.sessionToken); + const { secret } = await totp.setUpTwoStepAuthWithQrAndBackupCodesChoice( + credentials, + recoveryPhoneAvailable + ); + + await expect(settings.settingsHeading).toBeVisible(); + await expect(settings.totp.status).toHaveText('Enabled'); + await settings.signOut(); + + // Start reset with auth state machine flag + await page.goto( + `${target.contentServerUrl}/reset_password?${AUTH_MACHINE_QUERY}` + ); + await page.waitForURL(/reset_password/); + + await resetPassword.fillOutEmailForm(credentials.email); + + const code = await target.emailClient.getResetPasswordCode( + credentials.email + ); + await resetPassword.fillOutResetPasswordCodeForm(code); + + // Machine's routeAfterResetOtp sends TOTP accounts to confirm_totp_reset_password + await page.waitForURL(/confirm_totp_reset_password/); + await expect(page.getByLabel('Enter 6-digit code')).toBeVisible(); + + const totpCode = await getTotpCode(secret); + await resetPassword.fillOutTotpForm(totpCode); + + await resetPassword.fillOutNewPasswordForm(newPassword); + + await expect(settings.settingsHeading).toBeVisible(); + await expect(settings.alertBar).toHaveText('Your password has been reset'); + + testAccountTracker.updateAccountPassword(credentials.email, newPassword); + }); + + test('account with recovery key routes to account_recovery_confirm_key after OTP', async ({ + target, + page, + pages: { signin, resetPassword, settings, recoveryKey }, + testAccountTracker, + }) => { + const credentials = await testAccountTracker.signUp(); + const newPassword = testAccountTracker.generatePassword(); + + // Sign in and create a recovery key + await signin.goto(); + await signin.fillOutEmailFirstForm(credentials.email); + await signin.fillOutPasswordForm(credentials.password); + await expect(settings.settingsHeading).toBeVisible(); + + await settings.recoveryKey.createButton.click(); + await settings.confirmMfaGuard(credentials.email); + const key = await recoveryKey.createRecoveryKey( + credentials.password, + 'hint' + ); + await expect(settings.recoveryKey.status).toHaveText('Enabled'); + await settings.signOut(); + + // Start reset with auth state machine flag + await page.goto( + `${target.contentServerUrl}/reset_password?${AUTH_MACHINE_QUERY}` + ); + await page.waitForURL(/reset_password/); + + await resetPassword.fillOutEmailForm(credentials.email); + + const code = await target.emailClient.getResetPasswordCode( + credentials.email + ); + await resetPassword.fillOutResetPasswordCodeForm(code); + + // Machine routes to recovery key confirmation (account_recovery_confirm_key) + await expect(resetPassword.confirmRecoveryKeyHeading).toBeVisible(); + + await resetPassword.fillOutRecoveryKeyForm(key); + await resetPassword.fillOutNewPasswordForm(newPassword); + + // After using a recovery key, a new one is generated — confirm the save screen + await expect(resetPassword.passwordResetPasswordSaved).toBeVisible(); + await resetPassword.continueWithoutDownloadingRecoveryKey(); + await resetPassword.recoveryKeyFinishButton.click(); + + await expect(settings.settingsHeading).toBeVisible(); + // Recovery key is consumed and automatically regenerated + await expect(settings.recoveryKey.status).toHaveText('Enabled'); + + credentials.password = newPassword; + }); + + test('skip recovery key uses forgotKeyLink then completes reset', async ({ + target, + page, + pages: { signin, resetPassword, settings, recoveryKey }, + testAccountTracker, + }) => { + const credentials = await testAccountTracker.signUp(); + const newPassword = testAccountTracker.generatePassword(); + + // Sign in and create a recovery key + await signin.goto(); + await signin.fillOutEmailFirstForm(credentials.email); + await signin.fillOutPasswordForm(credentials.password); + await expect(settings.settingsHeading).toBeVisible(); + + await settings.recoveryKey.createButton.click(); + await settings.confirmMfaGuard(credentials.email); + await recoveryKey.createRecoveryKey(credentials.password, 'hint'); + await expect(settings.recoveryKey.status).toHaveText('Enabled'); + await settings.signOut(); + + // Start reset with auth state machine flag + await page.goto( + `${target.contentServerUrl}/reset_password?${AUTH_MACHINE_QUERY}` + ); + await page.waitForURL(/reset_password/); + + await resetPassword.fillOutEmailForm(credentials.email); + + const code = await target.emailClient.getResetPasswordCode( + credentials.email + ); + await resetPassword.fillOutResetPasswordCodeForm(code); + + // Machine routes to recovery key confirmation, but user skips it + await expect(resetPassword.confirmRecoveryKeyHeading).toBeVisible(); + await resetPassword.forgotKeyLink.click(); + + // Data-loss warning should be visible before completing reset without the key + await expect(resetPassword.dataLossWarning).toBeVisible(); + await resetPassword.fillOutNewPasswordForm(newPassword); + + await expect(settings.settingsHeading).toBeVisible(); + await expect(settings.alertBar).toHaveText('Your password has been reset'); + // Key is deleted when skipped + await expect(settings.recoveryKey.status).toHaveText('Not set'); + + credentials.password = newPassword; + }); +}); diff --git a/packages/functional-tests/tests/authMachine/settingsAalGuard.spec.ts b/packages/functional-tests/tests/authMachine/settingsAalGuard.spec.ts new file mode 100644 index 00000000000..6f6bc2a5bd7 --- /dev/null +++ b/packages/functional-tests/tests/authMachine/settingsAalGuard.spec.ts @@ -0,0 +1,76 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { getTotpCode } from '../../lib/totp'; +import { expect, test } from '../../lib/fixtures/standard'; + +/** + * Auth state machine — Settings AAL2 access guard (routeSettingsAccess) E2E. + * + * The machine owns the Settings root access decision: + * - unverified → / (handled elsewhere) + * - session below minimum AAL (TOTP account, session hasn't satisfied 2FA) + * → /signin_totp_code (isSessionAALUpgrade) + * - else → allow settings + */ +test.describe('auth-machine: settings AAL guard', () => { + test('ALLOW: fully-verified non-TOTP account reaches settings via routeSettingsAccess', async ({ + target, + page, + pages: { signin, settings }, + testAccountTracker, + }) => { + const credentials = await testAccountTracker.signUp(); + await page.goto(`${target.contentServerUrl}?authStateMachine=true`); + await signin.fillOutEmailFirstForm(credentials.email); + await signin.fillOutPasswordForm(credentials.password); + await expect(settings.settingsHeading).toBeVisible(); + + // Navigate directly to settings with flag; routeSettingsAccess must allow. + await page.goto( + `${target.contentServerUrl}/settings?authStateMachine=true` + ); + await expect(settings.settingsHeading).toBeVisible(); + }); + + test('AAL2 step-up: TOTP account session below AAL2 redirects to /signin_totp_code', async ({ + target, + page, + pages: { signin, settings, totp, signinTotpCode }, + testAccountTracker, + }) => { + const credentials = await testAccountTracker.signUp(); + + // Sign in and enable TOTP. + await page.goto(`${target.contentServerUrl}?authStateMachine=true`); + await signin.fillOutEmailFirstForm(credentials.email); + await signin.fillOutPasswordForm(credentials.password); + await expect(settings.settingsHeading).toBeVisible(); + + await settings.totp.addButton.click(); + await settings.confirmMfaGuard(credentials.email); + // Read recovery-phone availability so TOTP setup skips the chooser when it's unavailable. + const { available: recoveryPhoneAvailable } = + await target.authClient.recoveryPhoneAvailable(credentials.sessionToken); + const { secret } = await totp.setUpTwoStepAuthWithQrAndBackupCodesChoice( + credentials, + recoveryPhoneAvailable + ); + await expect(settings.totp.status).toHaveText('Enabled'); + await settings.signOut(); + + // Sign in with email+password only — session does not yet satisfy AAL2. + await page.goto(`${target.contentServerUrl}?authStateMachine=true`); + await signin.fillOutEmailFirstForm(credentials.email); + await signin.fillOutPasswordForm(credentials.password); + // The machine's routeSettingsAccess must redirect to /signin_totp_code + // because the TOTP account session hasn't satisfied the minimum AAL. + await expect(page).toHaveURL(/signin_totp_code/); + + // Complete TOTP to confirm recovery. + const code = await getTotpCode(secret); + await signinTotpCode.fillOutCodeForm(code); + await expect(settings.settingsHeading).toBeVisible(); + }); +}); diff --git a/packages/functional-tests/tests/authMachine/signin.spec.ts b/packages/functional-tests/tests/authMachine/signin.spec.ts new file mode 100644 index 00000000000..dfd04644c8e --- /dev/null +++ b/packages/functional-tests/tests/authMachine/signin.spec.ts @@ -0,0 +1,21 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { expect, test } from '../../lib/fixtures/standard'; + +/** Auth state machine — sign-in E2E. */ +test.describe('auth-machine: signin', () => { + test('email + password reaches settings (machine on)', async ({ + target, + page, + pages: { signin, settings }, + testAccountTracker, + }) => { + const credentials = await testAccountTracker.signUp(); + await page.goto(`${target.contentServerUrl}?authStateMachine=true`); + await signin.fillOutEmailFirstForm(credentials.email); + await signin.fillOutPasswordForm(credentials.password); + await expect(settings.settingsHeading).toBeVisible(); + }); +}); diff --git a/packages/functional-tests/tests/authMachine/signup.spec.ts b/packages/functional-tests/tests/authMachine/signup.spec.ts new file mode 100644 index 00000000000..f0c8a9939db --- /dev/null +++ b/packages/functional-tests/tests/authMachine/signup.spec.ts @@ -0,0 +1,181 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { expect, test } from '../../lib/fixtures/standard'; +import { FirefoxCommand } from '../../lib/channels'; +import { + syncDesktopOAuthQueryParams, + syncDesktopV3QueryParams, +} from '../../lib/query-params'; + +/** + * Auth state machine — sign-up E2E. + * + * Covers the ConfirmSignupCode routing categories that the machine handles: + * - web-settings: plain web signup → /settings + * - sync-desktop-v3: fx_desktop_v3 signup → signup_confirmed_sync + web-channel + * - oauth-resolve: OAuth RP signup → RP redirect + * - oauth-native: oauth_webchannel_v1 signup → signup_confirmed_sync + fxaOAuthLogin web-channel + * - (oauth-totp-setup: inline_totp_setup — fixme, env hang) + * + * Flag delivery per integration type: + * - plain web: `?authStateMachine=true` on contentServerUrl + * - sync fx_desktop_v3: `&authStateMachine=true` appended to sync URL + * - oauth-web (relier): relier.goto('authStateMachine=true') forwards via 123done → FxA + * - oauth-native: params.set('authStateMachine','true') on syncDesktopOAuthQueryParams + */ + +const MACHINE_QUERY = 'authStateMachine=true'; + +test.describe('auth-machine: signup', () => { + test('plain web signup → confirm code → /settings', async ({ + target, + page, + pages: { signup, confirmSignupCode, settings }, + testAccountTracker, + }) => { + const { email, password } = + testAccountTracker.generateSignupAccountDetails(); + + await page.goto( + `${target.contentServerUrl}?force_passwordless=false&forceExperiment=generalizedReactApp&forceExperimentGroup=react&authStateMachine=true` + ); + + await signup.fillOutEmailForm(email); + await signup.fillOutSignupForm(password); + + await expect(page).toHaveURL(/confirm_signup_code/); + await expect(page).toHaveURL(/authStateMachine=true/); + + const code = await target.emailClient.getVerifyShortCode(email); + await confirmSignupCode.fillOutCodeForm(code); + + await expect(settings.settingsHeading).toBeVisible(); + }); + + test('sync desktop v3 signup → confirm code → signup_confirmed_sync + web-channel Login message', async ({ + target, + syncBrowserPages: { page, signup, confirmSignupCode, signupConfirmedSync }, + testAccountTracker, + }) => { + const { email, password } = + testAccountTracker.generateSignupAccountDetails(); + + // Build the sync URL with authStateMachine flag appended + const syncParams = new URLSearchParams(syncDesktopV3QueryParams); + syncParams.set('authStateMachine', 'true'); + + await signup.listenToWebChannelMessages(); + await signup.goto('/', syncParams); + + await signup.fillOutEmailForm(email); + + await expect(signup.signupFormHeading).toBeVisible(); + + // Sync signup form requires password confirmation + await signup.fillOutSyncSignupForm(password); + + await expect(page).toHaveURL(/confirm_signup_code/); + + const code = await target.emailClient.getVerifyShortCode(email); + await confirmSignupCode.fillOutCodeForm(code); + + await expect(page).toHaveURL(/signup_confirmed_sync/); + await expect(signupConfirmedSync.bannerConfirmed).toBeVisible(); + + await signup.checkWebChannelMessage(FirefoxCommand.Login); + }); + + test('OAuth web signup via relier → confirm code → RP redirect', async ({ + target, + page, + pages: { signup, confirmSignupCode, relier }, + testAccountTracker, + }) => { + const { email, password } = + testAccountTracker.generateSignupAccountDetails(); + + // relier.goto with the machine flag; 123done forwards all query params to FxA + await relier.goto(`${MACHINE_QUERY}&force_passwordless=false`); + await relier.clickEmailFirst(); + + // Confirm the flag landed on the FxA OAuth URL + await expect(page).toHaveURL(/authStateMachine=true/); + + await signup.fillOutEmailForm(email); + await signup.fillOutSignupForm(password); + + await expect(page).toHaveURL(/confirm_signup_code/); + + const code = await target.emailClient.getVerifyShortCode(email); + await confirmSignupCode.fillOutCodeForm(code); + + // After confirmation the machine resolves the OAuth flow and redirects back to the RP + await page.waitForURL(`${target.relierUrl}/**`); + expect(await relier.isLoggedIn()).toBe(true); + }); + + test('OAuth native (oauth_webchannel_v1) signup → confirm code → signup_confirmed_sync + fxaOAuthLogin web-channel', async ({ + target, + syncOAuthBrowserPages: { + page, + signup, + confirmSignupCode, + signupConfirmedSync, + }, + testAccountTracker, + }) => { + const { email, password } = + testAccountTracker.generateSignupAccountDetails(); + + const nativeParams = new URLSearchParams(syncDesktopOAuthQueryParams); + nativeParams.set('authStateMachine', 'true'); + + await signup.listenToWebChannelMessages(); + await signup.goto('/authorization', nativeParams); + + await expect(page).toHaveURL(/authStateMachine=true/); + + await signup.fillOutEmailForm(email); + + await expect(signup.signupFormHeading).toBeVisible(); + + // Native Sync signup requires password + confirm password + await signup.fillOutSyncSignupForm(password); + + await expect(page).toHaveURL(/confirm_signup_code/); + + const code = await target.emailClient.getVerifyShortCode(email); + await confirmSignupCode.fillOutCodeForm(code); + + await expect(page).toHaveURL(/signup_confirmed_sync/); + await expect(signupConfirmedSync.bannerConfirmed).toBeVisible(); + + await signup.checkWebChannelMessage(FirefoxCommand.OAuthLogin); + await signup.checkWebChannelMessage(FirefoxCommand.Login); + }); + + test('OAuth signup requiring TOTP → inline_totp_setup', async ({ + target, + page, + pages: { signup, confirmSignupCode, relier }, + testAccountTracker, + }) => { + const { email, password } = + testAccountTracker.generateSignupAccountDetails(); + + await relier.goto(`${MACHINE_QUERY}&force_passwordless=false`); + await relier.clickRequire2FA(); + + await signup.fillOutEmailForm(email); + await signup.fillOutSignupForm(password); + + await expect(page).toHaveURL(/confirm_signup_code/); + + const code = await target.emailClient.getVerifyShortCode(email); + await confirmSignupCode.fillOutCodeForm(code); + + await expect(page).toHaveURL(/inline_totp_setup/); + }); +}); diff --git a/packages/functional-tests/tests/authMachine/syncSignin.spec.ts b/packages/functional-tests/tests/authMachine/syncSignin.spec.ts new file mode 100644 index 00000000000..428a330c8fc --- /dev/null +++ b/packages/functional-tests/tests/authMachine/syncSignin.spec.ts @@ -0,0 +1,169 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { getTotpCode } from '../../lib/totp'; +import { expect, test } from '../../lib/fixtures/standard'; +import { FirefoxCommand } from '../../lib/channels'; + +/** + * Auth state machine — Sync (fx_desktop_v3 / web-channel) sign-in E2E. + * + * All tests append &authStateMachine=true to the Sync URL so the machine + * handles the flow, then assert the same destinations the non-machine Sync + * path reaches (connectAnotherDevice.fxaConnected, /signin_token_code, etc.). + */ + +const SYNC_URL = (contentServerUrl: string) => + `${contentServerUrl}?context=fx_desktop_v3&service=sync&action=email&authStateMachine=true`; + +test.describe('auth-machine: sync sign-in', () => { + test('verified account reaches connect-another-device and fires fxaLogin web-channel message', async ({ + target, + syncBrowserPages: { page, signin, connectAnotherDevice }, + testAccountTracker, + }) => { + const credentials = await testAccountTracker.signUp(); + + await signin.listenToWebChannelMessages(); + await page.goto(SYNC_URL(target.contentServerUrl)); + await signin.fillOutEmailFirstForm(credentials.email); + await signin.fillOutPasswordForm(credentials.password); + + await expect(connectAnotherDevice.fxaConnected).toBeEnabled(); + await signin.checkWebChannelMessage(FirefoxCommand.Login); + }); + + test('unverified session routes to /signin_token_code, then reaches connect-another-device after code entry', async ({ + target, + syncBrowserPages: { page, signin, connectAnotherDevice, signinTokenCode }, + testAccountTracker, + }) => { + // signUpSync with preVerified: 'true' creates an account whose email is + // verified but whose sessions require email-OTP confirmation on each sign-in. + const credentials = await testAccountTracker.signUpSync({ + lang: 'en', + service: 'sync', + preVerified: 'true', + }); + + await page.goto(SYNC_URL(target.contentServerUrl)); + await signin.fillOutEmailFirstForm(credentials.email); + await signin.fillOutPasswordForm(credentials.password); + + await expect(page).toHaveURL(/signin_token_code/); + const code = await target.emailClient.getVerifyLoginCode(credentials.email); + await signinTokenCode.fillOutCodeForm(code); + + await expect(connectAnotherDevice.fxaConnected).toBeVisible(); + }); + + test('TOTP-enabled account routes to /signin_totp_code, then reaches connect-another-device', async ({ + target, + syncBrowserPages: { + page, + signin, + connectAnotherDevice, + settings, + totp, + signinTotpCode, + }, + testAccountTracker, + }) => { + const credentials = await testAccountTracker.signUp(); + + // Sign in to settings via the non-Sync flow and enable TOTP. + await page.goto(target.contentServerUrl); + await signin.fillOutEmailFirstForm(credentials.email); + await signin.fillOutPasswordForm(credentials.password); + await page.waitForURL(/settings/); + await expect(settings.settingsHeading).toBeVisible(); + + await settings.totp.addButton.click(); + await settings.confirmMfaGuard(credentials.email); + // Read recovery-phone availability so TOTP setup skips the chooser when it's unavailable. + const { available: recoveryPhoneAvailable } = + await target.authClient.recoveryPhoneAvailable(credentials.sessionToken); + const { secret } = await totp.setUpTwoStepAuthWithQrAndBackupCodesChoice( + credentials, + recoveryPhoneAvailable + ); + await expect(settings.totp.status).toHaveText('Enabled'); + await settings.signOut(); + + // Now sign in via Sync with the machine flag on. + await page.goto( + `${target.contentServerUrl}?context=fx_desktop_v3&service=sync&authStateMachine=true`, + { waitUntil: 'load' } + ); + await signin.fillOutEmailFirstForm(credentials.email); + await signin.fillOutPasswordForm(credentials.password); + + await expect(page).toHaveURL(/signin_totp_code/); + const code = await getTotpCode(secret); + await signinTotpCode.fillOutCodeForm(code); + + await expect(connectAnotherDevice.fxaConnected).toBeVisible(); + }); + + test('unverified account (email not confirmed) routes to /confirm_signup_code, then reaches signupConfirmedSync', async ({ + target, + syncBrowserPages: { page, signin, signupConfirmedSync, confirmSignupCode }, + testAccountTracker, + }) => { + // preVerified: 'false' creates an account whose email has not been confirmed. + const credentials = await testAccountTracker.signUpSync({ + lang: 'en', + service: 'sync', + preVerified: 'false', + }); + + await page.goto(SYNC_URL(target.contentServerUrl)); + await signin.fillOutEmailFirstForm(credentials.email); + await signin.fillOutPasswordForm(credentials.password); + + await expect(page).toHaveURL(/confirm_signup_code/); + const code = await target.emailClient.getVerifyLoginCode(credentials.email); + await confirmSignupCode.fillOutCodeForm(code); + + await expect(signupConfirmedSync.bannerConfirmed).toBeVisible(); + }); + + test('blocked account routes to /signin_unblock, then reaches connect-another-device after unblock code', async ({ + target, + syncBrowserPages: { + page, + signin, + signinUnblock, + connectAnotherDevice, + settings, + deleteAccount, + }, + testAccountTracker, + }) => { + const credentials = await testAccountTracker.signUpBlocked({ + lang: 'en', + service: 'sync', + preVerified: 'true', + }); + + await page.goto( + `${target.contentServerUrl}?context=fx_desktop_v3&service=sync&authStateMachine=true` + ); + await signin.fillOutEmailFirstForm(credentials.email); + await signin.fillOutPasswordForm(credentials.password); + + await expect(page).toHaveURL(/signin_unblock/); + const code = await target.emailClient.getUnblockCode(credentials.email); + await signinUnblock.fillOutCodeForm(code); + + await expect(connectAnotherDevice.fxaConnected).toBeVisible(); + + // Blocked accounts must be deleted before teardown can succeed. + await connectAnotherDevice.clickNotNowPair(); + await page.waitForURL(/settings/); + await settings.deleteAccountButton.click(); + await deleteAccount.deleteAccount(credentials.password); + await expect(page.getByText('Account deleted successfully')).toBeVisible(); + }); +}); diff --git a/packages/functional-tests/tests/authMachine/unblock.spec.ts b/packages/functional-tests/tests/authMachine/unblock.spec.ts new file mode 100644 index 00000000000..77d9e27ea4e --- /dev/null +++ b/packages/functional-tests/tests/authMachine/unblock.spec.ts @@ -0,0 +1,83 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { getTotpCode } from '../../lib/totp'; +import { expect, test } from '../../lib/fixtures/standard'; + +/** + * Auth state machine — sign-in unblock + 2FA E2E (the FXA-12084 chain). + * + * This is the re-enabled FXA-12084 scenario (the original lives skipped at + * tests/signin/signinBlocked.spec.ts 'sync with 2fa'). A 'blocked.'-prefixed + * account is forced through /signin_unblock on every fresh sign-in by the + * customs server — a reliable trigger, unlike the canonical's flaky + * five-wrong-passwords approach. It asserts the chain: + * correct password -> /signin_unblock -> unblock code -> /signin_totp_code + */ +test.describe('auth-machine: unblock', () => { + test('blocked account with 2fa: unblock then routes to /signin_totp_code (FXA-12084)', async ({ + target, + page, + pages: { + signin, + signinUnblock, + signinTotpCode, + settings, + totp, + deleteAccount, + }, + testAccountTracker, + }) => { + const credentials = await testAccountTracker.signUpBlocked(); + + // First sign-in is blocked; unblock to reach settings and enable TOTP. + await signin.goto(); + await signin.fillOutEmailFirstForm(credentials.email); + await signin.fillOutPasswordForm(credentials.password); + await expect(page).toHaveURL(/signin_unblock/); + const setupUnblockCode = await target.emailClient.getUnblockCode( + credentials.email + ); + await signinUnblock.fillOutCodeForm(setupUnblockCode); + await expect(settings.settingsHeading).toBeVisible(); + + await settings.totp.addButton.click(); + await settings.confirmMfaGuard(credentials.email); + // Read recovery-phone availability so TOTP setup skips the chooser when it's unavailable. + const { available: recoveryPhoneAvailable } = + await target.authClient.recoveryPhoneAvailable(credentials.sessionToken); + const { secret } = await totp.setUpTwoStepAuthWithQrAndBackupCodesChoice( + credentials, + recoveryPhoneAvailable + ); + + await expect(settings.settingsHeading).toBeVisible(); + await expect(settings.alertBar).toContainText( + 'Two-step authentication has been enabled' + ); + await expect(settings.totp.status).toHaveText('Enabled'); + await settings.signOut(); + + // Second sign-in (machine flag on): blocked again → unblock → the machine + // must route a TOTP account to /signin_totp_code, not straight to settings. + await page.goto(`${target.contentServerUrl}?authStateMachine=true`); + await signin.fillOutEmailFirstForm(credentials.email); + await signin.fillOutPasswordForm(credentials.password); + await expect(page).toHaveURL(/signin_unblock/); + const unblockCode = await target.emailClient.getUnblockCode( + credentials.email + ); + await signinUnblock.fillOutCodeForm(unblockCode); + + await expect(page).toHaveURL(/signin_totp_code/); + const code = await getTotpCode(secret); + await signinTotpCode.fillOutCodeForm(code); + await expect(settings.settingsHeading).toBeVisible(); + + // Blocked accounts must be deleted via the UI — auto-teardown cannot sign in. + await settings.deleteAccountButton.click(); + await deleteAccount.deleteAccount(credentials.password); + await expect(page.getByText('Account deleted successfully')).toBeVisible(); + }); +}); diff --git a/packages/functional-tests/tests/authMachine/verify.spec.ts b/packages/functional-tests/tests/authMachine/verify.spec.ts new file mode 100644 index 00000000000..4d731c11735 --- /dev/null +++ b/packages/functional-tests/tests/authMachine/verify.spec.ts @@ -0,0 +1,58 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { getTotpCode } from '../../lib/totp'; +import { expect, test } from '../../lib/fixtures/standard'; + +/** + * Auth state machine — verification routing E2E. + * + * The machine's verification routing — including the safety net where a TOTP + * account routes to /signin_totp_code even if the response method echoes + * email-otp — is covered at the unit level in + * src/lib/auth-machine/funnel.verifying.test.ts. This E2E re-proves it + * end-to-end against a target with the authStateMachine flag enabled. + */ +test.describe('auth-machine: verify', () => { + test('totp account routes to /signin_totp_code', async ({ + target, + page, + pages: { signin, settings, totp, signinTotpCode }, + testAccountTracker, + }) => { + const credentials = await testAccountTracker.signUp(); + + // Sign in to settings and enable TOTP, capturing the shared secret. + await signin.goto(); + await signin.fillOutEmailFirstForm(credentials.email); + await signin.fillOutPasswordForm(credentials.password); + await expect(settings.settingsHeading).toBeVisible(); + + await settings.totp.addButton.click(); + await settings.confirmMfaGuard(credentials.email); + // Read recovery-phone availability so TOTP setup skips the chooser when it's unavailable. + const { available: recoveryPhoneAvailable } = + await target.authClient.recoveryPhoneAvailable(credentials.sessionToken); + const { secret } = await totp.setUpTwoStepAuthWithQrAndBackupCodesChoice( + credentials, + recoveryPhoneAvailable + ); + + await expect(settings.settingsHeading).toBeVisible(); + await expect(settings.alertBar).toContainText( + 'Two-step authentication has been enabled' + ); + await expect(settings.totp.status).toHaveText('Enabled'); + await settings.signOut(); + + // Sign in with the machine flag on — the machine must route to TOTP. + await page.goto(`${target.contentServerUrl}?authStateMachine=true`); + await signin.fillOutEmailFirstForm(credentials.email); + await signin.fillOutPasswordForm(credentials.password); + await expect(page).toHaveURL(/signin_totp_code/); + const code = await getTotpCode(secret); + await signinTotpCode.fillOutCodeForm(code); + await expect(settings.settingsHeading).toBeVisible(); + }); +}); diff --git a/packages/fxa-settings/scripts/generate-statechart.ts b/packages/fxa-settings/scripts/generate-statechart.ts new file mode 100644 index 00000000000..000f24da567 --- /dev/null +++ b/packages/fxa-settings/scripts/generate-statechart.ts @@ -0,0 +1,12 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { writeFileSync } from 'fs'; +import { join } from 'path'; +import { transitionTableToMermaid } from '../src/lib/auth-machine/chart'; + +const out = join(__dirname, '..', 'src', 'lib', 'auth-machine', 'funnel.mmd'); +writeFileSync(out, transitionTableToMermaid()); +// eslint-disable-next-line no-console +console.log(`wrote ${out}`); diff --git a/packages/fxa-settings/src/components/Settings/index.test.tsx b/packages/fxa-settings/src/components/Settings/index.test.tsx index ec924859f6f..82203adb35f 100644 --- a/packages/fxa-settings/src/components/Settings/index.test.tsx +++ b/packages/fxa-settings/src/components/Settings/index.test.tsx @@ -207,9 +207,7 @@ describe('Settings App', () => { }); renderWithRouter( - + , { route: SETTINGS_PATH } @@ -345,6 +343,148 @@ describe('Settings App', () => { warnSpy.mockRestore(); }); + describe('auth state machine flag-on (machineEnabled = true)', () => { + const FLAG_ROUTE = SETTINGS_PATH + '?authStateMachine=true'; + + it('redirects to root when account data is unavailable in localStorage (flag-on)', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const brokenAccount = { ...MOCK_ACCOUNT } as unknown as Account; + Object.defineProperty(brokenAccount, 'primaryEmail', { + get() { + throw new Error('Account data not loaded from localStorage'); + }, + }); + + renderWithRouter( + + + , + { route: FLAG_ROUTE } + ); + + await waitFor(() => { + expect(mockNavigateWithQuery).toHaveBeenCalledWith('/'); + }); + expect(warnSpy).toHaveBeenCalledWith( + 'Account data unavailable, redirecting to sign-in' + ); + warnSpy.mockRestore(); + }); + + it('redirects to root when account email is not verified (flag-on)', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation((msg) => { + if ( + msg === + 'Account or email verification is required to access /settings!' + ) + return; + }); + const unverifiedAccount = { + ...MOCK_ACCOUNT, + primaryEmail: { ...MOCK_ACCOUNT.primaryEmail, verified: false }, + emails: MOCK_ACCOUNT.emails.map((email) => + email.isPrimary ? { ...email, verified: false } : email + ), + } as unknown as Account; + + renderWithRouter( + + + , + { route: FLAG_ROUTE } + ); + + await waitFor(() => { + expect(mockNavigateWithQuery).toHaveBeenCalledWith('/'); + }); + warnSpy.mockRestore(); + }); + + it('redirects to root when session is not verified (flag-on)', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation((msg) => { + if ( + msg === + 'Account or email verification is required to access /settings!' + ) + return; + }); + mockSessionStatus.mockResolvedValue({ + details: { + sessionVerified: false, + sessionVerificationMeetsMinimumAAL: true, + }, + }); + + renderWithRouter( + + + , + { route: FLAG_ROUTE } + ); + + await waitFor(() => { + expect(mockNavigateWithQuery).toHaveBeenCalledWith('/'); + }); + warnSpy.mockRestore(); + }); + + it('redirects to /signin_totp_code when AAL is not met (flag-on, security-critical)', async () => { + // This is the critical AAL step-up assertion: the machine path MUST redirect + // to /signin_totp_code and MUST NOT fall through to render settings. + const warnSpy = jest.spyOn(console, 'warn').mockImplementation((msg) => { + if (msg === '2FA must be entered to access /settings!') return; + }); + mockSessionStatus.mockResolvedValue({ + details: { + sessionVerified: true, + sessionVerificationMeetsMinimumAAL: false, + }, + }); + + renderWithRouter( + + + , + { route: FLAG_ROUTE } + ); + + await waitFor(() => { + expect(mockNavigateWithQuery).toHaveBeenCalledWith( + '/signin_totp_code', + { + state: { + email: MOCK_ACCOUNT.primaryEmail.email, + sessionToken: 'mock-session-token', + uid: 'mock-uid', + verified: true, + isSessionAALUpgrade: true, + }, + } + ); + }); + warnSpy.mockRestore(); + }); + + it('renders settings when all checks pass (flag-on)', async () => { + const { + getByTestId, + history: { navigate }, + } = renderWithRouter( + + + , + { route: FLAG_ROUTE } + ); + + await navigate(FLAG_ROUTE); + + expect(getByTestId('settings-profile')).toBeInTheDocument(); + }); + }); + it('routes to PageSettings', async () => { const { getByTestId, diff --git a/packages/fxa-settings/src/components/Settings/index.tsx b/packages/fxa-settings/src/components/Settings/index.tsx index 305ccab7882..de3ef7167ea 100644 --- a/packages/fxa-settings/src/components/Settings/index.tsx +++ b/packages/fxa-settings/src/components/Settings/index.tsx @@ -42,6 +42,8 @@ import { PageMfaGuardRecoveryPhoneRemove } from './PageRecoveryPhoneRemove'; import { MfaGuardPagePasskeyAdd } from './PagePasskeyAdd'; import { SettingsIntegration } from './interfaces'; import { useNavigateWithQuery } from '../../lib/hooks/useNavigateWithQuery'; +import { isAuthStateMachineEnabled } from '../../lib/auth-machine/flag'; +import { routeSettingsAccess } from '../../lib/auth-machine/session'; import PageMfaGuardTestWithAuthClient from './PageMfaGuardTest'; @@ -150,27 +152,56 @@ export const Settings = ({ return ; } - // Redirect to root if the account or session is unverified. The try-catch - // handles the case where account data may be missing from localStorage - // (e.g. WKWebView storage eviction on iOS). + // Determine whether this session may access Settings. The try-catch handles + // the case where account data may be missing from localStorage (e.g. WKWebView + // storage eviction on iOS) — a thrown read lands in the catch, never reaching + // the AAL/allow path, matching legacy behavior exactly. + let accessTarget: 'root' | 'root-missing-account' | 'totp' | 'allow'; try { - if (account.primaryEmail.verified === false || sessionVerified === false) { - console.warn( - 'Account or email verification is required to access /settings!' - ); - navigateWithQuery('/'); - return ; + const emailVerified = account.primaryEmail.verified; + const machineEnabled = isAuthStateMachineEnabled( + location.search, + config.featureFlags?.authStateMachine === true + ); + if (machineEnabled) { + const decision = routeSettingsAccess({ + emailVerified, + sessionVerified: sessionVerified === true, + sessionVerificationMeetsAAL: sessionVerificationMeetsAAL === true, + }); + accessTarget = + decision.kind === 'allow' + ? 'allow' + : decision.to === '/' + ? 'root' + : 'totp'; + } else { + // Legacy inline decision, identical outcomes. + if (emailVerified === false || sessionVerified === false) { + accessTarget = 'root'; + } else if (sessionVerificationMeetsAAL === false) { + accessTarget = 'totp'; + } else { + accessTarget = 'allow'; + } } } catch { + accessTarget = 'root-missing-account'; + } + + if (accessTarget === 'root') { + console.warn( + 'Account or email verification is required to access /settings!' + ); + navigateWithQuery('/'); + return ; + } + if (accessTarget === 'root-missing-account') { console.warn('Account data unavailable, redirecting to sign-in'); navigateWithQuery('/'); return ; } - - // This happens when a multi-device user sets up 2FA on device A and tries - // to access Settings on device B. If they haven't upgraded the assurance level - // on device B's session token with TOTP, we require them to. - if (sessionVerificationMeetsAAL === false) { + if (accessTarget === 'totp') { console.warn('2FA must be entered to access /settings!'); const storedAccount = currentAccount(); navigateWithQuery('/signin_totp_code', { @@ -184,6 +215,7 @@ export const Settings = ({ }); return ; } + // accessTarget === 'allow': fall through to render settings const hasPassword = account.hasPassword; diff --git a/packages/fxa-settings/src/lib/auth-machine/chart.test.ts b/packages/fxa-settings/src/lib/auth-machine/chart.test.ts new file mode 100644 index 00000000000..31b36cb2a67 --- /dev/null +++ b/packages/fxa-settings/src/lib/auth-machine/chart.test.ts @@ -0,0 +1,14 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { transitionTableToMermaid } from './chart'; + +it('emits a mermaid state diagram including a known edge', () => { + const mmd = transitionTableToMermaid(); + expect(mmd).toContain('stateDiagram-v2'); + expect(mmd).toContain( + 'identifying.index --> identifying.checkingAccountStatus' + ); + expect(mmd).toContain('verifying.totp --> finalizing.handoff'); +}); diff --git a/packages/fxa-settings/src/lib/auth-machine/chart.ts b/packages/fxa-settings/src/lib/auth-machine/chart.ts new file mode 100644 index 00000000000..c52d142db87 --- /dev/null +++ b/packages/fxa-settings/src/lib/auth-machine/chart.ts @@ -0,0 +1,64 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { funnelReducer } from './funnel'; +import { + FUNNEL_STATES, + type AuthContext, + type AuthEvent, + type FlowState, +} from './types'; + +// One representative event per type, enough to exercise every edge in the funnel. +const PROBE_EVENTS: AuthEvent[] = [ + { type: 'INTEGRATION_RESOLVED' }, + { type: 'SERVICE_UNAVAILABLE' }, + { type: 'SUBMIT_EMAIL', email: 'a@example.com' }, + { type: 'ACCOUNT_STATUS', exists: true }, + { type: 'ACCOUNT_STATUS', exists: false }, + { type: 'SUBMIT_PASSWORD', password: 'x' }, + { type: 'SIGNIN_OK', emailVerified: true, sessionVerified: false }, + { type: 'CACHED_RESULT', emailVerified: true, sessionVerified: true }, + { type: 'REQUEST_BLOCKED', canUnblock: true }, + { type: 'UNBLOCK_CODE_SENT' }, + { type: 'UNBLOCK_OK', emailVerified: true, sessionVerified: false }, + { type: 'CODE_OK' }, + { type: 'TROUBLE' }, + { type: 'CHOOSE_RECOVERY_PHONE' }, + { type: 'CHOOSE_RECOVERY_CODE' }, + { type: 'SESSION_EXPIRED' }, +]; + +// A neutral context exercising the default branches. +const PROBE_CTX = { + hasPassword: true, + emailVerified: true, + sessionVerified: false, + accountHasTotp: false, + hasRecoveryPhone: false, + hasLinkedAccount: false, + hasCachedSession: false, + passwordlessSupported: false, + isOAuth: false, + isOAuthWeb: false, + isOAuthNative: false, + isSync: false, + isWebChannelIntegration: false, + supportsKeysOptionalLogin: false, + requiresKeys: false, + wantsKeysIfPasswordEntered: false, + wantsLogin: false, + clientInfoLoadFailed: false, +} as unknown as AuthContext; + +export function transitionTableToMermaid(): string { + const edges = new Set(); + for (const from of FUNNEL_STATES) { + for (const ev of PROBE_EVENTS) { + const { state: to } = funnelReducer(from as FlowState, ev, PROBE_CTX); + if (to !== from) edges.add(` ${from} --> ${to}: ${ev.type}`); + } + } + return ['stateDiagram-v2', ...Array.from(edges).sort()].join('\n'); +} diff --git a/packages/fxa-settings/src/lib/auth-machine/context.test.ts b/packages/fxa-settings/src/lib/auth-machine/context.test.ts new file mode 100644 index 00000000000..8dac3698c17 --- /dev/null +++ b/packages/fxa-settings/src/lib/auth-machine/context.test.ts @@ -0,0 +1,38 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { buildAuthContext } from './context'; + +const integration = { + isSync: () => true, + isFirefoxNonSync: () => false, + type: 'OAuthNative', + requiresKeys: () => true, + wantsKeysIfPasswordEntered: () => false, + wantsLogin: () => false, + isOAuth: () => true, +} as any; + +describe('buildAuthContext', () => { + it('freezes Reliant capabilities from the integration', () => { + const ctx = buildAuthContext({ + integration, + stored: { + email: 'user@example.com', + hasPassword: true, + sessionToken: 'abc', + }, + live: { + accountHasTotp: true, + hasCachedSession: true, + supportsKeysOptionalLogin: false, + }, + }); + expect(ctx.isSync).toBe(true); + expect(ctx.requiresKeys).toBe(true); + expect(ctx.accountHasTotp).toBe(true); + expect(ctx.email).toBe('user@example.com'); + expect(ctx.clientInfoLoadFailed).toBe(false); + }); +}); diff --git a/packages/fxa-settings/src/lib/auth-machine/context.ts b/packages/fxa-settings/src/lib/auth-machine/context.ts new file mode 100644 index 00000000000..1c87f9d8c90 --- /dev/null +++ b/packages/fxa-settings/src/lib/auth-machine/context.ts @@ -0,0 +1,85 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import type { Integration, IntegrationType } from '../../models'; +import { isOAuthIntegration } from '../../models'; +import type { AuthContext } from './types'; +import VerificationMethods from '../../constants/verification-methods'; +import VerificationReasons from '../../constants/verification-reasons'; + +export interface BuildContextInput { + /** + * Pick only the methods/properties we actually read from Integration. + * isOAuth is a standalone function in the real codebase, but test stubs may + * supply it as a method; we accept it optionally here and fall back to + * isOAuthIntegration() for real Integration instances. + */ + integration: Pick< + Integration, + | 'isSync' + | 'isFirefoxNonSync' + | 'type' + | 'requiresKeys' + | 'wantsKeysIfPasswordEntered' + | 'wantsLogin' + | 'clientInfoLoadFailed' + > & { + /** Optional: test stubs may provide this; real Integration instances do not. */ + isOAuth?: () => boolean; + }; + stored: { + email?: string; + uid?: string; + sessionToken?: string; + hasPassword?: boolean; + emailVerified?: boolean; + sessionVerified?: boolean; + }; + live: { + accountHasTotp?: boolean; + hasCachedSession?: boolean; + supportsKeysOptionalLogin?: boolean; + hasRecoveryPhone?: boolean; + hasLinkedAccount?: boolean; + passwordlessSupported?: boolean; + verificationMethod?: VerificationMethods; + verificationReason?: VerificationReasons; + }; +} + +export function buildAuthContext(input: BuildContextInput): AuthContext { + const { integration: i, stored, live } = input; + const integrationType = i.type as IntegrationType | string; + // Support both stub-supplied isOAuth() and the real standalone helper. + const oAuth = + typeof i.isOAuth === 'function' + ? i.isOAuth() + : isOAuthIntegration(i as Integration); + + return { + email: stored.email, + uid: stored.uid, + sessionToken: stored.sessionToken, + emailVerified: stored.emailVerified ?? false, + sessionVerified: stored.sessionVerified ?? false, + verificationMethod: live.verificationMethod, + verificationReason: live.verificationReason, + accountHasTotp: live.accountHasTotp ?? false, + hasRecoveryPhone: live.hasRecoveryPhone ?? false, + hasPassword: stored.hasPassword ?? true, + hasLinkedAccount: live.hasLinkedAccount ?? false, + hasCachedSession: live.hasCachedSession ?? false, + passwordlessSupported: live.passwordlessSupported ?? false, + isOAuth: oAuth, + isOAuthWeb: integrationType === 'OAuthWeb', + isOAuthNative: integrationType === 'OAuthNative', + isSync: i.isSync(), + isWebChannelIntegration: i.isSync() || i.isFirefoxNonSync(), + supportsKeysOptionalLogin: live.supportsKeysOptionalLogin ?? false, + requiresKeys: i.requiresKeys(), + wantsKeysIfPasswordEntered: i.wantsKeysIfPasswordEntered(), + wantsLogin: i.wantsLogin(), + clientInfoLoadFailed: i.clientInfoLoadFailed ?? false, + }; +} diff --git a/packages/fxa-settings/src/lib/auth-machine/deps.ts b/packages/fxa-settings/src/lib/auth-machine/deps.ts new file mode 100644 index 00000000000..bb88bee3494 --- /dev/null +++ b/packages/fxa-settings/src/lib/auth-machine/deps.ts @@ -0,0 +1,83 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import type { useAuthClient, useSession } from '../../models'; +import type { SensitiveDataClient } from '../../lib/sensitive-data-client'; +import { cachedSignIn } from '../../pages/Signin/utils'; +import type { Integration } from '../../models'; +import type { EffectDeps } from './effects'; + +interface MachineDepInput { + authClient: ReturnType; + integration: Pick; + sensitiveDataClient: SensitiveDataClient; + sessionToken?: string; + session: ReturnType; +} + +/** + * Adapts the existing auth-client methods into the EffectDeps shape + * consumed by the auth-machine effect runner. + * + * Slice 1: minimal adapters; upgradeCredentials is a no-op placeholder + * (full credential upgrade wiring is deferred to a later slice). + */ +export function makeMachineDeps({ + authClient, + integration, + sensitiveDataClient: _sensitiveDataClient, + sessionToken, + session, +}: MachineDepInput): EffectDeps { + return { + checkAccountStatus: async (email) => { + const { exists } = await authClient.accountStatusByEmail(email, { + thirdPartyAuthStatus: true, + clientId: integration.getClientId(), + service: integration.getService(), + }); + return { exists }; + }, + + // beginSignin is a thin wrapper; the full credential-stretching path + // (v1/v2 key upgrade) remains in the legacy handler for now (Slice 1 scaffolding). + beginSignin: async ({ password: _password, unblockCode: _unblockCode }) => { + // Slice 1: flag-gate scaffolding; full submit cutover deferred to a later slice. + // This stub satisfies the EffectDeps type and keeps the module tree sound; + // machine.send('SUBMIT_PASSWORD') is not yet wired from the submit handler. + throw new Error('beginSignin: not yet wired in Slice 1'); + }, + + cachedSignin: async () => { + if (!sessionToken) { + throw new Error('cachedSignin: no sessionToken available'); + } + const result = await cachedSignIn(sessionToken, authClient, session); + if (!result.data) { + throw new Error('cachedSignin: no data returned'); + } + const { + emailVerified, + sessionVerified, + verificationMethod, + verificationReason, + } = result.data; + return { + emailVerified: emailVerified ?? false, + sessionVerified: sessionVerified ?? false, + verificationMethod: verificationMethod as string | undefined, + verificationReason: verificationReason as string | undefined, + }; + }, + + sendUnblockEmail: async () => { + // Slice 1: flag-gate scaffolding; unblock email sending deferred to a later slice. + throw new Error('sendUnblockEmail: not yet wired in Slice 1'); + }, + + upgradeCredentials: async () => { + // Slice 1: no-op; full v1→v2 upgrade is wired in the legacy beginSigninHandler. + }, + }; +} diff --git a/packages/fxa-settings/src/lib/auth-machine/effects.test.ts b/packages/fxa-settings/src/lib/auth-machine/effects.test.ts new file mode 100644 index 00000000000..7d8163a0f89 --- /dev/null +++ b/packages/fxa-settings/src/lib/auth-machine/effects.test.ts @@ -0,0 +1,61 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { runEffect } from './effects'; +import { AuthUiErrors } from '../auth-errors/auth-errors'; + +const deps = { + checkAccountStatus: jest.fn(), + beginSignin: jest.fn(), + cachedSignin: jest.fn(), + sendUnblockEmail: jest.fn(), + upgradeCredentials: jest.fn(), +}; + +beforeEach(() => jest.clearAllMocks()); + +describe('runEffect', () => { + it('CHECK_ACCOUNT_STATUS → ACCOUNT_STATUS event', async () => { + deps.checkAccountStatus.mockResolvedValue({ exists: true }); + const ev = await runEffect( + { kind: 'CHECK_ACCOUNT_STATUS', email: 'a@example.com' }, + deps as any + ); + expect(ev).toEqual({ type: 'ACCOUNT_STATUS', exists: true }); + }); + + it('BEGIN_SIGNIN maps errno 125 to REQUEST_BLOCKED with canUnblock', async () => { + deps.beginSignin.mockRejectedValue({ + errno: AuthUiErrors.REQUEST_BLOCKED.errno, + }); + const ev = await runEffect( + { kind: 'BEGIN_SIGNIN', password: 'pw' }, + deps as any + ); + expect(ev).toEqual({ type: 'REQUEST_BLOCKED', canUnblock: true }); + }); + + it('BEGIN_SIGNIN success → SIGNIN_OK carrying verification fields', async () => { + deps.beginSignin.mockResolvedValue({ + emailVerified: true, + sessionVerified: false, + verificationMethod: 'totp-2fa', + }); + const ev = await runEffect( + { kind: 'BEGIN_SIGNIN', password: 'pw' }, + deps as any + ); + expect(ev).toMatchObject({ + type: 'SIGNIN_OK', + verificationMethod: 'totp-2fa', + }); + }); + + it('UPGRADE_CREDENTIALS never blocks the funnel (returns null even on failure)', async () => { + deps.upgradeCredentials.mockRejectedValue(new Error('upgrade-failed')); + const ev = await runEffect({ kind: 'UPGRADE_CREDENTIALS' }, deps as any); + expect(ev).toBeNull(); + expect(deps.upgradeCredentials).toHaveBeenCalled(); + }); +}); diff --git a/packages/fxa-settings/src/lib/auth-machine/effects.ts b/packages/fxa-settings/src/lib/auth-machine/effects.ts new file mode 100644 index 00000000000..04a29a107ca --- /dev/null +++ b/packages/fxa-settings/src/lib/auth-machine/effects.ts @@ -0,0 +1,95 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { AuthUiErrors } from '../auth-errors/auth-errors'; +import type { AuthEvent, Effect } from './types'; + +export interface EffectDeps { + checkAccountStatus: (email: string) => Promise<{ exists: boolean }>; + // Simplified: verificationMethod/verificationReason are compared as strings in the funnel. + beginSignin: (opts: { password: string; unblockCode?: string }) => Promise<{ + emailVerified: boolean; + sessionVerified: boolean; + verificationMethod?: string; + verificationReason?: string; + }>; + cachedSignin: () => Promise<{ + emailVerified: boolean; + sessionVerified: boolean; + verificationMethod?: string; + verificationReason?: string; + }>; + sendUnblockEmail: () => Promise; + upgradeCredentials: () => Promise; +} + +export async function runEffect( + effect: Effect, + deps: EffectDeps +): Promise { + switch (effect.kind) { + case 'RESOLVE_INTEGRATION': + return { type: 'INTEGRATION_RESOLVED' }; + + case 'CHECK_ACCOUNT_STATUS': { + const { exists } = await deps.checkAccountStatus(effect.email); + return { type: 'ACCOUNT_STATUS', exists }; + } + + case 'BEGIN_SIGNIN': + try { + const r = await deps.beginSignin({ + password: effect.password, + unblockCode: effect.unblockCode, + }); + const eventType = effect.unblockCode ? 'UNBLOCK_OK' : 'SIGNIN_OK'; + return { + type: eventType, + emailVerified: r.emailVerified, + sessionVerified: r.sessionVerified, + verificationMethod: r.verificationMethod as any, + verificationReason: r.verificationReason as any, + } as AuthEvent; + } catch (e: any) { + if (e?.errno === AuthUiErrors.REQUEST_BLOCKED.errno) + return { type: 'REQUEST_BLOCKED', canUnblock: true }; + if (e?.errno === AuthUiErrors.THROTTLED.errno) + return { type: 'THROTTLED', canUnblock: true }; + throw e; // incorrect-password etc. handled by the form, not the funnel + } + + case 'CACHED_SIGNIN': { + try { + const r = await deps.cachedSignin(); + return { + type: 'CACHED_RESULT', + emailVerified: r.emailVerified, + sessionVerified: r.sessionVerified, + verificationMethod: r.verificationMethod as any, + verificationReason: r.verificationReason as any, + }; + } catch (e: any) { + if (e?.errno === AuthUiErrors.INVALID_TOKEN.errno) + return { type: 'SESSION_EXPIRED' }; + throw e; + } + } + + case 'SEND_UNBLOCK_EMAIL': + await deps.sendUnblockEmail(); + return { type: 'UNBLOCK_CODE_SENT' }; + + case 'UPGRADE_CREDENTIALS': + // Fire-and-forget: a failed upgrade must never block sign-in. + try { + await deps.upgradeCredentials(); + } catch { + /* stay v1 */ + } + return null; + + case 'DELEGATE_LEGACY': + return null; + } +} diff --git a/packages/fxa-settings/src/lib/auth-machine/flag.test.ts b/packages/fxa-settings/src/lib/auth-machine/flag.test.ts new file mode 100644 index 00000000000..e6066a99a2f --- /dev/null +++ b/packages/fxa-settings/src/lib/auth-machine/flag.test.ts @@ -0,0 +1,57 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { isAuthStateMachineEnabled } from './flag'; + +describe('isAuthStateMachineEnabled', () => { + describe('?authStateMachine=true', () => { + it('returns true even when configEnabled is false', () => { + expect(isAuthStateMachineEnabled('?authStateMachine=true', false)).toBe( + true + ); + }); + + it('returns true when configEnabled is also true', () => { + expect(isAuthStateMachineEnabled('?authStateMachine=true', true)).toBe( + true + ); + }); + }); + + describe('?authStateMachine=false', () => { + it('returns false even when configEnabled is true', () => { + expect(isAuthStateMachineEnabled('?authStateMachine=false', true)).toBe( + false + ); + }); + + it('returns false when configEnabled is also false', () => { + expect(isAuthStateMachineEnabled('?authStateMachine=false', false)).toBe( + false + ); + }); + }); + + describe('param absent (falls back to configEnabled)', () => { + it('returns true when configEnabled is true', () => { + expect(isAuthStateMachineEnabled('?other=1', true)).toBe(true); + }); + + it('returns false when configEnabled is false', () => { + expect(isAuthStateMachineEnabled('?other=1', false)).toBe(false); + }); + }); + + describe('empty or undefined search string', () => { + it('returns configEnabled when search is undefined', () => { + expect(isAuthStateMachineEnabled(undefined, true)).toBe(true); + expect(isAuthStateMachineEnabled(undefined, false)).toBe(false); + }); + + it('returns configEnabled when search is an empty string', () => { + expect(isAuthStateMachineEnabled('', true)).toBe(true); + expect(isAuthStateMachineEnabled('', false)).toBe(false); + }); + }); +}); diff --git a/packages/fxa-settings/src/lib/auth-machine/flag.ts b/packages/fxa-settings/src/lib/auth-machine/flag.ts new file mode 100644 index 00000000000..a7ba28fc2ee --- /dev/null +++ b/packages/fxa-settings/src/lib/auth-machine/flag.ts @@ -0,0 +1,18 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Resolves whether the auth state machine is enabled. An explicit + * `?authStateMachine=true|false` URL query param always wins (a force on/off + * switch for rollout and debugging); otherwise the config feature flag decides. + */ +export function isAuthStateMachineEnabled( + search: string | undefined, + configEnabled: boolean +): boolean { + const raw = new URLSearchParams(search || '').get('authStateMachine'); + if (raw === 'true') return true; + if (raw === 'false') return false; + return configEnabled; +} diff --git a/packages/fxa-settings/src/lib/auth-machine/funnel.authenticating.test.ts b/packages/fxa-settings/src/lib/auth-machine/funnel.authenticating.test.ts new file mode 100644 index 00000000000..7917942874b --- /dev/null +++ b/packages/fxa-settings/src/lib/auth-machine/funnel.authenticating.test.ts @@ -0,0 +1,70 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { funnelReducer } from './funnel'; +import VerificationMethods from '../../constants/verification-methods'; +import { makeCtx as ctx } from './mocks'; + +describe('funnelReducer: authenticating', () => { + it('password submit begins signin', () => { + const r = funnelReducer( + 'authenticating.passwordSignin', + { type: 'SUBMIT_PASSWORD', password: 'pw' }, + ctx() + ); + expect(r.state).toBe('authenticating.awaitSigninResult'); + expect(r.effects).toContainEqual({ kind: 'BEGIN_SIGNIN', password: 'pw' }); + }); + + it('blocked sign-in with canUnblock goes to the unblock gate', () => { + const r = funnelReducer( + 'authenticating.awaitSigninResult', + { type: 'REQUEST_BLOCKED', canUnblock: true }, + ctx() + ); + expect(r.state).toBe('authenticating.unblockGate'); + expect(r.effects).toContainEqual({ kind: 'SEND_UNBLOCK_EMAIL' }); + }); + + it('hard block (no unblock) is terminal-ish: stays for banner, no email', () => { + const r = funnelReducer( + 'authenticating.awaitSigninResult', + { type: 'REQUEST_BLOCKED', canUnblock: false }, + ctx() + ); + expect(r.effects).not.toContainEqual({ kind: 'SEND_UNBLOCK_EMAIL' }); + }); + + it('SIGNIN_OK with v1 password account also fires UPGRADE_CREDENTIALS', () => { + const r = funnelReducer( + 'authenticating.awaitSigninResult', + { + type: 'SIGNIN_OK', + emailVerified: true, + sessionVerified: false, + verificationMethod: VerificationMethods.TOTP_2FA, + }, + ctx({ hasPassword: true }) + ); + expect(r.effects).toContainEqual({ kind: 'UPGRADE_CREDENTIALS' }); + }); + + it('cached SIGNIN never fires UPGRADE_CREDENTIALS (no password to re-stretch)', () => { + const r = funnelReducer( + 'authenticating.cachedSignin', + { type: 'CACHED_RESULT', emailVerified: true, sessionVerified: true }, + ctx({ hasPassword: false }) + ); + expect(r.effects).not.toContainEqual({ kind: 'UPGRADE_CREDENTIALS' }); + }); + + it('unblock gate → verifying.unblock once the email is sent', () => { + const r = funnelReducer( + 'authenticating.unblockGate', + { type: 'UNBLOCK_CODE_SENT' }, + ctx() + ); + expect(r.state).toBe('verifying.unblock'); + }); +}); diff --git a/packages/fxa-settings/src/lib/auth-machine/funnel.signin-decider.test.ts b/packages/fxa-settings/src/lib/auth-machine/funnel.signin-decider.test.ts new file mode 100644 index 00000000000..6fc2b9d3ce4 --- /dev/null +++ b/packages/fxa-settings/src/lib/auth-machine/funnel.signin-decider.test.ts @@ -0,0 +1,80 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { funnelReducer } from './funnel'; +import { makeCtx as ctx } from './mocks'; + +describe('funnelReducer: bootstrapping + identifying + signinDecider', () => { + it('clientInfo failure hard-fails to serviceUnavailable', () => { + const r = funnelReducer( + 'bootstrapping.resolving', + { type: 'SERVICE_UNAVAILABLE' }, + ctx() + ); + expect(r.state).toBe('terminal.serviceUnavailable'); + }); + + it('email submit checks account status', () => { + const r = funnelReducer( + 'identifying.index', + { type: 'SUBMIT_EMAIL', email: 'a@example.com' }, + ctx() + ); + expect(r.state).toBe('identifying.checkingAccountStatus'); + expect(r.effects).toContainEqual({ + kind: 'CHECK_ACCOUNT_STATUS', + email: 'a@example.com', + }); + }); + + it('existing account auto-advances through the decider to password sign-in', () => { + const r = funnelReducer( + 'identifying.checkingAccountStatus', + { type: 'ACCOUNT_STATUS', exists: true }, + ctx() + ); + expect(r.state).toBe('authenticating.passwordSignin'); + }); + + it('non-existent account delegates to legacy (signup is Slice 4)', () => { + const r = funnelReducer( + 'identifying.checkingAccountStatus', + { type: 'ACCOUNT_STATUS', exists: false }, + ctx() + ); + expect(r.state).toBe('delegated.legacy'); + }); + + it('decider Branch B: cached when no password needed', () => { + const r = funnelReducer( + 'authenticating.signinDecider', + { type: 'INTEGRATION_RESOLVED' }, + ctx({ + hasCachedSession: true, + hasPassword: true, + supportsKeysOptionalLogin: true, + }) + ); + expect(r.state).toBe('authenticating.cachedSignin'); + expect(r.effects).toContainEqual({ kind: 'CACHED_SIGNIN' }); + }); + + it('decider Branch D: password sign-in by default', () => { + const r = funnelReducer( + 'authenticating.signinDecider', + { type: 'INTEGRATION_RESOLVED' }, + ctx() + ); + expect(r.state).toBe('authenticating.passwordSignin'); + }); + + it('decider Branch A: passwordless delegates (Slice 2)', () => { + const r = funnelReducer( + 'authenticating.signinDecider', + { type: 'INTEGRATION_RESOLVED' }, + ctx({ passwordlessSupported: true, hasPassword: false }) + ); + expect(r.state).toBe('delegated.legacy'); + }); +}); diff --git a/packages/fxa-settings/src/lib/auth-machine/funnel.ts b/packages/fxa-settings/src/lib/auth-machine/funnel.ts new file mode 100644 index 00000000000..a01dad08ad0 --- /dev/null +++ b/packages/fxa-settings/src/lib/auth-machine/funnel.ts @@ -0,0 +1,203 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { guards } from './guards'; +import VerificationReasons from '../../constants/verification-reasons'; +import type { + AuthContext, + AuthEvent, + Effect, + FlowState, + ReducerResult, +} from './types'; + +const delegate = (reason: string): ReducerResult => ({ + state: 'delegated.legacy', + effects: [{ kind: 'DELEGATE_LEGACY', reason }], +}); +const go = (state: FlowState, effects: Effect[] = []): ReducerResult => ({ + state, + effects, +}); + +// States that auto-advance when entered: they route solely on guards or +// re-apply the triggering event to pick a sub-state. funnelReducer re-reduces +// with the same event until it exits this set. +const PASSTHROUGH_STATES = new Set([ + 'authenticating.signinDecider', + 'verifying.router', +]); + +/** Public reducer: reduce once, then auto-advance through passthrough states. */ +export function funnelReducer( + state: FlowState, + event: AuthEvent, + ctx: AuthContext +): ReducerResult { + let result = reduceOnce(state, event, ctx); + const effects: Effect[] = [...result.effects]; + + while (PASSTHROUGH_STATES.has(result.state)) { + const next = reduceOnce(result.state, event, ctx); + effects.push(...next.effects); + if (next.state === result.state) break; // safety: prevent infinite loop + result = next; + } + + return { state: result.state, effects }; +} + +function reduceOnce( + state: FlowState, + event: AuthEvent, + ctx: AuthContext +): ReducerResult { + switch (state) { + case 'bootstrapping.resolving': + if (event.type === 'SERVICE_UNAVAILABLE') + return go('terminal.serviceUnavailable'); + if (event.type === 'INTEGRATION_RESOLVED') return go('identifying.index'); + return go(state); + + case 'identifying.index': + if (event.type === 'SUBMIT_EMAIL') + return go('identifying.checkingAccountStatus', [ + { kind: 'CHECK_ACCOUNT_STATUS', email: event.email }, + ]); + return go(state); + + case 'identifying.checkingAccountStatus': + if (event.type === 'ACCOUNT_STATUS') + return event.exists + ? go('authenticating.signinDecider') + : delegate('signup-out-of-slice'); + return go(state); + + case 'authenticating.signinDecider': { + if (guards.shouldRedirectToPasswordless(ctx)) + return delegate('passwordless-out-of-slice'); + if (guards.showCached(ctx)) + return go('authenticating.cachedSignin', [{ kind: 'CACHED_SIGNIN' }]); + if (guards.showAlternativeAuth(ctx)) + return delegate('third-party-out-of-slice'); + return go('authenticating.passwordSignin'); + } + + default: + return funnelReducerPart2(state, event, ctx); // Task 5 extends this + } +} + +export function funnelReducerPart2( + state: FlowState, + event: AuthEvent, + ctx: AuthContext +): ReducerResult { + switch (state) { + case 'authenticating.passwordSignin': + if (event.type === 'SUBMIT_PASSWORD') + return go('authenticating.awaitSigninResult', [ + { kind: 'BEGIN_SIGNIN', password: event.password }, + ]); + return go(state); + + case 'authenticating.cachedSignin': + if (event.type === 'SESSION_EXPIRED') + return go('authenticating.signinDecider'); // ctx.hasCachedSession flipped by hook + if (event.type === 'CACHED_RESULT') return go('verifying.router'); // no upgrade: cached has no password + return go(state); + + case 'authenticating.awaitSigninResult': { + if (event.type === 'REQUEST_BLOCKED' || event.type === 'THROTTLED') + return event.canUnblock + ? go('authenticating.unblockGate', [{ kind: 'SEND_UNBLOCK_EMAIL' }]) + : go(state); // hard block: banner only, no unblock path + if (event.type === 'SIGNIN_OK') { + const effects: Effect[] = guards.canUpgradeCredentials(ctx) + ? [{ kind: 'UPGRADE_CREDENTIALS' }] + : []; + return go('verifying.router', effects); + } + return go(state); + } + + case 'authenticating.unblockGate': + if (event.type === 'UNBLOCK_CODE_SENT') return go('verifying.unblock'); + return go(state); + + default: + return funnelReducerPart3(state, event, ctx); // Task 6 extends this + } +} + +function routeAfterVerify( + ctx: AuthContext, + ev: { + emailVerified: boolean; + sessionVerified: boolean; + verificationMethod?: AuthContext['verificationMethod']; + verificationReason?: AuthContext['verificationReason']; + } +): FlowState { + // Fully verified (email AND session): nothing left to confirm, hand off. + // A verified session on an unverified-email account must still confirm signup + // (legacy isFullyVerified requires both), so don't finalize on session alone. + if (ev.emailVerified && ev.sessionVerified) return 'finalizing.handoff'; + const merged: AuthContext = { + ...ctx, + emailVerified: ev.emailVerified, + verificationMethod: ev.verificationMethod, + }; + if (guards.needsTotp(merged)) return 'verifying.totp'; // R-18 safety net: live fact wins + // Legacy routes SIGN_UP to confirm_signup_code regardless of emailVerified. + if ( + !ev.emailVerified || + ev.verificationReason === VerificationReasons.SIGN_UP + ) + return 'delegated.legacy'; // confirm_signup_code is a later slice + return 'verifying.emailTokenCode'; +} + +export function funnelReducerPart3( + state: FlowState, + event: AuthEvent, + ctx: AuthContext +): ReducerResult { + switch (state) { + case 'verifying.router': + if ( + event.type === 'SIGNIN_OK' || + event.type === 'UNBLOCK_OK' || + event.type === 'CACHED_RESULT' + ) + return go(routeAfterVerify(ctx, event)); + return go(state); + + case 'verifying.unblock': + if (event.type === 'UNBLOCK_OK') return go('verifying.router'); + if (event.type === 'REQUEST_BLOCKED' || event.type === 'THROTTLED') + return event.canUnblock + ? go('authenticating.unblockGate', [{ kind: 'SEND_UNBLOCK_EMAIL' }]) + : go(state); + return go(state); + + case 'verifying.totp': + case 'verifying.emailTokenCode': + case 'verifying.recoveryCode': + case 'verifying.recoveryPhone': + if (event.type === 'CODE_OK') return go('finalizing.handoff'); + if (event.type === 'TROUBLE') return go('verifying.recoveryChoice'); + return go(state); + + case 'verifying.recoveryChoice': + if (event.type === 'CHOOSE_RECOVERY_PHONE') + return go('verifying.recoveryPhone'); + if (event.type === 'CHOOSE_RECOVERY_CODE') + return go('verifying.recoveryCode'); + return go(state); + + default: + return go(state); // terminal / delegated / finalizing are inert + } +} diff --git a/packages/fxa-settings/src/lib/auth-machine/funnel.verifying.test.ts b/packages/fxa-settings/src/lib/auth-machine/funnel.verifying.test.ts new file mode 100644 index 00000000000..4d5066fc09a --- /dev/null +++ b/packages/fxa-settings/src/lib/auth-machine/funnel.verifying.test.ts @@ -0,0 +1,168 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { funnelReducer } from './funnel'; +import VerificationMethods from '../../constants/verification-methods'; +import VerificationReasons from '../../constants/verification-reasons'; +import { makeCtx as ctx } from './mocks'; + +describe('funnelReducer: verifying', () => { + // The verifying.router decision matrix. Each row enters the router with a + // resolved auth event (from SIGNIN_OK / UNBLOCK_OK / CACHED_RESULT — the + // router treats all three identically) and asserts the next state. + // + // Key rules under test: + // - finalize only when email AND session are verified (legacy isFullyVerified). + // - needsTotp safety net (FXA-12084): a live accountHasTotp wins over an + // echoed EMAIL_OTP method, on every entry path (signin/unblock/cached). + // - SIGN_UP always delegates to legacy (→ /confirm_signup_code). + it.each([ + { + name: 'method=TOTP → totp', + event: { sessionVerified: false, method: VerificationMethods.TOTP_2FA }, + ctx: {}, + expected: 'verifying.totp', + }, + { + name: 'live accountHasTotp beats EMAIL_OTP → totp (safety net)', + event: { sessionVerified: false, method: VerificationMethods.EMAIL_OTP }, + ctx: { accountHasTotp: true }, + expected: 'verifying.totp', + }, + { + name: 'SIGN_UP with verified email → delegated.legacy', + event: { + sessionVerified: false, + method: VerificationMethods.EMAIL_OTP, + reason: VerificationReasons.SIGN_UP, + }, + ctx: {}, + expected: 'delegated.legacy', + }, + { + name: 'SIGN_UP with unverified email → delegated.legacy', + event: { + emailVerified: false, + sessionVerified: false, + method: VerificationMethods.EMAIL_OTP, + reason: VerificationReasons.SIGN_UP, + }, + ctx: { emailVerified: false }, + expected: 'delegated.legacy', + }, + { + name: 'verified email, no totp → emailTokenCode', + event: { sessionVerified: false, method: VerificationMethods.EMAIL_OTP }, + ctx: {}, + expected: 'verifying.emailTokenCode', + }, + { + name: 'fully verified → handoff', + event: { sessionVerified: true, method: VerificationMethods.EMAIL_OTP }, + ctx: {}, + expected: 'finalizing.handoff', + }, + { + name: 'fully verified even with totp → handoff', + event: { sessionVerified: true, method: VerificationMethods.EMAIL_OTP }, + ctx: { accountHasTotp: true }, + expected: 'finalizing.handoff', + }, + { + name: 'verified session but unverified email → delegated.legacy', + event: { + emailVerified: false, + sessionVerified: true, + method: VerificationMethods.EMAIL_OTP, + }, + ctx: { emailVerified: false }, + expected: 'delegated.legacy', + }, + ])('router (SIGNIN_OK): $name', ({ event, ctx: over, expected }) => { + const r = funnelReducer( + 'verifying.router', + { + type: 'SIGNIN_OK', + emailVerified: event.emailVerified ?? true, + sessionVerified: event.sessionVerified, + verificationMethod: event.method, + verificationReason: event.reason, + }, + ctx(over) + ); + expect(r.state).toBe(expected); + }); + + // The safety net must fire regardless of which event re-enters the router. + it.each(['UNBLOCK_OK', 'CACHED_RESULT'] as const)( + 'router (%s) with accountHasTotp → totp (per-path safety net)', + (type) => { + const r = funnelReducer( + 'verifying.router', + { + type, + emailVerified: true, + sessionVerified: false, + verificationMethod: VerificationMethods.EMAIL_OTP, + }, + ctx({ accountHasTotp: true }) + ); + expect(r.state).toBe('verifying.totp'); + } + ); + + it('router + CACHED_RESULT no totp, emailVerified → emailTokenCode', () => { + const r = funnelReducer( + 'verifying.router', + { + type: 'CACHED_RESULT', + emailVerified: true, + sessionVerified: false, + verificationMethod: VerificationMethods.EMAIL_OTP, + }, + ctx() + ); + expect(r.state).toBe('verifying.emailTokenCode'); + }); + + it('unblock OK auto-advances through the router to totp (carrying the server method, totp-2fa)', () => { + const r = funnelReducer( + 'verifying.unblock', + { + type: 'UNBLOCK_OK', + emailVerified: true, + sessionVerified: false, + verificationMethod: VerificationMethods.TOTP_2FA, + }, + ctx({ accountHasTotp: true }) + ); + expect(r.state).toBe('verifying.totp'); + }); + + it('totp success finalizes (handoff)', () => { + const r = funnelReducer( + 'verifying.totp', + { type: 'CODE_OK' }, + ctx({ sessionVerified: true }) + ); + expect(r.state).toBe('finalizing.handoff'); + }); + + it('recovery choice routes to phone/code', () => { + expect( + funnelReducer( + 'verifying.recoveryChoice', + { type: 'CHOOSE_RECOVERY_PHONE' }, + ctx() + ).state + ).toBe('verifying.recoveryPhone'); + expect( + funnelReducer( + 'verifying.recoveryChoice', + { type: 'CHOOSE_RECOVERY_CODE' }, + ctx() + ).state + ).toBe('verifying.recoveryCode'); + }); +}); diff --git a/packages/fxa-settings/src/lib/auth-machine/guards.test.ts b/packages/fxa-settings/src/lib/auth-machine/guards.test.ts new file mode 100644 index 00000000000..182708e7cad --- /dev/null +++ b/packages/fxa-settings/src/lib/auth-machine/guards.test.ts @@ -0,0 +1,81 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { guards } from './guards'; +import VerificationMethods from '../../constants/verification-methods'; +import { makeCtx } from './mocks'; + +const base = makeCtx(); + +describe('guards', () => { + it('requiresPasswordForLogin: Sync always true', () => { + expect( + guards.requiresPasswordForLogin({ ...base, requiresKeys: true }) + ).toBe(true); + }); + + it('requiresPasswordForLogin: keys-optional browser is respected', () => { + expect( + guards.requiresPasswordForLogin({ + ...base, + requiresKeys: false, + wantsKeysIfPasswordEntered: true, + supportsKeysOptionalLogin: true, + }) + ).toBe(false); + }); + + it('needsTotp is true when the live account has TOTP even if the method says email-otp (R-18 safety net)', () => { + expect( + guards.needsTotp({ + ...base, + accountHasTotp: true, + verificationMethod: VerificationMethods.EMAIL_OTP, + }) + ).toBe(true); + }); + + it('shouldRedirectToPasswordless requires no password, no linked account, no cached session', () => { + expect( + guards.shouldRedirectToPasswordless({ + ...base, + passwordlessSupported: true, + hasPassword: false, + hasLinkedAccount: false, + hasCachedSession: false, + }) + ).toBe(true); + expect( + guards.shouldRedirectToPasswordless({ + ...base, + passwordlessSupported: true, + hasPassword: false, + hasLinkedAccount: true, + }) + ).toBe(false); + }); + + it('canUpgradeCredentials is false without a password (cached/passwordless/passkey)', () => { + expect(guards.canUpgradeCredentials({ ...base, hasPassword: false })).toBe( + false + ); + }); + + it('isFullyVerified conjoins email and session', () => { + expect( + guards.isFullyVerified({ + ...base, + emailVerified: true, + sessionVerified: true, + }) + ).toBe(true); + expect( + guards.isFullyVerified({ + ...base, + emailVerified: true, + sessionVerified: false, + }) + ).toBe(false); + }); +}); diff --git a/packages/fxa-settings/src/lib/auth-machine/guards.ts b/packages/fxa-settings/src/lib/auth-machine/guards.ts new file mode 100644 index 00000000000..82ecaa8f832 --- /dev/null +++ b/packages/fxa-settings/src/lib/auth-machine/guards.ts @@ -0,0 +1,43 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import VerificationMethods from '../../constants/verification-methods'; +import type { AuthContext } from './types'; + +export const guards = { + isFullyVerified: (c: AuthContext) => c.emailVerified && c.sessionVerified, + + requiresPasswordForLogin: (c: AuthContext) => + c.requiresKeys || + (!c.supportsKeysOptionalLogin && c.wantsKeysIfPasswordEntered), + + /** R-18/R-23 safety net: prefer the live account fact over the echoed method. */ + needsTotp: (c: AuthContext) => + c.accountHasTotp || c.verificationMethod === VerificationMethods.TOTP_2FA, + + shouldRedirectToPasswordless: (c: AuthContext) => + c.passwordlessSupported && + !c.hasPassword && + !c.hasLinkedAccount && + !c.hasCachedSession && + !c.skipPasswordlessRedirect, + + showAlternativeAuth: (c: AuthContext) => c.hasLinkedAccount && !c.hasPassword, + + passwordNeeded: (c: AuthContext) => + !c.hasCachedSession || + c.requiresKeys || + (!c.supportsKeysOptionalLogin && c.wantsKeysIfPasswordEntered) || + (c.isOAuthWeb && (c.requiresKeys || c.wantsKeysIfPasswordEntered)) || + (c.isOAuth && c.wantsLogin), + + showCached: (c: AuthContext) => + c.hasCachedSession && + (!c.hasPassword || + !guards.passwordNeeded(c) || + (c.hasCachedSession && c.supportsKeysOptionalLogin)), + + /** Only a freshly-entered password can re-stretch v1→v2; cached/passwordless/passkey cannot. */ + canUpgradeCredentials: (c: AuthContext) => c.hasPassword, +}; diff --git a/packages/fxa-settings/src/lib/auth-machine/inline.test.ts b/packages/fxa-settings/src/lib/auth-machine/inline.test.ts new file mode 100644 index 00000000000..0af41ca9ec1 --- /dev/null +++ b/packages/fxa-settings/src/lib/auth-machine/inline.test.ts @@ -0,0 +1,74 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { routeAfterInlineTotpSetup, InlineTotpSetupFacts } from './inline'; + +describe('routeAfterInlineTotpSetup', () => { + const base: InlineTotpSetupFacts = { + isSignedIn: true, + hasSigninState: true, + totpVerified: false, + sessionVerified: true, + }; + + it('routes to / when the user is not signed in', () => { + expect(routeAfterInlineTotpSetup({ ...base, isSignedIn: false })).toBe('/'); + }); + + it('routes to / when signin state is absent', () => { + expect(routeAfterInlineTotpSetup({ ...base, hasSigninState: false })).toBe( + '/' + ); + }); + + it('routes to / when both isSignedIn and hasSigninState are false', () => { + expect( + routeAfterInlineTotpSetup({ + ...base, + isSignedIn: false, + hasSigninState: false, + }) + ).toBe('/'); + }); + + it('routes to /signin_totp_code when TOTP is already verified', () => { + expect(routeAfterInlineTotpSetup({ ...base, totpVerified: true })).toBe( + '/signin_totp_code' + ); + }); + + it('routes to /signin_totp_code when TOTP is verified even if session is also unverified', () => { + // TOTP-verified check has higher priority than session-unverified check. + expect( + routeAfterInlineTotpSetup({ + ...base, + totpVerified: true, + sessionVerified: false, + }) + ).toBe('/signin_totp_code'); + }); + + it('routes to /signin_token_code when the session is not verified', () => { + expect(routeAfterInlineTotpSetup({ ...base, sessionVerified: false })).toBe( + '/signin_token_code' + ); + }); + + it('returns null when signed in, state present, TOTP not verified, and session is verified', () => { + // No redirect needed; the TOTP setup UI should be shown. + expect(routeAfterInlineTotpSetup(base)).toBeNull(); + }); + + it('returns null when sessionVerified is undefined (check still in-flight)', () => { + expect( + routeAfterInlineTotpSetup({ ...base, sessionVerified: undefined }) + ).toBeNull(); + }); + + it('returns null when totpVerified is undefined (status check still in-flight)', () => { + expect( + routeAfterInlineTotpSetup({ ...base, totpVerified: undefined }) + ).toBeNull(); + }); +}); diff --git a/packages/fxa-settings/src/lib/auth-machine/inline.ts b/packages/fxa-settings/src/lib/auth-machine/inline.ts new file mode 100644 index 00000000000..f110309bd31 --- /dev/null +++ b/packages/fxa-settings/src/lib/auth-machine/inline.ts @@ -0,0 +1,44 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Destinations the InlineTotpSetup state-settled redirect can route to. + * null means "stay on the page, nothing to do yet". + */ +export type InlineTotpSetupRoute = + | '/' + | '/signin_totp_code' + | '/signin_token_code' + | null; + +export interface InlineTotpSetupFacts { + isSignedIn: boolean; + /** null when signinState is absent. */ + hasSigninState: boolean; + /** undefined while the TOTP-status check is still in-flight. */ + totpVerified?: boolean; + /** undefined while the session-verified check is still in-flight. */ + sessionVerified?: boolean; +} + +/** + * Decides where InlineTotpSetup redirects once state has settled. + * Mirrors the legacy priority order in the container's useEffect: + * missing auth context -> root; TOTP already active -> totp-code page; + * session not verified -> token-code page; otherwise stay (null). + */ +export function routeAfterInlineTotpSetup( + facts: InlineTotpSetupFacts +): InlineTotpSetupRoute { + if (!facts.isSignedIn || !facts.hasSigninState) { + return '/'; + } + if (facts.totpVerified) { + return '/signin_totp_code'; + } + if (facts.sessionVerified === false) { + return '/signin_token_code'; + } + return null; +} diff --git a/packages/fxa-settings/src/lib/auth-machine/mocks.ts b/packages/fxa-settings/src/lib/auth-machine/mocks.ts new file mode 100644 index 00000000000..0171851b11e --- /dev/null +++ b/packages/fxa-settings/src/lib/auth-machine/mocks.ts @@ -0,0 +1,31 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import type { AuthContext } from './types'; + +/** + * Canonical AuthContext for reducer/guard unit tests. Defaults to a verified + * email, unverified session, plain-web non-TOTP account; override per test. + */ +export const makeCtx = (over: Partial = {}): AuthContext => ({ + emailVerified: true, + sessionVerified: false, + accountHasTotp: false, + hasRecoveryPhone: false, + hasPassword: true, + hasLinkedAccount: false, + hasCachedSession: false, + passwordlessSupported: false, + isOAuth: false, + isOAuthWeb: false, + isOAuthNative: false, + isSync: false, + isWebChannelIntegration: false, + supportsKeysOptionalLogin: false, + requiresKeys: false, + wantsKeysIfPasswordEntered: false, + wantsLogin: false, + clientInfoLoadFailed: false, + ...over, +}); diff --git a/packages/fxa-settings/src/lib/auth-machine/reset.test.ts b/packages/fxa-settings/src/lib/auth-machine/reset.test.ts new file mode 100644 index 00000000000..2ca60bf0c15 --- /dev/null +++ b/packages/fxa-settings/src/lib/auth-machine/reset.test.ts @@ -0,0 +1,167 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + routeAfterResetOtp, + routeAfterResetComplete, + decideRecoveryChoice, + RecoveryChoiceFacts, +} from './reset'; + +describe('routeAfterResetOtp', () => { + it('routes to /confirm_totp_reset_password when totp is set and recoveryKeyExists is false', () => { + expect( + routeAfterResetOtp({ totpExists: true, recoveryKeyExists: false }) + ).toBe('/confirm_totp_reset_password'); + }); + + it('routes to /account_recovery_confirm_key when recoveryKeyExists is true and totp is true', () => { + expect( + routeAfterResetOtp({ totpExists: true, recoveryKeyExists: true }) + ).toBe('/account_recovery_confirm_key'); + }); + + it('routes to /account_recovery_confirm_key when recoveryKeyExists is true and totp is false', () => { + expect( + routeAfterResetOtp({ totpExists: false, recoveryKeyExists: true }) + ).toBe('/account_recovery_confirm_key'); + }); + + it('routes to /complete_reset_password when neither totp nor recovery key is present', () => { + expect( + routeAfterResetOtp({ totpExists: false, recoveryKeyExists: false }) + ).toBe('/complete_reset_password'); + }); + + it('routes to /complete_reset_password when totp is true but recoveryKeyExists is undefined (status check failed)', () => { + // undefined !== false, so the TOTP branch must NOT fire + expect( + routeAfterResetOtp({ totpExists: true, recoveryKeyExists: undefined }) + ).toBe('/complete_reset_password'); + }); + + it('routes to /complete_reset_password when both facts are undefined', () => { + expect(routeAfterResetOtp({})).toBe('/complete_reset_password'); + }); +}); + +describe('routeAfterResetComplete', () => { + it('routes to /signin when sessionVerified is false and isOAuthWeb is false', () => { + expect( + routeAfterResetComplete({ sessionVerified: false, isOAuthWeb: false }) + ).toBe('/signin'); + }); + + it('routes to /signin when sessionVerified is false and isOAuthWeb is true', () => { + expect( + routeAfterResetComplete({ sessionVerified: false, isOAuthWeb: true }) + ).toBe('/signin'); + }); + + it('routes to /reset_password_verified when sessionVerified is true and isOAuthWeb is true', () => { + expect( + routeAfterResetComplete({ sessionVerified: true, isOAuthWeb: true }) + ).toBe('/reset_password_verified'); + }); + + it('routes to /settings when sessionVerified is true and isOAuthWeb is false', () => { + expect( + routeAfterResetComplete({ sessionVerified: true, isOAuthWeb: false }) + ).toBe('/settings'); + }); +}); + +describe('decideRecoveryChoice', () => { + const base: RecoveryChoiceFacts = { + hasToken: true, + hasDataFetchError: false, + loading: false, + hasPhone: true, + autoSendAttempted: false, + numBackupCodes: 3, + }; + + it('returns redirect-reset when there is no token, even if a data error is also present', () => { + // Priority 1 wins over Priority 2 + expect( + decideRecoveryChoice({ + ...base, + hasToken: false, + hasDataFetchError: true, + }) + ).toBe('redirect-reset'); + }); + + it('returns redirect-reset when there is no token', () => { + expect(decideRecoveryChoice({ ...base, hasToken: false })).toBe( + 'redirect-reset' + ); + }); + + it('returns handle-data-error when a data error is present and loading is also true', () => { + // Priority 2 wins over Priority 3 + expect( + decideRecoveryChoice({ ...base, hasDataFetchError: true, loading: true }) + ).toBe('handle-data-error'); + }); + + it('returns handle-data-error when a data fetch error is present', () => { + expect(decideRecoveryChoice({ ...base, hasDataFetchError: true })).toBe( + 'handle-data-error' + ); + }); + + it('returns wait when loading is true and no phone is available', () => { + // Priority 3 wins over Priority 4 + expect( + decideRecoveryChoice({ ...base, loading: true, hasPhone: false }) + ).toBe('wait'); + }); + + it('returns wait when still loading', () => { + expect(decideRecoveryChoice({ ...base, loading: true })).toBe('wait'); + }); + + it('returns backup-codes when there is no phone even though auto-send has not been attempted', () => { + // Priority 4 wins over Priority 5 + expect( + decideRecoveryChoice({ + ...base, + hasPhone: false, + autoSendAttempted: false, + numBackupCodes: 0, + }) + ).toBe('backup-codes'); + }); + + it('returns backup-codes when there is no phone', () => { + expect(decideRecoveryChoice({ ...base, hasPhone: false })).toBe( + 'backup-codes' + ); + }); + + it('returns auto-send-phone when phone is present, auto-send not yet attempted, and no backup codes', () => { + expect( + decideRecoveryChoice({ + ...base, + autoSendAttempted: false, + numBackupCodes: 0, + }) + ).toBe('auto-send-phone'); + }); + + it('returns show-choice when auto-send was already attempted', () => { + expect( + decideRecoveryChoice({ + ...base, + autoSendAttempted: true, + numBackupCodes: 0, + }) + ).toBe('show-choice'); + }); + + it('returns show-choice when phone is present and backup codes exist', () => { + expect(decideRecoveryChoice({ ...base })).toBe('show-choice'); + }); +}); diff --git a/packages/fxa-settings/src/lib/auth-machine/reset.ts b/packages/fxa-settings/src/lib/auth-machine/reset.ts new file mode 100644 index 00000000000..27ea445c446 --- /dev/null +++ b/packages/fxa-settings/src/lib/auth-machine/reset.ts @@ -0,0 +1,99 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** Destinations the reset-password post-OTP step can route to. */ +export type ResetOtpRoute = + | '/confirm_totp_reset_password' + | '/account_recovery_confirm_key' + | '/complete_reset_password'; + +export interface ResetOtpFacts { + totpExists?: boolean; + /** undefined when the recovery-key status check failed (treated as "no key"). */ + recoveryKeyExists?: boolean; +} + +/** + * Decides where the reset-password flow goes after the email OTP is verified. + * Behaviorally identical to the legacy ConfirmResetPassword branch: a recovery + * key wins; otherwise TOTP (only when we positively know there is no recovery + * key) gates a TOTP check; otherwise go straight to the reset form. + */ +export function routeAfterResetOtp(facts: ResetOtpFacts): ResetOtpRoute { + const { totpExists, recoveryKeyExists } = facts; + if (totpExists && recoveryKeyExists === false) { + return '/confirm_totp_reset_password'; + } + if (recoveryKeyExists === true) { + return '/account_recovery_confirm_key'; + } + return '/complete_reset_password'; +} + +/** Destinations the reset-password completion step can hand off to. */ +export type ResetCompleteRoute = + | '/reset_password_verified' + | '/settings' + | '/signin'; + +export interface ResetCompleteFacts { + sessionVerified: boolean; + /** OAuth web RP only: isOAuth AND not Sync AND not Firefox-non-sync. */ + isOAuthWeb: boolean; +} + +/** + * Decides where the reset-password flow hands off after the password is reset + * (the no-recovery-key path). Behaviorally identical to the legacy + * CompleteResetPassword branch: an unverified session goes to sign-in for 2FA; + * a verified OAuth web session goes to the RP confirmation page; everything else + * (web, Sync) lands in settings. + */ +export function routeAfterResetComplete( + facts: ResetCompleteFacts +): ResetCompleteRoute { + if (!facts.sessionVerified) { + return '/signin'; + } + return facts.isOAuthWeb ? '/reset_password_verified' : '/settings'; +} + +/** The action the reset-password recovery-choice step should take. */ +export type RecoveryChoiceAction = + | 'redirect-reset' + | 'handle-data-error' + | 'wait' + | 'backup-codes' + | 'auto-send-phone' + | 'show-choice'; + +export interface RecoveryChoiceFacts { + hasToken: boolean; + hasDataFetchError: boolean; + loading: boolean; + hasPhone: boolean; + autoSendAttempted: boolean; + numBackupCodes: number; +} + +/** + * Decides what the reset-password recovery-choice step does, in the legacy + * priority order: no token sends back to reset; a data-fetch error defers to + * the caller's error handler; while still loading nothing happens; with no + * phone the user goes to backup codes; a phone-only account auto-sends an SMS + * once; otherwise the choice UI is shown. The actual side effects (SMS send, + * the data-error sub-routing) stay with the caller; this only picks the action. + */ +export function decideRecoveryChoice( + facts: RecoveryChoiceFacts +): RecoveryChoiceAction { + if (!facts.hasToken) return 'redirect-reset'; + if (facts.hasDataFetchError) return 'handle-data-error'; + if (facts.loading) return 'wait'; + if (!facts.hasPhone) return 'backup-codes'; + if (!facts.autoSendAttempted && facts.numBackupCodes === 0) { + return 'auto-send-phone'; + } + return 'show-choice'; +} diff --git a/packages/fxa-settings/src/lib/auth-machine/route-adapter.test.ts b/packages/fxa-settings/src/lib/auth-machine/route-adapter.test.ts new file mode 100644 index 00000000000..169ae27cd0f --- /dev/null +++ b/packages/fxa-settings/src/lib/auth-machine/route-adapter.test.ts @@ -0,0 +1,29 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { routeFor } from './route-adapter'; +import { FUNNEL_STATES } from './types'; + +describe('routeFor', () => { + it.each([ + ['verifying.totp', '/signin_totp_code'], + ['verifying.emailTokenCode', '/signin_token_code'], + ['verifying.unblock', '/signin_unblock'], + ['verifying.recoveryCode', '/signin_recovery_code'], + ['verifying.recoveryPhone', '/signin_recovery_phone'], + ['verifying.recoveryChoice', '/signin_recovery_choice'], + ] as const)('maps %s → %s', (state, to) => { + expect(routeFor(state)).toEqual({ to }); + }); + + it('delegated state defers to legacy navigation', () => { + expect(routeFor('delegated.legacy')).toEqual({ delegate: true }); + }); + + it('every funnel state has a decision (no gaps)', () => { + for (const s of FUNNEL_STATES) { + expect(routeFor(s as any)).toBeDefined(); + } + }); +}); diff --git a/packages/fxa-settings/src/lib/auth-machine/route-adapter.ts b/packages/fxa-settings/src/lib/auth-machine/route-adapter.ts new file mode 100644 index 00000000000..1fbf775ec7b --- /dev/null +++ b/packages/fxa-settings/src/lib/auth-machine/route-adapter.ts @@ -0,0 +1,31 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import type { FlowState } from './types'; + +export type RouteDecision = + | { to: string } + | { stay: true } + | { delegate: true }; + +const ROUTES: Partial> = { + 'identifying.index': '/signin', + 'verifying.emailTokenCode': '/signin_token_code', + 'verifying.totp': '/signin_totp_code', + 'verifying.recoveryChoice': '/signin_recovery_choice', + 'verifying.recoveryCode': '/signin_recovery_code', + 'verifying.recoveryPhone': '/signin_recovery_phone', + 'verifying.unblock': '/signin_unblock', + 'finalizing.handoff': '/settings', + 'terminal.serviceUnavailable': '/', // renders ServiceUnavailable via error boundary +}; + +export function routeFor(state: FlowState): RouteDecision { + if (state === 'delegated.legacy') return { delegate: true }; + const to = ROUTES[state]; + if (to) return { to }; + // bootstrapping / checkingAccountStatus / signinDecider / awaitSigninResult / cachedSignin / unblockGate + // are transient compute states with no route of their own. + return { stay: true }; +} diff --git a/packages/fxa-settings/src/lib/auth-machine/session.test.ts b/packages/fxa-settings/src/lib/auth-machine/session.test.ts new file mode 100644 index 00000000000..54b4ebc9f07 --- /dev/null +++ b/packages/fxa-settings/src/lib/auth-machine/session.test.ts @@ -0,0 +1,87 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { routeSettingsAccess } from './session'; + +describe('routeSettingsAccess', () => { + describe('email not verified', () => { + it('redirects to / when emailVerified is false, sessionVerified is true, AAL is met', () => { + expect( + routeSettingsAccess({ + emailVerified: false, + sessionVerified: true, + sessionVerificationMeetsAAL: true, + }) + ).toEqual({ kind: 'redirect', to: '/' }); + }); + + it('redirects to / when emailVerified is false, sessionVerified is false, AAL is not met', () => { + expect( + routeSettingsAccess({ + emailVerified: false, + sessionVerified: false, + sessionVerificationMeetsAAL: false, + }) + ).toEqual({ kind: 'redirect', to: '/' }); + }); + + it('redirects to / when emailVerified is false even if AAL is not met (email check wins)', () => { + expect( + routeSettingsAccess({ + emailVerified: false, + sessionVerified: true, + sessionVerificationMeetsAAL: false, + }) + ).toEqual({ kind: 'redirect', to: '/' }); + }); + }); + + describe('session not verified', () => { + it('redirects to / when sessionVerified is false, emailVerified is true, AAL is met', () => { + expect( + routeSettingsAccess({ + emailVerified: true, + sessionVerified: false, + sessionVerificationMeetsAAL: true, + }) + ).toEqual({ kind: 'redirect', to: '/' }); + }); + + it('redirects to / when sessionVerified is false and AAL is not met', () => { + expect( + routeSettingsAccess({ + emailVerified: true, + sessionVerified: false, + sessionVerificationMeetsAAL: false, + }) + ).toEqual({ kind: 'redirect', to: '/' }); + }); + }); + + describe('AAL step-up required (security-critical)', () => { + it('redirects to /signin_totp_code when email and session are verified but AAL is not met', () => { + // This is the 2FA enforcement path: both verified, but the session has not + // yet satisfied the account's minimum assurance level. Must redirect to TOTP. + expect( + routeSettingsAccess({ + emailVerified: true, + sessionVerified: true, + sessionVerificationMeetsAAL: false, + }) + ).toEqual({ kind: 'redirect', to: '/signin_totp_code' }); + }); + }); + + describe('allow access', () => { + it('allows access when email verified, session verified, and AAL is met', () => { + expect( + routeSettingsAccess({ + emailVerified: true, + sessionVerified: true, + sessionVerificationMeetsAAL: true, + }) + ).toEqual({ kind: 'allow' }); + }); + }); +}); diff --git a/packages/fxa-settings/src/lib/auth-machine/session.ts b/packages/fxa-settings/src/lib/auth-machine/session.ts new file mode 100644 index 00000000000..7845dabe8ab --- /dev/null +++ b/packages/fxa-settings/src/lib/auth-machine/session.ts @@ -0,0 +1,36 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** Outcome of the Settings access guard (the AAL2 / verification gate). */ +export type SettingsAccessDecision = + | { kind: 'allow' } + | { kind: 'redirect'; to: '/' } + | { kind: 'redirect'; to: '/signin_totp_code' }; + +export interface SettingsAccessFacts { + emailVerified: boolean; + sessionVerified: boolean; + /** Session meets the account's minimum AAL (false when 2FA is required but not yet satisfied this session). */ + sessionVerificationMeetsAAL: boolean; +} + +/** + * Decides whether a session may access Settings. Behaviorally identical to the + * legacy Settings root guard: an unverified email or session is sent to the + * root (re-auth); a verified session that does not meet the account's minimum + * AAL is sent to TOTP entry to step up; otherwise access is allowed. The + * missing-account localStorage case (a thrown read) is handled by the caller, + * not here. + */ +export function routeSettingsAccess( + facts: SettingsAccessFacts +): SettingsAccessDecision { + if (!facts.emailVerified || !facts.sessionVerified) { + return { kind: 'redirect', to: '/' }; + } + if (!facts.sessionVerificationMeetsAAL) { + return { kind: 'redirect', to: '/signin_totp_code' }; + } + return { kind: 'allow' }; +} diff --git a/packages/fxa-settings/src/lib/auth-machine/signup.test.ts b/packages/fxa-settings/src/lib/auth-machine/signup.test.ts new file mode 100644 index 00000000000..5309d268ac6 --- /dev/null +++ b/packages/fxa-settings/src/lib/auth-machine/signup.test.ts @@ -0,0 +1,75 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { routeSignupCategory, SignupCategoryFacts } from './signup'; + +const noMatch: SignupCategoryFacts = { + isSyncDesktopV3: false, + isOAuth: false, + wantsTwoStepAuthentication: false, + isWeb: false, + hasRedirectTo: false, +}; + +describe('routeSignupCategory', () => { + it('returns sync-desktop-v3 when isSyncDesktopV3 is true', () => { + expect(routeSignupCategory({ ...noMatch, isSyncDesktopV3: true })).toBe( + 'sync-desktop-v3' + ); + }); + + it('sync-desktop-v3 wins over oauth when both are true', () => { + expect( + routeSignupCategory({ + ...noMatch, + isSyncDesktopV3: true, + isOAuth: true, + wantsTwoStepAuthentication: true, + }) + ).toBe('sync-desktop-v3'); + }); + + it('returns oauth-totp-setup when isOAuth is true and wantsTwoStepAuthentication is true', () => { + expect( + routeSignupCategory({ + ...noMatch, + isOAuth: true, + wantsTwoStepAuthentication: true, + }) + ).toBe('oauth-totp-setup'); + }); + + it('returns oauth-resolve when isOAuth is true and wantsTwoStepAuthentication is false', () => { + expect(routeSignupCategory({ ...noMatch, isOAuth: true })).toBe( + 'oauth-resolve' + ); + }); + + it('returns web-redirect when isWeb is true and hasRedirectTo is true', () => { + expect( + routeSignupCategory({ ...noMatch, isWeb: true, hasRedirectTo: true }) + ).toBe('web-redirect'); + }); + + it('returns web-settings when isWeb is true and hasRedirectTo is false', () => { + expect(routeSignupCategory({ ...noMatch, isWeb: true })).toBe( + 'web-settings' + ); + }); + + it('returns none when no integration type flag is true', () => { + expect(routeSignupCategory(noMatch)).toBe('none'); + }); + + it('oauth wins over web when both isOAuth and isWeb are true', () => { + expect( + routeSignupCategory({ + ...noMatch, + isOAuth: true, + isWeb: true, + hasRedirectTo: true, + }) + ).toBe('oauth-resolve'); + }); +}); diff --git a/packages/fxa-settings/src/lib/auth-machine/signup.ts b/packages/fxa-settings/src/lib/auth-machine/signup.ts new file mode 100644 index 00000000000..c81cb6d3a2c --- /dev/null +++ b/packages/fxa-settings/src/lib/auth-machine/signup.ts @@ -0,0 +1,44 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** Top-level post-signup-confirmation routing category. The OAuth 'resolve' + * category still performs the async finishOAuthFlowHandler sub-routing in the + * caller; this only picks which legacy branch runs. */ +export type SignupCategory = + | 'sync-desktop-v3' + | 'oauth-totp-setup' + | 'oauth-resolve' + | 'web-redirect' + | 'web-settings' + | 'none'; + +export interface SignupCategoryFacts { + isSyncDesktopV3: boolean; + isOAuth: boolean; + wantsTwoStepAuthentication: boolean; + isWeb: boolean; + hasRedirectTo: boolean; +} + +/** + * Picks the post-signup-confirmation branch, matching the legacy + * ConfirmSignupCode if/else-if chain exactly: Sync Desktop v3 first, then OAuth + * (TOTP setup when the relier wants 2FA, otherwise resolve the OAuth flow), then + * web (redirect when a redirectTo is present, else settings). When no integration + * type matches, the legacy code navigates nowhere. + */ +export function routeSignupCategory( + facts: SignupCategoryFacts +): SignupCategory { + if (facts.isSyncDesktopV3) return 'sync-desktop-v3'; + if (facts.isOAuth) { + return facts.wantsTwoStepAuthentication + ? 'oauth-totp-setup' + : 'oauth-resolve'; + } + if (facts.isWeb) { + return facts.hasRedirectTo ? 'web-redirect' : 'web-settings'; + } + return 'none'; +} diff --git a/packages/fxa-settings/src/lib/auth-machine/types.test.ts b/packages/fxa-settings/src/lib/auth-machine/types.test.ts new file mode 100644 index 00000000000..7db9cb8672d --- /dev/null +++ b/packages/fxa-settings/src/lib/auth-machine/types.test.ts @@ -0,0 +1,14 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { isFunnelState } from './types'; + +describe('auth-machine types', () => { + // Completeness (every state maps to a route) is covered by route-adapter's + // "no gaps" test, which iterates FUNNEL_STATES; no need to restate the list here. + it('isFunnelState narrows known states from unknown strings', () => { + expect(isFunnelState('verifying.totp')).toBe(true); + expect(isFunnelState('nope')).toBe(false); + }); +}); diff --git a/packages/fxa-settings/src/lib/auth-machine/types.ts b/packages/fxa-settings/src/lib/auth-machine/types.ts new file mode 100644 index 00000000000..500f4150dd1 --- /dev/null +++ b/packages/fxa-settings/src/lib/auth-machine/types.ts @@ -0,0 +1,120 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Both constants use default exports, so import them accordingly. +import VerificationMethods from '../../constants/verification-methods'; +import VerificationReasons from '../../constants/verification-reasons'; + +export const FUNNEL_STATES = [ + 'bootstrapping.resolving', + 'bootstrapping.awaitFxaStatus', + 'identifying.index', + 'identifying.checkingAccountStatus', + 'authenticating.signinDecider', + 'authenticating.cachedSignin', + 'authenticating.passwordSignin', + 'authenticating.awaitSigninResult', + 'authenticating.unblockGate', + 'verifying.router', + 'verifying.emailTokenCode', + 'verifying.totp', + 'verifying.recoveryChoice', + 'verifying.recoveryCode', + 'verifying.recoveryPhone', + 'verifying.unblock', + 'finalizing.handoff', // hand off to the (not-yet-built) session machine / settings + 'terminal.serviceUnavailable', + 'delegated.legacy', // out-of-slice: hand control back to legacy navigation +] as const; + +export type FlowState = (typeof FUNNEL_STATES)[number]; + +export function isFunnelState(s: string): s is FlowState { + return (FUNNEL_STATES as readonly string[]).includes(s); +} + +/** The demoted Account + Reliant "regions": a flat bag of facts the guards read. */ +export interface AuthContext { + // identity + email?: string; + uid?: string; + sessionToken?: string; + // verification facts (Account region) + emailVerified: boolean; + sessionVerified: boolean; + verificationMethod?: VerificationMethods; + verificationReason?: VerificationReasons; + /** LIVE checkTotpTokenExists — distinct from verificationMethod. Guards prefer this. */ + accountHasTotp: boolean; + hasRecoveryPhone: boolean; + // credential / capability facts + hasPassword: boolean; + hasLinkedAccount: boolean; + hasCachedSession: boolean; + passwordlessSupported: boolean; + // Reliant capabilities (frozen post-clientInfo) + isOAuth: boolean; + isOAuthWeb: boolean; + isOAuthNative: boolean; + isSync: boolean; + isWebChannelIntegration: boolean; + supportsKeysOptionalLogin: boolean; + requiresKeys: boolean; + wantsKeysIfPasswordEntered: boolean; + wantsLogin: boolean; + clientInfoLoadFailed: boolean; + // scratch (survives reload; cosmetic only) + skipPasswordlessRedirect?: boolean; + isSessionAALUpgrade?: boolean; +} + +export type AuthEvent = + | { type: 'INTEGRATION_RESOLVED' } + | { type: 'SERVICE_UNAVAILABLE' } + | { type: 'SUBMIT_EMAIL'; email: string } + | { type: 'ACCOUNT_STATUS'; exists: boolean } + | { type: 'SUBMIT_PASSWORD'; password: string } + | { + type: 'CACHED_RESULT'; + emailVerified: boolean; + sessionVerified: boolean; + verificationMethod?: VerificationMethods; + verificationReason?: VerificationReasons; + } + | { + type: 'SIGNIN_OK'; + emailVerified: boolean; + sessionVerified: boolean; + verificationMethod?: VerificationMethods; + verificationReason?: VerificationReasons; + } + | { type: 'SESSION_EXPIRED' } + | { type: 'REQUEST_BLOCKED'; canUnblock: boolean } + | { type: 'THROTTLED'; canUnblock: boolean } + | { type: 'UNBLOCK_CODE_SENT' } + | { + type: 'UNBLOCK_OK'; + emailVerified: boolean; + sessionVerified: boolean; + verificationMethod?: VerificationMethods; + verificationReason?: VerificationReasons; + } + | { type: 'CODE_OK' } // token-code / totp / recovery success + | { type: 'CHOOSE_RECOVERY_CODE' } + | { type: 'CHOOSE_RECOVERY_PHONE' } + | { type: 'TROUBLE' }; + +export type Effect = + | { kind: 'RESOLVE_INTEGRATION' } + | { kind: 'CHECK_ACCOUNT_STATUS'; email: string } + | { kind: 'BEGIN_SIGNIN'; password: string; unblockCode?: string } + | { kind: 'CACHED_SIGNIN' } + | { kind: 'SEND_UNBLOCK_EMAIL' } + | { kind: 'UPGRADE_CREDENTIALS' } // fired alongside SIGNIN_OK when canUpgradeCredentials + | { kind: 'DELEGATE_LEGACY'; reason: string }; + +export interface ReducerResult { + state: FlowState; + effects: Effect[]; +} diff --git a/packages/fxa-settings/src/lib/auth-machine/useAuthMachine.test.tsx b/packages/fxa-settings/src/lib/auth-machine/useAuthMachine.test.tsx new file mode 100644 index 00000000000..8288de729dd --- /dev/null +++ b/packages/fxa-settings/src/lib/auth-machine/useAuthMachine.test.tsx @@ -0,0 +1,48 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useAuthMachine } from './useAuthMachine'; + +const baseCtx: any = { + hasPassword: true, + emailVerified: true, + sessionVerified: false, + accountHasTotp: true, +}; + +it('email → password → totp drives navigation to /signin_totp_code', async () => { + const navigate = jest.fn(); + const deps = { + checkAccountStatus: jest.fn().mockResolvedValue({ exists: true }), + beginSignin: jest.fn().mockResolvedValue({ + emailVerified: true, + sessionVerified: false, + verificationMethod: 'totp-2fa', + }), + cachedSignin: jest.fn(), + sendUnblockEmail: jest.fn(), + upgradeCredentials: jest.fn().mockResolvedValue(undefined), + }; + const { result } = renderHook(() => + useAuthMachine({ + initial: 'identifying.index', + ctx: baseCtx, + deps, + navigate, + delegate: jest.fn(), + }) + ); + + await act(async () => { + result.current.send({ type: 'SUBMIT_EMAIL', email: 'a@example.com' }); + }); + await act(async () => { + result.current.send({ type: 'SUBMIT_PASSWORD', password: 'pw' }); + }); + + await waitFor(() => + expect(navigate).toHaveBeenCalledWith('/signin_totp_code') + ); +}); diff --git a/packages/fxa-settings/src/lib/auth-machine/useAuthMachine.ts b/packages/fxa-settings/src/lib/auth-machine/useAuthMachine.ts new file mode 100644 index 00000000000..92c356bc91e --- /dev/null +++ b/packages/fxa-settings/src/lib/auth-machine/useAuthMachine.ts @@ -0,0 +1,62 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { useCallback, useReducer, useRef } from 'react'; +import { funnelReducer } from './funnel'; +import { runEffect, type EffectDeps } from './effects'; +import { routeFor } from './route-adapter'; +import type { AuthContext, AuthEvent, FlowState } from './types'; + +interface Params { + initial: FlowState; + ctx: AuthContext; + deps: EffectDeps; + navigate: (to: string) => void; + delegate: () => void; +} + +export function useAuthMachine({ + initial, + ctx, + deps, + navigate, + delegate, +}: Params) { + const ctxRef = useRef(ctx); + ctxRef.current = ctx; + + // stateRef tracks the authoritative current state for chained reductions. + // useReducer state is for rendering only and may lag behind async operations. + const stateRef = useRef(initial); + + const [renderState, rawDispatch] = useReducer( + (_s: FlowState, next: FlowState) => next, + initial + ); + + const send = useCallback( + async function send(event: AuthEvent): Promise { + const { state: nextState, effects } = funnelReducer( + stateRef.current, + event, + ctxRef.current + ); + + stateRef.current = nextState; + rawDispatch(nextState); + + const decision = routeFor(nextState); + if ('to' in decision) navigate(decision.to); + else if ('delegate' in decision) delegate(); + + for (const effect of effects) { + const next = await runEffect(effect, deps); + if (next) await send(next); + } + }, + [deps, navigate, delegate] + ); + + return { state: renderState, send }; +} diff --git a/packages/fxa-settings/src/lib/config.ts b/packages/fxa-settings/src/lib/config.ts index 68727117b33..12b6e74f8af 100644 --- a/packages/fxa-settings/src/lib/config.ts +++ b/packages/fxa-settings/src/lib/config.ts @@ -57,8 +57,8 @@ export interface Config { isPromptNoneEnabled: boolean; isPromptNoneEnabledClientIds: string[]; reactClientIdsEnabled: string[]; - clientInfoTimeout: number, - clientInfoRetries: number, + clientInfoTimeout: number; + clientInfoRetries: number; }; recoveryCodes: { count: number; @@ -115,6 +115,7 @@ export interface Config { passkeyRegistrationEnabled?: boolean; passkeyAuthenticationEnabled?: boolean; passwordlessEnabled?: boolean; + authStateMachine?: boolean; }; darkMode?: { enabled?: boolean; @@ -180,7 +181,7 @@ export function getDefault() { isPromptNoneEnabledClientIds: new Array(), reactClientIdsEnabled: new Array(), clientInfoRetries: 4, - clientInfoTimeout: 10_000 + clientInfoTimeout: 10_000, }, recoveryCodes: { count: 8, @@ -229,6 +230,7 @@ export function getDefault() { showLocaleToggle: false, paymentsNextSubscriptionManagement: false, passwordlessEnabled: false, + authStateMachine: false, }, darkMode: { enabled: false, diff --git a/packages/fxa-settings/src/models/pages/signin/query-params.ts b/packages/fxa-settings/src/models/pages/signin/query-params.ts index 1bf7356d1b5..ed895fc0e8d 100644 --- a/packages/fxa-settings/src/models/pages/signin/query-params.ts +++ b/packages/fxa-settings/src/models/pages/signin/query-params.ts @@ -55,4 +55,9 @@ export class SigninQueryParams extends ModelDataProvider { @IsBoolean() @bind(T.snakeCase) forcePasswordless: boolean | undefined = undefined; -} \ No newline at end of file + + @IsOptional() + @IsBoolean() + @bind() + authStateMachine: boolean | undefined = undefined; +} diff --git a/packages/fxa-settings/src/pages/InlineTotpSetup/container.tsx b/packages/fxa-settings/src/pages/InlineTotpSetup/container.tsx index 6a7feb47e32..2c04d5a04b4 100644 --- a/packages/fxa-settings/src/pages/InlineTotpSetup/container.tsx +++ b/packages/fxa-settings/src/pages/InlineTotpSetup/container.tsx @@ -8,7 +8,12 @@ import { useCallback, useEffect, useState, useRef } from 'react'; import InlineTotpSetup from '.'; import { MozServices, TotpInfo } from '../../lib/types'; import AppLayout from '../../components/AppLayout'; -import { Integration, useSession, useAuthClient } from '../../models'; +import { + Integration, + useSession, + useAuthClient, + useConfig, +} from '../../models'; import { AuthUiErrors } from '../../lib/auth-errors/auth-errors'; import { getSigninState } from '../Signin/utils'; import { SigninLocationState } from '../Signin/interfaces'; @@ -17,6 +22,8 @@ import { QueryParams } from '../..'; import { queryParamsToMetricsContext } from '../../lib/metrics'; import GleanMetrics from '../../lib/glean'; import * as Sentry from '@sentry/browser'; +import { isAuthStateMachineEnabled } from '../../lib/auth-machine/flag'; +import { routeAfterInlineTotpSetup } from '../../lib/auth-machine/inline'; export const InlineTotpSetupContainer = ({ isSignedIn, @@ -44,6 +51,7 @@ export const InlineTotpSetupContainer = ({ const navigateWithQuery = useNavigateWithQuery(); const session = useSession(); const authClient = useAuthClient(); + const config = useConfig(); const metricsContext = queryParamsToMetricsContext( flowQueryParams as unknown as Record ); @@ -136,22 +144,38 @@ export const InlineTotpSetupContainer = ({ // Once state has settled, determine if user should be directed to another page useEffect(() => { - if (!isSignedIn || !signinState) { + const machineEnabled = isAuthStateMachineEnabled( + location.search, + config.featureFlags?.authStateMachine === true + ); + + const legacyRoute = (() => { + if (!isSignedIn || !signinState) return '/' as const; + if (totpStatus?.verified) return '/signin_totp_code' as const; + if (sessionVerified === false) return '/signin_token_code' as const; + return null; + })(); + + const target = machineEnabled + ? routeAfterInlineTotpSetup({ + isSignedIn, + hasSigninState: !!signinState, + totpVerified: totpStatus?.verified, + sessionVerified, + }) + : legacyRoute; + + if (target === '/') { navTo('/'); - return; - } - if (totpStatus?.verified) { + } else if (target === '/signin_totp_code') { navTo('/signin_totp_code', signinState ? signinState : undefined); - return; - } - if (sessionVerified === false) { + } else if (target === '/signin_token_code') { (async () => { // The `/signin_token_code` does not automatically send a verification code, so we need to do it manually // before redirecting to the page await session.sendVerificationCode(); navTo('/signin_token_code', signinState ? signinState : undefined); })(); - return; } }, [ sessionVerified, @@ -162,6 +186,8 @@ export const InlineTotpSetupContainer = ({ session, navTo, navigateWithQuery, + config, + location.search, ]); const verifyCodeHandler = useCallback( diff --git a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/container.test.tsx b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/container.test.tsx index 41437f0d139..c56a4bdb6e3 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/container.test.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/container.test.tsx @@ -54,20 +54,28 @@ jest.mock('../../../models', () => ({ }), })); +// Default location used by most tests (flag off). +const MOCK_LOCATION_FLAG_OFF = { + state: { + email: MOCK_EMAIL, + uid: MOCK_UID, + token: 'tok', + code: '1234567890', + }, + pathname: '/complete_reset_password', + search: '', + hash: '', +}; + +let mockLocationSearch = ''; + jest.mock('@reach/router', () => { const actual = jest.requireActual('@reach/router'); return { ...actual, useLocation: () => ({ - state: { - email: MOCK_EMAIL, - uid: MOCK_UID, - token: 'tok', - code: '1234567890', - }, - pathname: '/complete_reset_password', - search: '', - hash: '', + ...MOCK_LOCATION_FLAG_OFF, + search: mockLocationSearch, }), }; }); @@ -76,6 +84,7 @@ describe('CompleteResetPasswordContainer', () => { let fxaLoginSignedInUserSpy: jest.SpyInstance; beforeEach(() => { + mockLocationSearch = ''; mockNavigateWithQuery.mockImplementation(() => {}); fxaLoginSignedInUserSpy = jest.spyOn(firefox, 'fxaLoginSignedInUser'); @@ -144,4 +153,40 @@ describe('CompleteResetPasswordContainer', () => { 'Your password has been reset' ); }); + + it('navigates to settings for relay integration when authStateMachine flag is on', async () => { + // Seam 3 flag-on path: routeAfterResetComplete should produce the same + // route as the legacy branch for a verified non-OAuth-web session. + mockLocationSearch = '?authStateMachine=true'; + + renderWithLocalizationProvider( + + + + ); + + expect(await screen.findByLabelText('New password')).toBeInTheDocument(); + + const user = userEvent.setup(); + await user.type(screen.getByLabelText('New password'), 'newPassword123!'); + await user.type( + screen.getByLabelText('Confirm password'), + 'newPassword123!' + ); + await user.click( + screen.getByRole('button', { name: 'Create new password' }) + ); + + await waitFor(() => { + expect(mockNavigateWithQuery).toHaveBeenCalledWith(SETTINGS_PATH, { + replace: true, + }); + }); + + expect(mockAlertBar.success).toHaveBeenCalledWith( + 'Your password has been reset' + ); + }); }); diff --git a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/container.tsx b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/container.tsx index 7108e75016d..c9832b63fab 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/container.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/container.tsx @@ -33,6 +33,8 @@ import { LocationState } from '../../Signin/interfaces'; import { useFinishOAuthFlowHandler } from '../../../lib/oauth/hooks'; import OAuthDataError from '../../../components/OAuthDataError'; import { SensitiveData } from '../../../lib/sensitive-data-client'; +import { isAuthStateMachineEnabled } from '../../../lib/auth-machine/flag'; +import { routeAfterResetComplete } from '../../../lib/auth-machine/reset'; // This component is used for both /complete_reset_password and /account_recovery_reset_password routes // for easier maintenance @@ -124,24 +126,45 @@ const CompleteResetPasswordContainer = ({ const handleNavigationWithoutRecoveryKey = async ( accountResetData: AccountResetData ) => { - if (accountResetData.sessionVerified) { - // For verified users with OAuth integration, navigate to confirmation page then to the relying party - if ( - isOAuth && - !(integration.isSync() || integration.isFirefoxNonSync()) - ) { - sensitiveDataClient.setDataType(SensitiveData.Key.AccountReset, { - keyFetchToken: accountResetData.keyFetchToken, - unwrapBKey: accountResetData.unwrapBKey, - }); - return navigateWithQuery('/reset_password_verified', { - replace: true, - }); + const isOAuthWeb = + isOAuth && !(integration.isSync() || integration.isFirefoxNonSync()); + + const machineEnabled = isAuthStateMachineEnabled( + location.search, + config.featureFlags?.authStateMachine === true + ); + + // Legacy inline decision mirrors routeAfterResetComplete exactly. + const legacyTarget = (() => { + if (!accountResetData.sessionVerified) { + return '/signin' as const; } + return isOAuthWeb + ? ('/reset_password_verified' as const) + : ('/settings' as const); + })(); + + const target = machineEnabled + ? routeAfterResetComplete({ + sessionVerified: accountResetData.sessionVerified, + isOAuthWeb, + }) + : legacyTarget; + + if (target === '/reset_password_verified') { + sensitiveDataClient.setDataType(SensitiveData.Key.AccountReset, { + keyFetchToken: accountResetData.keyFetchToken, + unwrapBKey: accountResetData.unwrapBKey, + }); + return navigateWithQuery('/reset_password_verified', { + replace: true, + }); + } - // For web integration and sync/relay/smart window navigate to settings + if (target === '/settings') { + // For web integration and sync/relay/smart window navigate to settings. // Sync users will see an account recovery key promotion banner in settings - // if they don't have one configured + // if they don't have one configured. alertBar.success( ftlMsgResolver.getMsg( 'reset-password-complete-header', @@ -151,7 +174,7 @@ const CompleteResetPasswordContainer = ({ return navigateWithQuery(SETTINGS_PATH, { replace: true }); } - // if the session is not verified (e.g., 2FA verification is required), navigate to the sign-in page + // target === '/signin': session not verified (e.g., 2FA required) return navigateWithQuery('/signin', { replace: true, state: { diff --git a/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/container.tsx b/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/container.tsx index 9bb58be75ef..60d3a1b7397 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/container.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/container.tsx @@ -4,7 +4,9 @@ import React, { useEffect, useState } from 'react'; import { RouteComponentProps, useLocation } from '@reach/router'; -import { useAuthClient, useFtlMsgResolver } from '../../../models'; +import { useAuthClient, useConfig, useFtlMsgResolver } from '../../../models'; +import { isAuthStateMachineEnabled } from '../../../lib/auth-machine/flag'; +import { routeAfterResetOtp } from '../../../lib/auth-machine/reset'; import { ResetPasswordIntegration } from '../interfaces'; import ConfirmResetPassword from '.'; import { @@ -27,6 +29,7 @@ const ConfirmResetPasswordContainer = ({ const authClient = useAuthClient(); const ftlMsgResolver = useFtlMsgResolver(); + const config = useConfig(); const navigateWithQuery = useNavigateWithQuery(); let location = useLocation(); @@ -50,7 +53,28 @@ const ConfirmResetPasswordContainer = ({ recoveryKeyHint?: string, totpExists?: boolean ) => { - if (totpExists && recoveryKeyExists === false) { + const machineEnabled = isAuthStateMachineEnabled( + location.search, + config.featureFlags?.authStateMachine === true + ); + + // When the machine is enabled, delegate the routing decision to the pure + // function; when off, reproduce the same three-way conditional inline. + const legacyTarget = (() => { + if (totpExists && recoveryKeyExists === false) { + return '/confirm_totp_reset_password' as const; + } + if (recoveryKeyExists === true) { + return '/account_recovery_confirm_key' as const; + } + return '/complete_reset_password' as const; + })(); + + const target = machineEnabled + ? routeAfterResetOtp({ totpExists, recoveryKeyExists }) + : legacyTarget; + + if (target === '/confirm_totp_reset_password') { navigateWithQuery('/confirm_totp_reset_password', { state: { code, @@ -64,7 +88,7 @@ const ConfirmResetPasswordContainer = ({ }, replace: true, }); - } else if (recoveryKeyExists === true) { + } else if (target === '/account_recovery_confirm_key') { navigateWithQuery('/account_recovery_confirm_key', { state: { code, diff --git a/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordRecoveryChoice/container.test.tsx b/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordRecoveryChoice/container.test.tsx index 545998e20cb..a8232de39a9 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordRecoveryChoice/container.test.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordRecoveryChoice/container.test.tsx @@ -21,6 +21,7 @@ jest.mock('../../../models', () => { return { ...jest.requireActual('../../../models'), useAuthClient: jest.fn(), + useConfig: jest.fn(), }; }); @@ -38,7 +39,13 @@ function mockModelsModule({ phoneNumber: MOCK_MASKED_NUMBER_ENDING_IN_1234, }), mockRecoveryPhonePasswordResetSendCode = jest.fn().mockResolvedValue(true), -}) { + authStateMachineEnabled = false, +}: { + mockGetRecoveryCodesExist?: jest.Mock; + mockRecoveryPhoneGet?: jest.Mock; + mockRecoveryPhonePasswordResetSendCode?: jest.Mock; + authStateMachineEnabled?: boolean; +} = {}) { mockAuthClient.getRecoveryCodesExistWithPasswordForgotToken = mockGetRecoveryCodesExist; mockAuthClient.recoveryPhoneGetWithPasswordForgotToken = mockRecoveryPhoneGet; @@ -47,6 +54,9 @@ function mockModelsModule({ (ModelsModule.useAuthClient as jest.Mock).mockImplementation( () => mockAuthClient ); + (ModelsModule.useConfig as jest.Mock).mockReturnValue({ + featureFlags: { authStateMachine: authStateMachineEnabled }, + }); } let mockResetPasswordRecoveryChoice: jest.SpyInstance; @@ -291,4 +301,51 @@ describe('ResetPasswordRecoveryChoice container', () => { }); }); }); + + describe('authStateMachine flag enabled', () => { + it('auto-sends code and navigates when only phone available (flag on, 0 backup codes)', async () => { + mockModelsModule({ + mockGetRecoveryCodesExist: jest.fn().mockResolvedValue({ + hasBackupCodes: false, + count: 0, + }), + authStateMachineEnabled: true, + }); + render(); + await waitFor(() => { + expect( + mockAuthClient.recoveryPhonePasswordResetSendCode + ).toHaveBeenCalledWith('tok'); + expect(mockNavigate).toHaveBeenCalledWith( + '/reset_password_recovery_phone', + { + state: { + token: 'tok', + lastFourPhoneDigits: '1234', + numBackupCodes: 0, + sendError: undefined, + }, + replace: true, + } + ); + }); + }); + + it('redirects to backup codes when no phone is available (flag on)', async () => { + mockModelsModule({ + mockRecoveryPhoneGet: jest.fn().mockResolvedValue({ exists: false }), + authStateMachineEnabled: true, + }); + render(); + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith( + '/confirm_backup_code_reset_password', + { + replace: true, + state: { token: 'tok' }, + } + ); + }); + }); + }); }); diff --git a/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordRecoveryChoice/container.tsx b/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordRecoveryChoice/container.tsx index 1d9ae50a733..7194c6563b7 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordRecoveryChoice/container.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/ResetPasswordRecoveryChoice/container.tsx @@ -12,7 +12,12 @@ import { } from '../../../lib/auth-errors/auth-errors'; import { CompleteResetPasswordLocationState } from '../CompleteResetPassword/interfaces'; import { getHandledError, HandledError } from '../../../lib/error-utils'; -import { useAuthClient, useFtlMsgResolver } from '../../../models'; +import { useAuthClient, useConfig, useFtlMsgResolver } from '../../../models'; +import { isAuthStateMachineEnabled } from '../../../lib/auth-machine/flag'; +import { + decideRecoveryChoice, + RecoveryChoiceAction, +} from '../../../lib/auth-machine/reset'; import { formatPhoneNumber } from '../../../lib/recovery-phone-utils'; import AppLayout from '../../../components/AppLayout'; @@ -20,6 +25,7 @@ export const ResetPasswordRecoveryChoiceContainer = ( _: RouteComponentProps ) => { const authClient = useAuthClient(); + const config = useConfig(); const locationState = useLocation() as ReturnType & { state: CompleteResetPasswordLocationState; }; @@ -181,33 +187,50 @@ export const ResetPasswordRecoveryChoiceContainer = ( // Handle all navigation logic in a single effect with clear priority order useEffect(() => { - // Priority 1: Missing locationState or token - if (!locationState || !locationState.state.token) { - redirectToResetPassword(); - return; - } - - // Priority 2: Data fetch error - if (dataFetchError) { - handleDataFetchError(); - return; - } - - // Priority 3: Still loading - don't make any navigation decisions yet - if (loading) { - return; - } + const hasToken = !!(locationState && locationState.state.token); + const machineEnabled = isAuthStateMachineEnabled( + locationState?.search, + config.featureFlags?.authStateMachine === true + ); + const facts = { + hasToken, + hasDataFetchError: !!dataFetchError, + loading, + hasPhone: !!phoneData.phoneNumber, + autoSendAttempted, + numBackupCodes, + }; - // Priority 4: No phone available - if (!phoneData.phoneNumber) { - redirectToBackupCodes(); - return; - } + const action: RecoveryChoiceAction = machineEnabled + ? decideRecoveryChoice(facts) + : (() => { + if (!facts.hasToken) return 'redirect-reset'; + if (facts.hasDataFetchError) return 'handle-data-error'; + if (facts.loading) return 'wait'; + if (!facts.hasPhone) return 'backup-codes'; + if (!facts.autoSendAttempted && facts.numBackupCodes === 0) { + return 'auto-send-phone'; + } + return 'show-choice'; + })(); - // Priority 5: Auto-send SMS if only phone is available (no backup codes) - if (!autoSendAttempted && numBackupCodes === 0) { - autoSendPhoneCode(); - return; + switch (action) { + case 'redirect-reset': + redirectToResetPassword(); + return; + case 'handle-data-error': + handleDataFetchError(); + return; + case 'wait': + return; + case 'backup-codes': + redirectToBackupCodes(); + return; + case 'auto-send-phone': + autoSendPhoneCode(); + return; + case 'show-choice': + return; } }, [ locationState, @@ -220,6 +243,7 @@ export const ResetPasswordRecoveryChoiceContainer = ( handleDataFetchError, redirectToBackupCodes, autoSendPhoneCode, + config, ]); if (!locationState || !locationState.state.token) { diff --git a/packages/fxa-settings/src/pages/Signin/container.authmachine.test.tsx b/packages/fxa-settings/src/pages/Signin/container.authmachine.test.tsx new file mode 100644 index 00000000000..f73f8648bcd --- /dev/null +++ b/packages/fxa-settings/src/pages/Signin/container.authmachine.test.tsx @@ -0,0 +1,314 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Flag-gate scaffold tests for the auth-state-machine integration in SigninContainer. + * Asserts that when authStateMachine is OFF the machine is not instantiated, + * and when it is ON the machine is instantiated and observable via the + * data-testid="auth-state-machine-active" sentinel. + */ + +import * as UseValidateModule from '../../lib/hooks/useValidate'; +import * as SigninDeciderModule from './components/SigninDecider'; +import { SigninDeciderProps } from './components/SigninDecider'; +import * as ModelsModule from '../../models'; +import * as CacheModule from '../../lib/cache'; +import * as ReactUtils from 'fxa-react/lib/utils'; +import * as UseAuthMachineModule from '../../lib/auth-machine/useAuthMachine'; + +import { LocationProvider } from '@reach/router'; +import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; +import SigninContainer from './container'; +import { MozServices } from '../../lib/types'; +import { screen, waitFor } from '@testing-library/react'; +import { WebIntegration } from '../../models'; +import { GenericData, ModelDataProvider } from '../../lib/model-data'; +import { mockUseFxAStatus } from '../../lib/hooks/useFxAStatus/mocks'; +import AuthClient from 'fxa-auth-client/browser'; +import { mockSensitiveDataClient as createMockSensitiveDataClient } from '../../models/mocks'; +import { useFinishOAuthFlowHandler } from '../../lib/oauth/hooks'; +import { SigninQueryParams } from '../../models/pages/signin'; +import { MOCK_FLOW_ID, MOCK_SESSION_TOKEN, MOCK_UID } from './mocks'; + +// ---- Module-level mocks ---- + +jest.mock('../../lib/channels/firefox', () => ({ + ...jest.requireActual('../../lib/channels/firefox'), + firefox: { fxaCanLinkAccount: jest.fn() }, +})); + +jest.mock('../../lib/storage-utils', () => ({ + ...jest.requireActual('../../lib/storage-utils'), + storeAccountData: jest.fn(), +})); + +jest.mock('./utils', () => ({ + ...jest.requireActual('./utils'), + ensureCanLinkAcountOrRedirect: jest.fn(), +})); + +jest.mock('../../lib/oauth/hooks', () => ({ + ...jest.requireActual('../../lib/oauth/hooks'), + useFinishOAuthFlowHandler: jest.fn(), +})); + +jest.mock('../../lib/hooks', () => ({ + __esModule: true, + ...jest.requireActual('../../lib/hooks'), + useCheckReactEmailFirst: () => jest.fn().mockReturnValue(true)(), +})); + +jest.mock('../../models', () => ({ + ...jest.requireActual('../../models'), + useAuthClient: jest.fn(), + useSensitiveDataClient: jest.fn(), + useConfig: jest.fn(), + useSession: jest.fn(), +})); + +jest.mock('@reach/router', () => ({ + __esModule: true, + ...jest.requireActual('@reach/router'), + useNavigate: () => jest.fn(), + useLocation: () => ({ + pathname: '/signin', + search: '', + state: { + email: 'user@example.com', + hasPassword: true, + hasLinkedAccount: false, + }, + }), +})); + +// ---- Shared test state ---- + +const mockAuthClient = new AuthClient('http://localhost:9000', { + keyStretchVersion: 1, +}); +const mockSensitiveDataClient = createMockSensitiveDataClient(); + +const mockSession = { + isSessionVerified: jest.fn().mockResolvedValue(true), + isValid: jest.fn().mockResolvedValue(true), + sendVerificationCode: jest.fn().mockResolvedValue(undefined), + verified: false, + token: MOCK_SESSION_TOKEN, +}; + +let useAuthMachineSpy: jest.SpyInstance; + +function setupDefaultMocks( + featureFlags: Record = {}, + queryParams: Record = {} +) { + jest.resetAllMocks(); + jest.restoreAllMocks(); + + jest.spyOn(ReactUtils, 'hardNavigate').mockImplementation(() => {}); + + jest + .spyOn(SigninDeciderModule, 'default') + .mockImplementation((_props: SigninDeciderProps) =>
signin mock
); + + jest.spyOn(CacheModule, 'currentAccount').mockReturnValue({ + uid: MOCK_UID, + email: 'user@example.com', + sessionToken: undefined, + }); + jest.spyOn(CacheModule, 'findAccountByEmail').mockReturnValue(undefined); + jest.spyOn(CacheModule, 'discardSessionToken'); + + mockAuthClient.accountStatusByEmail = jest.fn().mockResolvedValue({ + exists: true, + hasLinkedAccount: false, + hasPassword: true, + }); + + (ModelsModule.useAuthClient as jest.Mock).mockImplementation( + () => mockAuthClient + ); + (ModelsModule.useSensitiveDataClient as jest.Mock).mockImplementation( + () => mockSensitiveDataClient + ); + (ModelsModule.useConfig as jest.Mock).mockImplementation(() => ({ + featureFlags: { + recoveryCodeSetupOnSyncSignIn: false, + ...featureFlags, + }, + servers: { profile: { url: 'http://localhost:1111' } }, + oauth: { clientId: 'mock-client-id' }, + })); + (ModelsModule.useSession as jest.Mock).mockImplementation(() => mockSession); + + (useFinishOAuthFlowHandler as jest.Mock).mockReturnValue({ + finishOAuthFlowHandler: jest.fn(), + oAuthDataError: undefined, + }); + + jest + .spyOn(UseValidateModule, 'useValidatedQueryParams') + .mockImplementation((Model) => { + if (Model === SigninQueryParams) { + return { + queryParamModel: { + email: '', + hasPassword: undefined, + hasLinkedAccount: undefined, + authStateMachine: undefined, + isV2: () => false, + ...queryParams, + } as unknown as ModelDataProvider, + validationError: undefined, + }; + } + return { + queryParamModel: {} as ModelDataProvider, + validationError: undefined, + }; + }); + + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + json: () => Promise.resolve({}), + }); + + // Spy on useAuthMachine so tests can assert call count. + useAuthMachineSpy = jest.spyOn(UseAuthMachineModule, 'useAuthMachine'); +} + +function makeIntegration() { + return new WebIntegration(new GenericData({ service: MozServices.Default })); +} + +function renderContainer() { + const integration = makeIntegration(); + const useFxAStatusResult = mockUseFxAStatus(); + + return renderWithLocalizationProvider( + + + + ); +} + +// ---- Tests ---- + +describe('SigninContainer auth-state-machine flag gate', () => { + describe('when authStateMachine flag is OFF (default)', () => { + beforeEach(() => { + setupDefaultMocks({ authStateMachine: false }); + }); + + it('does not render the auth-state-machine-active sentinel', async () => { + renderContainer(); + // Give the container time to settle async effects. + await waitFor(() => { + expect(SigninDeciderModule.default).toHaveBeenCalled(); + }); + expect( + screen.queryByTestId('auth-state-machine-active') + ).not.toBeInTheDocument(); + }); + + it('calls useAuthMachine with the no-op sentinel deps (not real machine deps)', async () => { + renderContainer(); + await waitFor(() => { + expect(SigninDeciderModule.default).toHaveBeenCalled(); + }); + // Hook is always called (React rules), but machineDeps should be null + // (no-op path) when the flag is off. + expect(useAuthMachineSpy).toHaveBeenCalled(); + const callArgs = useAuthMachineSpy.mock.calls[0][0]; + // navigate and delegate are both no-ops when the flag is off. + expect(typeof callArgs.navigate).toBe('function'); + expect(typeof callArgs.delegate).toBe('function'); + }); + }); + + describe('when authStateMachine flag is ON', () => { + beforeEach(() => { + setupDefaultMocks({ authStateMachine: true }); + }); + + it('renders the auth-state-machine-active sentinel', async () => { + renderContainer(); + await waitFor(() => { + expect(SigninDeciderModule.default).toHaveBeenCalled(); + }); + expect( + screen.getByTestId('auth-state-machine-active') + ).toBeInTheDocument(); + }); + + it('calls useAuthMachine with real (non-null) deps when flag is on', async () => { + renderContainer(); + await waitFor(() => { + expect(SigninDeciderModule.default).toHaveBeenCalled(); + }); + expect(useAuthMachineSpy).toHaveBeenCalled(); + // When the flag is on, the deps object comes from makeMachineDeps (not the + // no-op sentinel), so it will have the real checkAccountStatus function. + const { deps } = useAuthMachineSpy.mock.calls[0][0]; + expect(typeof deps.checkAccountStatus).toBe('function'); + expect(typeof deps.cachedSignin).toBe('function'); + }); + + it('does not auto-send events that would trigger beginSignin on mount', async () => { + renderContainer(); + await waitFor(() => { + expect(SigninDeciderModule.default).toHaveBeenCalled(); + }); + // beginSignin on the real deps throws "not yet wired"; if it were called + // during mount we would see an unhandled error. The test passing means + // no event that invokes beginSignin was sent. + expect(useAuthMachineSpy).toHaveBeenCalled(); + }); + }); + + describe('when authStateMachine is enabled via URL query param only', () => { + beforeEach(() => { + // config flag OFF, query param ON + setupDefaultMocks( + { authStateMachine: false }, + { authStateMachine: true } + ); + }); + + it('renders the auth-state-machine-active sentinel from the query param', async () => { + renderContainer(); + await waitFor(() => { + expect(SigninDeciderModule.default).toHaveBeenCalled(); + }); + expect( + screen.getByTestId('auth-state-machine-active') + ).toBeInTheDocument(); + }); + }); + + describe('when authStateMachine is disabled via URL query param', () => { + beforeEach(() => { + // config flag ON, query param explicitly OFF + setupDefaultMocks( + { authStateMachine: true }, + { authStateMachine: false } + ); + }); + + it('does not render the auth-state-machine-active sentinel when URL forces it off', async () => { + renderContainer(); + await waitFor(() => { + expect(SigninDeciderModule.default).toHaveBeenCalled(); + }); + expect( + screen.queryByTestId('auth-state-machine-active') + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/fxa-settings/src/pages/Signin/container.tsx b/packages/fxa-settings/src/pages/Signin/container.tsx index 9013f24e1e9..6925a144005 100644 --- a/packages/fxa-settings/src/pages/Signin/container.tsx +++ b/packages/fxa-settings/src/pages/Signin/container.tsx @@ -25,7 +25,7 @@ import { OAuthNativeSyncQueryParameters, OAuthQueryParams, } from '../../models/pages/signin'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { currentAccount, lastStoredAccount, @@ -72,6 +72,10 @@ import { cachedSignIn, ensureCanLinkAcountOrRedirect } from './utils'; import { PROFILE_OAUTH_TOKEN_TTL_SECONDS } from '../../lib/oauth'; import OAuthDataError from '../../components/OAuthDataError'; import { AppLayout } from '../../components/AppLayout'; +import { useAuthMachine } from '../../lib/auth-machine/useAuthMachine'; +import { buildAuthContext } from '../../lib/auth-machine/context'; +import { makeMachineDeps } from '../../lib/auth-machine/deps'; +import type { FlowState } from '../../lib/auth-machine/types'; /* * In Backbone, the `email` param is optional. If it's provided, we @@ -422,6 +426,114 @@ const SigninContainer = ({ })(); }, [needsSessionValidation, session, sessionToken]); + // Flag-gate scaffolding reserved for a future hook-driven takeover. The LIVE + // routing cutover (the machine deciding post-sign-in navigation) lives in + // handleNavigation in ./utils.ts; this hook does not drive navigation yet. + // Hooks are called unconditionally (React rules); the machine is only instantiated + // and observable when the flag is enabled. + const authStateMachineEnabled = + queryParamModel.authStateMachine ?? + config.featureFlags?.authStateMachine === true; + + const machineDeps = useMemo( + () => + authStateMachineEnabled + ? makeMachineDeps({ + authClient, + integration, + sensitiveDataClient, + sessionToken, + session, + }) + : null, + // Stable across renders when flag is off; re-memoize only on flag toggle. + // eslint-disable-next-line react-hooks/exhaustive-deps + [authStateMachineEnabled] + ); + + const machineCtx = useMemo( + () => + authStateMachineEnabled + ? buildAuthContext({ + integration, + stored: { + email, + uid, + sessionToken, + hasPassword: accountStatus.hasPassword, + }, + live: { + hasLinkedAccount: accountStatus.hasLinkedAccount, + hasCachedSession: !!sessionToken, + passwordlessSupported: accountStatus.passwordlessSupported, + }, + }) + : null, + // Rebuild context when flag or key account facts change. + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + authStateMachineEnabled, + email, + uid, + sessionToken, + accountStatus.hasPassword, + accountStatus.hasLinkedAccount, + accountStatus.passwordlessSupported, + ] + ); + + const INITIAL_MACHINE_STATE: FlowState = 'authenticating.signinDecider'; + + // useAuthMachine must be called unconditionally; internally the hook just + // reduces state — it only fires effects when `send` is called explicitly. + // When the flag is off, we supply a no-op sentinel so no real work happens. + const noopDeps = useMemo( + () => ({ + checkAccountStatus: async () => ({ exists: false }), + beginSignin: async () => { + throw new Error('not wired'); + }, + cachedSignin: async () => { + throw new Error('not wired'); + }, + sendUnblockEmail: async () => { + throw new Error('not wired'); + }, + upgradeCredentials: async () => {}, + }), + [] + ); + + useAuthMachine({ + initial: INITIAL_MACHINE_STATE, + ctx: machineCtx ?? { + email: undefined, + uid: undefined, + sessionToken: undefined, + emailVerified: false, + sessionVerified: false, + accountHasTotp: false, + hasRecoveryPhone: false, + hasPassword: true, + hasLinkedAccount: false, + hasCachedSession: false, + passwordlessSupported: false, + isOAuth: false, + isOAuthWeb: false, + isOAuthNative: false, + isSync: false, + isWebChannelIntegration: false, + supportsKeysOptionalLogin: false, + requiresKeys: false, + wantsKeysIfPasswordEntered: false, + wantsLogin: false, + clientInfoLoadFailed: false, + }, + deps: machineDeps ?? noopDeps, + navigate: () => {}, + delegate: () => {}, + }); + const beginSigninHandler: BeginSigninHandler = useCallback( async (email: string, password: string) => { // Guard: passwordless accounts should never hit password-based endpoints. @@ -572,7 +684,8 @@ const SigninContainer = ({ credentials.credentialStatus?.upgradeNeeded === true && credentials.v2Credentials ) { - const { sessionToken, emailVerified, sessionVerified } = result.data.signIn; + const { sessionToken, emailVerified, sessionVerified } = + result.data.signIn; // To simplify this process. Fetch the original account sign in email. const emails = await authClient.accountEmails(sessionToken); @@ -581,7 +694,10 @@ const SigninContainer = ({ // Update the v1Credentials object to make sure the authPW is in fact correct. It // needs to be derived from the original account email, not the current primary. if (emails.original !== email) { - credentials.v1Credentials = await getCredentials(emails.original, password); + credentials.v1Credentials = await getCredentials( + emails.original, + password + ); } sensitiveDataClient.KeyStretchUpgradeData = { @@ -695,7 +811,7 @@ const SigninContainer = ({ ); } - return ( + const signinDecider = ( ); + + // When the flag is on, wrap with a sentinel so tests can assert machine presence. + if (authStateMachineEnabled) { + return
{signinDecider}
; + } + + return signinDecider; }; export async function getCurrentCredentials( @@ -769,14 +892,18 @@ export async function trySignIn( ) { try { const authPW = v2Credentials?.authPW || v1Credentials.authPW; - const response = await authClient.signInWithAuthPW({ primary: email }, authPW, { - verificationMethod: options.verificationMethod, - keys: options.keys, - service: options.service, - metricsContext: options.metricsContext, - unblockCode: options.unblockCode, - originalLoginEmail: options.originalLoginEmail, - }); + const response = await authClient.signInWithAuthPW( + { primary: email }, + authPW, + { + verificationMethod: options.verificationMethod, + keys: options.keys, + service: options.service, + metricsContext: options.metricsContext, + unblockCode: options.unblockCode, + originalLoginEmail: options.originalLoginEmail, + } + ); if (response) { const unwrapBKey = v2Credentials diff --git a/packages/fxa-settings/src/pages/Signin/index.tsx b/packages/fxa-settings/src/pages/Signin/index.tsx index 55c02ba5612..91edb964fbe 100644 --- a/packages/fxa-settings/src/pages/Signin/index.tsx +++ b/packages/fxa-settings/src/pages/Signin/index.tsx @@ -13,6 +13,9 @@ import InputPassword from '../../components/InputPassword'; import TermsPrivacyAgreement from '../../components/TermsPrivacyAgreement'; import AlternativeAuthOptions from '../../components/AlternativeAuthOptions'; import { AuthUiErrors } from '../../lib/auth-errors/auth-errors'; +import VerificationMethods from '../../constants/verification-methods'; +import AuthenticationMethods from '../../constants/authentication-methods'; +import { isAuthStateMachineEnabled } from '../../lib/auth-machine/flag'; import GleanMetrics from '../../lib/glean'; import { useSensitiveDataClient, @@ -35,6 +38,23 @@ import SigninUserLockup from './components/SigninUserLockup'; export const viewName = 'signin'; +// The sign-in response omits the account's real 2FA status. Read it from the +// profile so the auth-machine's live-TOTP safety net can force /signin_totp_code +// even when the server echoes email-otp; fall back to false on error (legacy +// behaviour keys off verificationMethod). +async function fetchAccountHasTotp( + authClient: ReturnType, + sessionToken: string +): Promise { + try { + const { authenticationMethods } = + await authClient.accountProfile(sessionToken); + return authenticationMethods.includes(AuthenticationMethods.OTP); + } catch { + return false; + } +} + // Password-input signin. The container only renders this component when the // flow needs a password. const Signin = ({ @@ -153,6 +173,21 @@ const Signin = ({ const isFullyVerified = data.signIn.emailVerified && data.signIn.sessionVerified; + + // Only pay for the live-TOTP fact when the machine is on and a verify step + // follows (and totp isn't already signalled) — never on the legacy/happy path. + const machineEnabled = isAuthStateMachineEnabled( + location.search, + config.featureFlags?.authStateMachine === true + ); + const shouldResolveTotp = + machineEnabled && + !isFullyVerified && + data.signIn.verificationMethod !== VerificationMethods.TOTP_2FA; + const accountHasTotp = shouldResolveTotp + ? await fetchAccountHasTotp(authClient, data.signIn.sessionToken) + : false; + const navigationOptions = { email, signinData: data.signIn, @@ -165,6 +200,7 @@ const Signin = ({ : '', queryParams: location.search, showInlineRecoveryKeySetup: data.showInlineRecoveryKeySetup, + accountHasTotp, handleFxaLogin: true, handleFxaOAuthLogin: true, performNavigation: !( @@ -261,7 +297,9 @@ const Signin = ({ } }, [ + authClient, beginSigninHandler, + config.featureFlags?.authStateMachine, email, ftlMsgResolver, hasLinkedAccount, diff --git a/packages/fxa-settings/src/pages/Signin/utils.test.ts b/packages/fxa-settings/src/pages/Signin/utils.test.ts index f3279fbbf4a..f5e86b84fbc 100644 --- a/packages/fxa-settings/src/pages/Signin/utils.test.ts +++ b/packages/fxa-settings/src/pages/Signin/utils.test.ts @@ -11,13 +11,14 @@ import { MOCK_SESSION_TOKEN, MOCK_UID, } from '../mocks'; -import { NavigationOptions } from './interfaces'; +import { NavigationOptions, SigninIntegration } from './interfaces'; import { createMockSigninOAuthNativeSyncIntegration, createMockSigninOAuthNativeIntegration, createMockSigninOAuthIntegration, createMockSigninWebIntegration, } from './mocks'; +import { AuthUiErrors } from '../../lib/auth-errors/auth-errors'; import { handleNavigation, ensureCanLinkAcountOrRedirect, @@ -633,6 +634,829 @@ describe('Signin utils', () => { ).toBe('post-verify-set-password'); }); }); + + describe('auth state machine routing (flag on, plain Web)', () => { + it('routes a fully verified signin to /settings and preserves the query param', async () => { + const navigationOptions = createBaseNavigationOptions({ + integration: createMockSigninWebIntegration(), + queryParams: '?authStateMachine=true', + signinData: { + uid: MOCK_UID, + sessionToken: MOCK_SESSION_TOKEN, + keyFetchToken: MOCK_KEY_FETCH_TOKEN, + emailVerified: true, + sessionVerified: true, + verificationMethod: VerificationMethods.EMAIL, + verificationReason: VerificationReasons.SIGN_IN, + }, + }); + await handleNavigation(navigationOptions); + expect(navigateSpy).toHaveBeenCalledWith( + '/settings?authStateMachine=true', + expect.anything() + ); + }); + + it('routes a TOTP account to /signin_totp_code and preserves the query param', async () => { + const navigationOptions = createBaseNavigationOptions({ + integration: createMockSigninWebIntegration(), + queryParams: '?authStateMachine=true', + signinData: { + uid: MOCK_UID, + sessionToken: MOCK_SESSION_TOKEN, + keyFetchToken: MOCK_KEY_FETCH_TOKEN, + emailVerified: true, + sessionVerified: false, + verificationMethod: VerificationMethods.TOTP_2FA, + verificationReason: VerificationReasons.SIGN_IN, + }, + accountHasTotp: true, + }); + await handleNavigation(navigationOptions); + expect(navigateSpy).toHaveBeenCalledWith( + '/signin_totp_code?authStateMachine=true', + expect.anything() + ); + }); + + it('routes an unverified-session email-otp signin to /signin_token_code and preserves the query param', async () => { + const navigationOptions = createBaseNavigationOptions({ + integration: createMockSigninWebIntegration(), + queryParams: '?authStateMachine=true', + signinData: { + uid: MOCK_UID, + sessionToken: MOCK_SESSION_TOKEN, + keyFetchToken: MOCK_KEY_FETCH_TOKEN, + emailVerified: true, + sessionVerified: false, + verificationMethod: VerificationMethods.EMAIL_OTP, + verificationReason: VerificationReasons.SIGN_IN, + }, + }); + await handleNavigation(navigationOptions); + expect(navigateSpy).toHaveBeenCalledWith( + '/signin_token_code?authStateMachine=true', + expect.anything() + ); + }); + + it('TOTP account whose response echoes email-otp still routes to /signin_totp_code (live-fact safety net)', async () => { + // The machine reads accountHasTotp (a live fact) rather than trusting the + // verificationMethod echoed by the server response. Legacy getUnverifiedNavigationTarget + // only checks verificationMethod === TOTP_2FA, so it would send this to + // /signin_token_code — this test fails without the machine branch. + const navigationOptions = createBaseNavigationOptions({ + integration: createMockSigninWebIntegration(), + queryParams: '?authStateMachine=true', + accountHasTotp: true, + signinData: { + uid: MOCK_UID, + sessionToken: MOCK_SESSION_TOKEN, + keyFetchToken: MOCK_KEY_FETCH_TOKEN, + emailVerified: true, + sessionVerified: false, + verificationMethod: VerificationMethods.EMAIL_OTP, + verificationReason: VerificationReasons.SIGN_IN, + }, + }); + await handleNavigation(navigationOptions); + expect(navigateSpy).toHaveBeenCalledWith( + '/signin_totp_code?authStateMachine=true', + expect.anything() + ); + }); + + it('falls through to legacy navigation when isSessionAALUpgrade is true (machine excluded)', async () => { + // The guard excludes AAL upgrades. Legacy routes bare to /settings with no query string. + // If the machine had wrongly fired it would have appended ?authStateMachine=true. + const navigationOptions = createBaseNavigationOptions({ + integration: createMockSigninWebIntegration(), + queryParams: '?authStateMachine=true', + isSessionAALUpgrade: true, + signinData: { + uid: MOCK_UID, + sessionToken: MOCK_SESSION_TOKEN, + keyFetchToken: MOCK_KEY_FETCH_TOKEN, + emailVerified: true, + sessionVerified: true, + verificationMethod: VerificationMethods.EMAIL, + verificationReason: VerificationReasons.SIGN_IN, + }, + }); + await handleNavigation(navigationOptions); + expect(navigateSpy).toHaveBeenCalledWith('/settings'); + }); + + it('falls through to legacy navigation when ?authStateMachine=false overrides a config-enabled flag', async () => { + // URL forces the machine OFF even though the config flag is on. + // Legacy plain-web nav goes to /settings without the query param appended + // (the machine never runs so it cannot produce a machine-style URL). + const navigationOptions = createBaseNavigationOptions({ + integration: createMockSigninWebIntegration(), + queryParams: '?authStateMachine=false', + signinData: { + uid: MOCK_UID, + sessionToken: MOCK_SESSION_TOKEN, + keyFetchToken: MOCK_KEY_FETCH_TOKEN, + emailVerified: true, + sessionVerified: true, + verificationMethod: VerificationMethods.EMAIL, + verificationReason: VerificationReasons.SIGN_IN, + }, + }); + await handleNavigation(navigationOptions); + // Legacy non-OAuth path navigates to /settings (no machine-suffixed URL). + expect(navigateSpy).toHaveBeenCalledWith( + '/settings', + expect.anything() + ); + // The machine would have produced '/settings?authStateMachine=false'; confirm it did not. + expect(navigateSpy).not.toHaveBeenCalledWith( + '/settings?authStateMachine=false', + expect.anything() + ); + }); + + it('routes via machine when config flag is on and no authStateMachine query param is present', async () => { + // config.featureFlags.authStateMachine is set to true for this test only. + config.featureFlags!.authStateMachine = true; + const navigationOptions = createBaseNavigationOptions({ + integration: createMockSigninWebIntegration(), + queryParams: '', + signinData: { + uid: MOCK_UID, + sessionToken: MOCK_SESSION_TOKEN, + keyFetchToken: MOCK_KEY_FETCH_TOKEN, + emailVerified: true, + sessionVerified: true, + verificationMethod: VerificationMethods.EMAIL, + verificationReason: VerificationReasons.SIGN_IN, + }, + }); + await handleNavigation(navigationOptions); + // Machine produces '/settings' + queryParams (empty string here) via routeFor. + expect(navigateSpy).toHaveBeenCalledWith( + '/settings', + expect.anything() + ); + config.featureFlags!.authStateMachine = false; + }); + }); + + describe('auth state machine routing (flag on, Sync)', () => { + // Mirrors the legacy Sync mock above: a Web integration flipped to Sync. + // getWebChannelServices is overridden so the fxaLogin `services` payload is + // observable in the parity assertions. + const createSyncIntegration = () => ({ + ...createMockSigninWebIntegration(), + isSync: () => true, + wantsKeys: () => true, + getWebChannelServices: () => ({ sync: {} }), + }); + + // Captures the navigate destination + fxaLogin payload for one input under + // a given flag value, so the machine-on result can be asserted equal to the + // legacy (flag-off) result for the same Sync input. + const runSync = async ( + flag: 'true' | 'false', + overrides: Partial + ) => { + jest.clearAllMocks(); + const navigationOptions = createBaseNavigationOptions({ + integration: createSyncIntegration(), + queryParams: `?authStateMachine=${flag}&service=sync`, + handleFxaLogin: true, + ...overrides, + }); + await handleNavigation(navigationOptions); + const navCall = navigateSpy.mock.calls[0] as unknown as + | [string, { state?: unknown; replace?: boolean }?] + | undefined; + return { + navTo: navCall?.[0], + navState: navCall?.[1], + fxaLogin: fxaLoginSpy.mock.calls[0]?.[0], + }; + }; + + const unverified: Partial = { + signinData: { + uid: MOCK_UID, + sessionToken: MOCK_SESSION_TOKEN, + keyFetchToken: MOCK_KEY_FETCH_TOKEN, + emailVerified: true, + sessionVerified: false, + verificationMethod: VerificationMethods.EMAIL_OTP, + verificationReason: VerificationReasons.SIGN_IN, + }, + }; + const verified: Partial = { + signinData: { + uid: MOCK_UID, + sessionToken: MOCK_SESSION_TOKEN, + keyFetchToken: MOCK_KEY_FETCH_TOKEN, + emailVerified: true, + sessionVerified: true, + verificationMethod: VerificationMethods.EMAIL, + verificationReason: VerificationReasons.SIGN_IN, + }, + }; + + it('fires fxaLogin with verified:false and navigates to /signin_token_code for an unverified Sync sign-in', async () => { + const { navTo, fxaLogin } = await runSync('true', unverified); + expect(fxaLogin).toEqual( + expect.objectContaining({ verified: false, services: { sync: {} } }) + ); + expect(navTo).toBe( + '/signin_token_code?authStateMachine=true&service=sync' + ); + }); + + it('fires fxaLogin with verified:true and routes through getSyncNavigate to /pair for a verified Sync sign-in', async () => { + const { navTo, navState, fxaLogin } = await runSync('true', verified); + expect(fxaLogin).toEqual( + expect.objectContaining({ verified: true, services: { sync: {} } }) + ); + expect(navTo).toBe('/pair?authStateMachine=true&service=sync'); + expect( + (navState as unknown as { state: { origin: string } }).state.origin + ).toBe('signin'); + }); + + it('routes a verified Sync sign-in with showInlineRecoveryKeySetup to /inline_recovery_key_setup', async () => { + const { navTo, fxaLogin } = await runSync('true', { + ...verified, + showInlineRecoveryKeySetup: true, + }); + expect(fxaLogin).toEqual(expect.objectContaining({ verified: true })); + expect(navTo).toBe( + '/inline_recovery_key_setup?authStateMachine=true&service=sync' + ); + }); + + // #1: a send-tab entrypoint forces showInlineRecoveryKeySetup false, so even + // with the flag set the destination is /pair, not /inline_recovery_key_setup. + // The mutation runs ahead of the machine branch, so machine-on matches flag-off. + it('clears showInlineRecoveryKeySetup for a verified send-tab Sync sign-in and routes to /pair', async () => { + const sendTabIntegration = () => { + const i = createSyncIntegration(); + i.data.entrypoint = 'send-tab-toolbar-icon'; + return i; + }; + const runSendTab = async (flag: 'true' | 'false') => { + jest.clearAllMocks(); + await handleNavigation( + createBaseNavigationOptions({ + integration: sendTabIntegration(), + queryParams: `?authStateMachine=${flag}&service=sync`, + handleFxaLogin: true, + ...verified, + showInlineRecoveryKeySetup: true, + }) + ); + return navigateSpy.mock.calls[0]?.[0] as unknown as + | string + | undefined; + }; + const on = await runSendTab('true'); + const off = await runSendTab('false'); + expect(on).toContain('/pair?'); + expect(on).not.toContain('inline_recovery_key'); + expect( + on?.replace('authStateMachine=true', 'authStateMachine=false') + ).toBe(off); + }); + + // #2: verified Sync + performNavigation:false must not navigate, but fxaLogin + // still fires (legacy fires it before the performNavigation guard). + it('does not navigate a verified Sync sign-in when performNavigation is false, but still fires fxaLogin', async () => { + const runNoNav = async (flag: 'true' | 'false') => { + jest.clearAllMocks(); + await handleNavigation( + createBaseNavigationOptions({ + integration: createSyncIntegration(), + queryParams: `?authStateMachine=${flag}&service=sync`, + handleFxaLogin: true, + performNavigation: false, + ...verified, + }) + ); + return { + navCount: navigateSpy.mock.calls.length, + fxaLogin: fxaLoginSpy.mock.calls[0]?.[0], + }; + }; + const on = await runNoNav('true'); + const off = await runNoNav('false'); + expect(on.navCount).toBe(0); + expect(on.navCount).toBe(off.navCount); + expect(on.fxaLogin).toEqual( + expect.objectContaining({ verified: true }) + ); + expect(on.fxaLogin).toEqual(off.fxaLogin); + }); + + // #3: unverified Firefox-non-sync that does not want keys, with a non-mustVerify + // reason and performNavigation:false, must not navigate — matching legacy's + // performNavigation !== false gate. + it('does not navigate an unverified Firefox-non-sync sign-in when performNavigation is false and the case is not mustVerify', async () => { + const firefoxNonSyncIntegration = () => ({ + ...createMockSigninWebIntegration(), + isSync: () => false, + isFirefoxNonSync: () => true, + wantsKeys: () => false, + getWebChannelServices: () => ({ sync: {} }), + }); + const runNoNav = async (flag: 'true' | 'false') => { + jest.clearAllMocks(); + await handleNavigation( + createBaseNavigationOptions({ + integration: firefoxNonSyncIntegration(), + queryParams: `?authStateMachine=${flag}`, + handleFxaLogin: true, + performNavigation: false, + signinData: { + uid: MOCK_UID, + sessionToken: MOCK_SESSION_TOKEN, + keyFetchToken: MOCK_KEY_FETCH_TOKEN, + emailVerified: true, + sessionVerified: false, + verificationMethod: VerificationMethods.EMAIL_OTP, + verificationReason: VerificationReasons.SIGN_IN, + }, + }) + ); + return navigateSpy.mock.calls.length; + }; + const on = await runNoNav('true'); + const off = await runNoNav('false'); + expect(on).toBe(0); + expect(on).toBe(off); + }); + + // #5: SIGN_UP with emailVerified:true falls through to legacy, which routes to + // /confirm_signup_code (legacy returns it for SIGN_UP independent of emailVerified). + it('routes a SIGN_UP Sync sign-in with emailVerified true to /confirm_signup_code via legacy fall-through', async () => { + const signUpVerifiedEmail: Partial = { + signinData: { + uid: MOCK_UID, + sessionToken: MOCK_SESSION_TOKEN, + keyFetchToken: MOCK_KEY_FETCH_TOKEN, + emailVerified: true, + sessionVerified: false, + verificationMethod: VerificationMethods.EMAIL_OTP, + verificationReason: VerificationReasons.SIGN_UP, + }, + }; + const on = await runSync('true', signUpVerifiedEmail); + const off = await runSync('false', signUpVerifiedEmail); + expect(on.navTo).toContain('/confirm_signup_code'); + expect( + on.navTo?.replace('authStateMachine=true', 'authStateMachine=false') + ).toBe(off.navTo); + }); + + // #4 (ACCEPTED divergence, documented): the machine routes an unverified + // EMAIL_OTP sign-in to /signin_totp_code when the live account has TOTP, and + // skips fxaLogin. Legacy would send EMAIL_OTP to /signin_token_code. This is + // the one intentional, safer divergence (the R-18 live-TOTP safety net): it + // avoids prompting for an email code on an account that actually has 2FA, and + // avoids the double `verified: false` message before TOTP. It is therefore + // excluded from the parity gate above. + it('routes an unverified EMAIL_OTP Sync sign-in to /signin_totp_code (and skips fxaLogin) when the live account has TOTP — intended safer divergence from legacy', async () => { + const { navTo, fxaLogin } = await runSync('true', { + ...unverified, + accountHasTotp: true, + }); + expect(navTo).toBe( + '/signin_totp_code?authStateMachine=true&service=sync' + ); + expect(fxaLogin).toBeUndefined(); + }); + + // Parity gate: for each Sync input the machine-on destination and the + // fxaLogin payload must equal the legacy (flag-off) result. The only + // expected difference is the authStateMachine query value baked into the URL. + // accountHasTotp is deliberately excluded: it is the documented exception above. + it.each([ + { name: 'unverified email-otp', overrides: unverified }, + { name: 'verified default', overrides: verified }, + { + name: 'verified with inline recovery key setup', + overrides: { ...verified, showInlineRecoveryKeySetup: true }, + }, + ])( + 'machine-on destination and fxaLogin equal legacy for $name Sync', + async ({ overrides }) => { + const on = await runSync('true', overrides); + const off = await runSync('false', overrides); + const normalize = (url?: string) => + url?.replace('authStateMachine=false', 'authStateMachine=true'); + expect(normalize(on.navTo)).toBe(normalize(off.navTo)); + expect(on.fxaLogin).toEqual(off.fxaLogin); + } + ); + }); + + describe('auth state machine routing (flag on, OAuth web)', () => { + // OAuth web is an RP (relying-party) integration: not web-channel, so no + // sendFxaLogin and no firefox.fxaOAuthLogin. It resolves the destination + // through getOAuthNavigationTarget (finishOAuthFlowHandler -> RP redirect). + // Each case asserts machine-on (flag true) == legacy (flag off) for the + // same input, the one allowed difference being the authStateMachine query + // value baked into a URL. + const fxaOAuthLoginSpy = jest.spyOn(firefox, 'fxaOAuthLogin'); + + const verifiedSignin = { + uid: MOCK_UID, + sessionToken: MOCK_SESSION_TOKEN, + keyFetchToken: MOCK_KEY_FETCH_TOKEN, + emailVerified: true, + sessionVerified: true, + verificationMethod: VerificationMethods.EMAIL, + verificationReason: VerificationReasons.SIGN_IN, + }; + const unverifiedSignin = { + uid: MOCK_UID, + sessionToken: MOCK_SESSION_TOKEN, + keyFetchToken: MOCK_KEY_FETCH_TOKEN, + emailVerified: true, + sessionVerified: false, + verificationMethod: VerificationMethods.EMAIL_OTP, + verificationReason: VerificationReasons.SIGN_IN, + }; + + // Runs handleNavigation for one OAuth-web input under a given flag value, + // capturing every observable side effect so machine-on can be asserted + // equal to legacy for the same input. The integration factory is a thunk + // so each run gets a fresh mock (jest.clearAllMocks resets call history, + // not the integration object). + const run = async ( + flag: 'true' | 'false', + integrationFactory: () => SigninIntegration, + overrides: Partial = {} + ) => { + jest.clearAllMocks(); + const navigationOptions = createBaseNavigationOptions({ + integration: integrationFactory(), + queryParams: `?authStateMachine=${flag}&client_id=abc`, + ...overrides, + }); + const result = await handleNavigation(navigationOptions); + return { + error: result.error, + navCall: navigateSpy.mock.calls[0] as unknown as + | [string, { state?: unknown; replace?: boolean }?] + | undefined, + hardNavCall: hardNavigateSpy.mock.calls[0], + fxaOAuthLoginCalls: fxaOAuthLoginSpy.mock.calls.length, + fxaLoginCalls: fxaLoginSpy.mock.calls.length, + }; + }; + + it('redirects a verified OAuth web sign-in to the RP via hardNavigate (machine-on == legacy)', async () => { + const factory = () => createMockSigninOAuthIntegration(); + const on = await run('true', factory, { signinData: verifiedSignin }); + const off = await run('false', factory, { signinData: verifiedSignin }); + + expect(on.error).toBeUndefined(); + expect(on.hardNavCall).toEqual([ + MOCK_OAUTH_FLOW_HANDLER_RESPONSE.redirect, + undefined, + undefined, + true, + ]); + expect(on.hardNavCall).toEqual(off.hardNavCall); + // OAuth web must never reach the native browser-message path. + expect(on.fxaOAuthLoginCalls).toBe(0); + expect(on.fxaLoginCalls).toBe(0); + }); + + it('diverts a verified AAL2 OAuth web RP with no account TOTP to /inline_totp_setup (machine-on == legacy)', async () => { + const factory = () => { + const integration = createMockSigninOAuthIntegration(); + integration.wantsTwoStepAuthentication = jest + .fn() + .mockReturnValue(true); + return integration; + }; + const overrides: Partial = { + accountHasTotp: false, + // finishOAuthFlowHandler must not run when the AAL2 guard diverts. + finishOAuthFlowHandler: jest.fn(), + signinData: verifiedSignin, + }; + const on = await run('true', factory, overrides); + const off = await run('false', factory, overrides); + + expect(on.error).toBeUndefined(); + expect(on.navCall?.[0]).toBe( + '/inline_totp_setup?authStateMachine=true&client_id=abc' + ); + expect((on.navCall?.[1] as { replace?: boolean })?.replace).toBe(true); + expect(on.navCall?.[0]?.replace('=true', '=false')).toBe( + off.navCall?.[0] + ); + expect(on.fxaOAuthLoginCalls).toBe(0); + }); + + it('diverts a verified third-party-auth OAuth web sign-in that requires a password to /post_verify/set_password (machine-on == legacy)', async () => { + const factory = () => { + const integration = createMockSigninOAuthIntegration(); + // requiresPasswordForLogin returns true via wantsKeysIfPasswordEntered + // when keys are not optional. + integration.wantsKeysIfPasswordEntered = () => true; + return integration; + }; + const overrides: Partial = { + isSignInWithThirdPartyAuth: true, + supportsKeysOptionalLogin: false, + finishOAuthFlowHandler: jest.fn(), + signinData: verifiedSignin, + }; + const on = await run('true', factory, overrides); + const off = await run('false', factory, overrides); + + expect(on.error).toBeUndefined(); + expect(on.navCall?.[0]).toContain('/post_verify/set_password'); + expect( + ( + on.navCall?.[1] as { + state?: { passwordCreationReason?: string }; + } + )?.state?.passwordCreationReason + ).toBe('third_party_auth'); + expect(on.navCall?.[0]?.replace('=true', '=false')).toBe( + off.navCall?.[0] + ); + }); + + it('propagates the error and does not navigate when getOAuthNavigationTarget returns an error (machine-on == legacy)', async () => { + const factory = () => createMockSigninOAuthIntegration(); + const overrides: Partial = { + signinData: verifiedSignin, + finishOAuthFlowHandler: jest.fn().mockResolvedValue({ + error: AuthUiErrors.BACKEND_SERVICE_FAILURE, + }), + }; + const on = await run('true', factory, overrides); + const off = await run('false', factory, overrides); + + expect(on.error).toBe(AuthUiErrors.BACKEND_SERVICE_FAILURE); + expect(on.error).toBe(off.error); + expect(on.navCall).toBeUndefined(); + expect(on.hardNavCall).toBeUndefined(); + }); + + it('routes an unverified OAuth web mustVerify (wantsKeys) sign-in to the verify route (machine-on == legacy)', async () => { + const factory = () => { + const integration = createMockSigninOAuthIntegration(); + (integration as { wantsKeys: () => boolean }).wantsKeys = () => true; + return integration; + }; + const overrides: Partial = { + signinData: unverifiedSignin, + }; + const on = await run('true', factory, overrides); + const off = await run('false', factory, overrides); + + expect(on.error).toBeUndefined(); + expect(on.navCall?.[0]).toBe( + '/signin_token_code?authStateMachine=true&client_id=abc' + ); + expect(on.navCall?.[0]?.replace('=true', '=false')).toBe( + off.navCall?.[0] + ); + }); + + it('takes an unverified non-mustVerify OAuth web sign-in onward via getOAuthNavigationTarget type-C (machine-on == legacy)', async () => { + const factory = () => createMockSigninOAuthIntegration(); + const overrides: Partial = { + signinData: unverifiedSignin, + }; + const on = await run('true', factory, overrides); + const off = await run('false', factory, overrides); + + expect(on.error).toBeUndefined(); + // type-C: skip verification, hard-navigate to the RP redirect. + expect(on.hardNavCall).toEqual([ + MOCK_OAUTH_FLOW_HANDLER_RESPONSE.redirect, + undefined, + undefined, + true, + ]); + expect(on.hardNavCall).toEqual(off.hardNavCall); + expect(on.fxaOAuthLoginCalls).toBe(0); + }); + }); + + describe('auth state machine routing (flag on, OAuth native)', () => { + // OAuth native (Sync desktop/mobile, Firefox-non-sync) is BOTH OAuth and + // web-channel for Sync, so both sendFxaLogin (fxa_login) and + // firefox.fxaOAuthLogin fire. The destination resolves through + // getOAuthNavigationTarget. Each case asserts machine-on (flag true) == + // legacy (flag off) for the same input: same destination, same + // fxaOAuthLogin payload, same fxaLogin call count. The one allowed + // difference is the authStateMachine query value baked into a URL. + const fxaOAuthLoginSpy = jest.spyOn(firefox, 'fxaOAuthLogin'); + + const verifiedSignin = { + uid: MOCK_UID, + sessionToken: MOCK_SESSION_TOKEN, + keyFetchToken: MOCK_KEY_FETCH_TOKEN, + emailVerified: true, + sessionVerified: true, + verificationMethod: VerificationMethods.EMAIL, + verificationReason: VerificationReasons.SIGN_IN, + }; + const unverifiedSignin = { + uid: MOCK_UID, + sessionToken: MOCK_SESSION_TOKEN, + keyFetchToken: MOCK_KEY_FETCH_TOKEN, + emailVerified: true, + sessionVerified: false, + verificationMethod: VerificationMethods.EMAIL_OTP, + verificationReason: VerificationReasons.SIGN_IN, + }; + + // Runs handleNavigation for one OAuth-native input under a given flag value, + // capturing every observable side effect so machine-on can be asserted + // equal to legacy for the same input. The integration factory is a thunk so + // each run gets a fresh mock (jest.clearAllMocks resets call history, not + // the integration object). + const run = async ( + flag: 'true' | 'false', + integrationFactory: () => SigninIntegration, + overrides: Partial = {} + ) => { + jest.clearAllMocks(); + const navigationOptions = createBaseNavigationOptions({ + integration: integrationFactory(), + queryParams: `?authStateMachine=${flag}&service=sync`, + handleFxaLogin: true, + handleFxaOAuthLogin: true, + ...overrides, + }); + const result = await handleNavigation(navigationOptions); + return { + error: result.error, + navCall: navigateSpy.mock.calls[0] as unknown as + | [string, { state?: unknown; replace?: boolean }?] + | undefined, + hardNavCall: hardNavigateSpy.mock.calls[0], + fxaOAuthLoginCall: fxaOAuthLoginSpy.mock.calls[0]?.[0], + fxaOAuthLoginCalls: fxaOAuthLoginSpy.mock.calls.length, + fxaLoginCall: fxaLoginSpy.mock.calls[0]?.[0], + fxaLoginCalls: fxaLoginSpy.mock.calls.length, + }; + }; + + it('fires fxaLogin then fxaOAuthLogin and routes a verified native Sync sign-in to /pair (machine-on == legacy)', async () => { + const factory = () => createMockSigninOAuthNativeSyncIntegration(); + const on = await run('true', factory, { signinData: verifiedSignin }); + const off = await run('false', factory, { signinData: verifiedSignin }); + + expect(on.error).toBeUndefined(); + // Both browser messages fire for native Sync. + expect(on.fxaLoginCalls).toBe(1); + expect(on.fxaLoginCall).toEqual( + expect.objectContaining({ verified: true }) + ); + expect(on.fxaOAuthLoginCalls).toBe(1); + expect(on.fxaOAuthLoginCall).toEqual({ + action: 'signin', + code: MOCK_OAUTH_FLOW_HANDLER_RESPONSE.code, + redirect: MOCK_OAUTH_FLOW_HANDLER_RESPONSE.redirect, + state: MOCK_OAUTH_FLOW_HANDLER_RESPONSE.state, + scope: MOCK_OAUTH_FLOW_HANDLER_RESPONSE.scope, + }); + expect(on.navCall?.[0]).toBe( + '/pair?authStateMachine=true&service=sync' + ); + expect( + (on.navCall?.[1] as { state?: { origin?: string } })?.state?.origin + ).toBe('signin'); + expect(on.navCall?.[0]?.replace('=true', '=false')).toBe( + off.navCall?.[0] + ); + expect(on.fxaOAuthLoginCall).toEqual(off.fxaOAuthLoginCall); + expect(on.fxaLoginCalls).toBe(off.fxaLoginCalls); + }); + + it('routes a verified Firefox-non-sync VPN native sign-in to /post_verify/service_welcome via navigate (machine-on == legacy)', async () => { + const factory = () => + createMockSigninOAuthNativeIntegration({ + isSync: false, + service: OAuthNativeServices.Vpn, + }); + const on = await run('true', factory, { signinData: verifiedSignin }); + const off = await run('false', factory, { signinData: verifiedSignin }); + + expect(on.error).toBeUndefined(); + expect(on.fxaOAuthLoginCalls).toBe(1); + expect(on.navCall).toEqual([ + '/post_verify/service_welcome', + { state: { origin: 'signin' }, replace: true }, + ]); + expect(on.navCall).toEqual(off.navCall); + expect(on.fxaOAuthLoginCall).toEqual(off.fxaOAuthLoginCall); + }); + + it('routes a verified Firefox-non-sync non-VPN native sign-in to /settings (machine-on == legacy)', async () => { + const factory = () => { + const integration = createMockSigninOAuthNativeIntegration({ + isSync: false, + service: OAuthNativeServices.Relay, + }); + // A non-Sync service that does not request keys signs in directly. + integration.wantsKeysIfPasswordEntered = () => false; + return integration; + }; + const on = await run('true', factory, { signinData: verifiedSignin }); + const off = await run('false', factory, { signinData: verifiedSignin }); + + expect(on.error).toBeUndefined(); + expect(on.fxaOAuthLoginCalls).toBe(1); + expect(on.navCall).toEqual(['/settings', { replace: true }]); + expect(on.navCall).toEqual(off.navCall); + expect(on.fxaOAuthLoginCall).toEqual(off.fxaOAuthLoginCall); + }); + + it('defers fxaOAuthLogin and routes a verified third-party-auth native sign-in that requires a password to /post_verify/set_password (machine-on == legacy)', async () => { + const factory = () => + createMockSigninOAuthNativeIntegration({ + isSync: false, + service: OAuthNativeServices.Vpn, + }); + const overrides: Partial = { + isSignInWithThirdPartyAuth: true, + supportsKeysOptionalLogin: false, + // finishOAuthFlowHandler must not run when set_password diverts, so + // no oauthData is produced and fxaOAuthLogin must not fire. + finishOAuthFlowHandler: jest.fn(), + signinData: verifiedSignin, + }; + const on = await run('true', factory, overrides); + const off = await run('false', factory, overrides); + + expect(on.error).toBeUndefined(); + expect(on.fxaOAuthLoginCalls).toBe(0); + expect(on.navCall?.[0]).toContain('/post_verify/set_password'); + expect( + ( + on.navCall?.[1] as { + state?: { passwordCreationReason?: string }; + } + )?.state?.passwordCreationReason + ).toBe('third_party_auth'); + expect(on.navCall?.[0]?.replace('=true', '=false')).toBe( + off.navCall?.[0] + ); + expect(on.fxaOAuthLoginCalls).toBe(off.fxaOAuthLoginCalls); + }); + + it('propagates the error and does not navigate or fire fxaOAuthLogin when getOAuthNavigationTarget returns an error (machine-on == legacy)', async () => { + const factory = () => createMockSigninOAuthNativeSyncIntegration(); + const overrides: Partial = { + signinData: verifiedSignin, + finishOAuthFlowHandler: jest.fn().mockResolvedValue({ + error: AuthUiErrors.BACKEND_SERVICE_FAILURE, + }), + }; + const on = await run('true', factory, overrides); + const off = await run('false', factory, overrides); + + expect(on.error).toBe(AuthUiErrors.BACKEND_SERVICE_FAILURE); + expect(on.error).toBe(off.error); + expect(on.navCall).toBeUndefined(); + expect(on.hardNavCall).toBeUndefined(); + expect(on.fxaOAuthLoginCalls).toBe(0); + expect(on.fxaOAuthLoginCalls).toBe(off.fxaOAuthLoginCalls); + }); + + it('fires fxaLogin with verified:false and routes an unverified native Sync sign-in to the verify route (machine-on == legacy)', async () => { + const factory = () => createMockSigninOAuthNativeSyncIntegration(); + const on = await run('true', factory, { signinData: unverifiedSignin }); + const off = await run('false', factory, { + signinData: unverifiedSignin, + }); + + expect(on.error).toBeUndefined(); + expect(on.fxaLoginCalls).toBe(1); + expect(on.fxaLoginCall).toEqual( + expect.objectContaining({ verified: false }) + ); + // Unverified native does not resolve OAuth, so no fxaOAuthLogin. + expect(on.fxaOAuthLoginCalls).toBe(0); + expect(on.navCall?.[0]).toBe( + '/signin_token_code?authStateMachine=true&service=sync' + ); + expect(on.navCall?.[0]?.replace('=true', '=false')).toBe( + off.navCall?.[0] + ); + expect(on.fxaLoginCalls).toBe(off.fxaLoginCalls); + }); + }); }); describe('ensureCanLinkAcountOrRedirect', () => { diff --git a/packages/fxa-settings/src/pages/Signin/utils.ts b/packages/fxa-settings/src/pages/Signin/utils.ts index aa71b81f10e..d8ee2307493 100644 --- a/packages/fxa-settings/src/pages/Signin/utils.ts +++ b/packages/fxa-settings/src/pages/Signin/utils.ts @@ -26,6 +26,10 @@ import GleanMetrics from '../../lib/glean'; import { OAuthData } from '../../lib/oauth/hooks'; import AuthenticationMethods from '../../constants/authentication-methods'; import config from '../../lib/config'; +import { funnelReducer } from '../../lib/auth-machine/funnel'; +import { routeFor } from '../../lib/auth-machine/route-adapter'; +import { isAuthStateMachineEnabled } from '../../lib/auth-machine/flag'; +import type { AuthContext } from '../../lib/auth-machine/types'; interface NavigationTarget { to: string; @@ -205,6 +209,32 @@ export const cachedSignIn = async ( } }; +/** + * Owns navigation for non-OAuth, OAuth-web, and OAuth-native sign-ins when the + * authStateMachine flag is on (config flag, or a ?authStateMachine=true|false URL + * override). Covers plain Web, Sync/Firefox-non-sync web-channel integrations, + * OAuth web (RP) integrations, and OAuth native (Sync desktop/mobile, + * Firefox-non-sync). Pairing integrations are isOAuthIntegration but neither web + * nor native, so they stay on the legacy path, along with redirectTo, AAL + * upgrades, and forced password changes. + */ +function machineOwnsNavigation(o: NavigationOptions): boolean { + const enabled = isAuthStateMachineEnabled( + o.queryParams, + config.featureFlags?.authStateMachine === true + ); + if (!enabled) return false; + const i = o.integration; + return ( + (!isOAuthIntegration(i) || + isOAuthWebIntegration(i) || + isOAuthNativeIntegration(i)) && + !o.redirectTo && + !o.isSessionAALUpgrade && + o.signinData.verificationReason !== VerificationReasons.CHANGE_PASSWORD + ); +} + // In Backbone and React, 'confirm_signup_code' and 'signin_token_code' send key // and token data up to Sync with fxa_login and then the CAD/pair page (currently // Backbone) completes the signin with fxa_status. @@ -217,27 +247,11 @@ export const cachedSignIn = async ( // _before_ we hard navigate to CAD/pair in these flows. export async function handleNavigation(navigationOptions: NavigationOptions) { const { integration } = navigationOptions; - const isOAuth = isOAuthIntegration(integration); - const isWebChannelIntegration = - integration.isSync() || integration.isFirefoxNonSync(); - const wantsTwoStepAuthentication = - isOAuthWebIntegration(integration) && - integration.wantsTwoStepAuthentication(); - const wantsKeys = integration.wantsKeys(); - // If this is an AAL upgrade, the user was redirected from Settings to enter TOTP. - // RP redirects won't get into this state since they'll be taken to the RP and - // never Settings. This flow doesn't need Sync web channel messages or care about - // skipping navigating either because if a Sync user is inside Settings, we probably - // don't have the oauth query parameters required to begin a sign-in flow - // anyway. Just take all users back to /settings. - if (navigationOptions.isSessionAALUpgrade) { - navigate('/settings'); - return { error: undefined }; - } - - // Check CMS fleature flags to determine if we should hide promos, the - // default is to navigate to settings + // Check CMS feature flags to determine if we should hide promos, the + // default is to navigate to settings. Gated on isSync() so this is a no-op + // for plain Web and OAuth. Applied before the machine branch so both the + // machine and legacy getNonOAuthNavigationTarget see the mutated options. const cmsInfo = integration?.getCmsInfo(); if ( cmsInfo?.shared.featureFlags?.syncHidePromoAfterLogin && @@ -258,6 +272,202 @@ export async function handleNavigation(navigationOptions: NavigationOptions) { navigationOptions.showSignupConfirmedSync = false; } + if (machineOwnsNavigation(navigationOptions)) { + const { signinData, accountHasTotp } = navigationOptions; + const ctx: AuthContext = { + emailVerified: signinData.emailVerified, + sessionVerified: signinData.sessionVerified, + verificationMethod: signinData.verificationMethod, + verificationReason: signinData.verificationReason, + accountHasTotp: accountHasTotp ?? false, + // Remaining AuthContext facts are not read by the post-signin router. + hasRecoveryPhone: false, + hasPassword: true, + hasLinkedAccount: false, + hasCachedSession: false, + passwordlessSupported: false, + isOAuth: false, + isOAuthWeb: false, + isOAuthNative: false, + isSync: false, + isWebChannelIntegration: false, + supportsKeysOptionalLogin: false, + requiresKeys: false, + wantsKeysIfPasswordEntered: false, + wantsLogin: false, + clientInfoLoadFailed: false, + }; + + const { state } = funnelReducer( + 'verifying.router', + { + type: 'SIGNIN_OK', + emailVerified: signinData.emailVerified, + sessionVerified: signinData.sessionVerified, + verificationMethod: signinData.verificationMethod, + verificationReason: signinData.verificationReason, + }, + ctx + ); + const decision = routeFor(state); + if ('to' in decision) { + const { queryParams } = navigationOptions; + const isOAuth = isOAuthIntegration(integration); + const isWebChannel = + integration.isSync() || integration.isFirefoxNonSync(); + const isFullyVerified = + signinData.emailVerified && signinData.sessionVerified; + + // Verified: the machine routes to finalizing.handoff (/settings). For + // web-channel this is not the real destination — Sync/Firefox-non-sync + // resolve through getNonOAuthNavigationTarget (getSyncNavigate). OAuth web + // resolves through getOAuthNavigationTarget (RP redirect). Plain Web keeps + // the existing handoff-to-/settings behavior. + if (isFullyVerified) { + // Web-channel sends fxa_login before the OAuth/non-OAuth split, matching + // legacy. For native Sync (OAuth + web-channel) this must fire before + // OAuth resolution and the subsequent fxaOAuthLogin. OAuth web is not + // web-channel so this stays inert there. + if (isWebChannel && navigationOptions.handleFxaLogin === true) { + sendFxaLogin(navigationOptions); + } + // OAuth (web RP or native). Resolve the redirect async, propagate any + // error, fire firefox.fxaOAuthLogin for native, then navigate when not + // suppressed. Mirrors the legacy verified-OAuth block exactly. + if (isOAuth) { + const target = await getOAuthNavigationTarget(navigationOptions); + if (target.error) { + return { error: target.error }; + } + if ( + isOAuthNativeIntegration(integration) && + navigationOptions.handleFxaOAuthLogin === true && + target.oauthData + ) { + firefox.fxaOAuthLogin({ + action: 'signin', + code: target.oauthData.code, + redirect: target.oauthData.redirect, + state: target.oauthData.state, + scope: target.oauthData.scope, + }); + } + if (navigationOptions.performNavigation !== false) { + if (target.to === '/post_verify/service_welcome') { + navigate(target.to, { + state: { origin: 'signin' }, + replace: true, + }); + } else if (target.to) { + performNavigation({ + to: target.to, + locationState: target.locationState, + shouldHardNavigate: target.shouldHardNavigate, + replace: true, + }); + } + } + return { error: undefined }; + } + if (isWebChannel) { + // sendFxaLogin fires above regardless; legacy gates the actual + // navigation on performNavigation but always returns afterward. + if (navigationOptions.performNavigation !== false) { + const { to, locationState, shouldHardNavigate } = + await getNonOAuthNavigationTarget(navigationOptions); + performNavigation({ + to, + locationState, + shouldHardNavigate, + replace: navigationOptions.origin === 'post-verify-set-password', + }); + } + return { error: undefined }; + } + performNavigation({ + to: `${decision.to}${queryParams || ''}`, + locationState: createSigninLocationState(navigationOptions), + }); + return { error: undefined }; + } + + // Unverified: the machine routes to a verify page (/signin_token_code or + // /signin_totp_code). For web-channel, fire fxa_login before navigating, + // matching legacy. Skip it when the next page is signin_totp_code to avoid + // sending a `verified: false` message twice. + const to = `${decision.to}${queryParams || ''}`; + if ( + isWebChannel && + navigationOptions.handleFxaLogin === true && + !to.includes('signin_totp_code') + ) { + sendFxaLogin(navigationOptions); + } + // Mirror legacy's unverified gating: navigate immediately for the + // mustVerify cases, otherwise honor performNavigation. Applies to plain + // Web, web-channel, and OAuth web. wantsTwoStepAuthentication is + // OAuth-web-only; include it so an AAL2 RP forces verification before the + // type-C skip below, matching legacy. + const mustVerify = + signinData.verificationReason === VerificationReasons.SIGN_UP || + signinData.verificationMethod === VerificationMethods.TOTP_2FA || + signinData.verificationReason === VerificationReasons.CHANGE_PASSWORD || + navigationOptions.isServiceWithEmailVerification === true || + (isOAuthWebIntegration(integration) && + integration.wantsTwoStepAuthentication()) || + integration.wantsKeys(); + + // OAuth web "type C" skip-verification path: when not a mustVerify case, + // an OAuth web RP may take the user onward without a verified session. + // Mirror the legacy unverified OAuth-web branch exactly (no + // performNavigation guard around the navigate). + if (!mustVerify && isOAuthWebIntegration(integration)) { + const target = await getOAuthNavigationTarget(navigationOptions); + if (target.error) { + return { error: target.error }; + } + if (target.to) { + performNavigation({ + to: target.to, + locationState: target.locationState, + shouldHardNavigate: target.shouldHardNavigate, + replace: true, + }); + } + return { error: undefined }; + } + + if (mustVerify || navigationOptions.performNavigation !== false) { + performNavigation({ + to, + locationState: createSigninLocationState(navigationOptions), + }); + } + return { error: undefined }; + } + // delegate/stay (e.g. unverified email → confirm_signup, a later slice): + // fall through to the unchanged legacy navigation below. + } + + const isOAuth = isOAuthIntegration(integration); + const isWebChannelIntegration = + integration.isSync() || integration.isFirefoxNonSync(); + const wantsTwoStepAuthentication = + isOAuthWebIntegration(integration) && + integration.wantsTwoStepAuthentication(); + const wantsKeys = integration.wantsKeys(); + + // If this is an AAL upgrade, the user was redirected from Settings to enter TOTP. + // RP redirects won't get into this state since they'll be taken to the RP and + // never Settings. This flow doesn't need Sync web channel messages or care about + // skipping navigating either because if a Sync user is inside Settings, we probably + // don't have the oauth query parameters required to begin a sign-in flow + // anyway. Just take all users back to /settings. + if (navigationOptions.isSessionAALUpgrade) { + navigate('/settings'); + return { error: undefined }; + } + // When a session is unverified, we need to redirect to the appropriate page depending on status of // the account and the integration being used. // There are 3 types of unverified sessions: diff --git a/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/index.tsx b/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/index.tsx index b6d70129f7f..b395dee9aa0 100644 --- a/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/index.tsx +++ b/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/index.tsx @@ -13,9 +13,16 @@ import { logViewEvent, usePageViewEvent } from '../../../lib/metrics'; import { FtlMsg, hardNavigate } from 'fxa-react/lib/utils'; import { useAlertBar, + useConfig, useFtlMsgResolver, useSession, } from '../../../models/hooks'; +import { isAuthStateMachineEnabled } from '../../../lib/auth-machine/flag'; +import { + routeSignupCategory, + SignupCategory, + SignupCategoryFacts, +} from '../../../lib/auth-machine/signup'; import AppLayout from '../../../components/AppLayout'; import CardHeader, { getCmsHeadlineClassName, @@ -66,6 +73,7 @@ const ConfirmSignupCode = ({ }: ConfirmSignupCodeProps & RouteComponentProps) => { usePageViewEvent(viewName, REACT_ENTRYPOINT); + const config = useConfig(); const ftlMsgResolver = useFtlMsgResolver(); const location = useLocation(); const navigate = useNavigate(); @@ -210,18 +218,50 @@ const ConfirmSignupCode = ({ logViewEvent(`flow`, 'newsletter.subscribed', REACT_ENTRYPOINT); } - if (isSyncDesktopV3Integration(integration)) { - const { to } = getSyncNavigate(location.search, { - showSignupConfirmedSync: true, - }); - navigate(to); - } else if (isOAuthIntegration(integration)) { - // Check to see if the relier wants TOTP - // Certain reliers (currently AMO only) may require users to set up 2FA / TOTP - // before they can be redirected back to the RP. - // Newly created accounts wouldn't have this so let's redirect them to inline_totp_setup. - - if (integration.wantsTwoStepAuthentication()) { + const machineEnabled = isAuthStateMachineEnabled( + location.search, + config.featureFlags?.authStateMachine === true + ); + const facts: SignupCategoryFacts = { + isSyncDesktopV3: isSyncDesktopV3Integration(integration), + isOAuth: isOAuthIntegration(integration), + wantsTwoStepAuthentication: + isOAuthIntegration(integration) && + integration.wantsTwoStepAuthentication(), + isWeb: isWebIntegration(integration), + hasRedirectTo: + isWebIntegration(integration) && !!integration.data.redirectTo, + }; + // The flag selects the machine routing; the legacy fallback reproduces the + // same category inline, so disabling the flag is a true escape hatch. + const category: SignupCategory = machineEnabled + ? routeSignupCategory(facts) + : (() => { + if (facts.isSyncDesktopV3) return 'sync-desktop-v3'; + if (facts.isOAuth) { + return facts.wantsTwoStepAuthentication + ? 'oauth-totp-setup' + : 'oauth-resolve'; + } + if (facts.isWeb) { + return facts.hasRedirectTo ? 'web-redirect' : 'web-settings'; + } + return 'none'; + })(); + + switch (category) { + case 'sync-desktop-v3': { + const { to } = getSyncNavigate(location.search, { + showSignupConfirmedSync: true, + }); + navigate(to); + break; + } + case 'oauth-totp-setup': { + // Check to see if the relier wants TOTP + // Certain reliers (currently AMO only) may require users to set up 2FA / TOTP + // before they can be redirected back to the RP. + // Newly created accounts wouldn't have this so let's redirect them to inline_totp_setup. navigateWithQuery('/inline_totp_setup', { state: { email, @@ -234,7 +274,8 @@ const ConfirmSignupCode = ({ }, }); return; - } else { + } + case 'oauth-resolve': { // `scope` is the server-resolved scope per ADR 0049, only // forwarded to Firefox via fxaOAuthLogin; ignored otherwise. const { redirect, code, state, scope, error } = @@ -251,7 +292,7 @@ const ConfirmSignupCode = ({ return; } - if (integration.isSync()) { + if (isOAuthIntegration(integration) && integration.isSync()) { firefox.fxaOAuthLogin({ // OAuth desktop looks at the sync engine list in fxaLogin. Oauth // mobile currently looks at the engines provided here, but should @@ -265,7 +306,10 @@ const ConfirmSignupCode = ({ scope, }); // Mobile sync will close the web view, OAuth Desktop mimics DesktopV3 behavior - if (integration.isFirefoxDesktopClient()) { + if ( + isOAuthIntegration(integration) && + integration.isFirefoxDesktopClient() + ) { const isSendTab = isSendTabEntrypoint( integration.data.entrypoint ); @@ -285,7 +329,10 @@ const ConfirmSignupCode = ({ } } return; - } else if (integration.isFirefoxNonSync()) { + } else if ( + isOAuthIntegration(integration) && + integration.isFirefoxNonSync() + ) { firefox.fxaOAuthLogin({ action: 'signup', code, @@ -311,11 +358,11 @@ const ConfirmSignupCode = ({ } return; } + break; } - } else if (isWebIntegration(integration)) { - if (integration.data.redirectTo) { - if (webRedirectCheck.isValid) { - hardNavigate(integration.data.redirectTo); + case 'web-redirect': { + if (isWebIntegration(integration) && webRedirectCheck.isValid) { + hardNavigate(integration.data.redirectTo!); } else if (webRedirectCheck?.localizedInvalidRedirectError) { // Even if the code submission is successful, show the user this error // message if the redirect is invalid to match parity with content-server. @@ -324,9 +371,14 @@ const ConfirmSignupCode = ({ webRedirectCheck.localizedInvalidRedirectError ); } - } else { + break; + } + case 'web-settings': { goToSettingsWithAlertSuccess(); + break; } + case 'none': + break; } } catch (error) { GleanMetrics.signupConfirmation.error({