Skip to content

Commit 5e730b1

Browse files
committed
feat(signin): Add conditional keys fetch, scopes in fxaOAuthLogin, "Authorization" state + in Strapi
Because: * Non-Sync browser services (VPN, Relay, SmartWindow) should not force password entry just to fetch keys, but should fetch them opportunistically if a password is entered for another reason * The browser needs to know which scopes were granted after the authorization flow completes * The isSignedIntoFirefoxDesktop state was too narrow for the scope authorization flow which applies to all Firefox platforms, and we want this editable in Strapi This commit: * Splits wantsKeys into requiresKeys (Sync only, forces password) and wantsKeysIfPasswordEntered (non-Sync, opportunistic), with wantsKeys, to allow a "cached login" render without the "keys optional" capability, which is a capability intended for passwordless non-sync browser logins * Adds scopes field to fxaOAuthLogin WebChannel message at all call sites, because the browser needs to know which scopes were actually granted — with ADR 0049, FxA may deny requested scopes or grant additional ones, and the browser may not request scope at all * Renames isSignedIntoFirefoxDesktop to isSignedIntoFirefox and removes the Desktop-only check, adds a new page in Strapi for this state so the copy can be updated (e.g. "Authorize") * Adds tests/stories, updates Signin stories to new format closes FXA-12939
1 parent e7a3482 commit 5e730b1

40 files changed

Lines changed: 928 additions & 295 deletions

libs/shared/cms/src/__generated__/gql.ts

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

libs/shared/cms/src/__generated__/graphql.ts

Lines changed: 68 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

libs/shared/cms/src/lib/queries/relying-party/factories.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,13 @@ export const RelyingPartyResultFactory = (
175175
pageTitle: faker.string.sample(),
176176
splitLayout: faker.datatype.boolean(),
177177
},
178+
AuthorizePage: {
179+
headline: faker.string.sample(),
180+
description: faker.string.sample(),
181+
primaryButtonText: faker.string.sample(),
182+
pageTitle: faker.string.sample(),
183+
splitLayout: faker.datatype.boolean(),
184+
},
178185
SigninPasswordlessCodePage: {
179186
logoUrl: faker.internet.url(),
180187
logoAltText: faker.internet.url(),

libs/shared/cms/src/lib/queries/relying-party/query.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,13 @@ export const relyingPartyQuery = graphql(`
112112
pageTitle
113113
splitLayout
114114
}
115+
AuthorizePage {
116+
headline
117+
description
118+
primaryButtonText
119+
pageTitle
120+
splitLayout
121+
}
115122
SigninPasswordlessCodePage {
116123
headline
117124
description

libs/shared/cms/src/lib/queries/relying-party/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export interface RelyingPartyResult {
7373
SignupConfirmedSyncPage: Page;
7474
SigninPage: Page;
7575
SigninCachedPage?: Page;
76+
AuthorizePage?: Page;
7677
SigninTokenCodePage?: Page;
7778
SigninUnblockCodePage?: Page;
7879
SigninTotpCodePage?: Page;

packages/functional-tests/lib/query-params.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,32 @@ export const smartWindowDesktopOAuthQueryParams = new URLSearchParams({
6969
service: 'smartwindow',
7070
});
7171

72+
export const syncMobileOAuthFenixQueryParams = new URLSearchParams({
73+
...Object.fromEntries(oauthWebchannelV1.entries()),
74+
client_id: 'a2270f727f45f648', // Fenix (Android)
75+
code_challenge_method: 'S256',
76+
code_challenge: '2oc_C4v1qHeefWAGu5LI5oDG1oX4FV_Itc148D8_oQI',
77+
// eslint-disable-next-line camelcase
78+
keys_jwk:
79+
'eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2IiwieCI6ImdUejVIWFJfa2pxSFRtMG43ZjhxcDMybVZFaHZ1cGo1dXNUV1h5TWZsb1kiLCJ5IjoiVER5TlhkalhibHZld1pWLVc5MXNDZU9fRWd0NU9WYXhpblBzOEFTQ3owZyJ9',
80+
scope:
81+
'https://identity.mozilla.com/apps/oldsync https://identity.mozilla.com/tokens/session',
82+
state: 'fakestate',
83+
automatedBrowser: 'true',
84+
service: 'sync',
85+
});
86+
87+
export const vpnMobileOAuthQueryParams = new URLSearchParams({
88+
...Object.fromEntries(oauthWebchannelV1.entries()),
89+
client_id: 'a2270f727f45f648', // Fenix (Android)
90+
code_challenge_method: 'S256',
91+
code_challenge: '2oc_C4v1qHeefWAGu5LI5oDG1oX4FV_Itc148D8_oQI',
92+
scope: 'https://identity.mozilla.com/apps/vpn',
93+
state: 'fakestate',
94+
automatedBrowser: 'true',
95+
service: 'vpn',
96+
});
97+
7298
export const syncDesktopV3QueryParams = new URLSearchParams({
7399
context: 'fx_desktop_v3',
74100
service: 'sync',

packages/functional-tests/pages/layout.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,12 @@ export abstract class BaseLayout {
9595
});
9696
}
9797

98+
async clearWebChannelEvents() {
99+
await this.page.evaluate(() => {
100+
sessionStorage.removeItem('webChannelEvents');
101+
});
102+
}
103+
98104
/**
99105
* Asserts that a web channel message with the given command was sent
100106
* and contains the expected services object in its data.
@@ -117,6 +123,28 @@ export abstract class BaseLayout {
117123
}
118124
}
119125

126+
/**
127+
* Asserts that a web channel message with the given command was sent
128+
* and contains the expected scope string in its data.
129+
*/
130+
async checkWebChannelMessageScopes(
131+
command: FirefoxCommand,
132+
expectedScope: string
133+
) {
134+
await this.checkWebChannelMessage(command);
135+
const events = await this.getWebChannelEvents();
136+
const event = events.find((e) => e.command === command);
137+
if (!event) {
138+
throw new Error(`No web channel event found for command: ${command}`);
139+
}
140+
const scopes = (event.data as { scopes?: string })?.scopes;
141+
if (!scopes?.includes(expectedScope)) {
142+
throw new Error(
143+
`Expected scopes to contain "${expectedScope}" but got "${scopes}"`
144+
);
145+
}
146+
}
147+
120148
async listenToWebChannelMessages() {
121149
await this.page.evaluate(() => {
122150
function listener(msg: { detail: string }) {
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { FirefoxCommand } from '../../lib/channels';
6+
import { expect, test } from '../../lib/fixtures/standard';
7+
import {
8+
syncMobileOAuthFenixQueryParams,
9+
vpnMobileOAuthQueryParams,
10+
} from '../../lib/query-params';
11+
12+
test.describe('vpn integration', () => {
13+
test('authorization flow - user already signed into Firefox', async ({
14+
syncOAuthBrowserPages: { page, signin },
15+
testAccountTracker,
16+
}) => {
17+
const { email, password } = await testAccountTracker.signUp();
18+
19+
// First, sign into Sync with Fenix (Android) client ID
20+
await signin.goto('/authorization', syncMobileOAuthFenixQueryParams);
21+
await signin.fillOutEmailFirstForm(email);
22+
await signin.fillOutPasswordForm(password);
23+
24+
// Wait for Sync sign-in to complete, then clear events for the
25+
// VPN scope check later in the test
26+
await signin.checkWebChannelMessage(FirefoxCommand.OAuthLogin);
27+
await signin.clearWebChannelEvents();
28+
29+
// Now navigate to VPN authorization — user is already signed into Firefox
30+
await signin.goto('/authorization', vpnMobileOAuthQueryParams);
31+
32+
// User is already signed in — cached signin view, no password required
33+
await expect(signin.cachedSigninHeading).toBeVisible();
34+
await expect(page.getByText(email)).toBeVisible();
35+
36+
await signin.signInButton.click();
37+
38+
// Verify fxaOAuthLogin was sent with VPN scopes
39+
await signin.checkWebChannelMessageScopes(
40+
FirefoxCommand.OAuthLogin,
41+
'https://identity.mozilla.com/apps/vpn'
42+
);
43+
44+
// Verify services data includes vpn
45+
await signin.checkWebChannelMessageServices(FirefoxCommand.Login, {
46+
vpn: {},
47+
});
48+
});
49+
});

packages/functional-tests/tests/oauth/signinTokenCode.spec.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import { expect, test } from '../../lib/fixtures/standard';
66

77
test.describe('severity-2 #smoke', () => {
8-
test.describe('OAuth signin token code', () => {
8+
test.describe('signin token code for OAuth RP redirect with client requesting scoped keys', () => {
99
function toQueryString(obj: Record<string, any>) {
1010
return Object.entries(obj)
1111
.map((x) => `${x[0]}=${x[1]}`)
@@ -19,7 +19,6 @@ test.describe('severity-2 #smoke', () => {
1919
code_challenge_method: 'S256',
2020
forceUA:
2121
'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.181 Mobile Safari/537.36',
22-
2322
keys_jwk:
2423
'eyJrdHkiOiJFQyIsImtpZCI6Im9DNGFudFBBSFZRX1pmQ09RRUYycTRaQlZYblVNZ2xISGpVRzdtSjZHOEEiLCJjcnYiOiJQLTI1NiIsIngiOiJDeUpUSjVwbUNZb2lQQnVWOTk1UjNvNTFLZVBMaEg1Y3JaQlkwbXNxTDk0IiwieSI6IkJCWDhfcFVZeHpTaldsdXU5MFdPTVZwamIzTlpVRDAyN0xwcC04RW9vckEifQ',
2524
redirect_uri: 'https://mozilla.github.io/notes/fxa/android-redirect.html',

packages/fxa-settings/src/components/App/index.tsx

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -219,9 +219,10 @@ export const App = ({
219219
// Determine if user is actually signed in
220220
const [isSignedIn, setIsSignedIn] = useState<boolean | undefined>(undefined);
221221

222-
// Track whether the user is signed into Firefox Desktop via WebChannel
223-
const [isSignedIntoFirefoxDesktop, setIsSignedIntoFirefoxDesktop] =
224-
useState(false);
222+
// Determine whether the user is signed into Firefox via WebChannel.
223+
// This also partially tells us if the user is in a Firefox "authorization" flow,
224+
// where they are already signed in but need to consent to a new scope.
225+
const [isSignedIntoFirefox, setIsSignedIntoFirefox] = useState(false);
225226

226227
// Track current page's split layout state to prevent visual flashing during navigation.
227228
// This state is updated by AppLayout and read by the Suspense fallback to preserve
@@ -256,10 +257,7 @@ export const App = ({
256257
userFromBrowser.sessionToken
257258
);
258259
if (isValidSession) {
259-
setIsSignedIntoFirefoxDesktop(
260-
!!userFromBrowser?.sessionToken &&
261-
integration.isFirefoxDesktopClient()
262-
);
260+
setIsSignedIntoFirefox(true);
263261
const cachedUser = getAccountByUid(userFromBrowser.uid);
264262
// Refresh the token without switching the "current" account.
265263
persistAccount(
@@ -421,7 +419,7 @@ export const App = ({
421419
isSignedIn,
422420
integration,
423421
flowQueryParams: updatedFlowQueryParams,
424-
isSignedIntoFirefoxDesktop,
422+
isSignedIntoFirefox,
425423
setCurrentSplitLayout,
426424
}}
427425
path="/*"
@@ -496,13 +494,13 @@ const AuthAndAccountSetupRoutes = ({
496494
isSignedIn,
497495
integration,
498496
flowQueryParams,
499-
isSignedIntoFirefoxDesktop,
497+
isSignedIntoFirefox,
500498
setCurrentSplitLayout,
501499
}: {
502500
isSignedIn: boolean;
503501
integration: Integration;
504502
flowQueryParams: QueryParams;
505-
isSignedIntoFirefoxDesktop: boolean;
503+
isSignedIntoFirefox: boolean;
506504
setCurrentSplitLayout: (value: boolean) => void;
507505
} & RouteComponentProps) => {
508506
const localAccount = currentAccount();
@@ -639,7 +637,7 @@ const AuthAndAccountSetupRoutes = ({
639637
serviceName,
640638
flowQueryParams,
641639
useFxAStatusResult,
642-
isSignedIntoFirefoxDesktop,
640+
isSignedIntoFirefox,
643641
setCurrentSplitLayout,
644642
}}
645643
/>
@@ -650,7 +648,7 @@ const AuthAndAccountSetupRoutes = ({
650648
serviceName,
651649
flowQueryParams,
652650
useFxAStatusResult,
653-
isSignedIntoFirefoxDesktop,
651+
isSignedIntoFirefox,
654652
setCurrentSplitLayout,
655653
}}
656654
/>
@@ -679,7 +677,7 @@ const AuthAndAccountSetupRoutes = ({
679677
serviceName,
680678
flowQueryParams,
681679
useFxAStatusResult,
682-
isSignedIntoFirefoxDesktop,
680+
isSignedIntoFirefox,
683681
setCurrentSplitLayout,
684682
}}
685683
/>
@@ -690,7 +688,7 @@ const AuthAndAccountSetupRoutes = ({
690688
serviceName,
691689
flowQueryParams,
692690
useFxAStatusResult,
693-
isSignedIntoFirefoxDesktop,
691+
isSignedIntoFirefox,
694692
setCurrentSplitLayout,
695693
}}
696694
/>

0 commit comments

Comments
 (0)