mirror of
https://github.com/LukeHagar/better-auth.git
synced 2025-12-07 20:37:44 +00:00
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:
committed by
Bereket Engida
parent
868a63c047
commit
8a08ae9eee
178
demo/nextjs/components/one-tap.tsx
Normal file
178
demo/nextjs/components/one-tap.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,6 +24,9 @@ export const client = createAuthClient({
|
|||||||
multiSessionClient(),
|
multiSessionClient(),
|
||||||
oneTapClient({
|
oneTapClient({
|
||||||
clientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!,
|
clientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!,
|
||||||
|
promptOptions: {
|
||||||
|
maxAttempts: 2,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
oidcClient(),
|
oidcClient(),
|
||||||
genericOAuthClient(),
|
genericOAuthClient(),
|
||||||
|
|||||||
@@ -143,11 +143,11 @@ export const auth = betterAuth({
|
|||||||
bearer(),
|
bearer(),
|
||||||
admin(),
|
admin(),
|
||||||
multiSession(),
|
multiSession(),
|
||||||
oneTap(),
|
|
||||||
oAuthProxy(),
|
oAuthProxy(),
|
||||||
nextCookies(),
|
nextCookies(),
|
||||||
oidcProvider({
|
oidcProvider({
|
||||||
loginPage: "/sign-in",
|
loginPage: "/sign-in",
|
||||||
}),
|
}),
|
||||||
|
oneTap(),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,87 +3,120 @@ title: One Tap
|
|||||||
description: One Tap plugin for Better Auth
|
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
|
## Installation
|
||||||
|
|
||||||
<Steps>
|
### Add the Server Plugin
|
||||||
<Step>
|
|
||||||
### 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"
|
```ts title="auth.ts"
|
||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
import { oneTap } from "better-auth/plugins";
|
import { oneTap } from "better-auth/plugins";
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
plugins: [ // [!code highlight]
|
plugins: [
|
||||||
oneTap(), // [!code highlight]
|
oneTap(), // Add the One Tap server plugin
|
||||||
] // [!code highlight]
|
]
|
||||||
})
|
});
|
||||||
```
|
```
|
||||||
</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 { createAuthClient } from "better-auth/client"
|
import { oneTapClient } from "better-auth/client/plugins";
|
||||||
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.
|
|
||||||
|
|
||||||
```ts
|
```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
|
### Usage
|
||||||
avoid this, you can pass `fetchOptions` to the `oneTap` method.
|
|
||||||
|
|
||||||
```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({
|
authClient.oneTap({
|
||||||
fetchOptions: {
|
fetchOptions: {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
router.push("/dashboard")
|
// 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({
|
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.
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { BetterFetchOption } from "@better-fetch/fetch";
|
import type { BetterFetchOption } from "@better-fetch/fetch";
|
||||||
import type { BetterAuthClientPlugin } from "../../types";
|
import type { BetterAuthClientPlugin } from "../../types";
|
||||||
|
import { generateRandomString } from "../../crypto";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -7,7 +8,7 @@ declare global {
|
|||||||
accounts: {
|
accounts: {
|
||||||
id: {
|
id: {
|
||||||
initialize: (config: any) => void;
|
initialize: (config: any) => void;
|
||||||
prompt: () => void;
|
prompt: (callback?: (notification: any) => void) => void;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -29,15 +30,53 @@ export interface GoogleOneTapOptions {
|
|||||||
*/
|
*/
|
||||||
cancelOnTapOutside?: boolean;
|
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
|
export interface GoogleOneTapActionOptions
|
||||||
extends Omit<GoogleOneTapOptions, "clientId"> {
|
extends Omit<GoogleOneTapOptions, "clientId" | "promptOptions"> {
|
||||||
fetchOptions?: BetterFetchOption;
|
fetchOptions?: BetterFetchOption;
|
||||||
|
/**
|
||||||
|
* Callback URL.
|
||||||
|
*/
|
||||||
callbackURL?: string;
|
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;
|
let isRequestInProgress = false;
|
||||||
@@ -72,31 +111,73 @@ export const oneTapClient = (options: GoogleOneTapOptions) => {
|
|||||||
|
|
||||||
await loadGoogleScript();
|
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({
|
window.google?.accounts.id.initialize({
|
||||||
client_id: options.clientId,
|
client_id: options.clientId,
|
||||||
callback: async (response: { credential: string }) => {
|
callback: async (response: { credential: string }) => {
|
||||||
await $fetch("/one-tap/callback", {
|
isResolved = true;
|
||||||
method: "POST",
|
try {
|
||||||
body: { idToken: response.credential },
|
await $fetch("/one-tap/callback", {
|
||||||
...opts?.fetchOptions,
|
method: "POST",
|
||||||
...fetchOptions,
|
body: { idToken: response.credential },
|
||||||
});
|
...opts?.fetchOptions,
|
||||||
|
...fetchOptions,
|
||||||
|
});
|
||||||
|
|
||||||
// Redirect if no fetch options are provided or a callbackURL is specified
|
if (
|
||||||
if (
|
(!opts?.fetchOptions && !fetchOptions) ||
|
||||||
(!opts?.fetchOptions && !fetchOptions) ||
|
opts?.callbackURL
|
||||||
opts?.callbackURL
|
) {
|
||||||
) {
|
window.location.href = opts?.callbackURL ?? "/";
|
||||||
window.location.href = opts?.callbackURL ?? "/";
|
}
|
||||||
|
resolve();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error during One Tap callback:", error);
|
||||||
|
reject(error);
|
||||||
}
|
}
|
||||||
resolve();
|
|
||||||
},
|
},
|
||||||
auto_select: autoSelect,
|
auto_select: autoSelect,
|
||||||
cancel_on_tap_outside: cancelOnTapOutside,
|
cancel_on_tap_outside: cancelOnTapOutside,
|
||||||
context: contextValue,
|
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) {
|
} catch (error) {
|
||||||
console.error("Error during Google One Tap flow:", error);
|
console.error("Error during Google One Tap flow:", error);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { z } from "zod";
|
|||||||
import { APIError, createAuthEndpoint } from "../../api";
|
import { APIError, createAuthEndpoint } from "../../api";
|
||||||
import { setSessionCookie } from "../../cookies";
|
import { setSessionCookie } from "../../cookies";
|
||||||
import type { BetterAuthPlugin } from "../../types";
|
import type { BetterAuthPlugin } from "../../types";
|
||||||
import { betterFetch } from "@better-fetch/fetch";
|
import { jwtVerify, createRemoteJWKSet } from "jose";
|
||||||
import { toBoolean } from "../../utils/boolean";
|
import { toBoolean } from "../../utils/boolean";
|
||||||
|
|
||||||
interface OneTapOptions {
|
interface OneTapOptions {
|
||||||
@@ -12,6 +12,13 @@ interface OneTapOptions {
|
|||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
disableSignup?: boolean;
|
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) =>
|
export const oneTap = (options?: OneTapOptions) =>
|
||||||
@@ -61,65 +68,86 @@ export const oneTap = (options?: OneTapOptions) =>
|
|||||||
},
|
},
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const { idToken } = ctx.body;
|
const { idToken } = ctx.body;
|
||||||
const { data, error } = await betterFetch<{
|
let payload: any;
|
||||||
email: string;
|
try {
|
||||||
email_verified: string;
|
const JWKS = createRemoteJWKSet(
|
||||||
name: string;
|
new URL("https://www.googleapis.com/oauth2/v3/certs"),
|
||||||
picture: string;
|
);
|
||||||
sub: string;
|
const { payload: verifiedPayload } = await jwtVerify(
|
||||||
}>("https://oauth2.googleapis.com/tokeninfo?id_token=" + idToken);
|
idToken,
|
||||||
if (error) {
|
JWKS,
|
||||||
return ctx.json({
|
{
|
||||||
error: "Invalid token",
|
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(
|
const { email, email_verified, name, picture, sub } = payload;
|
||||||
data.email,
|
if (!email) {
|
||||||
);
|
return ctx.json({ error: "Email not available in token" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await ctx.context.internalAdapter.findUserByEmail(email);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
if (options?.disableSignup) {
|
if (options?.disableSignup) {
|
||||||
throw new APIError("BAD_GATEWAY", {
|
throw new APIError("BAD_GATEWAY", {
|
||||||
message: "User not found",
|
message: "User not found",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const user = await ctx.context.internalAdapter.createOAuthUser(
|
const newUser = await ctx.context.internalAdapter.createOAuthUser(
|
||||||
{
|
{
|
||||||
email: data.email,
|
email,
|
||||||
emailVerified: toBoolean(data.email_verified),
|
emailVerified:
|
||||||
name: data.name,
|
typeof email_verified === "boolean"
|
||||||
image: data.picture,
|
? email_verified
|
||||||
|
: toBoolean(email_verified),
|
||||||
|
name,
|
||||||
|
image: picture,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
providerId: "google",
|
providerId: "google",
|
||||||
accountId: data.sub,
|
accountId: sub,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!user) {
|
if (!newUser) {
|
||||||
throw new APIError("INTERNAL_SERVER_ERROR", {
|
throw new APIError("INTERNAL_SERVER_ERROR", {
|
||||||
message: "Could not create user",
|
message: "Could not create user",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const session = await ctx.context.internalAdapter.createSession(
|
const session = await ctx.context.internalAdapter.createSession(
|
||||||
user?.user.id,
|
newUser.user.id,
|
||||||
ctx.request,
|
ctx.request,
|
||||||
);
|
);
|
||||||
await setSessionCookie(ctx, {
|
await setSessionCookie(ctx, {
|
||||||
user: user.user,
|
user: newUser.user,
|
||||||
session,
|
session,
|
||||||
});
|
});
|
||||||
return ctx.json({
|
return ctx.json({
|
||||||
token: session.token,
|
token: session.token,
|
||||||
user: {
|
user: {
|
||||||
id: user.user.id,
|
id: newUser.user.id,
|
||||||
email: user.user.email,
|
email: newUser.user.email,
|
||||||
emailVerified: user.user.emailVerified,
|
emailVerified: newUser.user.emailVerified,
|
||||||
name: user.user.name,
|
name: newUser.user.name,
|
||||||
image: user.user.image,
|
image: newUser.user.image,
|
||||||
createdAt: user.user.createdAt,
|
createdAt: newUser.user.createdAt,
|
||||||
updatedAt: user.user.updatedAt,
|
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(
|
const session = await ctx.context.internalAdapter.createSession(
|
||||||
user.user.id,
|
user.user.id,
|
||||||
ctx.request,
|
ctx.request,
|
||||||
|
|||||||
Reference in New Issue
Block a user