Skip to content

Commit eb87859

Browse files
authored
Merge pull request #14335 from guardian/ash/add-google-one-ta
feat: Add Google One Tap
2 parents 1272f2b + 8a50d3d commit eb87859

File tree

6 files changed

+424
-28
lines changed

6 files changed

+424
-28
lines changed

dotcom-rendering/src/components/FrontPage.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { BrazeMessaging } from './BrazeMessaging.importable';
1212
import { useConfig } from './ConfigContext';
1313
import { DarkModeMessage } from './DarkModeMessage';
1414
import { FocusStyles } from './FocusStyles.importable';
15+
import { GoogleOneTap, isInGoogleOneTapTest } from './GoogleOneTap.importable';
1516
import { Island } from './Island';
1617
import { Metrics } from './Metrics.importable';
1718
import { ReaderRevenueDev } from './ReaderRevenueDev.importable';
@@ -82,7 +83,6 @@ export const FrontPage = ({ front, NAV }: Props) => {
8283
serverSideTests={front.config.abTests}
8384
/>
8485
</Island>
85-
8686
<Island priority="critical">
8787
<SetAdTargeting adTargeting={adTargeting} />
8888
</Island>
@@ -92,6 +92,11 @@ export const FrontPage = ({ front, NAV }: Props) => {
9292
<Island priority="feature" defer={{ until: 'idle' }}>
9393
<ReaderRevenueDev shouldHideReaderRevenue={false} />
9494
</Island>
95+
{isInGoogleOneTapTest(front.config.abTests) && (
96+
<Island priority="enhancement" defer={{ until: 'idle' }}>
97+
<GoogleOneTap />
98+
</Island>
99+
)}
95100
{darkModeAvailable && (
96101
<DarkModeMessage>
97102
Dark mode is a work-in-progress.
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import { log } from '@guardian/libs';
2+
import { useIsSignedIn } from '../lib/useAuthStatus';
3+
import { useConsent } from '../lib/useConsent';
4+
import { useOnce } from '../lib/useOnce';
5+
import type { ServerSideTests, StageType } from '../types/config';
6+
7+
type IdentityProviderConfig = {
8+
configURL: string;
9+
clientId: string;
10+
};
11+
12+
type CredentialsProvider = {
13+
get: (options: {
14+
mediation: 'required';
15+
identity: {
16+
context: 'continue';
17+
providers: IdentityProviderConfig[];
18+
};
19+
}) => Promise<{ token: string }>;
20+
};
21+
22+
export const isInGoogleOneTapTest = (tests: ServerSideTests): boolean =>
23+
tests['googleOneTapVariant'] === 'variant';
24+
25+
/**
26+
* Detect the current stage of the application based on the hostname.
27+
*
28+
* We do have a `window.guardian.config.stage` field, but it is based on the
29+
* environment of the article that DCR is rendering, which may not be the same as environment
30+
* DCR is running in.
31+
*
32+
* For example, running DCR locally and loading `https://r.thegulocal.com/Front/https://www.theguardian.com/international` will
33+
* give a stage of `PROD` as the article is in PROD, but DCR is running in `DEV`.
34+
*/
35+
const getStage = (hostname: string): StageType => {
36+
if (window.location.hostname === 'm.code.dev-theguardian.com') {
37+
return 'CODE';
38+
} else if (['r.thegulocal.com', 'localhost'].includes(hostname)) {
39+
return 'DEV';
40+
}
41+
42+
return 'PROD';
43+
};
44+
45+
export const getRedirectUrl = ({
46+
stage,
47+
token,
48+
currentLocation,
49+
}: {
50+
stage: StageType;
51+
token: string;
52+
currentLocation: string;
53+
}): string => {
54+
const profileDomain = {
55+
PROD: 'https://profile.theguardian.com',
56+
CODE: 'https://profile.code.dev-theguardian.com',
57+
DEV: 'https://profile.thegulocal.com',
58+
}[stage];
59+
const queryParams = new URLSearchParams({
60+
token,
61+
returnUrl: currentLocation,
62+
});
63+
64+
return `${profileDomain}/signin/google?${queryParams.toString()}`;
65+
};
66+
67+
// TODO: Do we want to use different Google Client IDs for One Tap than we use for social sign in?
68+
const getProviders = (stage: StageType): IdentityProviderConfig[] => {
69+
switch (stage) {
70+
case 'PROD':
71+
return [
72+
{
73+
configURL: 'https://accounts.google.com/gsi/fedcm.json',
74+
clientId: '774465807556.apps.googleusercontent.com',
75+
},
76+
];
77+
case 'CODE':
78+
case 'DEV':
79+
return [
80+
{
81+
configURL: 'https://accounts.google.com/gsi/fedcm.json',
82+
// TODO: m.code.dev-theguardian.com is not a supported origin for this Client ID
83+
clientId:
84+
'774465807556-pkevncqpfs9486ms0bo5q1f2g9vhpior.apps.googleusercontent.com',
85+
},
86+
];
87+
}
88+
};
89+
90+
export const initializeFedCM = async ({
91+
isSignedIn,
92+
}: {
93+
isSignedIn?: boolean;
94+
isInTest?: boolean;
95+
}): Promise<void> => {
96+
if (isSignedIn) return;
97+
98+
/**
99+
* Firefox does not support the FedCM API at the time of writting,
100+
* and it seems like it will not support it in the near future.
101+
*
102+
* Instead they're focusing on an alternative API called "Lightweight FedCM"
103+
* which may not support Google One Tap.
104+
*
105+
* See: https://bugzilla.mozilla.org/show_bug.cgi?id=1803629
106+
*/
107+
if (!('IdentityCredential' in window)) {
108+
// TODO: Track Ophan "FedCM" unsupported event here.
109+
log('identity', 'FedCM API not supported in this browser');
110+
return;
111+
}
112+
113+
const stage = getStage(window.location.hostname);
114+
115+
/**
116+
* Typescripts built-in DOM types do not include the full `CredentialsProvider`
117+
* interface, so we need to cast `window.navigator.credentials` to our own
118+
* `CredentialsProvider` type which includes the FedCM API.
119+
*
120+
* Related issue: https://github.com/microsoft/TypeScript/issues/60641
121+
*/
122+
const credentialsProvider = window.navigator
123+
.credentials as unknown as CredentialsProvider;
124+
125+
const credentials = await credentialsProvider
126+
.get({
127+
/**.
128+
* Default `mediation` is "optional" which auto-authenticates the user if they have already interacted with FedCM
129+
* prompt on a previous page view.
130+
*
131+
* In practice this shouldn't happen as we won't trigger the prompt if the user is already signed in. But just in
132+
* case, we set `mediation` to "required" to ensure the user isn't put in a login loop where they are signed in,
133+
* FedCM auto-authenticates them, and they're sent to Gateway to get a new Okta token.
134+
*/
135+
mediation: 'required',
136+
identity: {
137+
context: 'continue',
138+
providers: getProviders(stage),
139+
},
140+
})
141+
.catch((error) => {
142+
/**
143+
* The fedcm API hides issues with the user's federated login state
144+
* behind a generic NetworkError. This error is thrown up to 60
145+
* seconds after the prompt is triggered to avoid timing attacks.
146+
*
147+
* This allows the browser to avoid leaking sensitive information
148+
* about the user's login state to the website.
149+
*
150+
* Unfortunately for us it means we can't differentiate between
151+
* a genuine network error and a user declining the FedCM prompt.
152+
*/
153+
if (error instanceof Error && error.name === 'NetworkError') {
154+
log(
155+
'identity',
156+
'FedCM prompt failed, potentially due to user declining',
157+
);
158+
} else {
159+
throw error;
160+
}
161+
});
162+
163+
if (credentials) {
164+
// TODO: Track Ophan "FedCM" success event here.
165+
log('identity', 'FedCM credentials received', {
166+
credentials,
167+
});
168+
169+
window.location.replace(
170+
getRedirectUrl({
171+
stage,
172+
token: credentials.token,
173+
currentLocation: window.location.href,
174+
}),
175+
);
176+
} else {
177+
// TODO: Track Ophan "FedCM" skip event here.
178+
log('identity', 'No FedCM credentials received');
179+
}
180+
};
181+
182+
// TODO: GoogleOneTap is currently only used on the front page, but we do probably want to use it on other pages in the future.
183+
export const GoogleOneTap = () => {
184+
// We don't care what consent we get, we just want to make sure Google One Tap is not shown above the consent banner.
185+
// TODO: FedCM doesn't require cookies? Do we need to check consent?
186+
const consent = useConsent();
187+
const isSignedIn = useIsSignedIn();
188+
// useIsSignedIn returns 'Pending' until the auth status is known.
189+
// We don't want to initialize FedCM until we know the auth status, so we pass `undefined` to `useOnce` if it is 'Pending'
190+
// to stop it from initializing.
191+
const isSignedInWithoutPending =
192+
isSignedIn !== 'Pending' ? isSignedIn : undefined;
193+
194+
useOnce(() => {
195+
void initializeFedCM({
196+
isSignedIn: isSignedInWithoutPending,
197+
});
198+
}, [isSignedInWithoutPending, consent]);
199+
200+
return <></>;
201+
};

0 commit comments

Comments
 (0)