Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 68 additions & 2 deletions packages/shared/src/components/auth/AuthOptionsInner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import {
getBetterAuthErrorMessage,
betterAuthSignInWithIdToken,
betterAuthOneTapCallback,
betterAuthSendVerificationOTP,
betterAuthVerifyEmailOTP,
getBetterAuthSocialRedirectData,
Expand All @@ -30,7 +31,14 @@ import {
} from './socialAuth';
import { webappUrl, broadcastChannel, isTesting } from '../../lib/constants';
import { getUserDefaultTimezone } from '../../lib/timezones';
import { shouldUseSocialAuthPopup } from '../../lib/func';
import {
checkIsExtension,
isIOSNative,
shouldUseSocialAuthPopup,
} from '../../lib/func';
import { useConditionalFeature } from '../../hooks/useConditionalFeature';
import { featureAuthGoogleOneTap } from '../../lib/featureManagement';
import { useGoogleOneTap } from '../../hooks/auth/useGoogleOneTap';
import { generateNameFromEmail } from '../../lib/strings';
import { generateUsername, claimClaimableItem } from '../../graphql/users';
import useRegistration from '../../hooks/useRegistration';
Expand All @@ -50,6 +58,7 @@ import {
import type { SignBackProvider } from '../../hooks/auth/useSignBack';
import { SIGNIN_METHOD_KEY, useSignBack } from '../../hooks/auth/useSignBack';
import type { LoggedUser } from '../../lib/user';
import { Origin } from '../../lib/log';
import { labels } from '../../lib';
import { IconSize } from '../Icon';
import { MailIcon } from '../icons';
Expand Down Expand Up @@ -147,7 +156,7 @@ function AuthOptionsInner({
const [registrationHints, setRegistrationHints] = useState<RegistrationError>(
{},
);
const { refetchBoot, user, isFunnel } = useAuthContext();
const { refetchBoot, user, isFunnel, isAndroidApp } = useAuthContext();
const router = useRouter();
const isOnboardingOrFunnel =
!!router?.pathname?.startsWith('/onboarding') || isFunnel;
Expand Down Expand Up @@ -593,6 +602,63 @@ function AuthOptionsInner({
}, 500);
};

const handleOneTapCredential = async (idToken: string) => {
if (isSocialAuthLoading) {
return;
}
const isLogin = isLoginFlow ?? true;
socialErrorEventName.current = isLogin
? AuthEventNames.LoginError
: AuthEventNames.RegistrationError;
authFlowSucceededRef.current = false;
setIsSocialAuthLoading(true);
logEvent({
event_name: 'click',
target_type: isLogin
? AuthEventNames.LoginProvider
: AuthEventNames.SignUpProvider,
target_id: 'google',
extra: JSON.stringify({ trigger, origin: Origin.AuthOneTap }),
});

const result = await betterAuthOneTapCallback({
idToken,
timezone: getUserDefaultTimezone(),
});

if (result.error) {
logEvent({
event_name: socialErrorEventName.current,
extra: JSON.stringify({
provider: 'google',
error: result.error,
origin: Origin.AuthOneTap,
}),
});
setIsSocialAuthLoading(false);
displayToast(SOCIAL_AUTH_RETRY_MESSAGE);
return;
}

await setChosenProvider('google');
await handleLoginMessage();
};

const canUseOneTap =
!user &&
!checkIsExtension() &&
!isIOSNative() &&
!isAndroidApp &&
!isNativeAuthSupported('google');
const { value: isOneTapEnabled } = useConditionalFeature({
feature: featureAuthGoogleOneTap,
shouldEvaluate: canUseOneTap,
});
useGoogleOneTap({
enabled: canUseOneTap && isOneTapEnabled,
onCredential: handleOneTapCredential,
});

const onProviderClickRef = useRef(onProviderClick);
onProviderClickRef.current = onProviderClick;
const autoTriggerFiredProvider = useRef<string | null>(null);
Expand Down
115 changes: 115 additions & 0 deletions packages/shared/src/hooks/auth/useGoogleOneTap.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { renderHook, waitFor } from '@testing-library/react';
import { useGoogleOneTap } from './useGoogleOneTap';

type Callback = (response: { credential?: string }) => void;

const initialize = jest.fn<void, [{ callback: Callback }]>();
const prompt = jest.fn();
const cancel = jest.fn();

const setGoogleGlobal = (present: boolean) => {
if (present) {
(globalThis as unknown as { google: unknown }).google = {
accounts: { id: { initialize, prompt, cancel } },
};
} else {
delete (globalThis as unknown as { google?: unknown }).google;
}
};

const ORIGINAL_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;

beforeEach(() => {
jest.clearAllMocks();
setGoogleGlobal(true);
});

afterEach(() => {
setGoogleGlobal(false);
if (ORIGINAL_CLIENT_ID === undefined) {
delete process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
} else {
process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID = ORIGINAL_CLIENT_ID;
}
});

describe('useGoogleOneTap', () => {
it('should initialize and prompt when enabled', async () => {
renderHook(() =>
useGoogleOneTap({
enabled: true,
clientId: 'client-123',
onCredential: jest.fn(),
}),
);

await waitFor(() => expect(prompt).toHaveBeenCalledTimes(1));
expect(initialize).toHaveBeenCalledWith(
expect.objectContaining({ client_id: 'client-123', context: 'signin' }),
);
});

it('should not initialize when disabled', async () => {
renderHook(() =>
useGoogleOneTap({
enabled: false,
clientId: 'client-123',
onCredential: jest.fn(),
}),
);

await Promise.resolve();
expect(initialize).not.toHaveBeenCalled();
expect(prompt).not.toHaveBeenCalled();
});

it('should not initialize without a clientId', async () => {
delete process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
renderHook(() =>
useGoogleOneTap({ enabled: true, onCredential: jest.fn() }),
);

await Promise.resolve();
expect(initialize).not.toHaveBeenCalled();
});

it('should default the clientId from env when not provided', async () => {
process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID = 'env-client';
renderHook(() =>
useGoogleOneTap({ enabled: true, onCredential: jest.fn() }),
);

await waitFor(() =>
expect(initialize).toHaveBeenCalledWith(
expect.objectContaining({ client_id: 'env-client' }),
),
);
});

it('should forward the credential to onCredential', async () => {
const onCredential = jest.fn();
renderHook(() =>
useGoogleOneTap({ enabled: true, clientId: 'client-123', onCredential }),
);

await waitFor(() => expect(initialize).toHaveBeenCalled());
const { callback } = initialize.mock.calls[0][0];
callback({ credential: 'id-token-abc' });

expect(onCredential).toHaveBeenCalledWith('id-token-abc');
});

it('should cancel the prompt on unmount', async () => {
const { unmount } = renderHook(() =>
useGoogleOneTap({
enabled: true,
clientId: 'client-123',
onCredential: jest.fn(),
}),
);

await waitFor(() => expect(prompt).toHaveBeenCalled());
unmount();
expect(cancel).toHaveBeenCalled();
});
});
125 changes: 125 additions & 0 deletions packages/shared/src/hooks/auth/useGoogleOneTap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { useEffect, useRef } from 'react';
import { useLogContext } from '../../contexts/LogContext';
import { LogEvent } from '../../lib/log';
import { GSI_SCRIPT_ID, GSI_SRC } from '../../types';

type CredentialResponse = { credential?: string };

type GoogleId = {
initialize: (config: {
client_id: string;
callback: (response: CredentialResponse) => void;
auto_select?: boolean;
cancel_on_tap_outside?: boolean;
context?: 'signin' | 'signup' | 'use';
ux_mode?: 'popup' | 'redirect';
itp_support?: boolean;
}) => void;
prompt: () => void;
cancel: () => void;
};

const getGoogleId = (): GoogleId | undefined =>
(globalThis as unknown as { google?: { accounts?: { id?: GoogleId } } })
.google?.accounts?.id;

const loadGsiScript = (): Promise<void> =>
new Promise((resolve, reject) => {
if (getGoogleId()) {
resolve();
return;
}

const existing = document.getElementById(
GSI_SCRIPT_ID,
) as HTMLScriptElement | null;
if (existing) {
existing.addEventListener('load', () => resolve());
existing.addEventListener('error', () =>
reject(new Error('Failed to load Google Identity Services')),
);
return;
}

const script = document.createElement('script');
script.id = GSI_SCRIPT_ID;
script.src = GSI_SRC;
script.async = true;
script.defer = true;
script.addEventListener('load', () => resolve());
script.addEventListener('error', () =>
reject(new Error('Failed to load Google Identity Services')),
);
document.head.appendChild(script);
});

type UseGoogleOneTapProps = {
enabled: boolean;
clientId?: string;
onCredential: (idToken: string) => void | Promise<void>;
};

export const useGoogleOneTap = ({
enabled,
clientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
onCredential,
}: UseGoogleOneTapProps): void => {
const { logEvent } = useLogContext();
const onCredentialRef = useRef(onCredential);
onCredentialRef.current = onCredential;
const logEventRef = useRef(logEvent);
logEventRef.current = logEvent;

useEffect(() => {
if (!enabled || !clientId) {
return undefined;
}

let cancelled = false;

const init = async () => {
try {
await loadGsiScript();
} catch (error) {
logEventRef.current({
event_name: LogEvent.GlobalError,
extra: JSON.stringify({
msg: error instanceof Error ? error.message : 'unknown',
url: window.location.href,
error,
stack: error instanceof Error ? error.stack : undefined,
feature: 'google one tap',
}),
});
return;
}

const googleId = getGoogleId();
if (cancelled || !googleId) {
return;
}

googleId.initialize({
client_id: clientId,
context: 'signin',
auto_select: false,
cancel_on_tap_outside: false,
ux_mode: 'popup',
itp_support: true,
callback: ({ credential }) => {
if (credential) {
onCredentialRef.current(credential);
}
},
});
googleId.prompt();
};

init();

return () => {
cancelled = true;
getGoogleId()?.cancel();
};
}, [enabled, clientId]);
};
14 changes: 14 additions & 0 deletions packages/shared/src/lib/betterAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,20 @@ export const betterAuthSignInWithIdToken = async ({
);
};

export const betterAuthOneTapCallback = async ({
idToken,
timezone,
}: {
idToken: string;
timezone?: string;
}): Promise<BetterAuthResponse> =>
betterAuthPost(
'one-tap/callback',
{ idToken },
'One Tap sign in failed',
timezone ? { 'x-timezone': timezone } : undefined,
);

export const getBetterAuthLinkSocialUrl = (
provider: string,
callbackURL: string,
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/lib/featureManagement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,3 +223,5 @@ export const featureOnboardingPermissionPrimer = new Feature(
'onboarding_permission_primer',
false,
);

export const featureAuthGoogleOneTap = new Feature('auth_google_onetap', false);
1 change: 1 addition & 0 deletions packages/shared/src/lib/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export enum Origin {
BetterAuthNativeIdToken = 'betterauth native id token',
BetterAuthNativeIdTokenBoot = 'betterauth native id token boot',
BetterAuthSocialUrl = 'betterauth social url',
AuthOneTap = 'auth one tap',
LoginTurnstile = 'login turnstile',
// Engagement ads
HighlightedKeyword = 'highlighted keyword',
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,6 @@ export type ErrorBoundaryFeature =
| 'extension-feed'
| 'onboarding'
| '404-page';

export const GSI_SRC = 'https://accounts.google.com/gsi/client';
export const GSI_SCRIPT_ID = 'google-gsi-client';
2 changes: 2 additions & 0 deletions packages/webapp/.env
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,5 @@ NEXT_PUBLIC_PADDLE_TOKEN=topsecret
NEXT_PUBLIC_ANDROID_APP="https://play.google.com/store/apps/details?id=dev.daily"

NEXT_PUBLIC_TURNSTILE_KEY=1x00000000000000000000AA

NEXT_PUBLIC_GOOGLE_CLIENT_ID=234794427174-3uu0mjstrrrstvnjaabp5vmamftmb7gu.apps.googleusercontent.com
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

client id is not sensitive value

Loading