diff --git a/dotcom-rendering/src/components/FrontPage.tsx b/dotcom-rendering/src/components/FrontPage.tsx index f01cd7461fe..132bb3118b3 100644 --- a/dotcom-rendering/src/components/FrontPage.tsx +++ b/dotcom-rendering/src/components/FrontPage.tsx @@ -12,6 +12,7 @@ import { BrazeMessaging } from './BrazeMessaging.importable'; import { useConfig } from './ConfigContext'; import { DarkModeMessage } from './DarkModeMessage'; import { FocusStyles } from './FocusStyles.importable'; +import { GoogleOneTap, isInGoogleOneTapTest } from './GoogleOneTap.importable'; import { Island } from './Island'; import { Metrics } from './Metrics.importable'; import { ReaderRevenueDev } from './ReaderRevenueDev.importable'; @@ -82,7 +83,6 @@ export const FrontPage = ({ front, NAV }: Props) => { serverSideTests={front.config.abTests} /> - @@ -92,6 +92,11 @@ export const FrontPage = ({ front, NAV }: Props) => { + {isInGoogleOneTapTest(front.config.abTests) && ( + + + + )} {darkModeAvailable && ( Dark mode is a work-in-progress. diff --git a/dotcom-rendering/src/components/GoogleOneTap.importable.tsx b/dotcom-rendering/src/components/GoogleOneTap.importable.tsx new file mode 100644 index 00000000000..23512233a23 --- /dev/null +++ b/dotcom-rendering/src/components/GoogleOneTap.importable.tsx @@ -0,0 +1,201 @@ +import { log } from '@guardian/libs'; +import { useIsSignedIn } from '../lib/useAuthStatus'; +import { useConsent } from '../lib/useConsent'; +import { useOnce } from '../lib/useOnce'; +import type { ServerSideTests, StageType } from '../types/config'; + +type IdentityProviderConfig = { + configURL: string; + clientId: string; +}; + +type CredentialsProvider = { + get: (options: { + mediation: 'required'; + identity: { + context: 'continue'; + providers: IdentityProviderConfig[]; + }; + }) => Promise<{ token: string }>; +}; + +export const isInGoogleOneTapTest = (tests: ServerSideTests): boolean => + tests['googleOneTapVariant'] === 'variant'; + +/** + * Detect the current stage of the application based on the hostname. + * + * We do have a `window.guardian.config.stage` field, but it is based on the + * environment of the article that DCR is rendering, which may not be the same as environment + * DCR is running in. + * + * For example, running DCR locally and loading `https://r.thegulocal.com/Front/https://www.theguardian.com/international` will + * give a stage of `PROD` as the article is in PROD, but DCR is running in `DEV`. + */ +const getStage = (hostname: string): StageType => { + if (window.location.hostname === 'm.code.dev-theguardian.com') { + return 'CODE'; + } else if (['r.thegulocal.com', 'localhost'].includes(hostname)) { + return 'DEV'; + } + + return 'PROD'; +}; + +export const getRedirectUrl = ({ + stage, + token, + currentLocation, +}: { + stage: StageType; + token: string; + currentLocation: string; +}): string => { + const profileDomain = { + PROD: 'https://profile.theguardian.com', + CODE: 'https://profile.code.dev-theguardian.com', + DEV: 'https://profile.thegulocal.com', + }[stage]; + const queryParams = new URLSearchParams({ + token, + returnUrl: currentLocation, + }); + + return `${profileDomain}/signin/google?${queryParams.toString()}`; +}; + +// TODO: Do we want to use different Google Client IDs for One Tap than we use for social sign in? +const getProviders = (stage: StageType): IdentityProviderConfig[] => { + switch (stage) { + case 'PROD': + return [ + { + configURL: 'https://accounts.google.com/gsi/fedcm.json', + clientId: '774465807556.apps.googleusercontent.com', + }, + ]; + case 'CODE': + case 'DEV': + return [ + { + configURL: 'https://accounts.google.com/gsi/fedcm.json', + // TODO: m.code.dev-theguardian.com is not a supported origin for this Client ID + clientId: + '774465807556-pkevncqpfs9486ms0bo5q1f2g9vhpior.apps.googleusercontent.com', + }, + ]; + } +}; + +export const initializeFedCM = async ({ + isSignedIn, +}: { + isSignedIn?: boolean; + isInTest?: boolean; +}): Promise => { + if (isSignedIn) return; + + /** + * Firefox does not support the FedCM API at the time of writting, + * and it seems like it will not support it in the near future. + * + * Instead they're focusing on an alternative API called "Lightweight FedCM" + * which may not support Google One Tap. + * + * See: https://bugzilla.mozilla.org/show_bug.cgi?id=1803629 + */ + if (!('IdentityCredential' in window)) { + // TODO: Track Ophan "FedCM" unsupported event here. + log('identity', 'FedCM API not supported in this browser'); + return; + } + + const stage = getStage(window.location.hostname); + + /** + * Typescripts built-in DOM types do not include the full `CredentialsProvider` + * interface, so we need to cast `window.navigator.credentials` to our own + * `CredentialsProvider` type which includes the FedCM API. + * + * Related issue: https://github.com/microsoft/TypeScript/issues/60641 + */ + const credentialsProvider = window.navigator + .credentials as unknown as CredentialsProvider; + + const credentials = await credentialsProvider + .get({ + /**. + * Default `mediation` is "optional" which auto-authenticates the user if they have already interacted with FedCM + * prompt on a previous page view. + * + * In practice this shouldn't happen as we won't trigger the prompt if the user is already signed in. But just in + * case, we set `mediation` to "required" to ensure the user isn't put in a login loop where they are signed in, + * FedCM auto-authenticates them, and they're sent to Gateway to get a new Okta token. + */ + mediation: 'required', + identity: { + context: 'continue', + providers: getProviders(stage), + }, + }) + .catch((error) => { + /** + * The fedcm API hides issues with the user's federated login state + * behind a generic NetworkError. This error is thrown up to 60 + * seconds after the prompt is triggered to avoid timing attacks. + * + * This allows the browser to avoid leaking sensitive information + * about the user's login state to the website. + * + * Unfortunately for us it means we can't differentiate between + * a genuine network error and a user declining the FedCM prompt. + */ + if (error instanceof Error && error.name === 'NetworkError') { + log( + 'identity', + 'FedCM prompt failed, potentially due to user declining', + ); + } else { + throw error; + } + }); + + if (credentials) { + // TODO: Track Ophan "FedCM" success event here. + log('identity', 'FedCM credentials received', { + credentials, + }); + + window.location.replace( + getRedirectUrl({ + stage, + token: credentials.token, + currentLocation: window.location.href, + }), + ); + } else { + // TODO: Track Ophan "FedCM" skip event here. + log('identity', 'No FedCM credentials received'); + } +}; + +// TODO: GoogleOneTap is currently only used on the front page, but we do probably want to use it on other pages in the future. +export const GoogleOneTap = () => { + // We don't care what consent we get, we just want to make sure Google One Tap is not shown above the consent banner. + // TODO: FedCM doesn't require cookies? Do we need to check consent? + const consent = useConsent(); + const isSignedIn = useIsSignedIn(); + // useIsSignedIn returns 'Pending' until the auth status is known. + // We don't want to initialize FedCM until we know the auth status, so we pass `undefined` to `useOnce` if it is 'Pending' + // to stop it from initializing. + const isSignedInWithoutPending = + isSignedIn !== 'Pending' ? isSignedIn : undefined; + + useOnce(() => { + void initializeFedCM({ + isSignedIn: isSignedInWithoutPending, + }); + }, [isSignedInWithoutPending, consent]); + + return <>; +}; diff --git a/dotcom-rendering/src/components/GoogleOneTap.test.tsx b/dotcom-rendering/src/components/GoogleOneTap.test.tsx new file mode 100644 index 00000000000..16793306a19 --- /dev/null +++ b/dotcom-rendering/src/components/GoogleOneTap.test.tsx @@ -0,0 +1,199 @@ +import { getRedirectUrl, initializeFedCM } from './GoogleOneTap.importable'; + +const mockWindow = ({ + replace, + get, + enableFedCM = true, +}: { + replace: jest.Mock; + get: jest.Mock; + enableFedCM?: boolean; +}) => + Object.defineProperty(globalThis, 'window', { + value: { + location: { + href: 'https://www.theguardian.com/uk', + hostname: 'm.code.theguardian.com', + replace, + }, + navigator: { credentials: { get } }, + ...(enableFedCM ? { IdentityCredential: 'mock value' } : {}), + }, + writable: true, + }); + +describe('GoogleOneTap', () => { + it('should return the correct signin URL after constructing it with the provided stage and token', () => { + expect( + getRedirectUrl({ + stage: 'PROD', + token: 'test-token', + currentLocation: 'https://www.theguardian.com/uk', + }), + ).toEqual( + 'https://profile.theguardian.com/signin/google?token=test-token&returnUrl=https%3A%2F%2Fwww.theguardian.com%2Fuk', + ); + + expect( + getRedirectUrl({ + stage: 'CODE', + token: 'test-token', + currentLocation: 'https://m.code.dev-theguardian.com/uk', + }), + ).toEqual( + 'https://profile.code.dev-theguardian.com/signin/google?token=test-token&returnUrl=https%3A%2F%2Fm.code.dev-theguardian.com%2Fuk', + ); + + expect( + getRedirectUrl({ + stage: 'DEV', + token: 'test-token', + currentLocation: + 'http://localhost/Front/https://m.code.dev-theguardian.com/uk', + }), + ).toEqual( + 'https://profile.thegulocal.com/signin/google?token=test-token&returnUrl=http%3A%2F%2Flocalhost%2FFront%2Fhttps%3A%2F%2Fm.code.dev-theguardian.com%2Fuk', + ); + }); + + it('should initializeFedCM and redirect to Gateway with token on success', async () => { + const navigatorGet = jest.fn(() => + Promise.resolve({ token: 'test-token' }), + ); + const locationReplace = jest.fn(); + + mockWindow({ + get: navigatorGet, + replace: locationReplace, + }); + + await initializeFedCM({ isSignedIn: false }); + + expect(navigatorGet).toHaveBeenCalledWith({ + identity: { + context: 'continue', + providers: [ + { + clientId: '774465807556.apps.googleusercontent.com', + configURL: 'https://accounts.google.com/gsi/fedcm.json', + }, + ], + }, + mediation: 'required', + }); + + expect(locationReplace).toHaveBeenCalledWith( + 'https://profile.theguardian.com/signin/google?token=test-token&returnUrl=https%3A%2F%2Fwww.theguardian.com%2Fuk', + ); + }); + + it('should initializeFedCM and not redirect to Gateway with token on failure', async () => { + const error = new Error('Network Error'); + error.name = 'NetworkError'; + + const navigatorGet = jest.fn(() => Promise.reject(error)); + const locationReplace = jest.fn(); + + mockWindow({ + get: navigatorGet, + replace: locationReplace, + }); + + await initializeFedCM({ isSignedIn: false }); + + expect(navigatorGet).toHaveBeenCalledWith({ + identity: { + context: 'continue', + providers: [ + { + clientId: '774465807556.apps.googleusercontent.com', + configURL: 'https://accounts.google.com/gsi/fedcm.json', + }, + ], + }, + mediation: 'required', + }); + + // Don't redirect whenever there is a "NetworkError" (aka user declined prompt) + expect(locationReplace).not.toHaveBeenCalled(); + }); + + it('should initializeFedCM and throw error when unexpected', async () => { + const error = new Error('window.navigator.credentials.get failed'); + error.name = 'DOMException'; + + const navigatorGet = jest.fn(() => Promise.reject(error)); + const locationReplace = jest.fn(); + + mockWindow({ + get: navigatorGet, + replace: locationReplace, + }); + + await expect(initializeFedCM({ isSignedIn: false })).rejects.toThrow( + 'window.navigator.credentials.get failed', + ); + + expect(navigatorGet).toHaveBeenCalledWith({ + identity: { + context: 'continue', + providers: [ + { + clientId: '774465807556.apps.googleusercontent.com', + configURL: 'https://accounts.google.com/gsi/fedcm.json', + }, + ], + }, + mediation: 'required', + }); + + // Don't redirect whenever there is an unexpected error + expect(locationReplace).not.toHaveBeenCalled(); + }); + + it('should not initializeFedCM when FedCM is unsupported', async () => { + const navigatorGet = jest.fn(); + const locationReplace = jest.fn(); + + mockWindow({ + get: navigatorGet, + replace: locationReplace, + enableFedCM: false, + }); + + await initializeFedCM({ isSignedIn: false }); + + expect(navigatorGet).not.toHaveBeenCalled(); + expect(locationReplace).not.toHaveBeenCalled(); + }); + + it('should not initializeFedCM when user is signed in', async () => { + const navigatorGet = jest.fn(); + const locationReplace = jest.fn(); + + mockWindow({ + get: navigatorGet, + replace: locationReplace, + }); + + await initializeFedCM({ isSignedIn: true }); + + expect(navigatorGet).not.toHaveBeenCalled(); + expect(locationReplace).not.toHaveBeenCalled(); + }); + + it('should not initializeFedCM when user is not in test', async () => { + const navigatorGet = jest.fn(); + const locationReplace = jest.fn(); + + mockWindow({ + get: navigatorGet, + replace: locationReplace, + }); + + await initializeFedCM({ isSignedIn: true }); + + expect(navigatorGet).not.toHaveBeenCalled(); + expect(locationReplace).not.toHaveBeenCalled(); + }); +}); diff --git a/dotcom-rendering/src/experiments/ab-tests.ts b/dotcom-rendering/src/experiments/ab-tests.ts index 9f571e796b2..893fc4bcbf4 100644 --- a/dotcom-rendering/src/experiments/ab-tests.ts +++ b/dotcom-rendering/src/experiments/ab-tests.ts @@ -1,7 +1,6 @@ import type { ABTest } from '@guardian/ab-core'; import { abTestTest } from './tests/ab-test-test'; import { auxiaSignInGate } from './tests/auxia-sign-in-gate'; -import { googleOneTap } from './tests/google-one-tap'; import { signInGateMainControl } from './tests/sign-in-gate-main-control'; import { signInGateMainVariant } from './tests/sign-in-gate-main-variant'; import { userBenefitsApi } from './tests/user-benefits-api'; @@ -14,5 +13,4 @@ export const tests: ABTest[] = [ signInGateMainControl, userBenefitsApi, auxiaSignInGate, - googleOneTap, ]; diff --git a/dotcom-rendering/src/experiments/tests/google-one-tap.ts b/dotcom-rendering/src/experiments/tests/google-one-tap.ts deleted file mode 100644 index 338780d583b..00000000000 --- a/dotcom-rendering/src/experiments/tests/google-one-tap.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { ABTest } from '@guardian/ab-core'; - -export const googleOneTap: ABTest = { - id: 'GoogleOneTap', - start: '2025-07-30', - expiry: '2025-12-01', - author: 'Ash (Identity & Trust)', - description: - 'This test is being used to prototype and roll out single sign-on with Google One Tap.', - audience: 0, - audienceOffset: 0, - successMeasure: - 'There are no new client side errors and the users are able to sign in with Google One Tap', - audienceCriteria: 'Signed-out Chrome Users on Fronts', - idealOutcome: - 'Increased sign in conversion rate for users who have Google accounts and Chrome', - showForSensitive: false, - canRun: () => true, - variants: [ - { - id: 'variant', - test: (): void => {}, - }, - ], -}; diff --git a/dotcom-rendering/src/lib/useConsent.ts b/dotcom-rendering/src/lib/useConsent.ts new file mode 100644 index 00000000000..774c086cf2a --- /dev/null +++ b/dotcom-rendering/src/lib/useConsent.ts @@ -0,0 +1,18 @@ +import type { ConsentState } from '@guardian/libs'; +import { onConsentChange } from '@guardian/libs'; +import { useEffect, useState } from 'react'; + +/** + * React hook to get consent state from CMP + */ +export const useConsent = (): ConsentState | undefined => { + const [consentState, setConsentState] = useState(); + + useEffect(() => { + onConsentChange((consent) => { + setConsentState(consent); + }); + }, []); + + return consentState; +};