+ );
+}
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,