feat: added anonymous auth plugin (#88)

* feat: added anonymous auth

* fix: log

* test: add tests

* docs

---------

Co-authored-by: Bereket Engida <bekacru@gmail.com>
This commit is contained in:
Kinfe Michael Tariku
2024-10-04 23:04:32 +03:00
committed by GitHub
parent 567e718cba
commit f9beea9edc
8 changed files with 295 additions and 1 deletions

View File

@@ -5,6 +5,7 @@ import {
Mailbox, Mailbox,
Phone, Phone,
ScanFace, ScanFace,
UserCircle,
Users2, Users2,
UserSquare2, UserSquare2,
} from "lucide-react"; } from "lucide-react";
@@ -582,6 +583,11 @@ export const contents: Content[] = [
icon: UserSquare2, icon: UserSquare2,
href: "/docs/plugins/username", href: "/docs/plugins/username",
}, },
{
title: "Anonymous",
icon: UserCircle,
href: "/docs/plugins/anonymous",
},
{ {
title: "Phone Number", title: "Phone Number",
icon: Phone, icon: Phone,
@@ -609,6 +615,7 @@ export const contents: Content[] = [
</svg> </svg>
), ),
}, },
{ {
title: "Authorization", title: "Authorization",
group: true, group: true,

View File

@@ -0,0 +1,94 @@
---
title: Aanonymous
description: Anonymous plugin for Better Auth.
---
Anonymous plugin allows you to provide users an authenticated experience without requiring users to enter an email address, password, use an OAuth provider or provide any other PII (Personally Identifiable Information). Later, when ready, the user can link an authentication method to their account.
## Installation
<Steps>
<Step>
### Add the plugin to your auth config
Add the anonymous plugin to your auth config.
```ts title="auth.ts"
import { betterAuth } from "better-auth"
import { anonymous } from "better-auth/plugins" // [!code highlight]
export const auth = await betterAuth({
// ... other config options
plugins: [
anonymous() // [!code highlight]
]
})
```
</Step>
<Step>
### Migrate your database:
Run migrate or generate to create the required tables in your database.
```bash title="terminal"
npx better-auth migrate # [!code highlight]
```
or
```bash title="terminal"
npx better-auth generate
```
See the [schema-section](#schema) to see what fields this plugin requires. You can also add these fields manually to your database.
</Step>
<Step>
### Add the client Plugin
Add the client plugin in your auth client instance.
```ts title="client.ts"
import { createAuthClient } from "better-auth/client"
import { anonymousClient } from "better-auth/client/plugins"
const client = createAuthClient({
plugins: [
anonymousClient()
]
})
```
</Step>
</Steps>
## Usage
### Sign In
To sign in a user anonymously, call the `signIn.anonymously` method.
```ts title="example.ts"
const user = await client.signIn.anonymous()
```
### Link Account
once the user is signed in, you can link an account to the user. Currenyly, only email/password is supported.
```ts title="example.ts"
const user = await client.anonymous.linkAccount({
email: "user@email.com",
password: "secure-password"
})
```
## Schema
The anonymous plugin requires one additinal field in the user table.
<DatabaseTable
fields={[
{ name: "isAnonymous", type: "boolean", description: "Whether the user is anonymous or not." },
]}
/>

View File

@@ -5,3 +5,4 @@ export * from "../../plugins/two-factor/client";
export * from "../../plugins/passkey/client"; export * from "../../plugins/passkey/client";
export * from "../../plugins/magic-link/client"; export * from "../../plugins/magic-link/client";
export * from "../../plugins/phone-number/client"; export * from "../../plugins/phone-number/client";
export * from "../../plugins/anonymous/client";

View File

@@ -31,7 +31,7 @@ export const createInternalAdapter = (
return null; return null;
} }
}, },
createUser: async (user: User) => { createUser: async (user: User & Record<string, any>) => {
const createdUser = await createWithHooks(user, "user"); const createdUser = await createWithHooks(user, "user");
return createdUser; return createdUser;
}, },

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { anonymous } from ".";
import { getTestInstance } from "../../test-utils/test-instance";
import { createAuthClient } from "../../client";
import { anonymousClient } from "./client";
describe("anonymous", async () => {
const { customFetchImpl, sessionSetter } = await getTestInstance({
plugins: [anonymous()],
});
const headers = new Headers();
const client = createAuthClient({
plugins: [anonymousClient()],
fetchOptions: {
customFetchImpl,
headers,
},
baseURL: "http://localhost:3000",
});
it("should sign in anonymously", async () => {
const anonUser = await client.signIn.anonymous({
fetchOptions: {
onSuccess: sessionSetter(headers),
},
});
const userId = anonUser.data?.user.id;
const isAnonymous = anonUser.data?.user.isAnonymous;
const sessionId = anonUser.data?.session.id;
expect(userId).toBeDefined();
expect(isAnonymous).toBeTruthy();
expect(sessionId).toBeDefined();
});
it("link anonymous user account", async () => {
const linkedAccount = await client.user.linkAnonymous({
email: "valid-email@email.com",
password: "valid-password",
});
expect(linkedAccount.data?.user).toBeDefined();
expect(linkedAccount.data?.session).toBeDefined();
});
it("should sign in after link", async () => {
const anonUser = await client.signIn.email({
email: "valid-email@email.com",
password: "valid-password",
});
expect(anonUser.data?.user.id).toBeDefined();
});
});

View File

@@ -0,0 +1,12 @@
import type { anonymous } from ".";
import type { BetterAuthClientPlugin } from "../../client/types";
export const anonymousClient = () => {
return {
id: "anonymous",
$InferServerPlugin: {} as ReturnType<typeof anonymous>,
pathMethods: {
"/sign-in/anonymous": "POST",
},
} satisfies BetterAuthClientPlugin;
};

View File

@@ -0,0 +1,129 @@
import { createAuthEndpoint, sessionMiddleware } from "../../api";
import { alphabet, generateRandomString } from "../../crypto/random";
import type { BetterAuthPlugin } from "../../types";
import { setSessionCookie } from "../../utils/cookies";
import { z } from "zod";
import { generateId } from "../../utils/id";
export const anonymous = () => {
return {
id: "anonymous",
endpoints: {
signInAnonymous: createAuthEndpoint(
"/sign-in/anonymous",
{
method: "POST",
},
async (ctx) => {
const tempEmail =
"temporary-" + Date.now().toString() + "-better-auth@email.com";
const newUser = await ctx.context.internalAdapter.createUser({
id: generateId(),
email: tempEmail,
emailVerified: false,
isAnonymous: true,
name: "Anonymous",
createdAt: new Date(),
updatedAt: new Date(),
});
if (!newUser) {
return ctx.json(null, {
status: 500,
body: {
message: "Failed to create user",
status: 500,
},
});
}
const session = await ctx.context.internalAdapter.createSession(
newUser.id,
ctx.request,
);
if (!session) {
return ctx.json(null, {
status: 400,
body: {
message: "Could not create session",
},
});
}
await setSessionCookie(ctx, session.id);
return ctx.json({ user: newUser, session });
},
),
linkAnonymous: createAuthEndpoint(
"/user/link-anonymous",
{
method: "POST",
body: z.object({
email: z.string().email().optional(),
password: z.string().min(6),
}),
use: [sessionMiddleware],
},
async (ctx) => {
const userId = ctx.context.session.user.id;
const { email, password } = ctx.body;
let updatedUser = null;
// handling both the email - password and updating the user
if (email && password) {
updatedUser = await ctx.context.internalAdapter.updateUser(userId, {
email: email,
});
}
if (!updatedUser) {
return ctx.json(null, {
status: 500,
body: {
message: "Failed to update user",
status: 500,
},
});
}
const hash = await ctx.context.password.hash(password);
const updateUserAccount =
await ctx.context.internalAdapter.linkAccount({
id: generateRandomString(32, alphabet("a-z", "0-9", "A-Z")),
userId: updatedUser.id,
providerId: "credential",
password: hash,
accountId: updatedUser.id,
});
if (!updateUserAccount) {
return ctx.json(null, {
status: 500,
body: {
message: "Failed to update account",
status: 500,
},
});
}
const session = await ctx.context.internalAdapter.createSession(
updatedUser.id,
ctx.request,
);
if (!session) {
return ctx.json(null, {
status: 400,
body: {
message: "Could not create session",
},
});
}
await setSessionCookie(ctx, session.id);
return ctx.json({ session, user: updatedUser });
},
),
},
schema: {
user: {
fields: {
isAnonymous: {
type: "boolean",
defaultValue: true,
required: false,
},
},
},
},
} satisfies BetterAuthPlugin;
};

View File

@@ -8,3 +8,4 @@ export * from "../api/call";
export * from "../utils/hide-metadata"; export * from "../utils/hide-metadata";
export * from "./magic-link"; export * from "./magic-link";
export * from "./phone-number"; export * from "./phone-number";
export * from "./anonymous";