From 8a08ae9eeea8272be103e5658d60af7b6bcf5763 Mon Sep 17 00:00:00 2001 From: Bereket Engida <86073083+Bekacru@users.noreply.github.com> Date: Sat, 15 Feb 2025 11:56:06 +0300 Subject: [PATCH] feat(one-tap): improve Google One Tap integration with JWT verification and improved prompt handling (#1452) * fix(auth): use options.baseURL instead of ctx.baseURL on trusted origins * feat(one-tap): improve Google One Tap integration with JWT verification and improved prompt handling * feat(one-tap): add One Tap component for user authentication and fix prompt options --- demo/nextjs/components/one-tap.tsx | 178 ++++++++++++++++++ demo/nextjs/lib/auth-client.ts | 3 + demo/nextjs/lib/auth.ts | 2 +- docs/content/docs/plugins/one-tap.mdx | 135 ++++++++----- .../better-auth/src/plugins/one-tap/client.ts | 119 ++++++++++-- .../better-auth/src/plugins/one-tap/index.ts | 88 ++++++--- 6 files changed, 424 insertions(+), 101 deletions(-) create mode 100644 demo/nextjs/components/one-tap.tsx diff --git a/demo/nextjs/components/one-tap.tsx b/demo/nextjs/components/one-tap.tsx new file mode 100644 index 00000000..42b3ac61 --- /dev/null +++ b/demo/nextjs/components/one-tap.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { client, signIn } from "@/lib/auth-client"; +import { useEffect, useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "./ui/dialog"; +import { Input } from "./ui/input"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { PasswordInput } from "./ui/password-input"; +import { Checkbox } from "./ui/checkbox"; +import { Button } from "./ui/button"; +import { Key, Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { Label } from "./ui/label"; + +export function OneTap() { + const [isOpen, setIsOpen] = useState(false); + useEffect(() => { + client.oneTap({ + onPromptNotification(notification) { + setIsOpen(true); + }, + }); + }, []); + return ( + setIsOpen(change)}> + + + Sign In + + Enter your email below to login to your account + + + + + + ); +} + +function SignInBox() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [rememberMe, setRememberMe] = useState(false); + const router = useRouter(); + const [loading, setLoading] = useState(false); + return ( +
+
+ + { + setEmail(e.target.value); + }} + value={email} + /> +
+
+
+ + + Forgot your password? + +
+ setPassword(e.target.value)} + autoComplete="password" + placeholder="Password" + /> +
+
+ { + setRememberMe(!rememberMe); + }} + /> + +
+ + + + +
+ ); +} diff --git a/demo/nextjs/lib/auth-client.ts b/demo/nextjs/lib/auth-client.ts index 0e9ac305..ee848db8 100644 --- a/demo/nextjs/lib/auth-client.ts +++ b/demo/nextjs/lib/auth-client.ts @@ -24,6 +24,9 @@ export const client = createAuthClient({ multiSessionClient(), oneTapClient({ clientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!, + promptOptions: { + maxAttempts: 2, + }, }), oidcClient(), genericOAuthClient(), diff --git a/demo/nextjs/lib/auth.ts b/demo/nextjs/lib/auth.ts index 7e6e2ed9..0bfb720c 100644 --- a/demo/nextjs/lib/auth.ts +++ b/demo/nextjs/lib/auth.ts @@ -143,11 +143,11 @@ export const auth = betterAuth({ bearer(), admin(), multiSession(), - oneTap(), oAuthProxy(), nextCookies(), oidcProvider({ loginPage: "/sign-in", }), + oneTap(), ], }); diff --git a/docs/content/docs/plugins/one-tap.mdx b/docs/content/docs/plugins/one-tap.mdx index 48a38e85..13a5cd6c 100644 --- a/docs/content/docs/plugins/one-tap.mdx +++ b/docs/content/docs/plugins/one-tap.mdx @@ -3,87 +3,120 @@ title: One Tap description: One Tap plugin for Better Auth --- -The One Tap plugin allows users to login with a single tap using Google's One Tap API. +The One Tap plugin allows users to log in with a single tap using Google's One Tap API. The plugin +provides a simple way to integrate One Tap into your application, handling the client-side and server-side logic for you. ## Installation - - -### Add the server Plugin +### Add the Server Plugin -Add the One Tap plugin to your auth config. +Add the One Tap plugin to your auth configuration: ```ts title="auth.ts" import { betterAuth } from "better-auth"; import { oneTap } from "better-auth/plugins"; export const auth = betterAuth({ - plugins: [ // [!code highlight] - oneTap(), // [!code highlight] - ] // [!code highlight] -}) + plugins: [ + oneTap(), // Add the One Tap server plugin + ] +}); ``` - - - ### Add the client Plugin +### Add the Client Plugin - Add the client plugin and Specify where the user should be redirected if they need to verify 2nd factor +Add the client plugin and specify where the user should be redirected after sign-in or if additional verification (like 2FA) is needed. - ```ts title="auth-client.ts" - import { createAuthClient } from "better-auth/client" - import { oneTapClient } from "better-auth/client/plugins" - - const authClient = createAuthClient({ - plugins: [ - oneTapClient({ - clientId: "YOUR_CLIENT_ID" - }) - ] - }) - ``` - - - -## Usage - -To make the one tap pop up appear, you can call the `oneTap` method. +import { createAuthClient } from "better-auth/client"; +import { oneTapClient } from "better-auth/client/plugins"; ```ts -await authClient.oneTap() +const authClient = createAuthClient({ + plugins: [ + oneTapClient({ + clientId: "YOUR_CLIENT_ID", + // Optional client configuration: + autoSelect: false, + cancelOnTapOutside: true, + context: "signin", + additionalOptions: { + // Any extra options for the Google initialize method + }, + // Configure prompt behavior and exponential backoff: + promptOptions: { + baseDelay: 1000, // Base delay in ms (default: 1000) + maxAttempts: 5 // Maximum number of attempts before triggering onPromptNotification (default: 5) + } + }) + ] +}); ``` -By default, the plugin will automatically redirect the user to "/" after the user has successfully logged in. It does a hard redirect, so the page will be reloaded. If you want to -avoid this, you can pass `fetchOptions` to the `oneTap` method. +### Usage -```tsx +To display the One Tap popup, simply call the oneTap method on your auth client: + +```ts +await authClient.oneTap(); +``` + +### Customizing Redirect Behavior + +By default, after a successful login the plugin will hard redirect the user to `/`. You can customize this behavior as follows: + +#### Avoiding a Hard Redirect + +Pass fetchOptions with an onSuccess callback to handle the login response without a page reload: + +```ts authClient.oneTap({ - fetchOptions: { - onSuccess: () => { - router.push("/dashboard") - } + fetchOptions: { + onSuccess: () => { + // For example, use a router to navigate without a full reload: + router.push("/dashboard"); } -}) + } +}); ``` -If you want it to hard redirect to a different page, you can pass the `callbackURL` option to the `oneTap` method. +#### Specifying a Custom Callback URL -```tsx +To perform a hard redirect to a different page after login, use the callbackURL option: + +```ts authClient.oneTap({ - callbackURL: "/dashboard" -}) + callbackURL: "/dashboard" +}); ``` -## Client Options +#### Handling Prompt Dismissals with Exponential Backoff -**clientId**: The client ID of your Google One Tap API +If the user dismisses or skips the prompt, the plugin will retry showing the One Tap prompt using exponential backoff based on your configured promptOptions. -**autoSelect**: Automatically select the first account in the list. Default is `false` +If the maximum number of attempts is reached without a successful sign-in, you can use the onPromptNotification callback to be notified—allowing you to render an alternative UI (e.g., a traditional Google Sign-In button) so users can restart the process manually: -**context**: The context in which the One Tap API should be used. Default is `signin` +```ts +authClient.oneTap({ + onPromptNotification: (notification) => { + console.warn("Prompt was dismissed or skipped. Consider displaying an alternative sign-in option.", notification); + // Render your alternative UI here + } +}); +``` -**cancelOnTapOutside**: Cancel the One Tap popup when the user taps outside of the popup. Default is `true`. +### Client Options -## Server Options +- **clientId**: The client ID for your Google One Tap API. +- **autoSelect**: Automatically select the account if the user is already signed in. Default is false. +- **context**: The context in which the One Tap API should be used (e.g., "signin"). Default is "signin". +- **cancelOnTapOutside**: Cancel the One Tap popup when the user taps outside it. Default is true. +- additionalOptions: Extra options to pass to Google's initialize method as per the [Google Identity Services docs](https://developers.google.com/identity/gsi/web/reference/js-reference#google.accounts.id.prompt). +- **promptOptions**: Configuration for the prompt behavior and exponential backoff: +- **baseDelay**: Base delay in milliseconds for retries. Default is 1000. +- **maxAttempts**: Maximum number of prompt attempts before invoking the onPromptNotification callback. Default is 5. + +### Server Options + +- **disableSignUp**: Disable the sign-up option, allowing only existing users to sign in. Default is `false`. +- **ClientId**: Optionally, pass a client ID here if it is not provided in your social provider configuration. -**disableSignUp**: Disable the sign up option. Default is `false`. If set to `true`, the user will only be able to sign in with an existing account. \ No newline at end of file diff --git a/packages/better-auth/src/plugins/one-tap/client.ts b/packages/better-auth/src/plugins/one-tap/client.ts index 64b73d1e..4c1cc49f 100644 --- a/packages/better-auth/src/plugins/one-tap/client.ts +++ b/packages/better-auth/src/plugins/one-tap/client.ts @@ -1,5 +1,6 @@ import type { BetterFetchOption } from "@better-fetch/fetch"; import type { BetterAuthClientPlugin } from "../../types"; +import { generateRandomString } from "../../crypto"; declare global { interface Window { @@ -7,7 +8,7 @@ declare global { accounts: { id: { initialize: (config: any) => void; - prompt: () => void; + prompt: (callback?: (notification: any) => void) => void; }; }; }; @@ -29,15 +30,53 @@ export interface GoogleOneTapOptions { */ cancelOnTapOutside?: boolean; /** - * Context of the Google One Tap flow + * The mode to use for the Google One Tap flow + * + * popup: Use a popup window + * redirect: Redirect the user to the Google One Tap flow + * + * @default "popup" */ - context?: "signin" | "signup" | "use"; + uxMode?: "popup" | "redirect"; + /** + * The context to use for the Google One Tap flow + * + * @default "signin" + */ + context?: string; + /** + * Additional configuration options to pass to the Google One Tap API. + */ + additionalOptions?: Record; + /** + * Configuration options for the prompt and exponential backoff behavior. + */ + promptOptions?: { + /** + * Base delay (in milliseconds) for exponential backoff. + * @default 1000 + */ + baseDelay?: number; + /** + * Maximum number of prompt attempts before calling onPromptNotification. + * @default 5 + */ + maxAttempts?: number; + }; } export interface GoogleOneTapActionOptions - extends Omit { + extends Omit { fetchOptions?: BetterFetchOption; + /** + * Callback URL. + */ callbackURL?: string; + /** + * Optional callback that receives the prompt notification if (or when) the prompt is dismissed or skipped. + * This lets you render an alternative UI (e.g. a Google Sign-In button) to restart the process. + */ + onPromptNotification?: (notification: any) => void; } let isRequestInProgress = false; @@ -72,31 +111,73 @@ export const oneTapClient = (options: GoogleOneTapOptions) => { await loadGoogleScript(); - await new Promise((resolve) => { + await new Promise((resolve, reject) => { + let isResolved = false; + const baseDelay = options.promptOptions?.baseDelay ?? 1000; + const maxAttempts = options.promptOptions?.maxAttempts ?? 5; + window.google?.accounts.id.initialize({ client_id: options.clientId, callback: async (response: { credential: string }) => { - await $fetch("/one-tap/callback", { - method: "POST", - body: { idToken: response.credential }, - ...opts?.fetchOptions, - ...fetchOptions, - }); + isResolved = true; + try { + await $fetch("/one-tap/callback", { + method: "POST", + body: { idToken: response.credential }, + ...opts?.fetchOptions, + ...fetchOptions, + }); - // Redirect if no fetch options are provided or a callbackURL is specified - if ( - (!opts?.fetchOptions && !fetchOptions) || - opts?.callbackURL - ) { - window.location.href = opts?.callbackURL ?? "/"; + if ( + (!opts?.fetchOptions && !fetchOptions) || + opts?.callbackURL + ) { + window.location.href = opts?.callbackURL ?? "/"; + } + resolve(); + } catch (error) { + console.error("Error during One Tap callback:", error); + reject(error); } - resolve(); }, auto_select: autoSelect, cancel_on_tap_outside: cancelOnTapOutside, context: contextValue, + nonce: generateRandomString(16), + ...options.additionalOptions, }); - window.google?.accounts.id.prompt(); + + const handlePrompt = (attempt: number) => { + if (isResolved) return; + + window.google?.accounts.id.prompt((notification: any) => { + if (isResolved) return; + + if ( + notification.isDismissedMoment && + notification.isDismissedMoment() + ) { + if (attempt < maxAttempts) { + const delay = Math.pow(2, attempt) * baseDelay; + setTimeout(() => handlePrompt(attempt + 1), delay); + } else { + opts?.onPromptNotification?.(notification); + } + } else if ( + notification.isSkippedMoment && + notification.isSkippedMoment() + ) { + if (attempt < maxAttempts) { + const delay = Math.pow(2, attempt) * baseDelay; + setTimeout(() => handlePrompt(attempt + 1), delay); + } else { + opts?.onPromptNotification?.(notification); + } + } + }); + }; + + handlePrompt(0); }); } catch (error) { console.error("Error during Google One Tap flow:", error); diff --git a/packages/better-auth/src/plugins/one-tap/index.ts b/packages/better-auth/src/plugins/one-tap/index.ts index 0c18f3d7..11c10f94 100644 --- a/packages/better-auth/src/plugins/one-tap/index.ts +++ b/packages/better-auth/src/plugins/one-tap/index.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { APIError, createAuthEndpoint } from "../../api"; import { setSessionCookie } from "../../cookies"; import type { BetterAuthPlugin } from "../../types"; -import { betterFetch } from "@better-fetch/fetch"; +import { jwtVerify, createRemoteJWKSet } from "jose"; import { toBoolean } from "../../utils/boolean"; interface OneTapOptions { @@ -12,6 +12,13 @@ interface OneTapOptions { * @default false */ disableSignup?: boolean; + /** + * Google Client ID + * + * If a client ID is provided in the social provider configuration, + * it will be used. + */ + clientId?: string; } export const oneTap = (options?: OneTapOptions) => @@ -61,65 +68,86 @@ export const oneTap = (options?: OneTapOptions) => }, async (ctx) => { const { idToken } = ctx.body; - const { data, error } = await betterFetch<{ - email: string; - email_verified: string; - name: string; - picture: string; - sub: string; - }>("https://oauth2.googleapis.com/tokeninfo?id_token=" + idToken); - if (error) { - return ctx.json({ - error: "Invalid token", + let payload: any; + try { + const JWKS = createRemoteJWKSet( + new URL("https://www.googleapis.com/oauth2/v3/certs"), + ); + const { payload: verifiedPayload } = await jwtVerify( + idToken, + JWKS, + { + issuer: ["https://accounts.google.com", "accounts.google.com"], + audience: + options?.clientId || + ctx.context.options.socialProviders?.google?.clientId, + }, + ); + payload = verifiedPayload; + } catch (error) { + throw new APIError("BAD_REQUEST", { + message: "invalid id token", }); } - const user = await ctx.context.internalAdapter.findUserByEmail( - data.email, - ); + const { email, email_verified, name, picture, sub } = payload; + if (!email) { + return ctx.json({ error: "Email not available in token" }); + } + + const user = await ctx.context.internalAdapter.findUserByEmail(email); if (!user) { if (options?.disableSignup) { throw new APIError("BAD_GATEWAY", { message: "User not found", }); } - const user = await ctx.context.internalAdapter.createOAuthUser( + const newUser = await ctx.context.internalAdapter.createOAuthUser( { - email: data.email, - emailVerified: toBoolean(data.email_verified), - name: data.name, - image: data.picture, + email, + emailVerified: + typeof email_verified === "boolean" + ? email_verified + : toBoolean(email_verified), + name, + image: picture, }, { providerId: "google", - accountId: data.sub, + accountId: sub, }, ); - if (!user) { + if (!newUser) { throw new APIError("INTERNAL_SERVER_ERROR", { message: "Could not create user", }); } const session = await ctx.context.internalAdapter.createSession( - user?.user.id, + newUser.user.id, ctx.request, ); await setSessionCookie(ctx, { - user: user.user, + user: newUser.user, session, }); return ctx.json({ token: session.token, user: { - id: user.user.id, - email: user.user.email, - emailVerified: user.user.emailVerified, - name: user.user.name, - image: user.user.image, - createdAt: user.user.createdAt, - updatedAt: user.user.updatedAt, + id: newUser.user.id, + email: newUser.user.email, + emailVerified: newUser.user.emailVerified, + name: newUser.user.name, + image: newUser.user.image, + createdAt: newUser.user.createdAt, + updatedAt: newUser.user.updatedAt, }, }); } + const account = await ctx.context.internalAdapter.findAccount(sub); + if (!account) { + throw new APIError("UNAUTHORIZED", { + message: "Google sub doesn't match", + }); + } const session = await ctx.context.internalAdapter.createSession( user.user.id, ctx.request,