|
| 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