Skip to content

Commit 9419771

Browse files
authored
feat(clerk-js): Handle PSU and MFA flows for <GoogleOneTap/> (clerk#3250)
* feat(clerk-js): WIP PSU * chore(clerk-js): WIP exclude pages * feat(clerk-js): Handle PSU and MFA flows with GoogleOneTap * feat(clerk-js): Handle after sign in/up after one tap - Use buildURLWithAuth - Build urls with correct redirect urls when leaving app for AP * feat(clerk-js): GoogleOneTap return back to same location * chore(clerk-js): Cleanup code * fix(clerk-js): Set active with correct session from sign up * chore(clerk-js): Cleanup * chore(clerk-js): Cleanup * chore(clerk-js): Mark as deprecated * chore(clerk-js): Add changeset * chore(*): Revert playground changes * fix(clerk-js): Drop `returnToCurrentLocation` * chore(clerk-js): Add support for `itpSupport` and `fedCmSupport` props * chore(clerk-js): Update changeset
1 parent e72ab2b commit 9419771

File tree

8 files changed

+298
-50
lines changed

8 files changed

+298
-50
lines changed

.changeset/breezy-monkeys-develop.md

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
'@clerk/types': minor
4+
---
5+
6+
Updates related to experimental Google One Tap support
7+
- By default we are returning back to the location where the flow started.
8+
To accomplish that internally we will use the redirect_url query parameter to build the url.
9+
```tsx
10+
<__experimental_GoogleOneTap />
11+
```
12+
13+
- In the above example if there is a SIGN_UP_FORCE_REDIRECT_URL or SIGN_IN_FORCE_REDIRECT_URL set then the developer would need to pass new values as props like this
14+
```tsx
15+
<__experimental_GoogleOneTap
16+
signInForceRedirectUrl=""
17+
signUpForceRedirectUrl=""
18+
/>
19+
```
20+
21+
- Let the developer configure the experience they want to offer. (All these values are true by default)
22+
```tsx
23+
<__experimental_GoogleOneTap
24+
cancelOnTapOutside={false}
25+
itpSupport={false}
26+
fedCmSupport={false}
27+
/>
28+
```
29+
30+
- Moved authenticateWithGoogleOneTap to Clerk singleton
31+
```ts
32+
Clerk.__experimental_authenticateWithGoogleOneTap
33+
```
34+
35+
- Created the handleGoogleOneTapCallback in Clerk singleton
36+
```ts
37+
Clerk.__experimental_handleGoogleOneTapCallback
38+
```

packages/clerk-js/src/core/clerk.ts

+104-7
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
handleValueOrFn,
55
inBrowser as inClientSide,
66
is4xxError,
7+
isClerkAPIResponseError,
78
isHttpOrHttps,
89
isValidBrowserOnline,
910
isValidProxyUrl,
@@ -16,6 +17,7 @@ import {
1617
} from '@clerk/shared';
1718
import { eventPrebuiltComponentMounted, TelemetryCollector } from '@clerk/shared/telemetry';
1819
import type {
20+
__experimental_AuthenticateWithGoogleOneTapParams,
1921
ActiveSessionResource,
2022
AuthenticateWithMetamaskParams,
2123
Clerk as ClerkInterface,
@@ -1015,14 +1017,47 @@ export class Clerk implements ClerkInterface {
10151017
return null;
10161018
};
10171019

1018-
public handleRedirectCallback = async (
1019-
params: HandleOAuthCallbackParams = {},
1020+
public __experimental_handleGoogleOneTapCallback = async (
1021+
signInOrUp: SignInResource | SignUpResource,
1022+
params: HandleOAuthCallbackParams,
10201023
customNavigate?: (to: string) => Promise<unknown>,
10211024
): Promise<unknown> => {
10221025
if (!this.loaded || !this.#environment || !this.client) {
10231026
return;
10241027
}
1025-
const { signIn, signUp } = this.client;
1028+
const { signIn: _signIn, signUp: _signUp } = this.client;
1029+
1030+
const signIn = 'identifier' in (signInOrUp || {}) ? (signInOrUp as SignInResource) : _signIn;
1031+
const signUp = 'missingFields' in (signInOrUp || {}) ? (signInOrUp as SignUpResource) : _signUp;
1032+
1033+
const navigate = (to: string) =>
1034+
customNavigate && typeof customNavigate === 'function'
1035+
? customNavigate(this.buildUrlWithAuth(to))
1036+
: this.navigate(this.buildUrlWithAuth(to));
1037+
1038+
return this._handleRedirectCallback(params, {
1039+
signUp,
1040+
signIn,
1041+
navigate,
1042+
});
1043+
};
1044+
1045+
private _handleRedirectCallback = async (
1046+
params: HandleOAuthCallbackParams,
1047+
{
1048+
signIn,
1049+
signUp,
1050+
navigate,
1051+
}: {
1052+
signIn: SignInResource;
1053+
signUp: SignUpResource;
1054+
navigate: (to: string) => Promise<unknown>;
1055+
},
1056+
): Promise<unknown> => {
1057+
if (!this.loaded || !this.#environment || !this.client) {
1058+
return;
1059+
}
1060+
10261061
const { displayConfig } = this.#environment;
10271062
const { firstFactorVerification } = signIn;
10281063
const { externalAccount } = signUp.verifications;
@@ -1032,18 +1067,17 @@ export class Clerk implements ClerkInterface {
10321067
externalAccountStatus: externalAccount.status,
10331068
externalAccountErrorCode: externalAccount.error?.code,
10341069
externalAccountSessionId: externalAccount.error?.meta?.sessionId,
1070+
sessionId: signUp.createdSessionId,
10351071
};
10361072

10371073
const si = {
10381074
status: signIn.status,
10391075
firstFactorVerificationStatus: firstFactorVerification.status,
10401076
firstFactorVerificationErrorCode: firstFactorVerification.error?.code,
10411077
firstFactorVerificationSessionId: firstFactorVerification.error?.meta?.sessionId,
1078+
sessionId: signIn.createdSessionId,
10421079
};
10431080

1044-
const navigate = (to: string) =>
1045-
customNavigate && typeof customNavigate === 'function' ? customNavigate(to) : this.navigate(to);
1046-
10471081
const makeNavigate = (to: string) => () => navigate(to);
10481082

10491083
const navigateToSignIn = makeNavigate(params.signInUrl || displayConfig.signInUrl);
@@ -1071,7 +1105,13 @@ export class Clerk implements ClerkInterface {
10711105

10721106
const navigateToContinueSignUp = makeNavigate(
10731107
params.continueSignUpUrl ||
1074-
buildURL({ base: displayConfig.signUpUrl, hashPath: '/continue' }, { stringify: true }),
1108+
buildURL(
1109+
{
1110+
base: displayConfig.signUpUrl,
1111+
hashPath: '/continue',
1112+
},
1113+
{ stringify: true },
1114+
),
10751115
);
10761116

10771117
const navigateToNextStepSignUp = ({ missingFields }: { missingFields: SignUpField[] }) => {
@@ -1091,8 +1131,16 @@ export class Clerk implements ClerkInterface {
10911131
});
10921132
};
10931133

1134+
if (si.status === 'complete') {
1135+
return this.setActive({
1136+
session: si.sessionId,
1137+
beforeEmit: navigateAfterSignIn,
1138+
});
1139+
}
1140+
10941141
const userExistsButNeedsToSignIn =
10951142
su.externalAccountStatus === 'transferable' && su.externalAccountErrorCode === 'external_account_exists';
1143+
10961144
if (userExistsButNeedsToSignIn) {
10971145
const res = await signIn.create({ transfer: true });
10981146
switch (res.status) {
@@ -1152,6 +1200,13 @@ export class Clerk implements ClerkInterface {
11521200
}
11531201
}
11541202

1203+
if (su.status === 'complete') {
1204+
return this.setActive({
1205+
session: su.sessionId,
1206+
beforeEmit: navigateAfterSignUp,
1207+
});
1208+
}
1209+
11551210
if (si.status === 'needs_second_factor') {
11561211
return navigateToFactorTwo();
11571212
}
@@ -1188,6 +1243,25 @@ export class Clerk implements ClerkInterface {
11881243
return navigateToSignIn();
11891244
};
11901245

1246+
public handleRedirectCallback = async (
1247+
params: HandleOAuthCallbackParams = {},
1248+
customNavigate?: (to: string) => Promise<unknown>,
1249+
): Promise<unknown> => {
1250+
if (!this.loaded || !this.#environment || !this.client) {
1251+
return;
1252+
}
1253+
const { signIn, signUp } = this.client;
1254+
1255+
const navigate = (to: string) =>
1256+
customNavigate && typeof customNavigate === 'function' ? customNavigate(to) : this.navigate(to);
1257+
1258+
return this._handleRedirectCallback(params, {
1259+
signUp,
1260+
signIn,
1261+
navigate,
1262+
});
1263+
};
1264+
11911265
public handleUnauthenticated = async (opts = { broadcast: true }): Promise<unknown> => {
11921266
if (!this.client || !this.session) {
11931267
return;
@@ -1203,6 +1277,29 @@ export class Clerk implements ClerkInterface {
12031277
return this.setActive({ session: null });
12041278
};
12051279

1280+
public __experimental_authenticateWithGoogleOneTap = async (
1281+
params: __experimental_AuthenticateWithGoogleOneTapParams,
1282+
): Promise<SignInResource | SignUpResource> => {
1283+
return this.client?.signIn
1284+
.create({
1285+
// TODO-ONETAP: Add new types when feature is ready for public beta
1286+
// @ts-expect-error
1287+
strategy: 'google_one_tap',
1288+
googleOneTapToken: params.token,
1289+
})
1290+
.catch(err => {
1291+
if (isClerkAPIResponseError(err) && err.errors[0].code === 'external_account_not_found') {
1292+
return this.client?.signUp.create({
1293+
// TODO-ONETAP: Add new types when feature is ready for public beta
1294+
// @ts-expect-error
1295+
strategy: 'google_one_tap',
1296+
googleOneTapToken: params.token,
1297+
});
1298+
}
1299+
throw err;
1300+
}) as Promise<SignInResource | SignUpResource>;
1301+
};
1302+
12061303
public authenticateWithMetamask = async ({
12071304
redirectUrl,
12081305
signUpContinueUrl,

packages/clerk-js/src/ui/components/GoogleOneTap/one-tap-start.tsx

+46-34
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,52 @@
11
import { useClerk, useUser } from '@clerk/shared/react';
2-
import { useEffect } from 'react';
2+
import { useEffect, useRef } from 'react';
33

4-
import { clerkInvalidFAPIResponse } from '../../../core/errors';
54
import type { GISCredentialResponse } from '../../../utils/one-tap';
65
import { loadGIS } from '../../../utils/one-tap';
7-
import { useCoreSignIn, useEnvironment, useGoogleOneTapContext } from '../../contexts';
6+
import { useEnvironment, useGoogleOneTapContext } from '../../contexts';
87
import { withCardStateProvider } from '../../elements';
98
import { useFetch } from '../../hooks';
10-
import { useSupportEmail } from '../../hooks/useSupportEmail';
9+
import { useRouter } from '../../router';
1110

1211
function _OneTapStart(): JSX.Element | null {
1312
const clerk = useClerk();
14-
const signIn = useCoreSignIn();
1513
const { user } = useUser();
1614
const environment = useEnvironment();
15+
const isPromptedRef = useRef(false);
16+
const { navigate } = useRouter();
1717

18-
const supportEmail = useSupportEmail();
1918
const ctx = useGoogleOneTapContext();
19+
const {
20+
signInUrl,
21+
signUpUrl,
22+
continueSignUpUrl,
23+
secondFactorUrl,
24+
firstFactorUrl,
25+
signUpForceRedirectUrl,
26+
signInForceRedirectUrl,
27+
} = ctx;
2028

2129
async function oneTapCallback(response: GISCredentialResponse) {
30+
isPromptedRef.current = false;
2231
try {
23-
const res = await signIn.__experimental_authenticateWithGoogleOneTap({
32+
const res = await clerk.__experimental_authenticateWithGoogleOneTap({
2433
token: response.credential,
2534
});
26-
27-
switch (res.status) {
28-
case 'complete':
29-
await clerk.setActive({
30-
session: res.createdSessionId,
31-
});
32-
break;
33-
// TODO-ONETAP: Add a new case in order to handle the `missing_requirements` status and the PSU flow
34-
default:
35-
clerkInvalidFAPIResponse(res.status, supportEmail);
36-
break;
37-
}
38-
} catch (err) {
39-
/**
40-
* Currently it is not possible to display an error in the UI.
41-
* As a fallback we simply open the SignIn modal for the user to sign in.
42-
*/
43-
clerk.openSignIn();
35+
await clerk.__experimental_handleGoogleOneTapCallback(
36+
res,
37+
{
38+
signInUrl,
39+
signUpUrl,
40+
continueSignUpUrl,
41+
secondFactorUrl,
42+
firstFactorUrl,
43+
signUpForceRedirectUrl,
44+
signInForceRedirectUrl,
45+
},
46+
navigate,
47+
);
48+
} catch (e) {
49+
console.error(e);
4450
}
4551
}
4652

@@ -50,32 +56,38 @@ function _OneTapStart(): JSX.Element | null {
5056
/**
5157
* Prevent GIS from initializing multiple times
5258
*/
53-
const { data: google } = useFetch(shouldLoadGIS ? loadGIS : undefined, 'google-identity-services-script', {
59+
useFetch(shouldLoadGIS ? loadGIS : undefined, 'google-identity-services-script', {
5460
onSuccess(google) {
5561
google.accounts.id.initialize({
5662
client_id: environmentClientID!,
5763
callback: oneTapCallback,
58-
itp_support: true,
64+
itp_support: ctx.itpSupport,
5965
cancel_on_tap_outside: ctx.cancelOnTapOutside,
6066
auto_select: false,
61-
use_fedcm_for_prompt: true,
67+
use_fedcm_for_prompt: ctx.fedCmSupport,
6268
});
6369

6470
google.accounts.id.prompt();
71+
isPromptedRef.current = true;
6572
},
6673
});
6774

68-
// Trigger only on mount/unmount. Above we handle the logic for the initial fetch + initialization
6975
useEffect(() => {
70-
if (google && !user?.id) {
71-
google.accounts.id.prompt();
76+
if (window.google && !user?.id && !isPromptedRef.current) {
77+
window.google.accounts.id.prompt();
78+
isPromptedRef.current = true;
7279
}
80+
}, [user?.id]);
81+
82+
// Trigger only on mount/unmount. Above we handle the logic for the initial fetch + initialization
83+
useEffect(() => {
7384
return () => {
74-
if (google) {
75-
google.accounts.id.cancel();
85+
if (window.google && isPromptedRef.current) {
86+
isPromptedRef.current = false;
87+
window.google.accounts.id.cancel();
7688
}
7789
};
78-
}, [user?.id]);
90+
}, []);
7991

8092
return null;
8193
}

0 commit comments

Comments
 (0)