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;
+};