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
This commit is contained in:
Bereket Engida
2025-02-15 11:56:06 +03:00
committed by GitHub
parent 5dfb788838
commit 8ff196ec0d
6 changed files with 424 additions and 101 deletions

View File

@@ -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 (
<Dialog open={isOpen} onOpenChange={(change) => setIsOpen(change)}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-lg md:text-xl">Sign In</DialogTitle>
<DialogDescription className="text-xs md:text-sm">
Enter your email below to login to your account
</DialogDescription>
</DialogHeader>
<SignInBox />
</DialogContent>
</Dialog>
);
}
function SignInBox() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [rememberMe, setRememberMe] = useState(false);
const router = useRouter();
const [loading, setLoading] = useState(false);
return (
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="m@example.com"
required
onChange={(e) => {
setEmail(e.target.value);
}}
value={email}
/>
</div>
<div className="grid gap-2">
<div className="flex items-center">
<Label htmlFor="password">Password</Label>
<Link
href="/forget-password"
className="ml-auto inline-block text-sm underline"
>
Forgot your password?
</Link>
</div>
<PasswordInput
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="password"
placeholder="Password"
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
onClick={() => {
setRememberMe(!rememberMe);
}}
/>
<Label>Remember me</Label>
</div>
<Button
type="submit"
className="w-full"
disabled={loading}
onClick={async () => {
await signIn.email(
{
email: email,
password: password,
callbackURL: "/dashboard",
rememberMe,
},
{
onRequest: () => {
setLoading(true);
},
onResponse: () => {
setLoading(false);
},
onError: (ctx) => {
toast.error(ctx.error.message);
},
},
);
}}
>
{loading ? <Loader2 size={16} className="animate-spin" /> : "Login"}
</Button>
<Button
variant="outline"
className=" gap-2"
onClick={async () => {
await signIn.social({
provider: "google",
callbackURL: "/dashboard",
});
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="0.98em"
height="1em"
viewBox="0 0 256 262"
>
<path
fill="#4285F4"
d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622l38.755 30.023l2.685.268c24.659-22.774 38.875-56.282 38.875-96.027"
/>
<path
fill="#34A853"
d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055c-34.523 0-63.824-22.773-74.269-54.25l-1.531.13l-40.298 31.187l-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1"
/>
<path
fill="#FBBC05"
d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82c0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602z"
/>
<path
fill="#EB4335"
d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0C79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251"
/>
</svg>
<p>Continue With Google</p>
</Button>
<Button
variant="outline"
className="gap-2"
onClick={async () => {
await signIn.passkey({
fetchOptions: {
onSuccess(context) {
router.push("/dashboard");
},
onError(context) {
toast.error(context.error.message);
},
},
});
}}
>
<Key size={16} />
Sign-in with Passkey
</Button>
</div>
);
}

View File

@@ -24,6 +24,9 @@ export const client = createAuthClient({
multiSessionClient(),
oneTapClient({
clientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!,
promptOptions: {
maxAttempts: 2,
},
}),
oidcClient(),
genericOAuthClient(),

View File

@@ -143,11 +143,11 @@ export const auth = betterAuth({
bearer(),
admin(),
multiSession(),
oneTap(),
oAuthProxy(),
nextCookies(),
oidcProvider({
loginPage: "/sign-in",
}),
oneTap(),
],
});

View File

@@ -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
<Steps>
<Step>
### 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
]
});
```
</Step>
<Step>
### 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"
})
]
})
```
</Step>
</Steps>
## 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.

View File

@@ -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<string, any>;
/**
* 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<GoogleOneTapOptions, "clientId"> {
extends Omit<GoogleOneTapOptions, "clientId" | "promptOptions"> {
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<void>((resolve) => {
await new Promise<void>((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);

View File

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