Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions libs/shared/cms/src/__generated__/gql.ts

Large diffs are not rendered by default.

70 changes: 68 additions & 2 deletions libs/shared/cms/src/__generated__/graphql.ts

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions libs/shared/cms/src/lib/queries/relying-party/factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,13 @@ export const RelyingPartyResultFactory = (
pageTitle: faker.string.sample(),
splitLayout: faker.datatype.boolean(),
},
AuthorizePage: {
headline: faker.string.sample(),
description: faker.string.sample(),
primaryButtonText: faker.string.sample(),
pageTitle: faker.string.sample(),
splitLayout: faker.datatype.boolean(),
},
SigninPasswordlessCodePage: {
logoUrl: faker.internet.url(),
logoAltText: faker.internet.url(),
Expand Down
7 changes: 7 additions & 0 deletions libs/shared/cms/src/lib/queries/relying-party/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,13 @@ export const relyingPartyQuery = graphql(`
pageTitle
splitLayout
}
AuthorizePage {
headline
description
primaryButtonText
pageTitle
splitLayout
}
SigninPasswordlessCodePage {
headline
description
Expand Down
1 change: 1 addition & 0 deletions libs/shared/cms/src/lib/queries/relying-party/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export interface RelyingPartyResult {
SignupConfirmedSyncPage: Page;
SigninPage: Page;
SigninCachedPage?: Page;
AuthorizePage?: Page;
SigninTokenCodePage?: Page;
SigninUnblockCodePage?: Page;
SigninTotpCodePage?: Page;
Expand Down
26 changes: 26 additions & 0 deletions packages/functional-tests/lib/query-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,32 @@ export const smartWindowDesktopOAuthQueryParams = new URLSearchParams({
service: 'smartwindow',
});

export const syncMobileOAuthFenixQueryParams = new URLSearchParams({
...Object.fromEntries(oauthWebchannelV1.entries()),
client_id: 'a2270f727f45f648', // Fenix (Android)
code_challenge_method: 'S256',
code_challenge: '2oc_C4v1qHeefWAGu5LI5oDG1oX4FV_Itc148D8_oQI',
// eslint-disable-next-line camelcase
keys_jwk:
'eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2IiwieCI6ImdUejVIWFJfa2pxSFRtMG43ZjhxcDMybVZFaHZ1cGo1dXNUV1h5TWZsb1kiLCJ5IjoiVER5TlhkalhibHZld1pWLVc5MXNDZU9fRWd0NU9WYXhpblBzOEFTQ3owZyJ9',
scope:
'https://identity.mozilla.com/apps/oldsync https://identity.mozilla.com/tokens/session',
state: 'fakestate',
automatedBrowser: 'true',
service: 'sync',
});

export const vpnMobileOAuthQueryParams = new URLSearchParams({
...Object.fromEntries(oauthWebchannelV1.entries()),
client_id: 'a2270f727f45f648', // Fenix (Android)
code_challenge_method: 'S256',
code_challenge: '2oc_C4v1qHeefWAGu5LI5oDG1oX4FV_Itc148D8_oQI',
scope: 'https://identity.mozilla.com/apps/vpn',
state: 'fakestate',
automatedBrowser: 'true',
service: 'vpn',
});

export const syncDesktopV3QueryParams = new URLSearchParams({
context: 'fx_desktop_v3',
service: 'sync',
Expand Down
28 changes: 28 additions & 0 deletions packages/functional-tests/pages/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@ export abstract class BaseLayout {
});
}

async clearWebChannelEvents() {
await this.page.evaluate(() => {
sessionStorage.removeItem('webChannelEvents');
});
}

/**
* Asserts that a web channel message with the given command was sent
* and contains the expected services object in its data.
Expand All @@ -117,6 +123,28 @@ export abstract class BaseLayout {
}
}

/**
* Asserts that a web channel message with the given command was sent
* and contains the expected scope string in its data.
*/
async checkWebChannelMessageScopes(
command: FirefoxCommand,
expectedScope: string
) {
await this.checkWebChannelMessage(command);
const events = await this.getWebChannelEvents();
const event = events.find((e) => e.command === command);
if (!event) {
throw new Error(`No web channel event found for command: ${command}`);
}
const scopes = (event.data as { scopes?: string })?.scopes;
if (!scopes?.includes(expectedScope)) {
throw new Error(
`Expected scopes to contain "${expectedScope}" but got "${scopes}"`
);
}
}

async listenToWebChannelMessages() {
await this.page.evaluate(() => {
function listener(msg: { detail: string }) {
Expand Down
49 changes: 49 additions & 0 deletions packages/functional-tests/tests/misc/vpnIntegration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/* 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 { FirefoxCommand } from '../../lib/channels';
import { expect, test } from '../../lib/fixtures/standard';
import {
syncMobileOAuthFenixQueryParams,
vpnMobileOAuthQueryParams,
} from '../../lib/query-params';

test.describe('vpn integration', () => {
test('authorization flow - user already signed into Firefox', async ({
syncOAuthBrowserPages: { page, signin },
testAccountTracker,
}) => {
const { email, password } = await testAccountTracker.signUp();

// First, sign into Sync with Fenix (Android) client ID
await signin.goto('/authorization', syncMobileOAuthFenixQueryParams);
await signin.fillOutEmailFirstForm(email);
await signin.fillOutPasswordForm(password);

// Wait for Sync sign-in to complete, then clear events for the
// VPN scope check later in the test
await signin.checkWebChannelMessage(FirefoxCommand.OAuthLogin);
await signin.clearWebChannelEvents();

// Now navigate to VPN authorization — user is already signed into Firefox
await signin.goto('/authorization', vpnMobileOAuthQueryParams);

// User is already signed in — cached signin view, no password required
await expect(signin.cachedSigninHeading).toBeVisible();
await expect(page.getByText(email)).toBeVisible();

await signin.signInButton.click();

// Verify fxaOAuthLogin was sent with VPN scopes
await signin.checkWebChannelMessageScopes(
FirefoxCommand.OAuthLogin,
'https://identity.mozilla.com/apps/vpn'
);

// Verify services data includes vpn
await signin.checkWebChannelMessageServices(FirefoxCommand.Login, {
vpn: {},
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import { expect, test } from '../../lib/fixtures/standard';

test.describe('severity-2 #smoke', () => {
test.describe('OAuth signin token code', () => {
test.describe('signin token code for OAuth RP redirect with client requesting scoped keys', () => {
function toQueryString(obj: Record<string, any>) {
return Object.entries(obj)
.map((x) => `${x[0]}=${x[1]}`)
Expand All @@ -19,7 +19,6 @@ test.describe('severity-2 #smoke', () => {
code_challenge_method: 'S256',
forceUA:
'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',

keys_jwk:
'eyJrdHkiOiJFQyIsImtpZCI6Im9DNGFudFBBSFZRX1pmQ09RRUYycTRaQlZYblVNZ2xISGpVRzdtSjZHOEEiLCJjcnYiOiJQLTI1NiIsIngiOiJDeUpUSjVwbUNZb2lQQnVWOTk1UjNvNTFLZVBMaEg1Y3JaQlkwbXNxTDk0IiwieSI6IkJCWDhfcFVZeHpTaldsdXU5MFdPTVZwamIzTlpVRDAyN0xwcC04RW9vckEifQ',
redirect_uri: 'https://mozilla.github.io/notes/fxa/android-redirect.html',
Expand Down
26 changes: 12 additions & 14 deletions packages/fxa-settings/src/components/App/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,9 +219,10 @@ export const App = ({
// Determine if user is actually signed in
const [isSignedIn, setIsSignedIn] = useState<boolean | undefined>(undefined);

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

// Track current page's split layout state to prevent visual flashing during navigation.
// This state is updated by AppLayout and read by the Suspense fallback to preserve
Expand Down Expand Up @@ -256,10 +257,7 @@ export const App = ({
userFromBrowser.sessionToken
);
if (isValidSession) {
setIsSignedIntoFirefoxDesktop(
!!userFromBrowser?.sessionToken &&
integration.isFirefoxDesktopClient()
);
setIsSignedIntoFirefox(true);
const cachedUser = getAccountByUid(userFromBrowser.uid);
// Refresh the token without switching the "current" account.
persistAccount(
Expand Down Expand Up @@ -421,7 +419,7 @@ export const App = ({
isSignedIn,
integration,
flowQueryParams: updatedFlowQueryParams,
isSignedIntoFirefoxDesktop,
isSignedIntoFirefox,
setCurrentSplitLayout,
}}
path="/*"
Expand Down Expand Up @@ -496,13 +494,13 @@ const AuthAndAccountSetupRoutes = ({
isSignedIn,
integration,
flowQueryParams,
isSignedIntoFirefoxDesktop,
isSignedIntoFirefox,
setCurrentSplitLayout,
}: {
isSignedIn: boolean;
integration: Integration;
flowQueryParams: QueryParams;
isSignedIntoFirefoxDesktop: boolean;
isSignedIntoFirefox: boolean;
setCurrentSplitLayout: (value: boolean) => void;
} & RouteComponentProps) => {
const localAccount = currentAccount();
Expand Down Expand Up @@ -639,7 +637,7 @@ const AuthAndAccountSetupRoutes = ({
serviceName,
flowQueryParams,
useFxAStatusResult,
isSignedIntoFirefoxDesktop,
isSignedIntoFirefox,
setCurrentSplitLayout,
}}
/>
Expand All @@ -650,7 +648,7 @@ const AuthAndAccountSetupRoutes = ({
serviceName,
flowQueryParams,
useFxAStatusResult,
isSignedIntoFirefoxDesktop,
isSignedIntoFirefox,
setCurrentSplitLayout,
}}
/>
Expand Down Expand Up @@ -679,7 +677,7 @@ const AuthAndAccountSetupRoutes = ({
serviceName,
flowQueryParams,
useFxAStatusResult,
isSignedIntoFirefoxDesktop,
isSignedIntoFirefox,
setCurrentSplitLayout,
}}
/>
Expand All @@ -690,7 +688,7 @@ const AuthAndAccountSetupRoutes = ({
serviceName,
flowQueryParams,
useFxAStatusResult,
isSignedIntoFirefoxDesktop,
isSignedIntoFirefox,
setCurrentSplitLayout,
}}
/>
Expand Down
3 changes: 3 additions & 0 deletions packages/fxa-settings/src/lib/channels/firefox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,9 @@ export type FxAOAuthLogin = {
// eventually move to look at fxaLogin as well to prevent FXA-10596.
declinedSyncEngines?: string[];
offeredSyncEngines?: string[];
// Space-separated list of granted scopes, sent so the browser knows
// which scopes were authorized in this flow.
scopes?: string;
};

// ref: https://searchfox.org/mozilla-central/rev/82828dba9e290914eddd294a0871533875b3a0b5/services/fxaccounts/FxAccountsWebChannel.sys.mjs#230
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -238,8 +238,15 @@ describe('lib/integrations/integration-factory', () => {
expect(integration.type).toEqual(IntegrationType.OAuthWeb);
expect(integration.isSync()).toBeFalsy();
expect(integration.wantsKeys()).toBeFalsy();
expect(integration.requiresKeys()).toBeFalsy();
expect(integration.isTrusted()).toBeTruthy();
});

it('getGrantedScopes returns undefined for non-native integration', () => {
// This setup produces OAuthWeb (not OAuthNative) since
// isOAuthWebChannelContext is not set. Base class returns undefined.
expect(integration.getGrantedScopes()).toBeUndefined();
});
});

describe('with sync', () => {
Expand All @@ -263,8 +270,29 @@ describe('lib/integrations/integration-factory', () => {
it('has correct state', async () => {
expect(integration.type).toEqual(IntegrationType.OAuthNative);
expect(integration.isSync()).toBeTruthy();
expect(integration.wantsKeys()).toBeTruthy();
expect(integration.isTrusted()).toBeTruthy();
expect(integration.isFirefoxClient()).toBeTruthy();
});

it('wantsKeys is false when keysJwk is not provided', () => {
// Without keysJwk in the data, _scopeRequestsKeys returns false
expect(integration.wantsKeys()).toBeFalsy();
expect(integration.requiresKeys()).toBeFalsy();
});

it('wantsKeys is true when keysJwk and scoped key validation are configured', () => {
integration.data.keysJwk = 'fakeKeysJwk';
sandbox.stub(integration, 'opts').value({
...integration.opts,
scopedKeysEnabled: true,
scopedKeysValidation: {
[Constants.OAUTH_OLDSYNC_SCOPE]: {
redirectUris: [clientInfo.redirectUri],
},
},
});
expect(integration.requiresKeys()).toBeTruthy();
expect(integration.wantsKeys()).toBeTruthy();
});
});
});
Expand Down
4 changes: 2 additions & 2 deletions packages/fxa-settings/src/lib/oauth/hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -305,15 +305,15 @@ export function useFinishOAuthFlowHandler(
* to /signin instead of showing an error component? FXA-10889
*/
export function useOAuthKeysCheck(
integration: Pick<OAuthIntegration, 'type' | 'wantsKeys'>,
integration: Pick<OAuthIntegration, 'type' | 'requiresKeys'>,
keyFetchToken?: hexstring,
unwrapBKey?: hexstring,
isSignInWithThirdPartyAuth?: boolean
) {
if (
(isOAuthIntegration(integration) ||
isSyncDesktopV3Integration(integration)) &&
integration.wantsKeys() &&
integration.requiresKeys() &&
// If the user has 2FA enabled but chose to login to the browser via third party
// auth, keys are not fetched because the user didn't enter a password.
// For this case, skip the keys check, the browser expects them to be undefined.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ import {
KeyTransforms as T,
ModelDataProvider,
} from '../../../lib/model-data';
import { IsEmailOrEmpty, IsFxaRedirectToUrl, IsFxaRedirectUri } from '../../../lib/validation';
import {
IsEmailOrEmpty,
IsFxaRedirectToUrl,
IsFxaRedirectUri,
} from '../../../lib/validation';

/**
* Base integration class. Fields in this class represents data commonly accessed across many pages and is useful for various flows.
Expand Down
Loading
Loading