From 920cc9f62d85b826fd5fe41843ab300ce68664d7 Mon Sep 17 00:00:00 2001 From: AshCorr Date: Mon, 4 Aug 2025 12:57:05 +0100 Subject: [PATCH 01/13] feat: Add Google One Tap proof of concept Co-authored-by: Mahesh Makani --- .../components/GoogleOneTap.importable.tsx | 132 ++++++++++++++++++ dotcom-rendering/src/layouts/FrontLayout.tsx | 4 + dotcom-rendering/window.guardian.ts | 7 +- 3 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 dotcom-rendering/src/components/GoogleOneTap.importable.tsx diff --git a/dotcom-rendering/src/components/GoogleOneTap.importable.tsx b/dotcom-rendering/src/components/GoogleOneTap.importable.tsx new file mode 100644 index 00000000000..d53eba83e96 --- /dev/null +++ b/dotcom-rendering/src/components/GoogleOneTap.importable.tsx @@ -0,0 +1,132 @@ +import { loadScript, log } from '@guardian/libs'; +import { useEffect } from 'react'; +import { useIsSignedIn } from '../lib/useAuthStatus'; + +const getGSIConfiguration = (): { clientId: string; loginUri: string } => { + switch (window.guardian.config.stage) { + case 'PROD': + return { + clientId: 'PROD CLIENT ID', + loginUri: 'https://profile.thegulocal.com/signin/google', + }; + case 'CODE': + return { + clientId: 'CODE CLIENT ID', + loginUri: 'https://profile.thegulocal.com/signin/google', + }; + default: + return { + clientId: + '774465807556-pkevncqpfs9486ms0bo5q1f2g9vhpior.apps.googleusercontent.com', + loginUri: 'https://profile.thegulocal.com/signin/google', + }; + } +}; + +const initializeGoogleOneTap = () => (response: { credential: string }) => { + const { credential } = response; + + const queryParams = new URLSearchParams(); + queryParams.append('got', credential); + queryParams.append('returnUrl', window.location.href); + + window.location.replace( + `https://profile.thegulocal.com/signin/google?${queryParams.toString()}`, + ); +}; + +type PromptMomentNotification = { + isSkippedMoment: () => boolean; + isDismissedMoment: () => boolean; + getDismissedReason: () => + | 'credential_returned' + | 'cancel_called' + | 'flow_restarted'; + getMomentType: () => 'display' | 'skipped' | 'dismissed'; +}; + +export type GoogleIdentityService = { + initialize: (config: { + client_id: string; + login_uri: string; + callback: (response: { credential: string }) => void; + auto_select?: boolean; + cancel_on_tap_outside?: boolean; + use_fedcm_for_prompt?: boolean; + }) => void; + prompt: ( + callback: (momentNotification: PromptMomentNotification) => void, + ) => void; +}; + +const loadGSI = async (): Promise => { + log('identity', 'Loading Google Sign-in Services (GSI)'); + // TODO: Can we invoke the built-in FedCM API instead of using GSI? + // This would reduce our dpenedency on a third-party library and all of the privacy + // implications that come with it. It would also save loading ~80KB of JS. + await loadScript('https://accounts.google.com/gsi/client').catch((e) => { + throw new Error( + `Failed to initialize Google One Tap: failed to load GSI`, + { cause: e }, + ); + }); + + if (!window.google?.accounts?.id) { + throw new Error('Failed to initialize Google One Tap: GSI not found'); + } + + log('identity', 'Loaded Google Sign-in Services (GSI)'); + return window.google.accounts.id; +}; + +export const GoogleOneTap = () => { + const isSignedIn = useIsSignedIn(); + + useEffect(() => { + if (isSignedIn === true) { + log( + 'identity', + 'User is already signed in, skipping Google One Tap initialization', + ); + return; + } else if (isSignedIn === 'Pending') { + // If the auth status is still pending, we don't want to initialize Google One Tap yet. + log( + 'identity', + 'User auth state is still pending, delaying Google One Tap initialization', + ); + return; + } + + const { clientId, loginUri } = getGSIConfiguration(); + + void loadGSI().then((gsi) => { + log('identity', 'Initializing Google One Tap', { + clientId, + loginUri, + }); + + gsi.initialize({ + client_id: clientId, + login_uri: loginUri, + callback: initializeGoogleOneTap, + auto_select: true, + cancel_on_tap_outside: false, + use_fedcm_for_prompt: true, + }); + + log('identity', 'Requesting Google One Tap prompt'); + gsi.prompt((notifcation) => { + // TODO: Handle tracking of the prompt moment notification. Ophan? + log('identity', 'Google One Tap prompt notification received', { + isSkippedMoment: notifcation.isSkippedMoment(), + isDismissedMoment: notifcation.isDismissedMoment(), + dismissedReason: notifcation.getDismissedReason(), + momentType: notifcation.getMomentType(), + }); + }); + }); + }, [isSignedIn]); + + return <>; +}; diff --git a/dotcom-rendering/src/layouts/FrontLayout.tsx b/dotcom-rendering/src/layouts/FrontLayout.tsx index 9e2d5519c55..8483f77f041 100644 --- a/dotcom-rendering/src/layouts/FrontLayout.tsx +++ b/dotcom-rendering/src/layouts/FrontLayout.tsx @@ -18,6 +18,7 @@ import { MobileAdSlot, } from '../components/FrontsAdSlots'; import { FrontSection } from '../components/FrontSection'; +import { GoogleOneTap } from '../components/GoogleOneTap.importable'; import { HeaderAdSlot } from '../components/HeaderAdSlot'; import { Island } from '../components/Island'; import { LabsHeader } from '../components/LabsHeader'; @@ -188,6 +189,9 @@ export const FrontLayout = ({ front, NAV }: Props) => { return ( <> + + +
{renderAds && ( diff --git a/dotcom-rendering/window.guardian.ts b/dotcom-rendering/window.guardian.ts index 46298388647..9aeae12ad07 100644 --- a/dotcom-rendering/window.guardian.ts +++ b/dotcom-rendering/window.guardian.ts @@ -7,6 +7,7 @@ import type { } from '@guardian/libs'; import type ophan from '@guardian/ophan-tracker-js'; import type { WeeklyArticleHistory } from '@guardian/support-dotcom-components/dist/dotcom/types'; +import type { GoogleIdentityService } from './src/components/GoogleOneTap.importable'; import type { google } from './src/components/YoutubeAtom/ima'; import type { DailyArticleHistory } from './src/lib/dailyArticleCount'; import type { ReaderRevenueDevUtils } from './src/lib/readerRevenueDevUtils'; @@ -67,7 +68,11 @@ declare global { ) => boolean; }; mockLiveUpdate: (data: LiveUpdateType) => void; - google?: typeof google; + google?: typeof google & { + accounts?: { + id?: GoogleIdentityService; + }; + }; YT?: typeof YT; onYouTubeIframeAPIReady?: () => void; } From 93276de04a0cd9f2bc49de510407bdfe21c4ae11 Mon Sep 17 00:00:00 2001 From: AshCorr Date: Mon, 4 Aug 2025 16:22:36 +0100 Subject: [PATCH 02/13] feat: Use built-in FedCM API instead of Google GSI --- .../components/GoogleOneTap.importable.tsx | 171 ++++++++---------- dotcom-rendering/window.guardian.ts | 7 +- 2 files changed, 76 insertions(+), 102 deletions(-) diff --git a/dotcom-rendering/src/components/GoogleOneTap.importable.tsx b/dotcom-rendering/src/components/GoogleOneTap.importable.tsx index d53eba83e96..d2ae625fbf2 100644 --- a/dotcom-rendering/src/components/GoogleOneTap.importable.tsx +++ b/dotcom-rendering/src/components/GoogleOneTap.importable.tsx @@ -1,82 +1,51 @@ -import { loadScript, log } from '@guardian/libs'; +import { log } from '@guardian/libs'; import { useEffect } from 'react'; import { useIsSignedIn } from '../lib/useAuthStatus'; - -const getGSIConfiguration = (): { clientId: string; loginUri: string } => { - switch (window.guardian.config.stage) { - case 'PROD': - return { - clientId: 'PROD CLIENT ID', - loginUri: 'https://profile.thegulocal.com/signin/google', - }; - case 'CODE': - return { - clientId: 'CODE CLIENT ID', - loginUri: 'https://profile.thegulocal.com/signin/google', - }; +import type { StageType } from '../types/config'; + +const getFedCMProviders = (stage: StageType): IdentityProviderConfig[] => { + switch (stage) { + // case 'PROD': + // return [ + // { + // configURL: 'https://accounts.google.com/gsi/fedcm.json', + // clientId: '774465807556.apps.googleusercontent.com', + // }, + // ]; + // case 'CODE': + // return [ + // { + // configURL: 'https://accounts.google.com/gsi/fedcm.json', + // clientId: '774465807556-pkevncqpfs9486ms0bo5q1f2g9vhpior.apps.googleusercontent.com', + // }, + // ]; default: - return { - clientId: - '774465807556-pkevncqpfs9486ms0bo5q1f2g9vhpior.apps.googleusercontent.com', - loginUri: 'https://profile.thegulocal.com/signin/google', - }; + return [ + { + configURL: 'https://accounts.google.com/gsi/fedcm.json', + clientId: + '774465807556-pkevncqpfs9486ms0bo5q1f2g9vhpior.apps.googleusercontent.com', + }, + ]; } }; -const initializeGoogleOneTap = () => (response: { credential: string }) => { - const { credential } = response; - - const queryParams = new URLSearchParams(); - queryParams.append('got', credential); - queryParams.append('returnUrl', window.location.href); - - window.location.replace( - `https://profile.thegulocal.com/signin/google?${queryParams.toString()}`, - ); +type IdentityCredentials = { + token: string; }; -type PromptMomentNotification = { - isSkippedMoment: () => boolean; - isDismissedMoment: () => boolean; - getDismissedReason: () => - | 'credential_returned' - | 'cancel_called' - | 'flow_restarted'; - getMomentType: () => 'display' | 'skipped' | 'dismissed'; +type IdentityProviderConfig = { + configURL: string; + clientId: string; }; -export type GoogleIdentityService = { - initialize: (config: { - client_id: string; - login_uri: string; - callback: (response: { credential: string }) => void; - auto_select?: boolean; - cancel_on_tap_outside?: boolean; - use_fedcm_for_prompt?: boolean; - }) => void; - prompt: ( - callback: (momentNotification: PromptMomentNotification) => void, - ) => void; -}; - -const loadGSI = async (): Promise => { - log('identity', 'Loading Google Sign-in Services (GSI)'); - // TODO: Can we invoke the built-in FedCM API instead of using GSI? - // This would reduce our dpenedency on a third-party library and all of the privacy - // implications that come with it. It would also save loading ~80KB of JS. - await loadScript('https://accounts.google.com/gsi/client').catch((e) => { - throw new Error( - `Failed to initialize Google One Tap: failed to load GSI`, - { cause: e }, - ); - }); - - if (!window.google?.accounts?.id) { - throw new Error('Failed to initialize Google One Tap: GSI not found'); - } - - log('identity', 'Loaded Google Sign-in Services (GSI)'); - return window.google.accounts.id; +type CredentialsProvider = { + get: (options: { + identity: { + context: 'signin'; + providers: IdentityProviderConfig[]; + }; + }) => Promise; }; export const GoogleOneTap = () => { @@ -98,34 +67,44 @@ export const GoogleOneTap = () => { return; } - const { clientId, loginUri } = getGSIConfiguration(); - - void loadGSI().then((gsi) => { - log('identity', 'Initializing Google One Tap', { - clientId, - loginUri, - }); - - gsi.initialize({ - client_id: clientId, - login_uri: loginUri, - callback: initializeGoogleOneTap, - auto_select: true, - cancel_on_tap_outside: false, - use_fedcm_for_prompt: true, - }); - - log('identity', 'Requesting Google One Tap prompt'); - gsi.prompt((notifcation) => { - // TODO: Handle tracking of the prompt moment notification. Ophan? - log('identity', 'Google One Tap prompt notification received', { - isSkippedMoment: notifcation.isSkippedMoment(), - isDismissedMoment: notifcation.isDismissedMoment(), - dismissedReason: notifcation.getDismissedReason(), - momentType: notifcation.getMomentType(), - }); + const credentialsProvider = window.navigator + .credentials as unknown as CredentialsProvider; + + void credentialsProvider + .get({ + identity: { + context: 'signin', + providers: getFedCMProviders(window.guardian.config.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', + ); + } + }) + .then((credentials) => { + if (credentials) { + log('identity', 'FedCM credentials received', { + credentials, + }); + } else { + log('identity', 'No FedCM credentials received'); + } }); - }); }, [isSignedIn]); return <>; diff --git a/dotcom-rendering/window.guardian.ts b/dotcom-rendering/window.guardian.ts index 9aeae12ad07..46298388647 100644 --- a/dotcom-rendering/window.guardian.ts +++ b/dotcom-rendering/window.guardian.ts @@ -7,7 +7,6 @@ import type { } from '@guardian/libs'; import type ophan from '@guardian/ophan-tracker-js'; import type { WeeklyArticleHistory } from '@guardian/support-dotcom-components/dist/dotcom/types'; -import type { GoogleIdentityService } from './src/components/GoogleOneTap.importable'; import type { google } from './src/components/YoutubeAtom/ima'; import type { DailyArticleHistory } from './src/lib/dailyArticleCount'; import type { ReaderRevenueDevUtils } from './src/lib/readerRevenueDevUtils'; @@ -68,11 +67,7 @@ declare global { ) => boolean; }; mockLiveUpdate: (data: LiveUpdateType) => void; - google?: typeof google & { - accounts?: { - id?: GoogleIdentityService; - }; - }; + google?: typeof google; YT?: typeof YT; onYouTubeIframeAPIReady?: () => void; } From 04dbebaba372c1ffc8135d74656206874ffb9ed1 Mon Sep 17 00:00:00 2001 From: AshCorr Date: Mon, 4 Aug 2025 16:34:48 +0100 Subject: [PATCH 03/13] chore: Only initialize FedCM if user is in test --- .../components/GoogleOneTap.importable.tsx | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/dotcom-rendering/src/components/GoogleOneTap.importable.tsx b/dotcom-rendering/src/components/GoogleOneTap.importable.tsx index d2ae625fbf2..6c36fa52035 100644 --- a/dotcom-rendering/src/components/GoogleOneTap.importable.tsx +++ b/dotcom-rendering/src/components/GoogleOneTap.importable.tsx @@ -1,5 +1,6 @@ import { log } from '@guardian/libs'; import { useEffect } from 'react'; +import { useAB } from '../lib/useAB'; import { useIsSignedIn } from '../lib/useAuthStatus'; import type { StageType } from '../types/config'; @@ -50,8 +51,18 @@ type CredentialsProvider = { export const GoogleOneTap = () => { const isSignedIn = useIsSignedIn(); + const abTests = useAB(); + const isUserInTest = abTests?.api.isUserInVariant( + 'GoogleOneTap', + 'variant', + ); useEffect(() => { + // Only initialize Google One Tap if the user is in the AB test. Currently 0% of users are in the test. + if (!isUserInTest) return; + + // FedCM has no knowledge of the user's auth state, so we need to check + // if the user is already signed in before initializing it. if (isSignedIn === true) { log( 'identity', @@ -67,6 +78,13 @@ export const GoogleOneTap = () => { return; } + /** + * 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; @@ -94,6 +112,8 @@ export const GoogleOneTap = () => { 'identity', 'FedCM prompt failed, potentially due to user declining', ); + } else { + throw error; } }) .then((credentials) => { @@ -105,7 +125,7 @@ export const GoogleOneTap = () => { log('identity', 'No FedCM credentials received'); } }); - }, [isSignedIn]); + }, [isSignedIn, isUserInTest]); return <>; }; From 39c4ffa3fa7c158b83ed69dfa5b61b5e34124d7d Mon Sep 17 00:00:00 2001 From: AshCorr Date: Mon, 4 Aug 2025 17:37:35 +0100 Subject: [PATCH 04/13] chore: Move Google One Tap to FrontPage component where the other script islands are loaded --- dotcom-rendering/src/components/FrontPage.tsx | 5 ++++- dotcom-rendering/src/layouts/FrontLayout.tsx | 4 ---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/dotcom-rendering/src/components/FrontPage.tsx b/dotcom-rendering/src/components/FrontPage.tsx index f01cd7461fe..b48e80e5de1 100644 --- a/dotcom-rendering/src/components/FrontPage.tsx +++ b/dotcom-rendering/src/components/FrontPage.tsx @@ -19,6 +19,7 @@ import { SetABTests } from './SetABTests.importable'; import { SetAdTargeting } from './SetAdTargeting.importable'; import { ShowHideContainers } from './ShowHideContainers.importable'; import { SkipTo } from './SkipTo'; +import { GoogleOneTap } from './GoogleOneTap.importable'; type Props = { front: Front; @@ -82,7 +83,6 @@ export const FrontPage = ({ front, NAV }: Props) => { serverSideTests={front.config.abTests} /> - @@ -92,6 +92,9 @@ export const FrontPage = ({ front, NAV }: Props) => { + + + {darkModeAvailable && ( Dark mode is a work-in-progress. diff --git a/dotcom-rendering/src/layouts/FrontLayout.tsx b/dotcom-rendering/src/layouts/FrontLayout.tsx index 8483f77f041..9e2d5519c55 100644 --- a/dotcom-rendering/src/layouts/FrontLayout.tsx +++ b/dotcom-rendering/src/layouts/FrontLayout.tsx @@ -18,7 +18,6 @@ import { MobileAdSlot, } from '../components/FrontsAdSlots'; import { FrontSection } from '../components/FrontSection'; -import { GoogleOneTap } from '../components/GoogleOneTap.importable'; import { HeaderAdSlot } from '../components/HeaderAdSlot'; import { Island } from '../components/Island'; import { LabsHeader } from '../components/LabsHeader'; @@ -189,9 +188,6 @@ export const FrontLayout = ({ front, NAV }: Props) => { return ( <> - - -
{renderAds && ( From f53fd83d8234aae2e19143b1eaae9ef807682844 Mon Sep 17 00:00:00 2001 From: AshCorr Date: Mon, 4 Aug 2025 17:38:20 +0100 Subject: [PATCH 05/13] chore: Only load FedCM on supported browsers and improve documentation --- .../components/GoogleOneTap.importable.tsx | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/dotcom-rendering/src/components/GoogleOneTap.importable.tsx b/dotcom-rendering/src/components/GoogleOneTap.importable.tsx index 6c36fa52035..a0d8e091d75 100644 --- a/dotcom-rendering/src/components/GoogleOneTap.importable.tsx +++ b/dotcom-rendering/src/components/GoogleOneTap.importable.tsx @@ -57,6 +57,8 @@ export const GoogleOneTap = () => { 'variant', ); + // TODO: Wait till CMP dismissed + useEffect(() => { // Only initialize Google One Tap if the user is in the AB test. Currently 0% of users are in the test. if (!isUserInTest) return; @@ -78,6 +80,26 @@ export const GoogleOneTap = () => { 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 + * + * Check if the `IdentityCredential` interface is available in the window object + * as an indicator of FedCM support. + */ + if (!('IdentityCredential' in window)) { + // TODO: + log('identity', 'FedCM API not supported in this browser'); + return; + } + + // TODO: Check if browser supports FedCM before initializing and track in Ophan if not. + /** * Typescripts built-in DOM types do not include the full `CredentialsProvider` * interface, so we need to cast `window.navigator.credentials` to our own @@ -118,10 +140,13 @@ export const GoogleOneTap = () => { }) .then((credentials) => { if (credentials) { + // TODO: Track Ophan "FedCM" success event here. + // TODO: Redirect to Gateway with credentials token. log('identity', 'FedCM credentials received', { credentials, }); } else { + // TODO: Track Ophan "FedCM" skip event here. log('identity', 'No FedCM credentials received'); } }); From d249893aa7d4f51d2fc7bda11aaa5ffb08377f0f Mon Sep 17 00:00:00 2001 From: AshCorr Date: Tue, 5 Aug 2025 15:07:52 +0100 Subject: [PATCH 06/13] feat: Redirect to Gateway after FedCM auth and check for Consent before displaying FedCM prompt --- .../components/GoogleOneTap.importable.tsx | 129 ++++++++++++------ 1 file changed, 87 insertions(+), 42 deletions(-) diff --git a/dotcom-rendering/src/components/GoogleOneTap.importable.tsx b/dotcom-rendering/src/components/GoogleOneTap.importable.tsx index a0d8e091d75..09cbd27dc7a 100644 --- a/dotcom-rendering/src/components/GoogleOneTap.importable.tsx +++ b/dotcom-rendering/src/components/GoogleOneTap.importable.tsx @@ -1,25 +1,71 @@ import { log } from '@guardian/libs'; -import { useEffect } from 'react'; import { useAB } from '../lib/useAB'; import { useIsSignedIn } from '../lib/useAuthStatus'; +import { useConsent } from '../lib/useConsent'; +import { useOnce } from '../lib/useOnce'; import type { StageType } from '../types/config'; -const getFedCMProviders = (stage: StageType): IdentityProviderConfig[] => { +/** + * 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`. + * @returns + */ +const getStage = (): StageType => { + if (window.location.hostname === 'm.code.dev-theguardian.com') { + return 'CODE'; + } else if ( + ['r.thegulocal.com', 'localhost'].includes(window.location.hostname) + ) { + return 'DEV'; + } + + return 'PROD'; +}; + +const getRedirectUrl = ({ + stage, + token, +}: { + stage: StageType; + token: string; +}): string => { + const profileDomain = { + PROD: 'https://www.theguardian.com', + CODE: 'https://m.code.dev-theguardian.com', + DEV: 'https://r.thegulocal.com', + }[stage]; + const queryParams = new URLSearchParams({ + token, + returnUrl: window.location.href, + }); + + return `${profileDomain}/signin/google?${queryParams.toString()}`; +}; + +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': - // return [ - // { - // configURL: 'https://accounts.google.com/gsi/fedcm.json', - // clientId: '774465807556-pkevncqpfs9486ms0bo5q1f2g9vhpior.apps.googleusercontent.com', - // }, - // ]; + case 'PROD': + return [ + { + configURL: 'https://accounts.google.com/gsi/fedcm.json', + clientId: '774465807556.apps.googleusercontent.com', + }, + ]; + case 'CODE': + 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', + }, + ]; default: return [ { @@ -42,6 +88,7 @@ type IdentityProviderConfig = { type CredentialsProvider = { get: (options: { + mediation: 'required'; identity: { context: 'signin'; providers: IdentityProviderConfig[]; @@ -50,36 +97,18 @@ type CredentialsProvider = { }; export const GoogleOneTap = () => { - const isSignedIn = useIsSignedIn(); + const consent = useConsent(); + const isSignedIn = useIsSignedIn() === true; const abTests = useAB(); const isUserInTest = abTests?.api.isUserInVariant( 'GoogleOneTap', 'variant', ); - // TODO: Wait till CMP dismissed - - useEffect(() => { + useOnce(() => { // Only initialize Google One Tap if the user is in the AB test. Currently 0% of users are in the test. if (!isUserInTest) return; - // FedCM has no knowledge of the user's auth state, so we need to check - // if the user is already signed in before initializing it. - if (isSignedIn === true) { - log( - 'identity', - 'User is already signed in, skipping Google One Tap initialization', - ); - return; - } else if (isSignedIn === 'Pending') { - // If the auth status is still pending, we don't want to initialize Google One Tap yet. - log( - 'identity', - 'User auth state is still pending, delaying Google One Tap initialization', - ); - 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. @@ -93,12 +122,12 @@ export const GoogleOneTap = () => { * as an indicator of FedCM support. */ if (!('IdentityCredential' in window)) { - // TODO: + // TODO: Track Ophan "FedCM" unsupported event here. log('identity', 'FedCM API not supported in this browser'); return; } - // TODO: Check if browser supports FedCM before initializing and track in Ophan if not. + const stage = getStage(); /** * Typescripts built-in DOM types do not include the full `CredentialsProvider` @@ -110,11 +139,21 @@ export const GoogleOneTap = () => { const credentialsProvider = window.navigator .credentials as unknown as CredentialsProvider; + log('identity', 'Initializing FedCM'); void 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: 'signin', - providers: getFedCMProviders(window.guardian.config.stage), + providers: getProviders(stage), }, }) .catch((error) => { @@ -141,16 +180,22 @@ export const GoogleOneTap = () => { .then((credentials) => { if (credentials) { // TODO: Track Ophan "FedCM" success event here. - // TODO: Redirect to Gateway with credentials token. log('identity', 'FedCM credentials received', { credentials, }); + + window.location.replace( + getRedirectUrl({ + stage, + token: credentials.token, + }), + ); } else { // TODO: Track Ophan "FedCM" skip event here. log('identity', 'No FedCM credentials received'); } }); - }, [isSignedIn, isUserInTest]); + }, [isSignedIn, isUserInTest, consent]); return <>; }; From 2ba186e14de385500a4e8ed211c8415babaa1433 Mon Sep 17 00:00:00 2001 From: AshCorr Date: Tue, 5 Aug 2025 15:08:44 +0100 Subject: [PATCH 07/13] chore: add missing useConsent hook --- dotcom-rendering/src/lib/useConsent.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 dotcom-rendering/src/lib/useConsent.ts 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; +}; From c5b4146cc64fa341fcc2e82be39dc16dfb28fa78 Mon Sep 17 00:00:00 2001 From: AshCorr Date: Tue, 5 Aug 2025 15:21:53 +0100 Subject: [PATCH 08/13] chore: Update Google One Tap gateway urls --- .../components/GoogleOneTap.importable.tsx | 27 ++++++------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/dotcom-rendering/src/components/GoogleOneTap.importable.tsx b/dotcom-rendering/src/components/GoogleOneTap.importable.tsx index 09cbd27dc7a..8f8479dc00e 100644 --- a/dotcom-rendering/src/components/GoogleOneTap.importable.tsx +++ b/dotcom-rendering/src/components/GoogleOneTap.importable.tsx @@ -14,7 +14,6 @@ import type { StageType } from '../types/config'; * * 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`. - * @returns */ const getStage = (): StageType => { if (window.location.hostname === 'm.code.dev-theguardian.com') { @@ -36,9 +35,9 @@ const getRedirectUrl = ({ token: string; }): string => { const profileDomain = { - PROD: 'https://www.theguardian.com', - CODE: 'https://m.code.dev-theguardian.com', - DEV: 'https://r.thegulocal.com', + PROD: 'https://profile.theguardian.com', + CODE: 'https://profile.code.dev-theguardian.com', + DEV: 'https://profile.thegulocal.com', }[stage]; const queryParams = new URLSearchParams({ token, @@ -58,6 +57,7 @@ const getProviders = (stage: StageType): IdentityProviderConfig[] => { }, ]; case 'CODE': + case 'DEV': return [ { configURL: 'https://accounts.google.com/gsi/fedcm.json', @@ -66,14 +66,6 @@ const getProviders = (stage: StageType): IdentityProviderConfig[] => { '774465807556-pkevncqpfs9486ms0bo5q1f2g9vhpior.apps.googleusercontent.com', }, ]; - default: - return [ - { - configURL: 'https://accounts.google.com/gsi/fedcm.json', - clientId: - '774465807556-pkevncqpfs9486ms0bo5q1f2g9vhpior.apps.googleusercontent.com', - }, - ]; } }; @@ -99,15 +91,12 @@ type CredentialsProvider = { export const GoogleOneTap = () => { const consent = useConsent(); const isSignedIn = useIsSignedIn() === true; - const abTests = useAB(); - const isUserInTest = abTests?.api.isUserInVariant( - 'GoogleOneTap', - 'variant', - ); + const isInTest = useAB()?.api.isUserInVariant('GoogleOneTap', 'variant'); useOnce(() => { // Only initialize Google One Tap if the user is in the AB test. Currently 0% of users are in the test. - if (!isUserInTest) return; + if (!isInTest) return; + if (!isSignedIn) return; /** * Firefox does not support the FedCM API at the time of writting, @@ -195,7 +184,7 @@ export const GoogleOneTap = () => { log('identity', 'No FedCM credentials received'); } }); - }, [isSignedIn, isUserInTest, consent]); + }, [isSignedIn, isInTest, consent]); return <>; }; From 7733a826c70a66726a7ebf3b89ef82b66072cd0a Mon Sep 17 00:00:00 2001 From: AshCorr Date: Thu, 7 Aug 2025 14:30:29 +0100 Subject: [PATCH 09/13] chore: Add tests for Google One Tap behaviour --- dotcom-rendering/src/components/FrontPage.tsx | 2 +- .../components/GoogleOneTap.importable.tsx | 214 +++++++++--------- 2 files changed, 108 insertions(+), 108 deletions(-) diff --git a/dotcom-rendering/src/components/FrontPage.tsx b/dotcom-rendering/src/components/FrontPage.tsx index b48e80e5de1..e4603fdd306 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 } from './GoogleOneTap.importable'; import { Island } from './Island'; import { Metrics } from './Metrics.importable'; import { ReaderRevenueDev } from './ReaderRevenueDev.importable'; @@ -19,7 +20,6 @@ import { SetABTests } from './SetABTests.importable'; import { SetAdTargeting } from './SetAdTargeting.importable'; import { ShowHideContainers } from './ShowHideContainers.importable'; import { SkipTo } from './SkipTo'; -import { GoogleOneTap } from './GoogleOneTap.importable'; type Props = { front: Front; diff --git a/dotcom-rendering/src/components/GoogleOneTap.importable.tsx b/dotcom-rendering/src/components/GoogleOneTap.importable.tsx index 8f8479dc00e..83c503a924a 100644 --- a/dotcom-rendering/src/components/GoogleOneTap.importable.tsx +++ b/dotcom-rendering/src/components/GoogleOneTap.importable.tsx @@ -5,6 +5,21 @@ import { useConsent } from '../lib/useConsent'; import { useOnce } from '../lib/useOnce'; import type { StageType } from '../types/config'; +type IdentityProviderConfig = { + configURL: string; + clientId: string; +}; + +type CredentialsProvider = { + get: (options: { + mediation: 'required'; + identity: { + context: 'signin'; + providers: IdentityProviderConfig[]; + }; + }) => Promise<{ token: string }>; +}; + /** * Detect the current stage of the application based on the hostname. * @@ -15,24 +30,24 @@ import type { StageType } from '../types/config'; * 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 = (): StageType => { +const getStage = (hostname: string): StageType => { if (window.location.hostname === 'm.code.dev-theguardian.com') { return 'CODE'; - } else if ( - ['r.thegulocal.com', 'localhost'].includes(window.location.hostname) - ) { + } else if (['r.thegulocal.com', 'localhost'].includes(hostname)) { return 'DEV'; } return 'PROD'; }; -const getRedirectUrl = ({ +export const getRedirectUrl = ({ stage, token, + currentLocation, }: { stage: StageType; token: string; + currentLocation: string; }): string => { const profileDomain = { PROD: 'https://profile.theguardian.com', @@ -41,12 +56,13 @@ const getRedirectUrl = ({ }[stage]; const queryParams = new URLSearchParams({ token, - returnUrl: window.location.href, + 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': @@ -69,26 +85,94 @@ const getProviders = (stage: StageType): IdentityProviderConfig[] => { } }; -type IdentityCredentials = { - token: string; -}; - -type IdentityProviderConfig = { - configURL: string; - clientId: string; -}; +export const initializeFedCM = async (): Promise => { + /** + * 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; + } -type CredentialsProvider = { - get: (options: { - mediation: 'required'; - identity: { - context: 'signin'; - providers: IdentityProviderConfig[]; - }; - }) => Promise; + 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: 'signin', + 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'); + } }; 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() === true; const isInTest = useAB()?.api.isUserInVariant('GoogleOneTap', 'variant'); @@ -98,92 +182,8 @@ export const GoogleOneTap = () => { if (!isInTest) return; 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 - * - * Check if the `IdentityCredential` interface is available in the window object - * as an indicator of FedCM support. - */ - if (!('IdentityCredential' in window)) { - // TODO: Track Ophan "FedCM" unsupported event here. - log('identity', 'FedCM API not supported in this browser'); - return; - } - - const stage = getStage(); - - /** - * 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; - log('identity', 'Initializing FedCM'); - void 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: 'signin', - 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; - } - }) - .then((credentials) => { - if (credentials) { - // TODO: Track Ophan "FedCM" success event here. - log('identity', 'FedCM credentials received', { - credentials, - }); - - window.location.replace( - getRedirectUrl({ - stage, - token: credentials.token, - }), - ); - } else { - // TODO: Track Ophan "FedCM" skip event here. - log('identity', 'No FedCM credentials received'); - } - }); + void initializeFedCM(); }, [isSignedIn, isInTest, consent]); return <>; From 8a807ab4d5c34d2200313e2bd0eade7f1494d6f5 Mon Sep 17 00:00:00 2001 From: AshCorr Date: Thu, 7 Aug 2025 14:59:39 +0100 Subject: [PATCH 10/13] chore: also test isInTest and isSignedIn behaviour in google one tap --- .../components/GoogleOneTap.importable.tsx | 35 ++- .../src/components/GoogleOneTap.test.tsx | 199 ++++++++++++++++++ 2 files changed, 223 insertions(+), 11 deletions(-) create mode 100644 dotcom-rendering/src/components/GoogleOneTap.test.tsx diff --git a/dotcom-rendering/src/components/GoogleOneTap.importable.tsx b/dotcom-rendering/src/components/GoogleOneTap.importable.tsx index 83c503a924a..9dfdb9db1a8 100644 --- a/dotcom-rendering/src/components/GoogleOneTap.importable.tsx +++ b/dotcom-rendering/src/components/GoogleOneTap.importable.tsx @@ -14,7 +14,7 @@ type CredentialsProvider = { get: (options: { mediation: 'required'; identity: { - context: 'signin'; + context: 'continue'; providers: IdentityProviderConfig[]; }; }) => Promise<{ token: string }>; @@ -85,7 +85,17 @@ const getProviders = (stage: StageType): IdentityProviderConfig[] => { } }; -export const initializeFedCM = async (): Promise => { +export const initializeFedCM = async ({ + isSignedIn, + isInTest, +}: { + isSignedIn?: boolean; + isInTest?: boolean; +}): Promise => { + // Only initialize Google One Tap if the user is in the AB test. Currently 0% of users are in the test. + if (!isInTest) return; + 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. @@ -125,7 +135,7 @@ export const initializeFedCM = async (): Promise => { */ mediation: 'required', identity: { - context: 'signin', + context: 'continue', providers: getProviders(stage), }, }) @@ -174,17 +184,20 @@ 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() === true; + 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; const isInTest = useAB()?.api.isUserInVariant('GoogleOneTap', 'variant'); useOnce(() => { - // Only initialize Google One Tap if the user is in the AB test. Currently 0% of users are in the test. - if (!isInTest) return; - if (!isSignedIn) return; - - log('identity', 'Initializing FedCM'); - void initializeFedCM(); - }, [isSignedIn, isInTest, consent]); + void initializeFedCM({ + isSignedIn: isSignedInWithoutPending, + isInTest, + }); + }, [isSignedInWithoutPending, isInTest, 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..0f45abaf2d4 --- /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, isInTest: true }); + + expect(navigatorGet).toHaveBeenCalledWith({ + identity: { + context: 'signin', + 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, isInTest: true }); + + expect(navigatorGet).toHaveBeenCalledWith({ + identity: { + context: 'signin', + 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, isInTest: true }), + ).rejects.toThrow('window.navigator.credentials.get failed'); + + expect(navigatorGet).toHaveBeenCalledWith({ + identity: { + context: 'signin', + 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, isInTest: true }); + + 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, isInTest: 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, isInTest: true }); + + expect(navigatorGet).not.toHaveBeenCalled(); + expect(locationReplace).not.toHaveBeenCalled(); + }); +}); From 4c9374427c45fca2a606bf03dd19610fb4396ba3 Mon Sep 17 00:00:00 2001 From: AshCorr Date: Thu, 7 Aug 2025 16:11:18 +0100 Subject: [PATCH 11/13] docs: add todo for adding google one tap to other pages --- dotcom-rendering/src/components/GoogleOneTap.importable.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/dotcom-rendering/src/components/GoogleOneTap.importable.tsx b/dotcom-rendering/src/components/GoogleOneTap.importable.tsx index 9dfdb9db1a8..f00353661a5 100644 --- a/dotcom-rendering/src/components/GoogleOneTap.importable.tsx +++ b/dotcom-rendering/src/components/GoogleOneTap.importable.tsx @@ -180,6 +180,7 @@ export const initializeFedCM = async ({ } }; +// 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? From ec2df7dcaaf93d509aa55946e1c9e4c5d4f05949 Mon Sep 17 00:00:00 2001 From: AshCorr Date: Thu, 7 Aug 2025 16:23:51 +0100 Subject: [PATCH 12/13] fix: failing google one tap tests --- dotcom-rendering/src/components/GoogleOneTap.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dotcom-rendering/src/components/GoogleOneTap.test.tsx b/dotcom-rendering/src/components/GoogleOneTap.test.tsx index 0f45abaf2d4..a90ce42cb1e 100644 --- a/dotcom-rendering/src/components/GoogleOneTap.test.tsx +++ b/dotcom-rendering/src/components/GoogleOneTap.test.tsx @@ -71,7 +71,7 @@ describe('GoogleOneTap', () => { expect(navigatorGet).toHaveBeenCalledWith({ identity: { - context: 'signin', + context: 'continue', providers: [ { clientId: '774465807556.apps.googleusercontent.com', @@ -103,7 +103,7 @@ describe('GoogleOneTap', () => { expect(navigatorGet).toHaveBeenCalledWith({ identity: { - context: 'signin', + context: 'continue', providers: [ { clientId: '774465807556.apps.googleusercontent.com', @@ -136,7 +136,7 @@ describe('GoogleOneTap', () => { expect(navigatorGet).toHaveBeenCalledWith({ identity: { - context: 'signin', + context: 'continue', providers: [ { clientId: '774465807556.apps.googleusercontent.com', From f449da67430529131c52ade9e6aca74fa5b808fd Mon Sep 17 00:00:00 2001 From: AshCorr Date: Thu, 14 Aug 2025 09:21:04 +0100 Subject: [PATCH 13/13] chore: Move to server side tests for Google One Tap to avoid loading unecessary bundles --- dotcom-rendering/src/components/FrontPage.tsx | 10 +++++--- .../components/GoogleOneTap.importable.tsx | 13 ++++------ .../src/components/GoogleOneTap.test.tsx | 16 ++++++------ dotcom-rendering/src/experiments/ab-tests.ts | 2 -- .../src/experiments/tests/google-one-tap.ts | 25 ------------------- 5 files changed, 19 insertions(+), 47 deletions(-) delete mode 100644 dotcom-rendering/src/experiments/tests/google-one-tap.ts diff --git a/dotcom-rendering/src/components/FrontPage.tsx b/dotcom-rendering/src/components/FrontPage.tsx index e4603fdd306..132bb3118b3 100644 --- a/dotcom-rendering/src/components/FrontPage.tsx +++ b/dotcom-rendering/src/components/FrontPage.tsx @@ -12,7 +12,7 @@ import { BrazeMessaging } from './BrazeMessaging.importable'; import { useConfig } from './ConfigContext'; import { DarkModeMessage } from './DarkModeMessage'; import { FocusStyles } from './FocusStyles.importable'; -import { GoogleOneTap } from './GoogleOneTap.importable'; +import { GoogleOneTap, isInGoogleOneTapTest } from './GoogleOneTap.importable'; import { Island } from './Island'; import { Metrics } from './Metrics.importable'; import { ReaderRevenueDev } from './ReaderRevenueDev.importable'; @@ -92,9 +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 index f00353661a5..23512233a23 100644 --- a/dotcom-rendering/src/components/GoogleOneTap.importable.tsx +++ b/dotcom-rendering/src/components/GoogleOneTap.importable.tsx @@ -1,9 +1,8 @@ import { log } from '@guardian/libs'; -import { useAB } from '../lib/useAB'; import { useIsSignedIn } from '../lib/useAuthStatus'; import { useConsent } from '../lib/useConsent'; import { useOnce } from '../lib/useOnce'; -import type { StageType } from '../types/config'; +import type { ServerSideTests, StageType } from '../types/config'; type IdentityProviderConfig = { configURL: string; @@ -20,6 +19,9 @@ type CredentialsProvider = { }) => Promise<{ token: string }>; }; +export const isInGoogleOneTapTest = (tests: ServerSideTests): boolean => + tests['googleOneTapVariant'] === 'variant'; + /** * Detect the current stage of the application based on the hostname. * @@ -87,13 +89,10 @@ const getProviders = (stage: StageType): IdentityProviderConfig[] => { export const initializeFedCM = async ({ isSignedIn, - isInTest, }: { isSignedIn?: boolean; isInTest?: boolean; }): Promise => { - // Only initialize Google One Tap if the user is in the AB test. Currently 0% of users are in the test. - if (!isInTest) return; if (isSignedIn) return; /** @@ -191,14 +190,12 @@ export const GoogleOneTap = () => { // to stop it from initializing. const isSignedInWithoutPending = isSignedIn !== 'Pending' ? isSignedIn : undefined; - const isInTest = useAB()?.api.isUserInVariant('GoogleOneTap', 'variant'); useOnce(() => { void initializeFedCM({ isSignedIn: isSignedInWithoutPending, - isInTest, }); - }, [isSignedInWithoutPending, isInTest, consent]); + }, [isSignedInWithoutPending, consent]); return <>; }; diff --git a/dotcom-rendering/src/components/GoogleOneTap.test.tsx b/dotcom-rendering/src/components/GoogleOneTap.test.tsx index a90ce42cb1e..16793306a19 100644 --- a/dotcom-rendering/src/components/GoogleOneTap.test.tsx +++ b/dotcom-rendering/src/components/GoogleOneTap.test.tsx @@ -67,7 +67,7 @@ describe('GoogleOneTap', () => { replace: locationReplace, }); - await initializeFedCM({ isSignedIn: false, isInTest: true }); + await initializeFedCM({ isSignedIn: false }); expect(navigatorGet).toHaveBeenCalledWith({ identity: { @@ -99,7 +99,7 @@ describe('GoogleOneTap', () => { replace: locationReplace, }); - await initializeFedCM({ isSignedIn: false, isInTest: true }); + await initializeFedCM({ isSignedIn: false }); expect(navigatorGet).toHaveBeenCalledWith({ identity: { @@ -130,9 +130,9 @@ describe('GoogleOneTap', () => { replace: locationReplace, }); - await expect( - initializeFedCM({ isSignedIn: false, isInTest: true }), - ).rejects.toThrow('window.navigator.credentials.get failed'); + await expect(initializeFedCM({ isSignedIn: false })).rejects.toThrow( + 'window.navigator.credentials.get failed', + ); expect(navigatorGet).toHaveBeenCalledWith({ identity: { @@ -161,7 +161,7 @@ describe('GoogleOneTap', () => { enableFedCM: false, }); - await initializeFedCM({ isSignedIn: false, isInTest: true }); + await initializeFedCM({ isSignedIn: false }); expect(navigatorGet).not.toHaveBeenCalled(); expect(locationReplace).not.toHaveBeenCalled(); @@ -176,7 +176,7 @@ describe('GoogleOneTap', () => { replace: locationReplace, }); - await initializeFedCM({ isSignedIn: true, isInTest: true }); + await initializeFedCM({ isSignedIn: true }); expect(navigatorGet).not.toHaveBeenCalled(); expect(locationReplace).not.toHaveBeenCalled(); @@ -191,7 +191,7 @@ describe('GoogleOneTap', () => { replace: locationReplace, }); - await initializeFedCM({ isSignedIn: true, isInTest: true }); + 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 => {}, - }, - ], -};