mirror of
https://github.com/LukeHagar/better-auth.git
synced 2025-12-09 12:27:43 +00:00
feat: one tap (#419)
This commit is contained in:
@@ -3,8 +3,14 @@
|
||||
import SignIn from "@/components/sign-in";
|
||||
import { SignUp } from "@/components/sign-up";
|
||||
import { Tabs } from "@/components/ui/tabs2";
|
||||
import { client } from "@/lib/auth-client";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function Page() {
|
||||
useEffect(() => {
|
||||
client.oneTap();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex items-center flex-col justify-center w-full md:py-10">
|
||||
|
||||
@@ -76,16 +76,21 @@ export default function AdminDashboard() {
|
||||
|
||||
const { data: users, isLoading: isUsersLoading } = useQuery({
|
||||
queryKey: ["users"],
|
||||
queryFn: () =>
|
||||
client.admin
|
||||
.listUsers({
|
||||
queryFn: async () => {
|
||||
const data = await client.admin.listUsers(
|
||||
{
|
||||
query: {
|
||||
limit: 10,
|
||||
sortBy: "createdAt",
|
||||
sortDirection: "desc",
|
||||
},
|
||||
})
|
||||
.then((res) => res.data?.users ?? []),
|
||||
},
|
||||
{
|
||||
throw: true,
|
||||
},
|
||||
);
|
||||
return data?.users || [];
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreateUser = async (e: React.FormEvent) => {
|
||||
|
||||
@@ -42,10 +42,9 @@ import {
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { UAParser } from "ua-parser-js";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -63,14 +62,7 @@ export default function UserCard(props: {
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { data, isPending, error } = useSession();
|
||||
const [ua, setUa] = useState<UAParser.UAParserInstance>();
|
||||
|
||||
const session = data || props.session;
|
||||
|
||||
useEffect(() => {
|
||||
setUa(new UAParser(session?.session.userAgent));
|
||||
}, [session?.session.userAgent]);
|
||||
|
||||
const [isTerminating, setIsTerminating] = useState<string>();
|
||||
const [isPendingTwoFa, setIsPendingTwoFa] = useState<boolean>(false);
|
||||
const [twoFaPassword, setTwoFaPassword] = useState<string>("");
|
||||
@@ -668,7 +660,6 @@ function EditUserDialog(props: { session: Session | null }) {
|
||||
function AddPasskey() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [passkeyName, setPasskeyName] = useState("");
|
||||
const queryClient = useQueryClient();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleAddPasskey = async () => {
|
||||
|
||||
@@ -26,25 +26,8 @@ export default function AccountSwitcher({
|
||||
}: {
|
||||
sessions: Session[];
|
||||
}) {
|
||||
const { data: users } = useQuery({
|
||||
queryKey: ["users"],
|
||||
queryFn: async () => {
|
||||
return;
|
||||
},
|
||||
});
|
||||
const { data: currentUser } = useSession();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleUserSelect = (user: Session) => {
|
||||
// setCurrentUser(user);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleAddAccount = () => {
|
||||
// Implement add account logic here
|
||||
console.log("Add account clicked");
|
||||
setOpen(false);
|
||||
};
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
|
||||
@@ -11,8 +11,8 @@ export const Logo = (props: SVGProps<any>) => {
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M0 0H15V15H30V30H15V45H0V30V15V0ZM45 30V15H30V0H45H60V15V30V45H45H30V30H45Z"
|
||||
className="fill-black dark:fill-white"
|
||||
/>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
twoFactorClient,
|
||||
adminClient,
|
||||
multiSessionClient,
|
||||
oneTapClient,
|
||||
} from "better-auth/client/plugins";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -18,6 +19,9 @@ export const client = createAuthClient({
|
||||
passkeyClient(),
|
||||
adminClient(),
|
||||
multiSessionClient(),
|
||||
oneTapClient({
|
||||
clientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!,
|
||||
}),
|
||||
],
|
||||
fetchOptions: {
|
||||
onError(e) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
organization,
|
||||
passkey,
|
||||
twoFactor,
|
||||
oneTap,
|
||||
} from "better-auth/plugins";
|
||||
import { reactInvitationEmail } from "./email/invitation";
|
||||
import { LibsqlDialect } from "@libsql/kysely-libsql";
|
||||
@@ -120,5 +121,6 @@ export const auth = betterAuth({
|
||||
bearer(),
|
||||
admin(),
|
||||
multiSession(),
|
||||
oneTap(),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -759,6 +759,26 @@ export const contents: Content[] = [
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
title: "One Tap",
|
||||
href: "/docs/plugins/one-tap",
|
||||
icon: () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1.2em"
|
||||
height="1.2em"
|
||||
viewBox="0 0 14 14"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M3.254 4.361a2.861 2.861 0 1 1 5.647.651a.75.75 0 0 0 1.461.34a4.361 4.361 0 1 0-8.495 0a.75.75 0 0 0 1.461-.34a3 3 0 0 1-.074-.651m1.63 5.335V4.26a1.26 1.26 0 0 1 2.518 0v4.077h2.464a2.573 2.573 0 0 1 2.573 2.573V13a1 1 0 0 1-1 1H4.83a1 1 0 0 1-.823-.433l-.764-1.11a1.715 1.715 0 0 1 1.097-2.66l.543-.102Z"
|
||||
clipRule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
title: "Authorization",
|
||||
group: true,
|
||||
|
||||
89
docs/content/docs/plugins/one-tap.mdx
Normal file
89
docs/content/docs/plugins/one-tap.mdx
Normal file
@@ -0,0 +1,89 @@
|
||||
---
|
||||
title: One Tap
|
||||
description: One Tap plugin for BetterAuth
|
||||
---
|
||||
|
||||
The One Tap plugin allows users to login with a single tap using Google's One Tap API.
|
||||
|
||||
## Installation
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
### Add the server Plugin
|
||||
|
||||
Add the One Tap plugin to your auth config.
|
||||
|
||||
```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]
|
||||
})
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Add the client Plugin
|
||||
|
||||
Add the client plugin and Specify where the user should be redirected if they need to verify 2nd factor
|
||||
|
||||
```ts title="client.ts"
|
||||
import { createAuthClient } from "better-auth/client"
|
||||
import { oneTapClient } from "better-auth/client/plugins"
|
||||
|
||||
const client = 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
|
||||
await authClient.oneTap()
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
```tsx
|
||||
authClient.oneTap({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
router.push("/dashboard")
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
If you want it to hard redirect to a different page, you can pass the `callbackURL` option to the `oneTap` method.
|
||||
|
||||
```tsx
|
||||
authClient.oneTap({
|
||||
callbackURL: "/dashboard"
|
||||
})
|
||||
```
|
||||
|
||||
## Client Options
|
||||
|
||||
**clientId**: The client ID of your Google One Tap API
|
||||
|
||||
**autoSelect**: Automatically select the first account in the list. Default is `false`
|
||||
|
||||
**context**: The context in which the One Tap API should be used. Default is `signin`
|
||||
|
||||
**cancelOnTapOutside**: Cancel the One Tap popup when the user taps outside of the popup. Default is `true`.
|
||||
|
||||
## Server Options
|
||||
|
||||
**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.
|
||||
@@ -67,7 +67,6 @@ export const getSession = <Option extends BetterAuthOptions>() =>
|
||||
|
||||
const session =
|
||||
await ctx.context.internalAdapter.findSession(sessionCookieToken);
|
||||
console.log({ session });
|
||||
|
||||
if (!session || session.session.expiresAt < new Date()) {
|
||||
deleteSessionCookie(ctx);
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { APIError } from "better-call";
|
||||
import { generateCodeVerifier } from "oslo/oauth2";
|
||||
import { z } from "zod";
|
||||
import { createAuthEndpoint } from "../call";
|
||||
import { setSessionCookie } from "../../cookies";
|
||||
import { socialProviderList } from "../../social-providers";
|
||||
import { createEmailVerificationToken } from "./email-verification";
|
||||
import { generateState, logger } from "../../utils";
|
||||
import { hmac } from "../../crypto/hash";
|
||||
|
||||
export const signInOAuth = createAuthEndpoint(
|
||||
"/sign-in/social",
|
||||
|
||||
@@ -12,3 +12,4 @@ export * from "../../plugins/generic-oauth/client";
|
||||
export * from "../../plugins/jwt/client";
|
||||
export * from "../../plugins/multi-session/client";
|
||||
export * from "../../plugins/email-otp/client";
|
||||
export * from "../../plugins/one-tap/client";
|
||||
|
||||
@@ -91,7 +91,7 @@ export type InferActions<O extends ClientOptions> = O["plugins"] extends Array<
|
||||
>
|
||||
? UnionToIntersection<
|
||||
Plugin extends BetterAuthClientPlugin
|
||||
? Plugin["getActions"] extends ($fetch: BetterFetch) => infer Actions
|
||||
? Plugin["getActions"] extends (...args: any) => infer Actions
|
||||
? Actions
|
||||
: {}
|
||||
: {}
|
||||
|
||||
@@ -146,7 +146,7 @@ export const createInternalAdapter = (
|
||||
},
|
||||
createSession: async (
|
||||
userId: string,
|
||||
request?: Request | Headers,
|
||||
request: Request | Headers | undefined,
|
||||
dontRememberMe?: boolean,
|
||||
override?: Partial<Session> & Record<string, any>,
|
||||
) => {
|
||||
|
||||
@@ -14,3 +14,4 @@ export * from "./generic-oauth";
|
||||
export * from "./jwt";
|
||||
export * from "./multi-session";
|
||||
export * from "./email-otp";
|
||||
export * from "./one-tap";
|
||||
|
||||
132
packages/better-auth/src/plugins/one-tap/client.ts
Normal file
132
packages/better-auth/src/plugins/one-tap/client.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import type { BetterFetchOption } from "@better-fetch/fetch";
|
||||
import type { BetterAuthClientPlugin } from "../../types";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
google?: {
|
||||
accounts: {
|
||||
id: {
|
||||
initialize: (config: any) => void;
|
||||
prompt: () => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
googleScriptInitialized?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
interface GoogleOneTapOptions {
|
||||
/**
|
||||
* Google client ID
|
||||
*/
|
||||
clientId: string;
|
||||
/**
|
||||
* Auto select the account if the user is already signed in
|
||||
*/
|
||||
autoSelect?: boolean;
|
||||
/**
|
||||
* Cancel the flow when the user taps outside the prompt
|
||||
*/
|
||||
cancelOnTapOutside?: boolean;
|
||||
/**
|
||||
* Context of the Google One Tap flow
|
||||
*/
|
||||
context?: "signin" | "signup" | "use";
|
||||
}
|
||||
|
||||
interface GoogleOneTapActionOptions
|
||||
extends Omit<GoogleOneTapOptions, "clientId"> {
|
||||
fetchOptions?: BetterFetchOption;
|
||||
callbackURL?: string;
|
||||
}
|
||||
|
||||
let isRequestInProgress = false;
|
||||
|
||||
export const oneTapClient = (options: GoogleOneTapOptions) => {
|
||||
return {
|
||||
id: "one-tap",
|
||||
getActions: ($fetch, _) => ({
|
||||
oneTap: async (
|
||||
opts?: GoogleOneTapActionOptions,
|
||||
fetchOptions?: BetterFetchOption,
|
||||
) => {
|
||||
if (isRequestInProgress) {
|
||||
console.warn(
|
||||
"A Google One Tap request is already in progress. Please wait.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
isRequestInProgress = true;
|
||||
|
||||
try {
|
||||
if (typeof window === "undefined" || !window.document) {
|
||||
console.warn(
|
||||
"Google One Tap is only available in browser environments",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { autoSelect, cancelOnTapOutside, context } = opts ?? {};
|
||||
const contextValue = context ?? options.context ?? "signin";
|
||||
|
||||
await loadGoogleScript();
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
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,
|
||||
});
|
||||
|
||||
// Redirect if no fetch options are provided or a callbackURL is specified
|
||||
if (
|
||||
(!opts?.fetchOptions && !fetchOptions) ||
|
||||
opts?.callbackURL
|
||||
) {
|
||||
window.location.href = opts?.callbackURL ?? "/";
|
||||
}
|
||||
resolve();
|
||||
},
|
||||
auto_select: autoSelect,
|
||||
cancel_on_tap_outside: cancelOnTapOutside,
|
||||
context: contextValue,
|
||||
});
|
||||
window.google?.accounts.id.prompt();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error during Google One Tap flow:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
isRequestInProgress = false;
|
||||
}
|
||||
},
|
||||
}),
|
||||
getAtoms($fetch) {
|
||||
return {};
|
||||
},
|
||||
} satisfies BetterAuthClientPlugin;
|
||||
};
|
||||
|
||||
const loadGoogleScript = (): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
if (window.googleScriptInitialized) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://accounts.google.com/gsi/client";
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = () => {
|
||||
window.googleScriptInitialized = true;
|
||||
resolve();
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
};
|
||||
97
packages/better-auth/src/plugins/one-tap/index.ts
Normal file
97
packages/better-auth/src/plugins/one-tap/index.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
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 { toBoolean } from "../../utils/boolean";
|
||||
|
||||
interface OneTapOptions {
|
||||
/**
|
||||
* Disable the signup flow
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
disableSignup?: boolean;
|
||||
}
|
||||
|
||||
export const oneTap = (options?: OneTapOptions) =>
|
||||
({
|
||||
id: "one-tap",
|
||||
endpoints: {
|
||||
oneTapCallback: createAuthEndpoint(
|
||||
"/one-tap/callback",
|
||||
{
|
||||
method: "POST",
|
||||
body: z.object({
|
||||
idToken: z.string(),
|
||||
}),
|
||||
},
|
||||
async (c) => {
|
||||
const { idToken } = c.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 c.json({
|
||||
error: "Invalid token",
|
||||
});
|
||||
}
|
||||
const user = await c.context.internalAdapter.findUserByEmail(
|
||||
data.email,
|
||||
);
|
||||
if (!user) {
|
||||
if (options?.disableSignup) {
|
||||
throw new APIError("BAD_GATEWAY", {
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
const user = await c.context.internalAdapter.createOAuthUser(
|
||||
{
|
||||
email: data.email,
|
||||
emailVerified: toBoolean(data.email_verified),
|
||||
name: data.name,
|
||||
image: data.picture,
|
||||
},
|
||||
{
|
||||
providerId: "google",
|
||||
accountId: data.sub,
|
||||
},
|
||||
);
|
||||
if (!user) {
|
||||
throw new APIError("INTERNAL_SERVER_ERROR", {
|
||||
message: "Could not create user",
|
||||
});
|
||||
}
|
||||
const session = await c.context.internalAdapter.createSession(
|
||||
user?.user.id,
|
||||
c.request,
|
||||
);
|
||||
await setSessionCookie(c, {
|
||||
user: user.user,
|
||||
session,
|
||||
});
|
||||
return c.json({
|
||||
session,
|
||||
user,
|
||||
});
|
||||
}
|
||||
const session = await c.context.internalAdapter.createSession(
|
||||
user.user.id,
|
||||
c.request,
|
||||
);
|
||||
await setSessionCookie(c, {
|
||||
user: user.user,
|
||||
session,
|
||||
});
|
||||
return c.json({
|
||||
session,
|
||||
user,
|
||||
});
|
||||
},
|
||||
),
|
||||
},
|
||||
}) satisfies BetterAuthPlugin;
|
||||
@@ -218,15 +218,17 @@ export const getFullOrganization = createAuthEndpoint(
|
||||
"/organization/get-full",
|
||||
{
|
||||
method: "GET",
|
||||
query: z.object({
|
||||
orgId: z.string().optional(),
|
||||
}),
|
||||
query: z.optional(
|
||||
z.object({
|
||||
orgId: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
requireHeaders: true,
|
||||
use: [orgMiddleware, orgSessionMiddleware],
|
||||
},
|
||||
async (ctx) => {
|
||||
const session = ctx.context.session;
|
||||
const orgId = ctx.query.orgId || session.session.activeOrganizationId;
|
||||
const orgId = ctx.query?.orgId || session.session.activeOrganizationId;
|
||||
if (!orgId) {
|
||||
return ctx.json(null, {
|
||||
status: 400,
|
||||
|
||||
@@ -84,6 +84,7 @@ export const twoFactor = (options?: TwoFactorOptions) => {
|
||||
);
|
||||
const newSession = await ctx.context.internalAdapter.createSession(
|
||||
updatedUser.id,
|
||||
ctx.request,
|
||||
);
|
||||
/**
|
||||
* Update the session cookie with the new user data
|
||||
|
||||
@@ -178,6 +178,7 @@ export const totp2fa = (options: TOTPOptions, twoFactorTable: string) => {
|
||||
);
|
||||
const newSession = await ctx.context.internalAdapter.createSession(
|
||||
user.id,
|
||||
ctx.request,
|
||||
);
|
||||
await setSessionCookie(ctx, {
|
||||
session: newSession,
|
||||
|
||||
3
packages/better-auth/src/utils/boolean.ts
Normal file
3
packages/better-auth/src/utils/boolean.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function toBoolean(value: any): boolean {
|
||||
return value === "true" || value === true;
|
||||
}
|
||||
Reference in New Issue
Block a user