feat: one tap (#419)

This commit is contained in:
Bereket Engida
2024-11-05 01:59:31 +03:00
committed by GitHub
parent 72de87f651
commit 2ba1584736
21 changed files with 378 additions and 43 deletions

View File

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

View File

@@ -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) => {

View File

@@ -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 () => {

View File

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

View File

@@ -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"
/>

View File

@@ -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) {

View File

@@ -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(),
],
});

View File

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

View 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.

View File

@@ -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);

View File

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

View File

@@ -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";

View File

@@ -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
: {}
: {}

View File

@@ -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>,
) => {

View File

@@ -14,3 +14,4 @@ export * from "./generic-oauth";
export * from "./jwt";
export * from "./multi-session";
export * from "./email-otp";
export * from "./one-tap";

View 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);
});
};

View 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;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
export function toBoolean(value: any): boolean {
return value === "true" || value === true;
}