feat(react): Convert third party auth callback page to React

This commit is contained in:
Vijay Budhram 2024-11-20 11:46:49 -05:00
Родитель 13777a2ab2
Коммит 921186405a
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 9778545895B2532B
14 изменённых файлов: 412 добавлений и 144 удалений

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

@ -32,6 +32,7 @@ import SignInTotpCodeView from '../views/sign_in_totp_code';
import SignInUnblockView from '../views/sign_in_unblock';
import SignUpPasswordView from '../views/sign_up_password';
import ThirdPartyAuthSetPasswordView from '../views/post_verify/third_party_auth/set_password';
import ThirdPartyAuthCallbackView from '../views/post_verify/third_party_auth/callback';
import Storage from './storage';
import SubscriptionsProductRedirectView from '../views/subscriptions_product_redirect';
import SubscriptionsManagementRedirectView from '../views/subscriptions_management_redirect';
@ -376,9 +377,12 @@ Router = Router.extend({
type: VerificationReasons.SECONDARY_EMAIL_VERIFIED,
}
),
'post_verify/third_party_auth/callback(/)': createViewHandler(
'post_verify/third_party_auth/callback'
),
'post_verify/third_party_auth/callback(/)': function () {
this.createReactOrBackboneViewHandler(
'post_verify/third_party_auth/callback',
ThirdPartyAuthCallbackView
);
},
'post_verify/third_party_auth/set_password(/)': function () {
this.createReactOrBackboneViewHandler(
'post_verify/third_party_auth/set_password',

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

@ -62,7 +62,8 @@
"resetPasswordRoutes": true,
"signUpRoutes": true,
"signInRoutes": true,
"emailFirstRoutes": false
"emailFirstRoutes": false,
"postVerifyThirdPartyAuthRoutes": true
},
"featureFlags": {
"sendFxAStatusOnSettings": true,

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

@ -142,4 +142,30 @@ export class DefaultIntegrationFlags implements IntegrationFlags {
}
return '';
}
isThirdPartyAuthCallback() {
if (!/third_party_auth\/callback/.test(this.pathname)) {
return false;
}
const state = this.searchParam('state');
if (!state) {
return false;
}
try {
const decodedState = decodeURIComponent(state);
// Maybe check for values in url?
new URL(decodedState);
} catch (err) {
return false;
}
const code = this.searchParam('code');
if (!code) {
return false;
}
return true;
}
}

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

@ -14,6 +14,7 @@ import {
RelierClientInfo,
RelierSubscriptionInfo,
OAuthIntegration,
ThirdPartyAuthCallbackIntegration,
} from '../../models/integrations';
import {
ModelDataStore,
@ -103,7 +104,9 @@ export class IntegrationFactory {
const flags = this.flags;
// The order of checks matters
if (flags.isDevicePairingAsAuthority()) {
if (flags.isThirdPartyAuthCallback()) {
return this.createThirdPartyAuthCallbackIntegration(data);
} else if (flags.isDevicePairingAsAuthority()) {
return this.createPairingAuthorityIntegration(channelData, storageData);
} else if (flags.isDevicePairingAsSupplicant()) {
return this.createPairingSupplicationIntegration(data, storageData);
@ -123,6 +126,12 @@ export class IntegrationFactory {
}
}
private createThirdPartyAuthCallbackIntegration(data: ModelDataStore) {
const integration = new ThirdPartyAuthCallbackIntegration(data);
this.initIntegration(integration);
return integration;
}
private createPairingAuthorityIntegration(
data: ModelDataStore,
storageData: ModelDataStore

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

@ -13,6 +13,7 @@ export interface IntegrationFlags {
isV3DesktopContext(): boolean;
isOAuthSuccessFlow(): { status: boolean; clientId: string };
isOAuthVerificationFlow(): boolean;
isThirdPartyAuthCallback(): boolean;
isServiceOAuth(): boolean;
isServiceSync(): boolean;

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

@ -30,7 +30,7 @@ export class UrlQueryData extends UrlData {
* Sets a new internal state from a set of URL search params
* @param params
*/
protected setParams(params: URLSearchParams) {
public setParams(params: URLSearchParams) {
// Immediately update the internal state
this.internalState = params;

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

@ -933,6 +933,15 @@ export class Account implements AccountData {
metricsContext
)
);
currentAccount(getStoredAccountData(linkedAccount));
sessionToken(linkedAccount.sessionToken);
this.apolloClient.cache.writeQuery({
query: GET_LOCAL_SIGNED_IN_STATUS,
data: { isSignedIn: true },
});
return linkedAccount;
}

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

@ -12,6 +12,7 @@ export enum IntegrationType {
SyncBasic = 'SyncBasic',
SyncDesktopV3 = 'SyncDesktopV3',
Web = 'Web', // default
ThirdPartyAuthCallback = 'ThirdPartyAuthCallback', // For third party auth callbacks
}
/* TODO, do we care about this feature (capability in content-server)?
@ -183,6 +184,10 @@ export abstract class Integration<
isTrusted() {
return true;
}
thirdPartyAuthParams() {
return {};
}
}
export class BaseIntegration<

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

@ -14,3 +14,4 @@ export * from './supplicant-info';
export * from './sync-basic-integration';
export * from './sync-desktop-v3-integration';
export * from './web-integration';
export * from './third-party-auth-callback-integration';

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

@ -0,0 +1,38 @@
/* 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 { ModelDataStore, GenericData } from '../../lib/model-data';
import { ThirdPartyAuthCallbackIntegration } from './third-party-auth-callback-integration';
import { AUTH_PROVIDER } from 'fxa-auth-client/browser';
describe('models/integrations/third-party-auth-callback-integration', function () {
let data: ModelDataStore;
let model: ThirdPartyAuthCallbackIntegration;
beforeEach(function () {
data = new GenericData({});
data.set('code', 'test-code');
data.set('provider', 'apple');
const state = encodeURIComponent('https://example.com?param=value');
data.set('state', state);
model = new ThirdPartyAuthCallbackIntegration(data);
});
it('exists', () => {
expect(model).toBeDefined();
});
it('should return third party auth params', () => {
const params = model.thirdPartyAuthParams();
expect(params).toEqual({
code: 'test-code',
provider: AUTH_PROVIDER.APPLE,
});
});
it('should return FxA params from state', () => {
const fxaParams = model.getFxAParams();
expect(fxaParams).toBe('?param=value');
});
});

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

@ -0,0 +1,76 @@
/* 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 {
BaseIntegration,
Integration,
IntegrationFeatures,
IntegrationType,
} from './base-integration';
import { bind, ModelDataStore } from '../../lib/model-data';
import { AUTH_PROVIDER } from 'fxa-auth-client/browser';
import { BaseIntegrationData } from './web-integration';
import { IsOptional, IsString } from 'class-validator';
export function isThirdPartyAuthCallbackIntegration(
integration: null | Integration<IntegrationFeatures>
): integration is ThirdPartyAuthCallbackIntegration {
if (!integration) {
return false;
}
return integration.type === IntegrationType.ThirdPartyAuthCallback;
}
export class ThirdPartyAuthCallbackIntegrationData extends BaseIntegrationData {
@IsString()
@bind()
state: string | undefined;
@IsString()
@bind()
code: string | undefined;
@IsOptional()
@IsString()
@bind()
provider: string | undefined;
}
export interface ThirdPartyAuthCallbackIntegrationFeatures
extends IntegrationFeatures {}
export class ThirdPartyAuthCallbackIntegration extends BaseIntegration<ThirdPartyAuthCallbackIntegrationFeatures> {
constructor(data: ModelDataStore) {
super(
IntegrationType.ThirdPartyAuthCallback,
new ThirdPartyAuthCallbackIntegrationData(data)
);
}
thirdPartyAuthParams() {
const code = this.data.code;
const providerFromParams = this.data.provider;
let provider: AUTH_PROVIDER | undefined;
if (providerFromParams === 'apple') {
provider = AUTH_PROVIDER.APPLE;
} else {
provider = AUTH_PROVIDER.GOOGLE;
}
return { code, provider };
}
getFxAParams() {
const state = this.data.state;
if (state) {
const decodedState = decodeURIComponent(state);
const url = new URL(decodedState);
return url.search;
}
return '';
}
}

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

@ -3,29 +3,126 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React from 'react';
import { screen } from '@testing-library/react';
import { screen, waitFor } from '@testing-library/react';
import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider';
import ThirdPartyAuthCallback from '.';
import { AppContext } from '../../../models';
import { mockAppContext } from '../../../models/mocks';
import { LocationProvider } from '@reach/router';
import { ThirdPartyAuthProps } from '../../../components/ThirdPartyAuth';
import { createAppContext, mockAppContext } from '../../../models/mocks';
import { useAccount, useIntegration } from '../../../models';
import { useFinishOAuthFlowHandler } from '../../../lib/oauth/hooks';
import { handleNavigation } from '../../Signin/utils';
import { isThirdPartyAuthCallbackIntegration } from '../../../models/integrations/third-party-auth-callback-integration';
import { QueryParams } from '../../../index';
function renderWith(props?: ThirdPartyAuthProps) {
jest.mock('../../../models', () => ({
...jest.requireActual('../../../models'),
useClientInfoState: jest.fn(),
useProductInfoState: jest.fn(),
useIntegration: jest.fn(),
useAccount: jest.fn(),
}));
jest.mock('@reach/router', () => ({
...jest.requireActual('@reach/router'),
useLocation: () => {
return {
search: '?',
};
},
}));
jest.mock('../../../lib/oauth/hooks', () => {
return {
__esModule: true,
useFinishOAuthFlowHandler: jest.fn(),
};
});
jest.mock('../../Signin/utils', () => {
return {
__esModule: true,
handleNavigation: jest.fn(),
};
});
jest.mock(
'../../../models/integrations/third-party-auth-callback-integration',
() => {
return {
__esModule: true,
isThirdPartyAuthCallbackIntegration: jest.fn(),
};
}
);
function renderWith(props?: { flowQueryParams?: QueryParams }) {
return renderWithLocalizationProvider(
<AppContext.Provider value={mockAppContext()}>
<LocationProvider>
<ThirdPartyAuthCallback {...props} />;
</LocationProvider>
<AppContext.Provider value={{ ...mockAppContext(), ...createAppContext() }}>
<ThirdPartyAuthCallback {...props} />;
</AppContext.Provider>
);
}
describe('ThirdPartyAuth component', () => {
it('renders as expected', () => {
renderWith();
describe('ThirdPartyAuthCallback component', () => {
beforeEach(() => {
(useFinishOAuthFlowHandler as jest.Mock).mockImplementation(() => ({
finishOAuthFlowHandler: jest.fn(),
oAuthDataError: null,
}));
});
afterEach(() => {
jest.resetAllMocks();
});
it('renders as expected', async () => {
renderWith({});
screen.getByText(
'Please wait, you are being redirected to the authorized application.'
);
});
it('verifies third-party auth response and navigates', async () => {
const mockVerifyAccountThirdParty = jest.fn().mockResolvedValue({
uid: 'uid',
sessionToken: 'sessionToken',
providerUid: 'providerUid',
email: 'email@example.com',
});
const mockAccount = {
verifyAccountThirdParty: mockVerifyAccountThirdParty,
};
(useAccount as jest.Mock).mockReturnValue(mockAccount);
const mockIntegration = {
thirdPartyAuthParams: () => ({ code: 'code', provider: 'provider' }),
getFxAParams: () => 'param=value',
};
(useIntegration as jest.Mock).mockReturnValue(mockIntegration);
(
isThirdPartyAuthCallbackIntegration as unknown as jest.Mock
).mockReturnValue(true);
const mockFinishOAuthFlowHandler = jest.fn();
(useFinishOAuthFlowHandler as jest.Mock).mockReturnValue({
finishOAuthFlowHandler: mockFinishOAuthFlowHandler,
});
const mockHandleNavigation = jest.fn().mockResolvedValue({ error: null });
(handleNavigation as jest.Mock).mockReturnValue(mockHandleNavigation);
renderWith({
flowQueryParams: {},
});
await waitFor(() => {
expect(mockVerifyAccountThirdParty).toHaveBeenCalledWith(
'code',
'provider',
undefined,
expect.any(Object)
);
});
});
});

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

@ -2,20 +2,31 @@
* 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 React, { useEffect } from 'react';
import React, { useEffect, useRef, useCallback } from 'react';
import { FtlMsg, hardNavigate } from 'fxa-react/lib/utils';
import { RouteComponentProps } from '@reach/router';
import { useNavigateWithQuery as useNavigate } from '../../../lib/hooks/useNavigateWithQuery';
import { RouteComponentProps, useLocation } from '@reach/router';
import LoadingSpinner from 'fxa-react/components/LoadingSpinner';
import AppLayout from '../../../components/AppLayout';
import { AUTH_PROVIDER } from 'fxa-auth-client/browser';
import { useAccount } from '../../../models';
import {
useAccount,
useIntegration,
useAuthClient,
Integration,
} from '../../../models';
import { handleNavigation } from '../../Signin/utils';
import { useFinishOAuthFlowHandler } from '../../../lib/oauth/hooks';
import {
StoredAccountData,
storeAccountData,
} from '../../../lib/storage-utils';
import { QueryParams } from '../../..';
import { queryParamsToMetricsContext } from '../../../lib/metrics';
import {
isThirdPartyAuthCallbackIntegration,
ThirdPartyAuthCallbackIntegration,
} from '../../../models/integrations/third-party-auth-callback-integration';
import { ReachRouterWindow } from '../../../lib/window';
import { UrlQueryData } from '../../../lib/model-data';
type LinkedAccountData = {
uid: hexstring;
@ -25,143 +36,134 @@ type LinkedAccountData = {
verificationMethod?: string;
};
// TODO this page to be completed/activated in FXA-8834
// User reaches this page when redirected back from third party auth provider.
// It requires /post_verify/third_party_auth/callback route to be turned on for react
// otherwise, users authenticating with the react version of signin/signup are directed
// to the backbone version of the callback to complete their third party authentication.
// All use of params should be reworked to use `useValidatedQueryParams` hook in FXA-8834
const ThirdPartyAuthCallback = ({
flowQueryParams,
}: { flowQueryParams?: QueryParams } & RouteComponentProps) => {
const navigate = useNavigate();
const account = useAccount();
const params = new URLSearchParams(window.location.search);
const integration = useIntegration();
const authClient = useAuthClient();
const location = useLocation();
const getRedirectUrl = () => {
// get the stashed state with origin information
// use it to reconstruct redirect for oauth
// the state is the entire URL of the origin and includes the redirect_uri in a param
// if authenticating from a RP
const state = params.get('state');
if (state) {
// we may need to deconstruct the state to access/modify the redirect URL
const stateParams = new URL(decodeURIComponent(state)).searchParams;
const redirect = stateParams.get('redirect_uri');
// if the state contains a redirect_uri, we need to redirect to RP
// otherwise we redirect internally
if (redirect) {
const url = new URL(redirect);
// TODO append other params from state to the redirect URL
return url;
}
}
return undefined;
};
const linkedAccountData = useRef({} as LinkedAccountData);
// Persist account data to local storage to match parity with content-server
// this allows the recent account to be used for /signin
const storeLinkedAccountData = async (linkedAccount: LinkedAccountData) => {
const accountData: StoredAccountData = {
// We are using the email that was returned from the Third Party Auth
// Not the email entered in the email-first form as they might be different
email: linkedAccount.email,
uid: linkedAccount.uid,
lastLogin: Date.now(),
sessionToken: linkedAccount.sessionToken,
verified: true,
metricsEnabled: true,
};
const { finishOAuthFlowHandler } = useFinishOAuthFlowHandler(
authClient,
integration || ({} as Integration)
);
storeAccountData(accountData);
};
const storeLinkedAccountData = useCallback(
async (linkedAccount: LinkedAccountData) => {
const accountData: StoredAccountData = {
email: linkedAccount.email,
uid: linkedAccount.uid,
lastLogin: Date.now(),
sessionToken: linkedAccount.sessionToken,
verified: true,
metricsEnabled: true,
};
return storeAccountData(accountData);
},
[]
);
const completeSignIn = async (linkedAccount: LinkedAccountData) => {
// TODO in FXA-8834, use SignIn method that should be ported in FXA-6488
// to complete sign in with the sessionToken obtained when verifying the third party auth
// this should also update graphQL cache (isSignedIn:true)
// await account.signIn(linkedAccount)
const verifyThirdPartyAuthResponse = useCallback(async () => {
const { code: thirdPartyOAuthCode, provider } = (
integration as ThirdPartyAuthCallbackIntegration
).thirdPartyAuthParams();
await storeLinkedAccountData(linkedAccount);
// TODO ensure correct redirects for all integrations (OAuth, Desktop, Mobile)
// redirect is constructed from state param in the URL params
const redirectURL = getRedirectUrl();
if (redirectURL) {
// get the stashed state with origin information
// use it to reconstruct redirect for oauth
// the state is the entire URL of the origin and includes the redirect_uri in a param
// if authenticating from a RP
const state = params.get('state');
if (state) {
// we may need to deconstruct the state to access/modify the redirect URL
const stateParams = new URL(decodeURIComponent(state)).searchParams;
const redirect = stateParams.get('redirect_uri');
// if the state contains a redirect_uri, we need to redirect to RP
// otherwise we redirect internally
if (redirect) {
hardNavigate(redirect);
} else {
// general redirect to settings for non-RP
// currently, redirect to /settings fails with an "unauthenticated" error from GQL
// and redirects to /signin (on backbone) where ThirdPArty Auth successfully
// navigates to /settings
navigate('/settings');
}
}
}
};
// auth params are received from the third party auth provider
// and are required to verify the account
const getAuthParams = () => {
const code = params.get('code');
const providerFromParams = params.get('provider');
let provider: AUTH_PROVIDER | undefined;
if (providerFromParams === 'apple') {
provider = AUTH_PROVIDER.APPLE;
} else {
provider = AUTH_PROVIDER.GOOGLE;
if (!thirdPartyOAuthCode) {
return hardNavigate('/');
}
return { code, provider };
};
try {
const linkedAccount: LinkedAccountData =
await account.verifyAccountThirdParty(
thirdPartyOAuthCode,
provider,
undefined,
queryParamsToMetricsContext(
flowQueryParams as unknown as Record<string, string>
)
);
await storeLinkedAccountData(linkedAccount);
async function verifyOAuthResponseAndSignIn() {
const { code, provider } = getAuthParams();
linkedAccountData.current = linkedAccount;
if (code && provider) {
try {
// Verify and link the third party account to FxA. Note, this
// will create a new FxA account if one does not exist.
// The response contains a session token that can be used
// to sign the user in to FxA or to complete an Oauth flow.
const linkedAccount: LinkedAccountData =
await account.verifyAccountThirdParty(
code,
provider,
undefined,
queryParamsToMetricsContext(
flowQueryParams as unknown as Record<string, string>
)
);
const fxaParams = (
integration as ThirdPartyAuthCallbackIntegration
).getFxAParams();
completeSignIn(linkedAccount);
} catch (error) {
// TODO add error handling
}
} else {
// TODO validate what should happen if we hit this page
// without the required auth params to verify the account
// HACK: Force the query params to be set in the URL, which then loads
// the integration stored in ThirdPartyAuthCallbackIntegration `state` value.
const urlQueryData = new UrlQueryData(new ReachRouterWindow());
urlQueryData.setParams(new URLSearchParams(fxaParams));
} catch (error) {
// TODO validate what should happen here
hardNavigate('/');
}
}
}, [account, flowQueryParams, integration, storeLinkedAccountData]);
/**
* Navigate to the next page
if Sync based integration -> navigate to set password or sign-in
if OAuth based integration -> verify OAuth and navigate to RP
if neither -> navigate to settings
*/
const performNavigation = useCallback(
async (linkedAccount: LinkedAccountData) => {
if (!integration) {
return;
}
const navigationOptions = {
email: linkedAccount.email,
signinData: {
uid: linkedAccount.uid,
sessionToken: linkedAccount.sessionToken,
verified: true,
},
integration,
finishOAuthFlowHandler,
queryParams: location.search,
};
const { error: navError } = await handleNavigation(navigationOptions, {
handleFxaLogin: false,
handleFxaOAuthLogin: false,
});
if (navError) {
// TODO validate what should happen here
hardNavigate('/');
}
},
[finishOAuthFlowHandler, integration, location.search]
);
const navigateNext = useCallback(
async (linkedAccount: LinkedAccountData) => {
if (!integration) {
return;
}
performNavigation(linkedAccount);
},
[integration, performNavigation]
);
// Ensure we only attempt to verify third party auth creds once
useEffect(() => {
verifyOAuthResponseAndSignIn();
});
if (isThirdPartyAuthCallbackIntegration(integration)) {
verifyThirdPartyAuthResponse();
}
}, [integration, verifyThirdPartyAuthResponse]);
// Once we have verified the third party auth, navigate to the next page
useEffect(() => {
if (integration && linkedAccountData.current.sessionToken) {
navigateNext(linkedAccountData.current);
}
}, [integration, navigateNext]);
return (
<AppLayout>

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

@ -2,13 +2,12 @@
* 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 React from 'react';
import { Link, RouteComponentProps, useLocation } from '@reach/router';
import { useNavigateWithQuery as useNavigate } from '../../lib/hooks/useNavigateWithQuery';
import classNames from 'classnames';
import LoadingSpinner from 'fxa-react/components/LoadingSpinner';
import { FtlMsg, hardNavigate } from 'fxa-react/lib/utils';
import { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import AppLayout from '../../components/AppLayout';
import CardHeader from '../../components/CardHeader';