feat(react): Add React signin Sync web channel events for fx_desktop_v3 + oauth_webchannel_v1

Because:
* We are moving from Backbone to React and want to meet parity with Sync functionality

This commit:
* Tweaks config-fxios script since iOS changed directory nesting
* Adds firefox.fxaLogin and firefox.fxaOAuthLogin web channel events where needed; it should talk to the browser with a happy path login, signin_token_code flow, and signin_totp_code flow
* Return unwrapBKey as part of signin callback data for sync
* Always displays password input for Sync (no cached login)
* Adds temp 'hack' (tempHandleSyncLogin) to allow a hard navigate to CAD to work in these flows
* Fixes bug where we were sending fxaLogin instead of fxaCanLinkAccount. Removed these from signin and signup container pages because we send one on the index page and it's causing multiple Sync dialogs
* Renames signinLocationState in signintotpcode to signinState since it can be set to local storage values
* Tweaks when to display third party auth for Sync (only show in the Sync flow when user does not have a PW set)

closes FXA-9059
This commit is contained in:
Lauren Zugai 2024-03-20 12:32:02 -05:00
Родитель 407c7e3cdb
Коммит 6d8745a2f4
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 0C86B71E24811D10
21 изменённых файлов: 495 добавлений и 320 удалений

Просмотреть файл

@ -19,7 +19,12 @@ const replaceFrom = /\bserver: server\b/;
const replaceTo = 'contentUrl: "http://localhost:3030"';
function replace() {
const filePath = path.join(iosPath, 'RustFxA', 'RustFirefoxAccounts.swift');
const filePath = path.join(
iosPath,
'firefox-ios',
'RustFxA',
'RustFirefoxAccounts.swift'
);
let fileContent = fs.readFileSync(filePath, 'utf8');
const match = fileContent.match(regex);

Просмотреть файл

@ -108,7 +108,7 @@ export type FxAOAuthLogin = {
code: string;
redirect: string;
state: string;
// For sync oauth
// For sync oauth signup
declinedSyncEngines?: string[];
offeredSyncEngines?: string[];
};
@ -337,7 +337,7 @@ export class Firefox extends EventTarget {
}
fxaCanLinkAccount(options: FxACanLinkAccount) {
this.send(FirefoxCommand.Login, options);
this.send(FirefoxCommand.CanLinkAccount, options);
}
async requestSignedInUser(context: string) {

Просмотреть файл

@ -131,9 +131,7 @@ describe('SigninTokenCode container', () => {
await waitFor(() => {
expect(CacheModule.currentAccount).not.toBeCalled();
});
expect(currentSigninTokenCodeProps?.signinLocationState.email).toBe(
MOCK_EMAIL
);
expect(currentSigninTokenCodeProps?.signinState.email).toBe(MOCK_EMAIL);
expect(currentSigninTokenCodeProps?.integration).toBe(integration);
expect(SigninTokenCodeModule.default).toBeCalled();
});
@ -142,7 +140,7 @@ describe('SigninTokenCode container', () => {
render();
expect(CacheModule.currentAccount).not.toBeCalled();
await waitFor(() => {
expect(currentSigninTokenCodeProps?.signinLocationState.email).toBe(
expect(currentSigninTokenCodeProps?.signinState.email).toBe(
MOCK_EMAIL
);
});
@ -154,7 +152,7 @@ describe('SigninTokenCode container', () => {
render();
expect(CacheModule.currentAccount).toBeCalled();
await waitFor(() => {
expect(currentSigninTokenCodeProps?.signinLocationState.email).toBe(
expect(currentSigninTokenCodeProps?.signinState.email).toBe(
MOCK_STORED_ACCOUNT.email
);
});

Просмотреть файл

@ -29,7 +29,7 @@ const SigninTokenCodeContainer = ({
state?: SigninLocationState;
};
const signinLocationState =
const signinState =
location.state && Object.keys(location.state).length > 0
? location.state
: getStoredAccountInfo();
@ -44,8 +44,8 @@ const SigninTokenCodeContainer = ({
const { data: totpData, loading: totpLoading } =
useQuery<TotpStatusResponse>(GET_TOTP_STATUS);
if (Object.keys(signinLocationState).length < 1) {
hardNavigateToContentServer(`/${location.search ? location.search : ''}`);
if (Object.keys(signinState).length < 1) {
hardNavigateToContentServer(`/${location.search || ''}`);
return <LoadingSpinner fullScreen />;
}
@ -77,7 +77,7 @@ const SigninTokenCodeContainer = ({
{...{
finishOAuthFlowHandler,
integration,
signinLocationState,
signinState,
}}
/>
);

Просмотреть файл

@ -18,8 +18,13 @@ import { Subject } from './mocks';
import { MOCK_SIGNUP_CODE } from '../../Signup/ConfirmSignupCode/mocks';
import { MOCK_EMAIL, MOCK_OAUTH_FLOW_HANDLER_RESPONSE } from '../../mocks';
import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors';
import { createMockSigninOAuthIntegration } from '../mocks';
import {
createMockSigninOAuthIntegration,
createMockSigninSyncIntegration,
} from '../mocks';
import { createMockSigninLocationState } from './mocks';
import VerificationReasons from '../../../constants/verification-reasons';
import firefox from '../../../lib/channels/firefox';
jest.mock('../../../lib/metrics', () => ({
usePageViewEvent: jest.fn(),
@ -45,22 +50,13 @@ function applyDefaultMocks() {
mockReactUtilsModule();
}
// Set this when testing location state
let mockLocationState = {};
const mockLocation = () => {
return {
pathname: '/signin_token_code',
search: '?' + new URLSearchParams(mockLocationState),
state: mockLocationState,
};
};
const mockNavigate = jest.fn();
jest.mock('@reach/router', () => {
return {
__esModule: true,
...jest.requireActual('@reach/router'),
useNavigate: () => mockNavigate,
useLocation: () => mockLocation(),
useLocation: () => () => {},
};
});
@ -129,6 +125,24 @@ describe('SigninTokenCode page', () => {
expect(GleanMetrics.loginConfirmation.view).toBeCalledTimes(1);
});
describe('fxaLogin webchannel message', () => {
let fxaLoginSpy: jest.SpyInstance;
beforeEach(() => {
fxaLoginSpy = jest.spyOn(firefox, 'fxaLogin');
});
it('is sent if Sync integration', () => {
const integration = createMockSigninSyncIntegration();
render({ integration });
expect(fxaLoginSpy).toHaveBeenCalledWith({
...createMockSigninLocationState(integration.wantsKeys()),
});
});
it('is not sent otherwise', () => {
render();
expect(fxaLoginSpy).not.toBeCalled();
});
});
describe('handleResendCode submission', () => {
async function renderAndResend() {
render();

Просмотреть файл

@ -26,6 +26,7 @@ import Banner, {
ResendEmailSuccessBanner,
} from '../../../components/Banner';
import { handleNavigation } from '../utils';
import firefox from '../../../lib/channels/firefox';
export const viewName = 'signin-token-code';
@ -34,7 +35,7 @@ const SIX_DIGIT_NUMBER_REGEX = /^\d{6}$/;
const SigninTokenCode = ({
finishOAuthFlowHandler,
integration,
signinLocationState,
signinState,
}: SigninTokenCodeProps & RouteComponentProps) => {
usePageViewEvent(viewName, REACT_ENTRYPOINT);
const session = useSession();
@ -48,7 +49,7 @@ const SigninTokenCode = ({
verificationReason,
keyFetchToken,
unwrapBKey,
} = signinLocationState;
} = signinState;
const [banner, setBanner] = useState<Partial<BannerProps>>({
type: undefined,
@ -81,6 +82,24 @@ const SigninTokenCode = ({
GleanMetrics.loginConfirmation.view();
}, []);
useEffect(() => {
(async () => {
if (integration.isSync()) {
await firefox.fxaLogin({
email,
// keyFetchToken and unwrapBKey should always exist if Sync integration
keyFetchToken: keyFetchToken!,
unwrapBKey: unwrapBKey!,
sessionToken,
uid,
verified: false,
});
}
})();
// Only send webchannel message if sync on initial render
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleAnimationEnd = () => {
// We add the "shake" animation to bring attention to the success banner
// when the success banner was already displayed. We have to remove the

Просмотреть файл

@ -9,7 +9,7 @@ import { SigninIntegration, SigninLocationState } from '../interfaces';
export interface SigninTokenCodeProps {
finishOAuthFlowHandler: FinishOAuthFlowHandler;
integration: SigninIntegration;
signinLocationState: SigninLocationState;
signinState: SigninLocationState;
}
export interface TotpStatusResponse {

Просмотреть файл

@ -3,13 +3,15 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { LocationProvider } from '@reach/router';
import { Integration, IntegrationType } from '../../../models';
import { IntegrationType } from '../../../models';
import { SigninTokenCodeProps } from './interfaces';
import SigninTokenCode from '.';
import {
MOCK_EMAIL,
MOCK_KEY_FETCH_TOKEN,
MOCK_SESSION_TOKEN,
MOCK_UID,
MOCK_UNWRAP_BKEY,
mockFinishOAuthFlowHandler,
} from '../../mocks';
import { MozServices } from '../../../lib/types';
@ -21,10 +23,11 @@ export function createMockWebIntegration() {
getService: () => MozServices.Default,
isSync: () => false,
wantsKeys: () => false,
} as Integration;
};
}
export const createMockSigninLocationState = (
wantsKeys = false,
verificationReason?: VerificationReasons
) => {
return {
@ -32,7 +35,11 @@ export const createMockSigninLocationState = (
uid: MOCK_UID,
sessionToken: MOCK_SESSION_TOKEN,
verified: false,
...(verificationReason && { verificationReason }),
verificationReason,
...(wantsKeys && {
keyFetchToken: MOCK_KEY_FETCH_TOKEN,
unwrapBKey: MOCK_UNWRAP_BKEY,
}),
};
};
@ -50,7 +57,10 @@ export const Subject = ({
finishOAuthFlowHandler,
integration,
}}
signinLocationState={createMockSigninLocationState(verificationReason)}
signinState={createMockSigninLocationState(
integration.wantsKeys(),
verificationReason
)}
/>
</LocationProvider>
);

Просмотреть файл

@ -16,9 +16,14 @@ import {
} from '../../../lib/auth-errors/auth-errors';
import { Subject } from './mocks';
import { MOCK_OAUTH_FLOW_HANDLER_RESPONSE } from '../../mocks';
import { createMockSigninOAuthIntegration } from '../mocks';
import {
createMockSigninOAuthIntegration,
createMockSigninSyncIntegration,
} from '../mocks';
import { SigninIntegration } from '../interfaces';
import { FinishOAuthFlowHandler } from '../../../lib/oauth/hooks';
import firefox from '../../../lib/channels/firefox';
import * as utils from 'fxa-react/lib/utils';
jest.mock('../../../lib/metrics', () => ({
usePageViewEvent: jest.fn(),
@ -142,6 +147,41 @@ describe('Sign in with TOTP code page', () => {
expect(mockNavigate).toHaveBeenCalledWith('/settings');
});
// When CAD is converted to React, just test navigation since CAD will handle fxaLogin
describe('fxaLogin webchannel message (tempHandleSyncLogin)', () => {
let fxaLoginSpy: jest.SpyInstance;
let hardNavigateSpy: jest.SpyInstance;
beforeEach(() => {
fxaLoginSpy = jest.spyOn(firefox, 'fxaLogin');
hardNavigateSpy = jest
.spyOn(utils, 'hardNavigate')
.mockImplementation(() => {});
});
it('is sent if Sync integration and navigates to CAD', async () => {
const integration = createMockSigninSyncIntegration();
await waitFor(() =>
renderAndSubmitTotpCode(
{
status: true,
},
undefined,
integration
)
);
expect(fxaLoginSpy).toHaveBeenCalled();
expect(hardNavigateSpy).toHaveBeenCalledWith(
'/connect_another_device?showSuccessMessage=true'
);
});
it('is not sent otherwise', async () => {
await renderAndSubmitTotpCode({
status: true,
});
expect(fxaLoginSpy).not.toHaveBeenCalled();
expect(hardNavigateSpy).not.toBeCalled();
});
});
it('shows error on invalid code', async () => {
await renderAndSubmitTotpCode({
status: false,
@ -167,7 +207,7 @@ describe('Sign in with TOTP code page', () => {
expect(mockNavigate).not.toHaveBeenCalled();
});
describe('withOAuth integration', () => {
describe('with OAuth integration', () => {
it('navigates to relying party on success', async () => {
const finishOAuthFlowHandler = jest
.fn()

Просмотреть файл

@ -145,7 +145,7 @@ export const SigninTotpCode = ({
queryParams: location.search,
};
handleNavigation(navigationOptions, navigate);
await handleNavigation(navigationOptions, navigate, true);
}
};

Просмотреть файл

@ -385,25 +385,6 @@ describe('signin container', () => {
);
});
});
describe('web channel messages', () => {
let fxaLoginSpy: jest.SpyInstance;
beforeEach(() => {
fxaLoginSpy = jest.spyOn(firefox, 'fxaCanLinkAccount');
});
// TODO in Sync ticket. Is this being sent under the right conditions?
// it('are sent for sync', async () => {
// mockSyncDesktopV3Integration();
// render();
// await waitFor(() => {
// expect(fxaLoginSpy).toBeCalledWith({ email: MOCK_QUERY_PARAM_EMAIL });
// });
// });
it('are not sent for non-sync', () => {
render([mockGqlAvatarUseQuery()]);
expect(fxaLoginSpy).not.toBeCalled();
});
});
});
describe('hasLinkedAccount and hasPassword are provided', () => {

Просмотреть файл

@ -6,8 +6,6 @@ import { RouteComponentProps, useLocation, useNavigate } from '@reach/router';
import Signin from '.';
import {
Integration,
isOAuthIntegration,
isSyncDesktopV3Integration,
useAuthClient,
useFtlMsgResolver,
useConfig,
@ -16,7 +14,6 @@ import { MozServices } from '../../lib/types';
import { useValidatedQueryParams } from '../../lib/hooks/useValidate';
import { SigninQueryParams } from '../../models/pages/signin';
import { useCallback, useEffect, useState } from 'react';
import firefox from '../../lib/channels/firefox';
import LoadingSpinner from 'fxa-react/components/LoadingSpinner';
import { cache, currentAccount, discardSessionToken } from '../../lib/cache';
import { FetchResult, useMutation, useQuery } from '@apollo/client';
@ -138,10 +135,7 @@ const SigninContainer = ({
queryParamModel.email || emailFromLocationState
);
const isOAuth = isOAuthIntegration(integration);
const isSyncOAuth = isOAuth && integration.isSync();
const isSyncDesktopV3 = isSyncDesktopV3Integration(integration);
const isSyncWebChannel = isSyncOAuth || isSyncDesktopV3;
const wantsKeys = integration.wantsKeys();
useEffect(() => {
(async () => {
@ -162,31 +156,24 @@ const SigninContainer = ({
// For now, just pass back emailStatusChecked. When we convert the Index page
// we'll want to read from router state.
navigate(`/signup?email=${email}&emailStatusChecked=true`);
// TODO: Probably move this to the Index page onsubmit once
// the index page is converted to React, we need to run it in
// signup and signin for Sync
} else {
// TODO: in FXA-9177, also set hasLinkedAccount and hasPassword in Apollo cache
setAccountStatus({
hasLinkedAccount,
hasPassword,
});
if (isSyncWebChannel) {
firefox.fxaCanLinkAccount({ email });
}
}
} catch (error) {
hardNavigateToContentServer(`/?prefillEmail=${email}`);
}
} else if (isSyncWebChannel) {
// TODO: Probably move this to the Index page onsubmit once
// the index page is converted to React, we need to run it in
// signup and signin for Sync
firefox.fxaCanLinkAccount({ email });
}
} else {
hardNavigateToContentServer('/');
}
})();
});
// Only run this on initial render
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { data: avatarData, loading: avatarLoading } =
useQuery<AvatarResponse>(AVATAR_QUERY);
@ -214,7 +201,7 @@ const SigninContainer = ({
const service = integration.getService();
const options = {
verificationMethod: VerificationMethods.EMAIL_OTP,
keys: integration.wantsKeys(),
keys: wantsKeys,
...(service !== MozServices.Default && { service }),
};
@ -296,10 +283,10 @@ const SigninContainer = ({
password,
clientSalt:
credentialStatusData.data?.credentialStatus.clientSalt ||
(await createSaltV2()),
createSaltV2(),
});
const kB = await unwrapKB(wrapKb, v1Credentials.unwrapBKey);
const kB = unwrapKB(wrapKb, v1Credentials.unwrapBKey);
const keys = await getKeysV2({
kB,
v1: v1Credentials,
@ -347,7 +334,17 @@ const SigninContainer = ({
},
});
return { data };
if (data) {
return {
data: {
...data,
...(wantsKeys && {
unwrapBKey: v2Credentials.unwrapBKey,
}),
},
};
}
return { data: undefined };
} catch (error) {
return handleGQLError(error);
}
@ -370,7 +367,18 @@ const SigninContainer = ({
},
},
});
return { data };
if (data) {
return {
data: {
...data,
...(wantsKeys && {
unwrapBKey: v1Credentials.unwrapBKey,
}),
},
};
}
return { data: undefined };
} catch (error) {
// TODO consider additional error handling - any non-gql errors will return an unexpected error
return handleGQLError(error);
@ -385,6 +393,7 @@ const SigninContainer = ({
keyStretchExp.queryParamModel,
passwordChangeFinish,
passwordChangeStart,
wantsKeys,
]
);

Просмотреть файл

@ -6,7 +6,11 @@ import React from 'react';
import Signin from '.';
import { MozServices } from '../../lib/types';
import { Meta } from '@storybook/react';
import { Subject, createMockSigninSyncIntegration } from './mocks';
import {
Subject,
createMockSigninOAuthIntegration,
createMockSigninSyncIntegration,
} from './mocks';
import { withLocalization } from 'fxa-react/lib/storybooks';
import { SigninProps } from './interfaces';
import { MOCK_SERVICE, MOCK_SESSION_TOKEN } from '../mocks';
@ -28,9 +32,10 @@ export const SignInToSettingsWithPassword = storyWithProps();
export const SignInToRelyingPartyWithPassword = storyWithProps({
serviceName: MOCK_SERVICE,
});
// TODO with OAuth ticket, needs OAuth integration
export const SignInToPocketWithPassword = storyWithProps({
serviceName: MozServices.Pocket,
integration: createMockSigninOAuthIntegration(),
});
export const SignInToSettingsWithCachedCredentials = storyWithProps({
@ -40,10 +45,16 @@ export const SignInToRelyingPartyWithCachedCredentials = storyWithProps({
sessionToken: MOCK_SESSION_TOKEN,
serviceName: MOCK_SERVICE,
});
// TODO with OAuth ticket, needs OAuth integration
export const SignInToPocketWithCachedCredentials = storyWithProps({
sessionToken: MOCK_SESSION_TOKEN,
serviceName: MozServices.Pocket,
integration: createMockSigninOAuthIntegration(),
});
export const SignInToSyncWithCachedCredentials = storyWithProps({
sessionToken: MOCK_SESSION_TOKEN,
integration: createMockSigninOAuthIntegration({ wantsKeys: true }),
});
export const HasLinkedAccountAndNoPassword = storyWithProps({

Просмотреть файл

@ -12,6 +12,7 @@ import {
createBeginSigninResponseError,
createCachedSigninResponseError,
createMockSigninOAuthIntegration,
createMockSigninSyncIntegration,
Subject,
} from './mocks';
import {
@ -34,6 +35,7 @@ import {
MONITOR_CLIENTIDS,
POCKET_CLIENTIDS,
} from '../../models/integrations/client-matching';
import firefox from '../../lib/channels/firefox';
// import { getFtlBundle, testAllL10n } from 'fxa-react/lib/test-utils';
// import { FluentBundle } from '@fluent/bundle';
jest.mock('../../lib/metrics', () => ({
@ -173,6 +175,19 @@ describe('Signin', () => {
differentAccountLinkRendered();
});
it('does not render third party auth for sync', () => {
const integration = createMockSigninSyncIntegration();
render({ integration });
enterPasswordAndSubmit();
expect(
screen.queryByRole('button', { name: /Continue with Google/ })
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /Continue with Apple/ })
).not.toBeInTheDocument();
});
it('emits an event on forgot password link click', async () => {
render();
fireEvent.click(screen.getByText('Forgot password?'));
@ -306,82 +321,159 @@ describe('Signin', () => {
});
});
describe('OAuth integration', () => {
describe('wants keys', () => {
it('navigates to /confirm_signup_code with router state including keys', async () => {
const beginSigninHandler = jest.fn().mockReturnValueOnce(
createBeginSigninResponse({
verified: false,
verificationReason: VerificationReasons.SIGN_UP,
keyFetchToken: MOCK_KEY_FETCH_TOKEN,
unwrapBKey: MOCK_UNWRAP_BKEY,
})
);
const finishOAuthFlowHandler = jest
.fn()
.mockReturnValueOnce(MOCK_OAUTH_FLOW_HANDLER_RESPONSE);
const integration = createMockSigninOAuthIntegration();
render({
beginSigninHandler,
integration,
finishOAuthFlowHandler,
});
// When CAD is converted to React, just test navigation since CAD will handle fxaLogin
describe('fxaLogin webchannel message (tempHandleSyncLogin)', () => {
let fxaLoginSpy: jest.SpyInstance;
let hardNavigateSpy: jest.SpyInstance;
beforeEach(() => {
fxaLoginSpy = jest.spyOn(firefox, 'fxaLogin');
hardNavigateSpy = jest
.spyOn(utils, 'hardNavigate')
.mockImplementation(() => {});
});
it('is sent if Sync integration and navigates to CAD', async () => {
const beginSigninHandler = jest
.fn()
.mockReturnValueOnce(createBeginSigninResponse());
const integration = createMockSigninSyncIntegration();
render({ beginSigninHandler, integration });
enterPasswordAndSubmit();
await waitFor(() => {
expect(fxaLoginSpy).toHaveBeenCalled();
});
expect(hardNavigateSpy).toHaveBeenCalledWith(
'/connect_another_device?showSuccessMessage=true'
);
});
it('is not sent otherwise', async () => {
render();
enterPasswordAndSubmit();
expect(fxaLoginSpy).not.toHaveBeenCalled();
expect(hardNavigateSpy).not.toBeCalled();
});
});
enterPasswordAndSubmit();
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith(
'/confirm_signup_code',
{
state: {
email: MOCK_EMAIL,
uid: MOCK_UID,
sessionToken: MOCK_SESSION_TOKEN,
verified: false,
verificationReason: 'signup',
verificationMethod: 'email-otp',
keyFetchToken: MOCK_KEY_FETCH_TOKEN,
unwrapBKey: MOCK_UNWRAP_BKEY,
},
}
);
});
describe('OAuth integration', () => {
let fxaOAuthLoginSpy: jest.SpyInstance;
let hardNavigateSpy: jest.SpyInstance;
let finishOAuthFlowHandler: jest.Mock;
beforeEach(() => {
fxaOAuthLoginSpy = jest.spyOn(firefox, 'fxaOAuthLogin');
hardNavigateSpy = jest
.spyOn(utils, 'hardNavigate')
.mockImplementation(() => {});
finishOAuthFlowHandler = jest
.fn()
.mockReturnValueOnce(MOCK_OAUTH_FLOW_HANDLER_RESPONSE);
});
it('unverified, wantsKeys, navigates to /confirm_signup_code with keys', async () => {
const beginSigninHandler = jest.fn().mockReturnValueOnce(
createBeginSigninResponse({
verified: false,
verificationReason: VerificationReasons.SIGN_UP,
keyFetchToken: MOCK_KEY_FETCH_TOKEN,
unwrapBKey: MOCK_UNWRAP_BKEY,
})
);
const integration = createMockSigninOAuthIntegration();
render({
beginSigninHandler,
integration,
finishOAuthFlowHandler,
});
enterPasswordAndSubmit();
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith(
'/confirm_signup_code',
{
state: {
email: MOCK_EMAIL,
uid: MOCK_UID,
sessionToken: MOCK_SESSION_TOKEN,
verified: false,
verificationReason: 'signup',
verificationMethod: 'email-otp',
keyFetchToken: MOCK_KEY_FETCH_TOKEN,
unwrapBKey: MOCK_UNWRAP_BKEY,
},
}
);
});
});
it('unverified, does not want keys, navigates to /confirm_signup_code without keys', async () => {
const beginSigninHandler = jest.fn().mockReturnValueOnce(
createBeginSigninResponse({
verified: false,
verificationReason: VerificationReasons.SIGN_UP,
})
);
const integration = createMockSigninOAuthIntegration();
render({
beginSigninHandler,
integration,
finishOAuthFlowHandler,
});
describe('does not want keys', () => {
it('navigates to /confirm_signup_code with router state and no keys', async () => {
const beginSigninHandler = jest.fn().mockReturnValueOnce(
createBeginSigninResponse({
verified: false,
verificationReason: VerificationReasons.SIGN_UP,
})
enterPasswordAndSubmit();
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith(
'/confirm_signup_code',
{
state: {
email: MOCK_EMAIL,
uid: MOCK_UID,
sessionToken: MOCK_SESSION_TOKEN,
verified: false,
verificationReason: 'signup',
verificationMethod: 'email-otp',
},
}
);
});
});
it('verified, not sync, navigates to RP redirect', async () => {
const beginSigninHandler = jest.fn().mockReturnValueOnce(
createBeginSigninResponse({
keyFetchToken: MOCK_KEY_FETCH_TOKEN,
unwrapBKey: MOCK_UNWRAP_BKEY,
})
);
const integration = createMockSigninOAuthIntegration();
render({
beginSigninHandler,
integration,
finishOAuthFlowHandler,
});
enterPasswordAndSubmit();
await waitFor(() => {
expect(fxaOAuthLoginSpy).not.toHaveBeenCalled();
expect(hardNavigateSpy).toHaveBeenCalledWith(
MOCK_OAUTH_FLOW_HANDLER_RESPONSE.redirect
);
});
});
it('verified, sync, navigates to CAD and sends fxaOAuthLogin', async () => {
const beginSigninHandler = jest.fn().mockReturnValueOnce(
createBeginSigninResponse({
keyFetchToken: MOCK_KEY_FETCH_TOKEN,
unwrapBKey: MOCK_UNWRAP_BKEY,
})
);
const integration = createMockSigninOAuthIntegration({
isSync: true,
});
render({
beginSigninHandler,
integration,
finishOAuthFlowHandler,
});
enterPasswordAndSubmit();
await waitFor(() => {
expect(fxaOAuthLoginSpy).toHaveBeenCalled();
expect(hardNavigateSpy).toHaveBeenCalledWith(
'/connect_another_device?showSuccessMessage=true'
);
const finishOAuthFlowHandler = jest
.fn()
.mockReturnValueOnce(MOCK_OAUTH_FLOW_HANDLER_RESPONSE);
const integration = createMockSigninOAuthIntegration();
render({
beginSigninHandler,
integration,
finishOAuthFlowHandler,
});
enterPasswordAndSubmit();
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith(
'/confirm_signup_code',
{
state: {
email: MOCK_EMAIL,
uid: MOCK_UID,
sessionToken: MOCK_SESSION_TOKEN,
verified: false,
verificationReason: 'signup',
verificationMethod: 'email-otp',
},
}
);
});
});
});
});
@ -583,6 +675,14 @@ describe('with sessionToken', () => {
hardNavigateSpy.mockRestore();
});
it('always renders password input when integration wants keys', () => {
const integration = createMockSigninOAuthIntegration({
wantsKeys: true,
});
render({ integration });
passwordInputRendered();
});
it('navigates to OAuth redirect', async () => {
const cachedSigninHandler = jest
.fn()
@ -682,10 +782,10 @@ describe('with sessionToken', () => {
<Subject
sessionToken={MOCK_SESSION_TOKEN}
serviceName={MozServices.Pocket}
integration={createMockSigninOAuthIntegration(
POCKET_CLIENTIDS[0],
false
)}
integration={createMockSigninOAuthIntegration({
clientId: POCKET_CLIENTIDS[0],
wantsKeys: false,
})}
/>
);
@ -696,7 +796,9 @@ describe('with sessionToken', () => {
it('shows Pocket-specific TOS', () => {
renderWithLocalizationProvider(
<Subject
integration={createMockSigninOAuthIntegration(POCKET_CLIENTIDS[0])}
integration={createMockSigninOAuthIntegration({
clientId: POCKET_CLIENTIDS[0],
})}
/>
);
@ -723,7 +825,9 @@ describe('with sessionToken', () => {
it('shows Monitor-specific TOS', async () => {
renderWithLocalizationProvider(
<Subject
integration={createMockSigninOAuthIntegration(MONITOR_CLIENTIDS[0])}
integration={createMockSigninOAuthIntegration({
clientId: MONITOR_CLIENTIDS[0],
})}
/>
);
@ -746,5 +850,3 @@ describe('with sessionToken', () => {
});
});
});
// TODO in FXA-9059: make sure third party auth is not rendered for sync if account has a password

Просмотреть файл

@ -79,7 +79,8 @@ const Signin = ({
// We must use a ref because we may update this value in a callback
let isPasswordNeededRef = useRef(
(!sessionToken && hasPassword) ||
(isOAuth && (integration.wantsKeys() || integration.wantsLogin()))
integration.wantsKeys() ||
(isOAuth && integration.wantsLogin())
);
const localizedPasswordFormLabel = ftlMsgResolver.getMsg(
@ -190,12 +191,13 @@ const Signin = ({
email,
signinData: data.signIn,
unwrapBKey: data.unwrapBKey,
verified: data.signIn.verified,
integration,
finishOAuthFlowHandler,
queryParams: location.search,
};
await handleNavigation(navigationOptions, navigate);
await handleNavigation(navigationOptions, navigate, true);
}
if (error) {
GleanMetrics.login.error({ reason: error.message });
@ -289,8 +291,7 @@ const Signin = ({
]
);
const hideThirdPartyAuth =
integration.isSync() && hasLinkedAccount && hasPassword;
const hideThirdPartyAuth = integration.isSync() && hasPassword;
return (
<AppLayout>

Просмотреть файл

@ -17,7 +17,7 @@ export interface AvatarResponse {
}
export type SigninIntegration =
| Pick<Integration, 'type' | 'isSync' | 'getService'>
| Pick<Integration, 'type' | 'isSync' | 'getService' | 'wantsKeys'>
| SigninOAuthIntegration;
export type SigninOAuthIntegration = Pick<
@ -160,10 +160,11 @@ export interface NavigationOptions {
verified: boolean;
verificationMethod?: VerificationMethods;
verificationReason?: VerificationReasons;
// keyFetchToken and unwrapBKey are included if options.keys=true
// These will never exist for the cached signin (prompt=none)
// keyFetchToken is included if options.keys=true
// This (and unwrapBKey) will never exist for the cached signin (prompt=none)
keyFetchToken?: hexstring;
};
// unwrapBKey is included if integration.wantsKeys()
unwrapBKey?: hexstring;
integration: SigninIntegration;
finishOAuthFlowHandler: FinishOAuthFlowHandler;

Просмотреть файл

@ -93,6 +93,7 @@ export function createMockSigninWebIntegration(): SigninIntegration {
type: IntegrationType.Web,
isSync: () => false,
getService: () => MozServices.Default,
wantsKeys: () => false,
};
}
@ -105,14 +106,19 @@ export function createMockSigninSyncIntegration(): SigninIntegration {
};
}
export function createMockSigninOAuthIntegration(
clientId?: string,
wantsKeys: boolean = true
): SigninOAuthIntegration {
export function createMockSigninOAuthIntegration({
clientId,
wantsKeys = true,
isSync = false,
}: {
clientId?: string;
wantsKeys?: boolean;
isSync?: boolean;
} = {}): SigninOAuthIntegration {
return {
type: IntegrationType.OAuth,
getService: () => clientId || MOCK_CLIENT_ID,
isSync: () => false,
isSync: () => isSync,
wantsKeys: () => wantsKeys,
wantsLogin: () => false,
wantsTwoStepAuthentication: () => false,

Просмотреть файл

@ -17,24 +17,56 @@ import {
import { isOAuthIntegration } from '../../models';
import { NavigateFn } from '@reach/router';
import { hardNavigate } from 'fxa-react/lib/utils';
import { FinishOAuthFlowHandler } from '../../lib/oauth/hooks';
import { currentAccount } from '../../lib/cache';
import firefox from '../../lib/channels/firefox';
// TODO in FXA-9059:
// function getSyncNavigate() {
// const searchParams = new URLSearchParams(location.search);
// searchParams.set('showSuccessMessage', 'true');
// const to = `/connect_another_device?${searchParams}`
// }
interface NavigationTarget {
to: string;
state?: SigninLocationState;
shouldHardNavigate?: boolean;
}
// TODO: don't hard navigate once ConnectAnotherDevice is converted to React
export function getSyncNavigate(queryParams: string) {
const searchParams = new URLSearchParams(queryParams);
searchParams.set('showSuccessMessage', 'true');
return {
to: `/connect_another_device?${searchParams}`,
shouldHardNavigate: true,
};
}
// 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 page (currently
// Backbone) completes the signin with fxa_status.
//
// In Backbone happy path signin (session is verified after signin) as well as
// Backbone 'signin_totp_code', we send key/token data up on the CAD page itself
// with an fxa_status message. We don't want to do this with React signin until
// CAD is converted to React because we'd need to pass this data back to
// Backbone. This means temporarily we need to send the sync data up _before_
// we hard navigate to CAD in these two flows.
export async function handleNavigation(
navigationOptions: NavigationOptions,
navigate: NavigateFn
navigate: NavigateFn,
tempHandleSyncLogin = false
) {
const { to, state, shouldHardNavigate } = await getNavigationTarget(
navigationOptions
);
if (shouldHardNavigate) {
if (tempHandleSyncLogin && navigationOptions.integration.isSync()) {
firefox.fxaLogin({
email: navigationOptions.email,
// keyFetchToken and unwrapBKey should always exist if Sync integration
keyFetchToken: navigationOptions.signinData.keyFetchToken!,
unwrapBKey: navigationOptions.unwrapBKey!,
sessionToken: navigationOptions.signinData.sessionToken,
uid: navigationOptions.signinData.uid,
verified: navigationOptions.signinData.verified,
});
}
// Hard navigate to RP, or (temp until CAD is Reactified) CAD
hardNavigate(to);
return;
@ -46,46 +78,6 @@ export async function handleNavigation(
}
}
export async function getOAuthRedirectAndHandleSync(
finishOAuthFlowHandler: FinishOAuthFlowHandler,
{
uid,
sessionToken,
keyFetchToken,
unwrapBKey,
}: {
uid: hexstring;
sessionToken: hexstring;
keyFetchToken?: string;
unwrapBKey?: string;
}
) {
const { redirect } =
keyFetchToken && unwrapBKey
? await finishOAuthFlowHandler(
uid,
sessionToken,
keyFetchToken,
unwrapBKey
)
: await finishOAuthFlowHandler(uid, sessionToken);
// TODO in FXA-9059 Sync signin ticket. Do we want to do firefox.fxAOAuthLogin here
// if the session isn't verified?
//
// if (integration.isSync()) {
// firefox.fxaOAuthLogin({
// action: 'signin',
// code,
// redirect,
// state,
// })
// TODO: don't hard navigate once ConnectAnotherDevice is converted to React
// return { to: getSyncNavigate(), shouldHardNavigate: true }
// }
return { to: redirect, shouldHardNavigate: true };
}
const getNavigationTarget = async ({
email,
signinData,
@ -94,7 +86,7 @@ const getNavigationTarget = async ({
finishOAuthFlowHandler,
redirectTo,
queryParams = '',
}: NavigationOptions) => {
}: NavigationOptions): Promise<NavigationTarget> => {
const isOAuth = isOAuthIntegration(integration);
const {
verified,
@ -105,73 +97,73 @@ const getNavigationTarget = async ({
sessionToken,
} = signinData;
// oAuthResult result will need to be obtained at the next step, once session is verified
if (!verified) {
const getUnverifiedNav = () => {
const state = {
email,
uid,
sessionToken,
verified,
...(verificationMethod && { verificationMethod }),
...(verificationReason && { verificationReason }),
...(keyFetchToken && { keyFetchToken }),
...(unwrapBKey && { unwrapBKey }),
verificationMethod,
verificationReason,
keyFetchToken,
unwrapBKey,
};
// TODO in FXA-9177 Consider storing state in Apollo cache instead of location state
if (
((verificationReason === VerificationReasons.SIGN_IN ||
verificationReason === VerificationReasons.CHANGE_PASSWORD) &&
verificationMethod === VerificationMethods.TOTP_2FA) ||
(isOAuth && integration.wantsTwoStepAuthentication())
) {
return {
to: `/signin_totp_code${queryParams}`,
state,
};
} else if (verificationReason === VerificationReasons.SIGN_UP) {
return {
to: `/confirm_signup_code${queryParams}`,
state,
};
} else {
return {
to: `/signin_token_code${queryParams}`,
state,
};
}
const getUnverifiedNavTo = () => {
// TODO in FXA-9177 Consider storing state in Apollo cache instead of location state
if (
((verificationReason === VerificationReasons.SIGN_IN ||
verificationReason === VerificationReasons.CHANGE_PASSWORD) &&
verificationMethod === VerificationMethods.TOTP_2FA) ||
(isOAuth && integration.wantsTwoStepAuthentication())
) {
return `/signin_totp_code${queryParams}`;
} else if (verificationReason === VerificationReasons.SIGN_UP) {
return `/confirm_signup_code${queryParams}`;
}
return `/signin_token_code${queryParams}`;
};
return { to: getUnverifiedNavTo(), state };
};
if (!verified) {
return getUnverifiedNav();
}
if (verificationReason === VerificationReasons.CHANGE_PASSWORD) {
return {
to:
queryParams.length > 1
? `/post_verify/password/force_password_change${queryParams}`
: '/post_verify/password/force_password_change',
to: `/post_verify/password/force_password_change${
(queryParams.length > 1 && queryParams) || ''
}`,
// TODO in FXA-6653: remove shouldHardNavigate when this route is converted to React
shouldHardNavigate: true,
};
}
// TODO in FXA-9059 handle sync desktop v3 integration post-sign in navigation
// oAuthResult can only be obtained when the session is verified
// OAuth redirect can only be obtained when the session is verified
// otherwise oauth/authorization endpoint throws an "unconfirmed session" error
if (verified && isOAuth) {
const oAuthResult = await getOAuthRedirectAndHandleSync(
finishOAuthFlowHandler,
{
uid,
sessionToken,
keyFetchToken,
unwrapBKey,
}
if (isOAuth) {
const { redirect, code, state } = await finishOAuthFlowHandler(
uid,
sessionToken,
keyFetchToken,
unwrapBKey
);
return {
to: oAuthResult.to,
shouldHardNavigate: oAuthResult.shouldHardNavigate,
};
if (integration.isSync()) {
firefox.fxaOAuthLogin({
action: 'signin',
code,
redirect,
state,
});
return getSyncNavigate(queryParams);
}
return { to: redirect, shouldHardNavigate: true };
}
if (integration.isSync()) {
return getSyncNavigate(queryParams);
}
if (redirectTo) {

Просмотреть файл

@ -43,6 +43,7 @@ import firefox from '../../../lib/channels/firefox';
import GleanMetrics from '../../../lib/glean';
import { useWebRedirect } from '../../../lib/hooks/useWebRedirect';
import { storeAccountData } from '../../../lib/storage-utils';
import { getSyncNavigate } from '../../Signin/utils';
export const viewName = 'confirm-signup-code';
@ -119,14 +120,6 @@ const ConfirmSignupCode = ({
}
}
function navigateToCAD() {
// Connect another device tells Sync the user is signed in
// TODO: regular navigate when this page is converted
const searchParams = new URLSearchParams(location.search);
searchParams.set('showSuccessMessage', 'true');
hardNavigateToContentServer(`/connect_another_device?${searchParams}`);
}
async function verifySession(code: string) {
logViewEvent(`flow.${viewName}`, 'submit', REACT_ENTRYPOINT);
GleanMetrics.signupConfirmation.submit();
@ -162,7 +155,8 @@ const ConfirmSignupCode = ({
}
if (isSyncDesktopV3Integration(integration)) {
navigateToCAD();
const { to } = getSyncNavigate(location.search);
hardNavigateToContentServer(to);
} else if (isOAuthIntegration(integration)) {
// Check to see if the relier wants TOTP.
// Newly created accounts wouldn't have this so lets redirect them to signin.
@ -197,7 +191,8 @@ const ConfirmSignupCode = ({
state,
});
// Mobile sync will close the web view, OAuth Desktop mimics DesktopV3 behavior
navigateToCAD();
const { to } = getSyncNavigate(location.search);
hardNavigateToContentServer(to);
return;
} else {
// Navigate to relying party

Просмотреть файл

@ -99,45 +99,38 @@ const SignupContainer = ({
const isSyncOAuth = isOAuth && integration.isSync();
const isSyncDesktopV3 = isSyncDesktopV3Integration(integration);
const isSyncWebChannel = isSyncOAuth || isSyncDesktopV3;
const wantsKeys = integration.wantsKeys();
useEffect(() => {
(async () => {
// Modify this once index is converted to React
if (!validationError) {
// emailStatusChecked can be passed from React Signin when users hit /signin
// with an email query param that we already determined doesn't exist.
// It's supplied by Backbone when going from Backbone Index page to React signup.
if (!queryParamModel.emailStatusChecked && !emailStatusChecked) {
const { exists, hasLinkedAccount, hasPassword } =
await authClient.accountStatusByEmail(queryParamModel.email, {
thirdPartyAuthStatus: true,
// emailStatusChecked can be passed from React Signin when users hit /signin
// with an email query param that we already determined doesn't exist.
// It's supplied by Backbone when going from Backbone Index page to React signup.
if (
!validationError &&
!queryParamModel.emailStatusChecked &&
!emailStatusChecked
) {
const { exists, hasLinkedAccount, hasPassword } =
await authClient.accountStatusByEmail(queryParamModel.email, {
thirdPartyAuthStatus: true,
});
if (exists) {
if (config.showReactApp.signInRoutes) {
navigate(`/signin`, {
replace: true,
state: {
email: queryParamModel.email,
hasLinkedAccount,
hasPassword,
},
});
if (exists) {
if (config.showReactApp.signInRoutes) {
navigate(`/signin`, {
replace: true,
state: {
email: queryParamModel.email,
hasLinkedAccount,
hasPassword,
},
});
} else {
hardNavigateToContentServer(
`/signin?email=${queryParamModel.email}`
);
}
// TODO: Probably move this to the Index page onsubmit once
// the index page is converted to React, we need to run it in
// signup and signin for Sync
} else if (isSyncWebChannel) {
firefox.fxaCanLinkAccount({ email: queryParamModel.email });
} else {
hardNavigateToContentServer(
`/signin?email=${queryParamModel.email}`
);
}
} else if (isSyncWebChannel) {
// TODO: Probably move this to the Index page onsubmit once
// the index page is converted to React, we need to run it in
// signup and signin for Sync
firefox.fxaCanLinkAccount({ email: queryParamModel.email });
}
}
setShowLoadingSpinner(false);
@ -195,7 +188,7 @@ const SignupContainer = ({
const service = integration.getService();
const options: BeginSignUpOptions = {
verificationMethod: VerificationMethods.EMAIL_OTP,
keys: integration.wantsKeys(),
keys: wantsKeys,
...(service !== MozServices.Default && { service }),
atLeast18AtReg,
};
@ -247,9 +240,11 @@ const SignupContainer = ({
return {
data: {
...data,
unwrapBKey: credentialsV2
? credentialsV2.unwrapBKey
: credentialsV1.unwrapBKey,
...(wantsKeys && {
unwrapBKey: credentialsV2
? credentialsV2.unwrapBKey
: credentialsV1.unwrapBKey,
}),
},
};
} else return { data: undefined };
@ -258,7 +253,7 @@ const SignupContainer = ({
return handleGQLError(error);
}
},
[beginSignup, integration, keyStretchExp, config]
[beginSignup, integration, keyStretchExp, config, wantsKeys]
);
// TODO: probably a better way to read this?

Просмотреть файл

@ -8,7 +8,6 @@ import { useForm } from 'react-hook-form';
import {
isOAuthIntegration,
isSyncDesktopV3Integration,
isSyncOAuthIntegration,
useFtlMsgResolver,
} from '../../models';
import {
@ -246,10 +245,7 @@ export const Signup = ({
const getOfferedSyncEngines = () =>
getSyncEngineIds(offeredSyncEngineConfigs || []);
if (
isSyncDesktopV3Integration(integration) ||
isSyncOAuthIntegration(integration)
) {
if (integration.isSync()) {
await firefox.fxaLogin({
email,
// keyFetchToken and unwrapBKey should always exist if Sync integration