Skip to content

Commit 9cc46f9

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 9cc46f9

39 files changed

Lines changed: 898 additions & 283 deletions

File tree

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: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,17 @@ export const smartWindowDesktopOAuthQueryParams = new URLSearchParams({
6969
service: 'smartwindow',
7070
});
7171

72+
export const vpnMobileOAuthQueryParams = 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+
scope: 'https://identity.mozilla.com/apps/vpn',
78+
state: 'fakestate',
79+
automatedBrowser: 'true',
80+
service: 'vpn',
81+
});
82+
7283
export const syncDesktopV3QueryParams = new URLSearchParams({
7384
context: 'fx_desktop_v3',
7485
service: 'sync',

packages/functional-tests/pages/layout.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,28 @@ export abstract class BaseLayout {
117117
}
118118
}
119119

120+
/**
121+
* Asserts that a web channel message with the given command was sent
122+
* and contains the expected scope string in its data.
123+
*/
124+
async checkWebChannelMessageScopes(
125+
command: FirefoxCommand,
126+
expectedScope: string
127+
) {
128+
await this.checkWebChannelMessage(command);
129+
const events = await this.getWebChannelEvents();
130+
const event = events.find((e) => e.command === command);
131+
if (!event) {
132+
throw new Error(`No web channel event found for command: ${command}`);
133+
}
134+
const scopes = (event.data as { scopes?: string })?.scopes;
135+
if (!scopes?.includes(expectedScope)) {
136+
throw new Error(
137+
`Expected scopes to contain "${expectedScope}" but got "${scopes}"`
138+
);
139+
}
140+
}
141+
120142
async listenToWebChannelMessages() {
121143
await this.page.evaluate(() => {
122144
function listener(msg: { detail: string }) {
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
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, FxAStatusResponse } from '../../lib/channels';
6+
import { expect, test } from '../../lib/fixtures/standard';
7+
import { vpnMobileOAuthQueryParams } from '../../lib/query-params';
8+
9+
test.describe('vpn integration', () => {
10+
test('authorization flow - user already signed into Firefox', async ({
11+
syncOAuthBrowserPages: { page, signin },
12+
testAccountTracker,
13+
}) => {
14+
const syncCredentials = await testAccountTracker.signUpSync();
15+
16+
// Simulate the browser reporting that the user is already signed into Firefox
17+
const fxaStatusResponse: FxAStatusResponse = {
18+
id: 'account_updates',
19+
message: {
20+
command: FirefoxCommand.FxAStatus,
21+
data: {
22+
signedInUser: syncCredentials,
23+
clientId: 'a2270f727f45f648', // Fenix (Android)
24+
capabilities: {
25+
engines: [],
26+
pairing: false,
27+
multiService: false,
28+
},
29+
},
30+
},
31+
};
32+
await signin.respondToWebChannelMessage(fxaStatusResponse);
33+
34+
await signin.goto('/authorization', vpnMobileOAuthQueryParams);
35+
36+
// User is already signed in — cached signin view, no password required
37+
await expect(signin.cachedSigninHeading).toBeVisible();
38+
await expect(page.getByText(syncCredentials.email)).toBeVisible();
39+
40+
await signin.signInButton.click();
41+
42+
// Mobile clients don't navigate — the browser receives WebChannel messages
43+
// and controls the webview lifecycle (closing it after processing).
44+
45+
// Verify fxaOAuthLogin was sent with VPN scopes
46+
await signin.checkWebChannelMessageScopes(
47+
FirefoxCommand.OAuthLogin,
48+
'https://identity.mozilla.com/apps/vpn'
49+
);
50+
51+
// Verify services data includes vpn
52+
await signin.checkWebChannelMessageServices(FirefoxCommand.Login, {
53+
vpn: {},
54+
});
55+
});
56+
});

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
/>

packages/fxa-settings/src/lib/channels/firefox.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,9 @@ export type FxAOAuthLogin = {
146146
// eventually move to look at fxaLogin as well to prevent FXA-10596.
147147
declinedSyncEngines?: string[];
148148
offeredSyncEngines?: string[];
149+
// Space-separated list of granted scopes, sent so the browser knows
150+
// which scopes were authorized in this flow.
151+
scopes?: string;
149152
};
150153

151154
// ref: https://searchfox.org/mozilla-central/rev/82828dba9e290914eddd294a0871533875b3a0b5/services/fxaccounts/FxAccountsWebChannel.sys.mjs#230

0 commit comments

Comments
 (0)