-
Notifications
You must be signed in to change notification settings - Fork 239
test(auth): add regex to force unverified sessions, fix stray code emails #21235
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,156 @@ | ||
| /* 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 { EmailHeader, EmailType } from '../../lib/email'; | ||
| import { expect, test } from '../../lib/fixtures/standard'; | ||
|
|
||
| // The non-Sync, non-2FA unverified session: the primary email is verified, the | ||
| // session is unverified, and `mustVerify` is unset. Real users reach it when the | ||
| // auth-server's sign-in heuristics decline to pre-verify (account must be older | ||
| // than `skipForNewAccounts.maxAge`). Test accounts are always new, so the | ||
| // auth-server's `forcedHeuristicEmailAddresses` default forces it for the | ||
| // `unverifiedsession` prefix instead. The `sync` prefix is a different state: | ||
| // `forcedSyncEmailAddresses` sets `mustVerify`, which blocks OAuth grants until | ||
| // the code is entered. | ||
| test.describe('severity-1 #smoke', () => { | ||
| test.describe('heuristic unverified session', () => { | ||
| test('RP outside servicesWithEmailVerification grants without a code; Settings then asks for it and only one verifyLoginCode is sent', async ({ | ||
| target, | ||
| pages: { page, relier, settings, signin, signinTokenCode }, | ||
| testAccountTracker, | ||
| }) => { | ||
| const credentials = await testAccountTracker.signUpUnverifiedSession(); | ||
| await target.emailClient.clear(credentials.email); | ||
|
|
||
| await relier.goto(); | ||
| await relier.clickEmailFirst(); | ||
| await signin.fillOutEmailFirstForm(credentials.email); | ||
| await signin.fillOutPasswordForm(credentials.password); | ||
|
|
||
| // Straight to the RP: no code page, and the session stays unverified. | ||
| expect(await relier.isLoggedIn()).toBe(true); | ||
|
|
||
| // The unverified session cannot use Settings, which bounces to the | ||
| // cached sign-in and from there to the code page. | ||
| await settings.goto(); | ||
| await expect(signin.cachedSigninSubmitButton).toBeVisible(); | ||
| await signin.cachedSigninSubmitButton.click(); | ||
| await expect(page).toHaveURL(/signin_token_code/); | ||
|
|
||
| const code = await target.emailClient.waitForEmail( | ||
| credentials.email, | ||
| EmailType.verifyLoginCode, | ||
| EmailHeader.signinCode | ||
| ); | ||
| await signinTokenCode.fillOutCodeForm(code); | ||
| await expect(settings.settingsHeading).toBeVisible(); | ||
|
|
||
| // The local inbox blocks reads on an empty mailbox, so the RP step | ||
| // cannot be counted on its own. `newDeviceLogin` follows verification | ||
| // and bounds both possible senders of the code email, on both steps. | ||
| await target.emailClient.waitForEmail( | ||
| credentials.email, | ||
| EmailType.newDeviceLogin | ||
| ); | ||
| expect( | ||
| await target.emailClient.countEmailsByType( | ||
| credentials.email, | ||
| EmailType.verifyLoginCode | ||
| ), | ||
| 'the RP pass-through must not email a code no one is asked for' | ||
| ).toBe(1); | ||
| }); | ||
|
|
||
| test('RP in servicesWithEmailVerification, Payments Next, lands on signin_token_code and sends exactly one verifyLoginCode', async ({ | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I have a note in the PR description about this and that I'll tweak this in the part II for this ticket. |
||
| target, | ||
| pages: { page, signin, signinTokenCode }, | ||
| testAccountTracker, | ||
| }) => { | ||
| test.skip( | ||
| target.name !== 'local', | ||
| 'uses the local Payments Next client id and redirect_uri' | ||
| ); | ||
| const credentials = await testAccountTracker.signUpUnverifiedSession(); | ||
| await target.emailClient.clear(credentials.email); | ||
|
|
||
| const params = new URLSearchParams({ | ||
| client_id: '32aaeb6f1c21316a', | ||
| redirect_uri: 'http://localhost:3035/api/auth/callback/fxa', | ||
| scope: 'https://identity.mozilla.com/account/subscriptions', | ||
| response_type: 'code', | ||
| state: 'fakestate', | ||
|
Comment on lines
+78
to
+82
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Seems like a hallucination? I pulled this up, and do not see this marked as a public client in |
||
| }); | ||
| await page.goto(`${target.contentServerUrl}/authorization?${params}`); | ||
| await signin.fillOutEmailFirstForm(credentials.email); | ||
| await signin.fillOutPasswordForm(credentials.password); | ||
| await expect(page).toHaveURL(/signin_token_code/); | ||
|
|
||
| const code = await target.emailClient.waitForEmail( | ||
| credentials.email, | ||
| EmailType.verifyLoginCode, | ||
| EmailHeader.signinCode | ||
| ); | ||
| // The backend guard for listed services releases the session once the | ||
| // code is accepted. The grant response is the assertion; the redirect | ||
| // to Payments Next then fails locally because it is not running. | ||
| const grant = page.waitForResponse( | ||
| (response) => | ||
| response.url().endsWith('/v1/oauth/authorization') && | ||
| response.status() === 200 | ||
| ); | ||
| await signinTokenCode.fillOutCodeForm(code); | ||
| await grant; | ||
|
|
||
| await target.emailClient.waitForEmail( | ||
| credentials.email, | ||
| EmailType.newDeviceLogin | ||
| ); | ||
| expect( | ||
| await target.emailClient.countEmailsByType( | ||
| credentials.email, | ||
| EmailType.verifyLoginCode | ||
| ) | ||
| ).toBe(1); | ||
| }); | ||
| }); | ||
|
|
||
| // The Sync-style state: the same forced confirmation, but `mustVerify` is | ||
| // set (the `sync` prefix), so the server refuses the OAuth grant and the | ||
| // front end falls back to the code page. The code email must follow it. | ||
| test.describe('mustVerify unverified session (scoped keys)', () => { | ||
| test('RP outside servicesWithEmailVerification is still forced to verify', async ({ | ||
| target, | ||
| pages: { page, relier, signin, signinTokenCode }, | ||
| testAccountTracker, | ||
| }) => { | ||
| const credentials = await testAccountTracker.signUpSync(); | ||
| await target.emailClient.clear(credentials.email); | ||
|
|
||
| await relier.goto(); | ||
| 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.waitForEmail( | ||
| credentials.email, | ||
| EmailType.verifyLoginCode, | ||
| EmailHeader.signinCode | ||
| ); | ||
| await signinTokenCode.fillOutCodeForm(code); | ||
| expect(await relier.isLoggedIn()).toBe(true); | ||
|
|
||
| await target.emailClient.waitForEmail( | ||
| credentials.email, | ||
| EmailType.newDeviceLogin | ||
| ); | ||
| expect( | ||
| await target.emailClient.countEmailsByType( | ||
| credentials.email, | ||
| EmailType.verifyLoginCode | ||
| ) | ||
| ).toBe(1); | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1809,22 +1809,33 @@ const convictConf = convict({ | |
| env: 'REMOTE_ADDRESS_CHAIN_OVERRIDE', | ||
| default: '', | ||
| }, | ||
| // Sign-in confirmation is decided in this order: a suspicious request, | ||
| // forceGlobally, forcedSyncEmailAddresses and forcedHeuristicEmailAddresses | ||
| // each force it and skip every bypass; otherwise a recognized device, a | ||
| // recently verified IP, a new account, and skipForEmailRegex each bypass it. | ||
| // TOTP is required regardless. | ||
| signinConfirmation: { | ||
| forcedEmailAddresses: { | ||
| doc: 'Force sign-in confirmation for email addresses matching this regex for those that do not request scoped keys. Sets "mustVerify: 0" on created session tokens but creates an entry in unverifiedTokens, simulating a non-Sync non-2FA unverified session state', | ||
| forcedSyncEmailAddresses: { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Okay so I verified this locally. There's a pm2 override here changing this to The only thing that actually put it into the For this PR, I'm keeping the logic there as-is, but I changed the return of |
||
| doc: 'Force Sync-style sign-in confirmation (mustVerify: 1) for matching emails. The session cannot complete an OAuth grant until the emailed code is entered, as when scoped keys are requested. Overrides the skip settings below.', | ||
| format: RegExp, | ||
| default: /.+@mozilla\.com$/, | ||
| env: 'SIGNIN_CONFIRMATION_FORCE_EMAIL_REGEX', | ||
| }, | ||
| forcedHeuristicEmailAddresses: { | ||
| doc: 'Force the heuristic (non-Sync non-2FA) unverified session state for matching emails: sign-in confirmation with mustVerify: 0. The session is created unverified, RP redirect flows outside servicesWithEmailVerification continue without a code, and Settings asks for it later. This is the state the heuristics produce for older accounts.', | ||
| format: RegExp, | ||
| default: /^unverifiedsession.*@restmail\.net$/, | ||
| env: 'SIGNIN_CONFIRMATION_FORCE_HEURISTIC_EMAIL_REGEX', | ||
| }, | ||
| skipForEmailRegex: { | ||
| doc: 'Regex pattern for email addresses that will always skip any non-TOTP sign-in confirmation.', | ||
| doc: 'Skip sign-in confirmation for matching emails even when scoped keys are requested. A customs suspect verdict, the forced regexes above, and TOTP still apply.', | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Another note for the mustVerify clean up issue: I didn't want to specify "A customs suspect verdict" here, but that seems to still be separate from our other heuristics-based checks. "Heuristic-based" should simply mean non-2FA non-Sync for less confusion later (edit: or this naming should at least not be confusing, I added a note in the mustVerify simplification issue). |
||
| format: RegExp, | ||
| default: /^$/, | ||
| env: 'SIGNIN_CONFIRMATION_SKIP_FOR_EMAIL_REGEX', | ||
| }, | ||
| skipForNewAccounts: { | ||
| enabled: { | ||
| doc: 'Skip all sign-in email confirmations for newly-created accounts', | ||
| doc: 'Skip all sign-in email confirmations for newly-created accounts. Set false locally to put any account into the heuristic (non-Sync non-2FA) unverified session state.', | ||
| default: true, | ||
| env: 'SIGNIN_CONFIRMATION_SKIP_FOR_NEW_ACCOUNTS', | ||
| }, | ||
|
|
@@ -1849,7 +1860,7 @@ const convictConf = convict({ | |
| }, | ||
| }, | ||
| forceGlobally: { | ||
| doc: 'Force sign-in confirmation for all accounts. Sets "mustVerify: 1" on created session tokens and creates an entry in unverifiedTokens, simulating a suspicious request or requesting scoped keys', | ||
| doc: 'Force the Sync-style state (mustVerify: 1) for every account, as a suspicious request or a scoped-key request would. Per-email equivalent: forcedSyncEmailAddresses.', | ||
| format: Boolean, | ||
| default: false, | ||
| env: 'SIGNIN_CONFIRMATION_FORCE_GLOBALLY', | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice work chasing this state down! One thing I'd like to understand; this PR adds unit tests in
Signin/utils.test.tsandAuthorization/container.test.tsxand then, for every branch these functional tests walk through the browser, duplicating the logic. What are we hoping the functional versions catch that the unit tests can't?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Responded to this in Slack but I'll note here too: having a couple of tests here, at least one for the "not in the client ID" gives me confidence in this flow. The bug these tests cover existed for a while and this state has proven to be hairy, and this tests the front-end state (skips vs doesn't skip the page) and the back-end state (sends vs doesn't send the email based on server-side heuristics).
With that said, I think it's fair to reduce some of these tests especially in the second PR.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the desire for additional confidence that the issue is covered is plenty enough reason for now to do these as functional tests; especially if that's how we're able to reason through the flows. It feels like they could be pushed to unit and integration tests in the auth-server so we're not using functional tests to reach through settings, auth-client, auth-server routing, and then test some route handler logic. But, it's usually better to over test than under test, so I'm good with it.