mirror of
https://github.com/LukeHagar/better-auth.git
synced 2025-12-07 20:37:44 +00:00
feat: multi session plugin (#204)
This commit is contained in:
@@ -3,21 +3,28 @@ import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import UserCard from "./user-card";
|
||||
import { OrganizationCard } from "./organization-card";
|
||||
import AccountSwitcher from "@/components/account-swtich";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const [session, activeSessions] = await Promise.all([
|
||||
const [session, activeSessions, deviceSessions] = await Promise.all([
|
||||
auth.api.getSession({
|
||||
headers: await headers(),
|
||||
}),
|
||||
auth.api.listSessions({
|
||||
headers: await headers(),
|
||||
}),
|
||||
auth.api.listDeviceSessions({
|
||||
headers: await headers(),
|
||||
}),
|
||||
]).catch((e) => {
|
||||
throw redirect("/sign-in");
|
||||
});
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex gap-4 flex-col">
|
||||
<AccountSwitcher
|
||||
sessions={JSON.parse(JSON.stringify(deviceSessions))}
|
||||
/>
|
||||
<UserCard
|
||||
session={JSON.parse(JSON.stringify(session))}
|
||||
activeSessions={JSON.parse(JSON.stringify(activeSessions))}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@tailwind utilities;
|
||||
|
||||
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
@@ -24,7 +25,7 @@
|
||||
--border: 20 5.9% 90%;
|
||||
--input: 20 5.9% 90%;
|
||||
--ring: 20 14.3% 4.1%;
|
||||
--radius: 0.3rem;
|
||||
--radius: 0rem;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
|
||||
141
demo/nextjs/components/account-swtich.tsx
Normal file
141
demo/nextjs/components/account-swtich.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Command,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command";
|
||||
import { ChevronDown, LogOutIcon, PlusCircle } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Session } from "@/lib/auth-types";
|
||||
import { client, useSession } from "@/lib/auth-client";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function AccountSwitcher({
|
||||
sessions,
|
||||
}: {
|
||||
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}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-label="Select a user"
|
||||
className="w-[250px] justify-between"
|
||||
>
|
||||
<Avatar className="mr-2 h-6 w-6">
|
||||
<AvatarImage
|
||||
src={currentUser?.user.image}
|
||||
alt={currentUser?.user.name}
|
||||
/>
|
||||
<AvatarFallback>{currentUser?.user.name.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
{currentUser?.user.name}
|
||||
<ChevronDown className="ml-auto h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[250px] p-0">
|
||||
<Command>
|
||||
<CommandList>
|
||||
<CommandGroup heading="Current Account">
|
||||
<CommandItem
|
||||
onSelect={() => {}}
|
||||
className="text-sm w-full justify-between"
|
||||
key={currentUser?.user.id}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Avatar className="mr-2 h-5 w-5">
|
||||
<AvatarImage
|
||||
src={currentUser?.user.image}
|
||||
alt={currentUser?.user.name}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{currentUser?.user.name.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{currentUser?.user.name}
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Switch Account">
|
||||
{sessions
|
||||
.filter((s) => s.user.id !== currentUser?.user.id)
|
||||
.map((u, i) => (
|
||||
<CommandItem
|
||||
key={i}
|
||||
onSelect={async () => {
|
||||
await client.multiSession.setActive({
|
||||
sessionId: u.session.id,
|
||||
});
|
||||
setOpen(false);
|
||||
}}
|
||||
className="text-sm"
|
||||
>
|
||||
<Avatar className="mr-2 h-5 w-5">
|
||||
<AvatarImage src={u.user.image} alt={u.user.name} />
|
||||
<AvatarFallback>{u.user.name.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div>
|
||||
<p>{u.user.name}</p>
|
||||
<p className="text-xs">({u.user.email})</p>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
<CommandSeparator />
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
router.push("/sign-in");
|
||||
setOpen(false);
|
||||
}}
|
||||
className="cursor-pointer text-sm"
|
||||
>
|
||||
<PlusCircle className="mr-2 h-5 w-5" />
|
||||
Add Account
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -16,10 +16,12 @@ const PasswordInput = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
{...props}
|
||||
type="text"
|
||||
autoComplete="new-password"
|
||||
name="password_fake"
|
||||
className={cn("hide-password-toggle pr-10", className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -29,7 +31,7 @@ const PasswordInput = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{showPassword && !disabled ? (
|
||||
{!showPassword && !disabled ? (
|
||||
<EyeIcon className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<EyeOffIcon className="h-4 w-4" aria-hidden="true" />
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
passkeyClient,
|
||||
twoFactorClient,
|
||||
adminClient,
|
||||
multiSessionClient,
|
||||
} from "better-auth/client/plugins";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -16,6 +17,7 @@ export const client = createAuthClient({
|
||||
}),
|
||||
passkeyClient(),
|
||||
adminClient(),
|
||||
multiSessionClient(),
|
||||
],
|
||||
fetchOptions: {
|
||||
onError(e) {
|
||||
|
||||
@@ -5,11 +5,13 @@ import {
|
||||
passkey,
|
||||
twoFactor,
|
||||
admin,
|
||||
multiSession,
|
||||
} from "better-auth/plugins";
|
||||
import { reactInvitationEmail } from "./email/invitation";
|
||||
import { LibsqlDialect } from "@libsql/kysely-libsql";
|
||||
import { reactResetPasswordEmail } from "./email/rest-password";
|
||||
import { resend } from "./email/resend";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
const from = process.env.BETTER_AUTH_EMAIL || "delivered@resend.dev";
|
||||
const to = process.env.TEST_EMAIL || "";
|
||||
@@ -19,6 +21,8 @@ const libsql = new LibsqlDialect({
|
||||
authToken: process.env.TURSO_AUTH_TOKEN || "",
|
||||
});
|
||||
|
||||
const database = new Database("auth.db");
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: {
|
||||
dialect: libsql,
|
||||
@@ -51,6 +55,28 @@ export const auth = betterAuth({
|
||||
});
|
||||
},
|
||||
},
|
||||
socialProviders: {
|
||||
github: {
|
||||
clientId: process.env.GITHUB_CLIENT_ID || "",
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET || "",
|
||||
},
|
||||
google: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID || "",
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
|
||||
},
|
||||
discord: {
|
||||
clientId: process.env.DISCORD_CLIENT_ID || "",
|
||||
clientSecret: process.env.DISCORD_CLIENT_SECRET || "",
|
||||
},
|
||||
microsoft: {
|
||||
clientId: process.env.MICROSOFT_CLIENT_ID || "",
|
||||
clientSecret: process.env.MICROSOFT_CLIENT_SECRET || "",
|
||||
},
|
||||
twitch: {
|
||||
clientId: process.env.TWITCH_CLIENT_ID || "",
|
||||
clientSecret: process.env.TWITCH_CLIENT_SECRET || "",
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
organization({
|
||||
async sendInvitationEmail(data) {
|
||||
@@ -91,27 +117,6 @@ export const auth = betterAuth({
|
||||
passkey(),
|
||||
bearer(),
|
||||
admin(),
|
||||
multiSession(),
|
||||
],
|
||||
socialProviders: {
|
||||
github: {
|
||||
clientId: process.env.GITHUB_CLIENT_ID || "",
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET || "",
|
||||
},
|
||||
google: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID || "",
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
|
||||
},
|
||||
discord: {
|
||||
clientId: process.env.DISCORD_CLIENT_ID || "",
|
||||
clientSecret: process.env.DISCORD_CLIENT_SECRET || "",
|
||||
},
|
||||
microsoft: {
|
||||
clientId: process.env.MICROSOFT_CLIENT_ID || "",
|
||||
clientSecret: process.env.MICROSOFT_CLIENT_SECRET || "",
|
||||
},
|
||||
twitch: {
|
||||
clientId: process.env.TWITCH_CLIENT_ID || "",
|
||||
clientSecret: process.env.TWITCH_CLIENT_SECRET || "",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,9 +5,7 @@ import { auth } from "@/lib/auth";
|
||||
export default authMiddleware({
|
||||
customRedirect: async (session, request) => {
|
||||
const baseURL = request.nextUrl.origin;
|
||||
if (request.nextUrl.pathname === "/sign-in" && session) {
|
||||
return NextResponse.redirect(new URL("/dashboard", baseURL));
|
||||
}
|
||||
|
||||
if (request.nextUrl.pathname === "/dashboard" && !session) {
|
||||
return NextResponse.redirect(new URL("/sign-in", baseURL));
|
||||
}
|
||||
|
||||
@@ -727,6 +727,23 @@ export const contents: Content[] = [
|
||||
icon: () => <Key className="w-4 h-4" />,
|
||||
href: "/docs/plugins/bearer",
|
||||
},
|
||||
{
|
||||
title: "Multi Session",
|
||||
icon: () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1.2em"
|
||||
height="1.2em"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M16.885 13.616q-.82 0-1.41-.591t-.59-1.41t.59-1.41t1.41-.59q.819 0 1.41.59q.59.591.59 1.41q0 .82-.59 1.41q-.591.59-1.41.59m-4.5 5v-.9q0-.465.232-.843q.232-.379.66-.545q.845-.356 1.748-.534q.904-.177 1.86-.177q.916 0 1.821.177q.905.178 1.786.534q.428.166.66.545q.232.378.232.844v.9zm-2.77-7.23q-1.237 0-2.118-.882t-.881-2.118t.88-2.12t2.12-.88t2.118.88t.882 2.12t-.882 2.118t-2.118.882m-7 7.23V16.97q0-.648.357-1.192q.358-.544.973-.804q1.327-.673 2.756-1.015t2.914-.342q.605 0 1.211.063t1.212.167l-.427.446l-.427.447q-.393-.077-.785-.1t-.784-.023q-1.354 0-2.675.292t-2.518.942q-.327.183-.567.456t-.24.663v.646h6v1zm7-8.23q.825 0 1.412-.588t.588-1.412t-.588-1.413t-1.412-.587t-1.413.587t-.587 1.413t.587 1.412t1.413.588"
|
||||
></path>
|
||||
</svg>
|
||||
),
|
||||
href: "/docs/plugins/multi-session",
|
||||
},
|
||||
{
|
||||
title: "JWT",
|
||||
icon: () => (
|
||||
|
||||
106
docs/content/docs/plugins/multi-session.mdx
Normal file
106
docs/content/docs/plugins/multi-session.mdx
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
title: Multi Session
|
||||
description: Learn how to use multi-session plugin in Better Auth.
|
||||
---
|
||||
|
||||
The multi-session plugin allows users to maintain multiple active sessions across different accounts in the same browser. This plugin is useful for applications that require users to switch between multiple accounts without logging out.
|
||||
|
||||
## Installation
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
### Add the plugin to your **auth** config
|
||||
```ts title="auth.ts"
|
||||
import { betterAuth } from "better-auth"
|
||||
import { multiSession } from "better-auth/plugins"
|
||||
|
||||
export const auth = betterAuth({
|
||||
plugins: [ // [!code highlight]
|
||||
multiSession(), // [!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 { multiSessionClient } from "better-auth/client/plugins"
|
||||
|
||||
const client = createAuthClient({
|
||||
plugins: [
|
||||
multiSessionClient()
|
||||
]
|
||||
})
|
||||
```
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
Whenver a user logs in, the plugin will add additional cookie to the browser. This cookie will be used to maintain multiple sessions across different accounts.
|
||||
|
||||
|
||||
### List all device sessions
|
||||
|
||||
To list all active sessions for the current user, you can call the `listDeviceSessions` method.
|
||||
|
||||
```ts
|
||||
await authClient.multiSession.listDeviceSessions()
|
||||
```
|
||||
|
||||
on the server you can call `listDeviceSessions` method.
|
||||
|
||||
```ts
|
||||
await auth.api.listDeviceSessions()
|
||||
```
|
||||
|
||||
### Set active session
|
||||
|
||||
To set the active session, you can call the `setActive` method.
|
||||
|
||||
```ts
|
||||
await authClient.multiSession.setActive({
|
||||
sessionId: "session-id"
|
||||
})
|
||||
```
|
||||
|
||||
### Revoke a session
|
||||
|
||||
To revoke a session, you can call the `revoke` method.
|
||||
|
||||
```ts
|
||||
await authClient.multiSession.revoke({
|
||||
sessionId: "session-id"
|
||||
})
|
||||
```
|
||||
|
||||
### Revoke all sessions
|
||||
|
||||
To revoke all sessions, you can call the existing `signOut` method.
|
||||
|
||||
```ts
|
||||
await authClient.signOut()
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
### Max Sessions
|
||||
|
||||
You can specify the maximum number of sessions a user can have by passing the `maximumSessions` option to the plugin. By default, the plugin allows 5 sessions per device.
|
||||
|
||||
```ts title="auth.ts"
|
||||
import { betterAuth } from "better-auth"
|
||||
|
||||
export const auth = betterAuth({
|
||||
plugins: [
|
||||
multiSession({
|
||||
maximumSessions: 3
|
||||
})
|
||||
]
|
||||
})
|
||||
```
|
||||
@@ -1,4 +1,4 @@
|
||||
import { APIError, type Endpoint, createRouter } from "better-call";
|
||||
import { APIError, type Endpoint, createRouter, statusCode } from "better-call";
|
||||
import type { AuthContext } from "../init";
|
||||
import type { BetterAuthOptions } from "../types";
|
||||
import type { UnionToIntersection } from "../types/helper";
|
||||
@@ -140,15 +140,46 @@ export function getEndpoints<
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let endpointRes: any;
|
||||
try {
|
||||
//@ts-ignore
|
||||
const endpointRes = await value({
|
||||
endpointRes = await value({
|
||||
...context,
|
||||
context: {
|
||||
...c,
|
||||
...context.context,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof APIError) {
|
||||
let response = new Response(JSON.stringify(e.body), {
|
||||
status: statusCode[e.status],
|
||||
headers: e.headers,
|
||||
});
|
||||
for (const plugin of options.plugins || []) {
|
||||
if (plugin.hooks?.after) {
|
||||
for (const hook of plugin.hooks.after) {
|
||||
const match = hook.matcher(context);
|
||||
if (match) {
|
||||
const obj = Object.assign(context, {
|
||||
context: {
|
||||
...ctx,
|
||||
returned: response,
|
||||
},
|
||||
});
|
||||
const hookRes = await hook.handler(obj);
|
||||
if (hookRes && "response" in hookRes) {
|
||||
response = hookRes.response as any;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
let response = endpointRes;
|
||||
for (const plugin of options.plugins || []) {
|
||||
if (plugin.hooks?.after) {
|
||||
|
||||
@@ -9,3 +9,4 @@ export * from "../../plugins/anonymous/client";
|
||||
export * from "../../plugins/additional-fields/client";
|
||||
export * from "../../plugins/admin/client";
|
||||
export * from "../../plugins/generic-oauth/client";
|
||||
export * from "../../plugins/multi-session/client";
|
||||
|
||||
@@ -207,5 +207,15 @@ export function parseSetCookieHeader(
|
||||
|
||||
return cookieMap;
|
||||
}
|
||||
export function parseCookies(cookieHeader: string) {
|
||||
const cookies = cookieHeader.split("; ");
|
||||
const cookieMap = new Map<string, string>();
|
||||
|
||||
cookies.forEach((cookie) => {
|
||||
const [name, value] = cookie.split("=");
|
||||
cookieMap.set(name, value);
|
||||
});
|
||||
return cookieMap;
|
||||
}
|
||||
|
||||
export type EligibleCookies = (string & {}) | (keyof BetterAuthCookies & {});
|
||||
|
||||
@@ -12,3 +12,4 @@ export * from "./anonymous";
|
||||
export * from "./admin";
|
||||
export * from "./generic-oauth";
|
||||
export * from "./jwt";
|
||||
export * from "./multi-session";
|
||||
|
||||
20
packages/better-auth/src/plugins/multi-session/client.ts
Normal file
20
packages/better-auth/src/plugins/multi-session/client.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { multiSession } from ".";
|
||||
import type { BetterAuthClientPlugin } from "../../types";
|
||||
|
||||
export const multiSessionClient = () => {
|
||||
return {
|
||||
id: "multi-session",
|
||||
$InferServerPlugin: {} as ReturnType<typeof multiSession>,
|
||||
pathMethods: {
|
||||
"/multi-session/sign-out-device-sessions": "POST",
|
||||
},
|
||||
atomListeners: [
|
||||
{
|
||||
matcher(path) {
|
||||
return path === "/multi-session/set-active";
|
||||
},
|
||||
signal: "_sessionSignal",
|
||||
},
|
||||
],
|
||||
} satisfies BetterAuthClientPlugin;
|
||||
};
|
||||
257
packages/better-auth/src/plugins/multi-session/index.ts
Normal file
257
packages/better-auth/src/plugins/multi-session/index.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
APIError,
|
||||
createAuthEndpoint,
|
||||
createAuthMiddleware,
|
||||
sessionMiddleware,
|
||||
} from "../../api";
|
||||
import { parseCookies, parseSetCookieHeader } from "../../cookies";
|
||||
import type { BetterAuthPlugin, Session, User } from "../../types";
|
||||
|
||||
interface MultiSessionConfig {
|
||||
/**
|
||||
* The maximum number of sessions a user can have
|
||||
* at a time
|
||||
* @default 5
|
||||
*/
|
||||
maximumSessions?: number;
|
||||
}
|
||||
|
||||
export const multiSession = (options?: MultiSessionConfig) => {
|
||||
const opts = {
|
||||
maximumSessions: 5,
|
||||
...options,
|
||||
};
|
||||
|
||||
const isMultiSessionCookie = (key: string) => key.includes("_multi-");
|
||||
|
||||
return {
|
||||
id: "multi-session",
|
||||
endpoints: {
|
||||
listDeviceSessions: createAuthEndpoint(
|
||||
"/multi-session/list-device-sessions",
|
||||
{
|
||||
method: "GET",
|
||||
requireHeaders: true,
|
||||
},
|
||||
async (ctx) => {
|
||||
const cookieHeader = ctx.headers?.get("cookie");
|
||||
if (!cookieHeader) return ctx.json([]);
|
||||
|
||||
const cookies = Object.fromEntries(parseCookies(cookieHeader));
|
||||
const sessions: {
|
||||
session: Session;
|
||||
user: User;
|
||||
}[] = [];
|
||||
|
||||
const sessionPromises = Object.entries(cookies)
|
||||
.filter(([key]) => isMultiSessionCookie(key))
|
||||
.map(async ([key]) => {
|
||||
const sessionId = await ctx.getSignedCookie(
|
||||
key,
|
||||
ctx.context.secret,
|
||||
);
|
||||
if (!sessionId) return null;
|
||||
|
||||
const session =
|
||||
await ctx.context.internalAdapter.findSession(sessionId);
|
||||
if (!session || session.session.expiresAt <= new Date()) {
|
||||
ctx.setCookie(key, "", {
|
||||
...ctx.context.authCookies.sessionToken.options,
|
||||
maxAge: 0,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
});
|
||||
|
||||
const validSessions = (await Promise.all(sessionPromises)).filter(
|
||||
Boolean,
|
||||
) as {
|
||||
session: Session;
|
||||
user: User;
|
||||
}[];
|
||||
|
||||
sessions.push(
|
||||
...validSessions.filter(
|
||||
(session, index, self) =>
|
||||
index === self.findIndex((s) => s.user.id === session.user.id),
|
||||
),
|
||||
);
|
||||
|
||||
return ctx.json(sessions);
|
||||
},
|
||||
),
|
||||
setActiveSession: createAuthEndpoint(
|
||||
"/multi-session/set-active",
|
||||
{
|
||||
method: "POST",
|
||||
body: z.object({
|
||||
sessionId: z.string(),
|
||||
}),
|
||||
requireHeaders: true,
|
||||
use: [sessionMiddleware],
|
||||
},
|
||||
async (ctx) => {
|
||||
const sessionId = ctx.body.sessionId;
|
||||
const multiSessionCookieName = `${ctx.context.authCookies.sessionToken.name}_multi-${sessionId}`;
|
||||
const sessionCookie = await ctx.getSignedCookie(
|
||||
multiSessionCookieName,
|
||||
ctx.context.secret,
|
||||
);
|
||||
if (!sessionCookie) {
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
message: "Invalid session id",
|
||||
});
|
||||
}
|
||||
const session =
|
||||
await ctx.context.internalAdapter.findSession(sessionId);
|
||||
if (!session || session.session.expiresAt < new Date()) {
|
||||
ctx.setCookie(multiSessionCookieName, "", {
|
||||
...ctx.context.authCookies.sessionToken.options,
|
||||
maxAge: 0,
|
||||
});
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
message: "Invalid session id",
|
||||
});
|
||||
}
|
||||
await ctx.setSignedCookie(
|
||||
ctx.context.authCookies.sessionToken.name,
|
||||
sessionId,
|
||||
ctx.context.secret,
|
||||
ctx.context.authCookies.sessionToken.options,
|
||||
);
|
||||
return ctx.json(session);
|
||||
},
|
||||
),
|
||||
DeviceSession: createAuthEndpoint(
|
||||
"/multi-session/revoke",
|
||||
{
|
||||
method: "POST",
|
||||
body: z.object({
|
||||
sessionId: z.string(),
|
||||
}),
|
||||
requireHeaders: true,
|
||||
use: [sessionMiddleware],
|
||||
},
|
||||
async (ctx) => {
|
||||
const sessionId = ctx.body.sessionId;
|
||||
const multiSessionCookieName = `${ctx.context.authCookies.sessionToken.name}_multi-${sessionId}`;
|
||||
const sessionCookie = await ctx.getSignedCookie(
|
||||
multiSessionCookieName,
|
||||
ctx.context.secret,
|
||||
);
|
||||
if (!sessionCookie) {
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
message: "Invalid session id",
|
||||
});
|
||||
}
|
||||
const session =
|
||||
await ctx.context.internalAdapter.findSession(sessionId);
|
||||
if (!session || session.session.expiresAt < new Date()) {
|
||||
ctx.setCookie(multiSessionCookieName, "", {
|
||||
...ctx.context.authCookies.sessionToken.options,
|
||||
maxAge: 0,
|
||||
});
|
||||
return ctx.json({
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
await ctx.context.internalAdapter.deleteSession(sessionId);
|
||||
ctx.setCookie(multiSessionCookieName, "", {
|
||||
...ctx.context.authCookies.sessionToken.options,
|
||||
maxAge: 0,
|
||||
});
|
||||
return ctx.json({
|
||||
success: true,
|
||||
});
|
||||
},
|
||||
),
|
||||
},
|
||||
hooks: {
|
||||
after: [
|
||||
{
|
||||
matcher: () => true,
|
||||
handler: createAuthMiddleware(async (ctx) => {
|
||||
if (
|
||||
!ctx.context.returned ||
|
||||
!(ctx.context.returned instanceof Response)
|
||||
)
|
||||
return;
|
||||
|
||||
const cookieString = ctx.context.returned.headers.get("set-cookie");
|
||||
if (!cookieString) return;
|
||||
|
||||
const setCookies = parseSetCookieHeader(cookieString);
|
||||
const sessionCookieConfig = ctx.context.authCookies.sessionToken;
|
||||
const sessionToken = setCookies.get(
|
||||
sessionCookieConfig.name,
|
||||
)?.value;
|
||||
if (!sessionToken) return;
|
||||
|
||||
const cookies = parseCookies(ctx.headers?.get("cookie") || "");
|
||||
const rawSession = sessionToken.split(".")[0];
|
||||
const cookieName = `${sessionCookieConfig.name}_multi-${rawSession}`;
|
||||
|
||||
if (setCookies.get(cookieName) || cookies.get(cookieName)) return;
|
||||
|
||||
const currentMultiSessions =
|
||||
Object.keys(cookies).filter(isMultiSessionCookie).length;
|
||||
const toBeAdded = Object.keys(setCookies).filter((key) =>
|
||||
key.includes("session_token"),
|
||||
).length;
|
||||
|
||||
if (currentMultiSessions + toBeAdded > opts.maximumSessions) {
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
message: "Maximum number of device sessions reached.",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.setSignedCookie(
|
||||
cookieName,
|
||||
rawSession,
|
||||
ctx.context.secret,
|
||||
sessionCookieConfig.options,
|
||||
);
|
||||
const response = ctx.context.returned;
|
||||
response.headers.append(
|
||||
"Set-Cookie",
|
||||
ctx.responseHeader.get("set-cookie")!,
|
||||
);
|
||||
|
||||
return { response };
|
||||
}),
|
||||
},
|
||||
{
|
||||
matcher: (context) => context.path === "/sign-out",
|
||||
handler: createAuthMiddleware(async (ctx) => {
|
||||
const cookieHeader = ctx.headers?.get("cookie");
|
||||
if (!cookieHeader) return;
|
||||
|
||||
const cookies = Object.fromEntries(parseCookies(cookieHeader));
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(cookies).map(async ([key, value]) => {
|
||||
if (isMultiSessionCookie(key)) {
|
||||
ctx.setCookie(key, "", { maxAge: 0 });
|
||||
await ctx.context.internalAdapter.deleteSession(
|
||||
key.split("_multi-")[1],
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const response = ctx.context.returned;
|
||||
response?.headers.append(
|
||||
"Set-Cookie",
|
||||
ctx.responseHeader.get("set-cookie")!,
|
||||
);
|
||||
|
||||
return { response };
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies BetterAuthPlugin;
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getTestInstance } from "../../test-utils/test-instance";
|
||||
import { multiSession } from ".";
|
||||
import { multiSessionClient } from "./client";
|
||||
import { parseSetCookieHeader } from "../../cookies";
|
||||
|
||||
describe("multi-session", async () => {
|
||||
const { auth, client, signInWithTestUser, testUser } = await getTestInstance(
|
||||
{
|
||||
plugins: [
|
||||
multiSession({
|
||||
maximumSessions: 2,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
clientOptions: {
|
||||
plugins: [multiSessionClient()],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let headers = new Headers();
|
||||
const testUser2 = {
|
||||
email: "second-email@test.com",
|
||||
password: "password",
|
||||
name: "Name",
|
||||
};
|
||||
|
||||
it("should set multi session when there is set-cookie header", async () => {
|
||||
await client.signIn.email(
|
||||
{
|
||||
email: testUser.email,
|
||||
password: testUser.password,
|
||||
},
|
||||
{
|
||||
onResponse(context) {
|
||||
const setCookieString = context.response.headers.get("set-cookie");
|
||||
const setCookies = parseSetCookieHeader(setCookieString || "");
|
||||
const sessionToken = setCookies
|
||||
.get("better-auth.session_token")
|
||||
?.value.split(".")[0];
|
||||
const multiSession = setCookies.get(
|
||||
`better-auth.session_token_multi-${sessionToken}`,
|
||||
)?.value;
|
||||
expect(sessionToken).not.toBe(null);
|
||||
expect(multiSession).not.toBe(null);
|
||||
expect(multiSession).toContain(sessionToken);
|
||||
expect(setCookieString).toContain("better-auth.session_token_multi-");
|
||||
headers.set("cookie", `${setCookieString?.split(",").join(";")}`);
|
||||
},
|
||||
},
|
||||
);
|
||||
await client.signUp.email(testUser2, {
|
||||
onSuccess(context) {
|
||||
const setCookieString = context.response.headers.get("set-cookie");
|
||||
headers.set(
|
||||
"cookie",
|
||||
`${headers.get("cookie")}; ${setCookieString?.split(",").join(";")}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should get active session", async () => {
|
||||
const session = await client.session({
|
||||
fetchOptions: {
|
||||
headers,
|
||||
},
|
||||
});
|
||||
expect(session.data?.user.email).toBe(testUser2.email);
|
||||
});
|
||||
|
||||
let sessionId = "";
|
||||
it("should list all device sessions", async () => {
|
||||
const res = await client.multiSession.listDeviceSessions({
|
||||
fetchOptions: {
|
||||
headers,
|
||||
},
|
||||
});
|
||||
if (res.data) {
|
||||
sessionId = res.data[0].session.id;
|
||||
}
|
||||
expect(res.data).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should set active session", async () => {
|
||||
const res = await client.multiSession.setActive({
|
||||
sessionId,
|
||||
fetchOptions: {
|
||||
headers,
|
||||
},
|
||||
});
|
||||
expect(res.data?.user.email).toBe(testUser.email);
|
||||
});
|
||||
|
||||
it("should throw error when setting above maximum sessions", async () => {
|
||||
const res = await client.signUp.email(
|
||||
{
|
||||
email: "new-email-2@email.com",
|
||||
password: "password",
|
||||
name: "Name",
|
||||
},
|
||||
{
|
||||
headers,
|
||||
},
|
||||
);
|
||||
console.log(res);
|
||||
});
|
||||
|
||||
it("should sign-out a session", async () => {
|
||||
await client.multiSession.revoke({
|
||||
fetchOptions: {
|
||||
headers,
|
||||
},
|
||||
sessionId,
|
||||
});
|
||||
const res = await client.multiSession.listDeviceSessions({
|
||||
fetchOptions: {
|
||||
headers,
|
||||
},
|
||||
});
|
||||
expect(res.data).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should sign-out all sessions", async () => {
|
||||
await client.signOut({
|
||||
fetchOptions: {
|
||||
headers,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await client.multiSession.listDeviceSessions({
|
||||
fetchOptions: {
|
||||
headers,
|
||||
},
|
||||
});
|
||||
expect(res.data).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user