Merge branch 'canary' into v1.3.8-staging

# Conflicts:
#	demo/nextjs/package.json
#	docs/package.json
#	packages/better-auth/package.json
#	pnpm-lock.yaml
This commit is contained in:
Alex Yang
2025-09-03 15:21:51 -07:00
275 changed files with 26939 additions and 6406 deletions

2
.github/CODEOWNERS vendored
View File

@@ -1,3 +1,3 @@
# https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners # https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners
* @Bekacru * @Bekacru @himself65

View File

@@ -1,46 +1,46 @@
{ {
"$schema": "https://docs.renovatebot.com/renovate-schema.json", $schema: 'https://docs.renovatebot.com/renovate-schema.json',
"extends": [ extends: [
"config:recommended", 'config:recommended',
"schedule:weekly", 'schedule:weekly',
"group:allNonMajor", 'group:allNonMajor',
":disablePeerDependencies", ':disablePeerDependencies',
"regexManagers:biomeVersions", 'customManagers:biomeVersions',
"helpers:pinGitHubActionDigestsToSemver" 'helpers:pinGitHubActionDigestsToSemver',
], ],
"labels": [ labels: [
"dependencies" 'dependencies',
], ],
"rangeStrategy": "bump", rangeStrategy: 'bump',
"postUpdateOptions": [ postUpdateOptions: [
"pnpmDedupe" 'pnpmDedupe',
], ],
"ignorePaths": [ ignorePaths: [
"**/node_modules/**" '**/node_modules/**',
], ],
"packageRules": [ packageRules: [
{ {
"groupName": "github-actions", groupName: 'github-actions',
"matchManagers": [ matchManagers: [
"github-actions" 'github-actions',
] ],
}, },
{ {
"groupName": "better-auth dependencies", groupName: 'better-auth dependencies',
"matchManagers": [ matchManagers: [
"npm" 'npm',
], ],
"matchFileNames": [ matchFileNames: [
"packages/better-auth/**" 'packages/better-auth/**',
] ],
} },
], ],
"ignoreDeps": [ ignoreDeps: [
"@biomejs/biome", '@biomejs/biome',
"@types/node", '@types/node',
"drizzle-orm", 'drizzle-orm',
"node", 'node',
"npm", 'npm',
"pnpm", 'pnpm',
], ],
} }

View File

@@ -17,9 +17,9 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
node-version: [20.x, 22.x] node-version: [22.x, 24.x]
steps: steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with: with:
fetch-depth: 0 fetch-depth: 0

115
.github/workflows/e2e.yml vendored Normal file
View File

@@ -0,0 +1,115 @@
name: E2E
on:
push:
branches: [ main, canary ]
pull_request:
jobs:
smoke:
name: Smoke test
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
- name: Cache turbo build setup
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22.x
registry-url: 'https://registry.npmjs.org'
cache: pnpm
- name: Install
run: pnpm install
- name: Build
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM || github.repository_owner }}
TURBO_REMOTE_ONLY: true
run: pnpm build
- name: Start Docker Containers
run: |
docker compose up -d
# Wait for services to be ready (optional)
sleep 10
- uses: oven-sh/setup-bun@v2
- uses: denoland/setup-deno@e95548e56dfa95d4e1a28d6f422fafe75c4c26fb # v2.0.3
with:
deno-version: v2.x
- name: Smoke
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM || github.repository_owner }}
TURBO_REMOTE_ONLY: true
run: pnpm e2e:smoke
- name: Stop Docker Containers
run: docker compose down
integration:
name: Integration test
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
- name: Cache turbo build setup
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22.x
registry-url: 'https://registry.npmjs.org'
cache: pnpm
- name: Install
run: pnpm install
- name: Install Playwright Browsers
run: pnpx playwright install --with-deps
- name: Build
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM || github.repository_owner }}
TURBO_REMOTE_ONLY: true
run: pnpm build
- name: Start Docker Containers
run: |
docker compose up -d
# Wait for services to be ready (optional)
sleep 10
- name: Integration
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM || github.repository_owner }}
TURBO_REMOTE_ONLY: true
run: pnpm e2e:integration
- name: Stop Docker Containers
run: docker compose down

View File

@@ -6,7 +6,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -22,7 +22,7 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version: 20.x node-version: 22.x
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
cache: pnpm cache: pnpm

View File

@@ -12,13 +12,13 @@ jobs:
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version: 20.x node-version: 22.x
- run: npx changelogithub - run: npx changelogithub
env: env:
@@ -28,7 +28,7 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version: 20.x node-version: 22.x
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
- run: pnpm install - run: pnpm install

3
.gitignore vendored
View File

@@ -194,3 +194,6 @@ android/
.vinxi .vinxi
# Turborepo # Turborepo
.turbo .turbo
playwright-report/
test-results/

2
.nvmrc
View File

@@ -1 +1 @@
22.10.0 22.18.0

View File

@@ -24,6 +24,6 @@
"editor.defaultFormatter": "biomejs.biome" "editor.defaultFormatter": "biomejs.biome"
}, },
"[mdx]": { "[mdx]": {
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.defaultFormatter": "unifiedjs.vscode-mdx"
} }
} }

View File

@@ -21,9 +21,9 @@
<a href="https://github.com/better-auth/better-auth/issues">Issues</a> <a href="https://github.com/better-auth/better-auth/issues">Issues</a>
</p> </p>
[![npm](https://img.shields.io/npm/dm/better-auth)](https://npm.chart.dev/better-auth?primary=neutral&gray=neutral&theme=dark) [![npm](https://img.shields.io/npm/dm/better-auth?style=flat&colorA=000000&colorB=000000)](https://npm.chart.dev/better-auth?primary=neutral&gray=neutral&theme=dark)
[![npm version](https://img.shields.io/npm/v/better-auth.svg)](https://www.npmjs.com/package/better-auth) [![npm version](https://img.shields.io/npm/v/better-auth.svg?style=flat&colorA=000000&colorB=000000)](https://www.npmjs.com/package/better-auth)
[![GitHub stars](https://img.shields.io/github/stars/better-auth/better-auth)](https://github.com/better-auth/better-auth/stargazers) [![GitHub stars](https://img.shields.io/github/stars/better-auth/better-auth?style=flat&colorA=000000&colorB=000000)](https://github.com/better-auth/better-auth/stargazers)
</p> </p>
## About the Project ## About the Project

View File

@@ -12,7 +12,8 @@
"recommended": false, "recommended": false,
"suspicious": { "suspicious": {
"noImplicitAnyLet": "warn", "noImplicitAnyLet": "warn",
"noDuplicateObjectKeys": "warn" "noDuplicateObjectKeys": "warn",
"noTsIgnore": "error"
}, },
"performance": { "performance": {
"noDelete": "error" "noDelete": "error"
@@ -25,7 +26,8 @@
"noUnusedImports": "warn" "noUnusedImports": "warn"
}, },
"nursery": { "nursery": {
"noMisusedPromises": "error" "noMisusedPromises": "error",
"noFloatingPromises": "error"
} }
} }
}, },
@@ -61,7 +63,8 @@
"!**/.source", "!**/.source",
"!**/.expo", "!**/.expo",
"!**/.cache", "!**/.cache",
"!**/dev/cloudflare/drizzle" "!**/dev/cloudflare/drizzle",
"!**/playwright-report"
] ]
} }
} }

View File

@@ -21,3 +21,6 @@ FACEBOOK_CLIENT_SECRET=
NODE_ENV= NODE_ENV=
STRIPE_KEY= STRIPE_KEY=
STRIPE_WEBHOOK_SECRET= STRIPE_WEBHOOK_SECRET=
PAYPAL_CLIENT_ID=
PAYPAL_CLIENT_SECRET=

View File

@@ -0,0 +1,10 @@
export default function SignInLoading() {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-gray-900 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading...</p>
</div>
</div>
);
}

View File

@@ -4,12 +4,14 @@ import SignIn from "@/components/sign-in";
import { SignUp } from "@/components/sign-up"; import { SignUp } from "@/components/sign-up";
import { Tabs } from "@/components/ui/tabs2"; import { Tabs } from "@/components/ui/tabs2";
import { client } from "@/lib/auth-client"; import { client } from "@/lib/auth-client";
import { useRouter } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react"; import { useEffect } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { getCallbackURL } from "@/lib/shared";
export default function Page() { export default function Page() {
const router = useRouter(); const router = useRouter();
const params = useSearchParams();
useEffect(() => { useEffect(() => {
client.oneTap({ client.oneTap({
fetchOptions: { fetchOptions: {
@@ -18,7 +20,7 @@ export default function Page() {
}, },
onSuccess: () => { onSuccess: () => {
toast.success("Successfully signed in"); toast.success("Successfully signed in");
router.push("/dashboard"); router.push(getCallbackURL(params));
}, },
}, },
}); });

View File

@@ -208,8 +208,9 @@ export default function UserCard(props: {
) : ( ) : (
<Laptop size={16} /> <Laptop size={16} />
)} )}
{new UAParser(session.userAgent || "").getOS().name},{" "} {new UAParser(session.userAgent || "").getOS().name ||
{new UAParser(session.userAgent || "").getBrowser().name} session.userAgent}
, {new UAParser(session.userAgent || "").getBrowser().name}
<button <button
className="text-red-500 opacity-80 cursor-pointer text-xs border-muted-foreground border-red-600 underline " className="text-red-500 opacity-80 cursor-pointer text-xs border-muted-foreground border-red-600 underline "
onClick={async () => { onClick={async () => {
@@ -392,7 +393,6 @@ export default function UserCard(props: {
setIsPendingTwoFa(true); setIsPendingTwoFa(true);
if (session?.user.twoFactorEnabled) { if (session?.user.twoFactorEnabled) {
const res = await client.twoFactor.disable({ const res = await client.twoFactor.disable({
//@ts-ignore
password: twoFaPassword, password: twoFaPassword,
fetchOptions: { fetchOptions: {
onError(context) { onError(context) {

View File

@@ -0,0 +1,122 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { client, useSession } from "@/lib/auth-client";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Loader2, Check, X } from "lucide-react";
export default function DeviceApprovalPage() {
const router = useRouter();
const searchParams = useSearchParams();
const userCode = searchParams.get("user_code");
const { data: session } = useSession();
const [isApprovePending, startApproveTransition] = useTransition();
const [isDenyPending, startDenyTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const handleApprove = () => {
if (!userCode) return;
setError(null);
startApproveTransition(async () => {
try {
await client.device.approve({
userCode,
});
router.push("/device/success");
} catch (err: any) {
setError(err.error?.message || "Failed to approve device");
}
});
};
const handleDeny = () => {
if (!userCode) return;
setError(null);
startDenyTransition(async () => {
try {
await client.device.deny({
userCode,
});
router.push("/device/denied");
} catch (err: any) {
setError(err.error?.message || "Failed to deny device");
}
});
};
if (!session) {
return null;
}
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-md p-6">
<div className="space-y-4">
<div className="text-center">
<h1 className="text-2xl font-bold">Approve Device</h1>
<p className="text-muted-foreground mt-2">
A device is requesting access to your account
</p>
</div>
<div className="space-y-4">
<div className="rounded-lg bg-muted p-4">
<p className="text-sm font-medium">Device Code</p>
<p className="font-mono text-lg">{userCode}</p>
</div>
<div className="rounded-lg bg-muted p-4">
<p className="text-sm font-medium">Signed in as</p>
<p>{session.user.email}</p>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex gap-3">
<Button
onClick={handleDeny}
variant="outline"
className="flex-1"
disabled={isDenyPending}
>
{isDenyPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<X className="mr-2 h-4 w-4" />
Deny
</>
)}
</Button>
<Button
onClick={handleApprove}
className="flex-1"
disabled={isApprovePending}
>
{isApprovePending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Check className="mr-2 h-4 w-4" />
Approve
</>
)}
</Button>
</div>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,35 @@
"use client";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { X } from "lucide-react";
import Link from "next/link";
export default function DeviceDeniedPage() {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-md p-6">
<div className="space-y-4 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
<X className="h-6 w-6 text-red-600" />
</div>
<div>
<h1 className="text-2xl font-bold">Device Denied</h1>
<p className="text-muted-foreground mt-2">
The device authorization request has been denied.
</p>
</div>
<p className="text-sm text-muted-foreground">
The device will not be able to access your account.
</p>
<Button asChild className="w-full">
<Link href="/">Return to Home</Link>
</Button>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export default async function DevicePage({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth.api.getSession({
headers: await headers(),
});
if (session === null) {
throw redirect("/sign-in?callbackUrl=/device");
}
return children;
}

View File

@@ -0,0 +1,94 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { client } from "@/lib/auth-client";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Loader2 } from "lucide-react";
export default function DeviceAuthorizationPage() {
const router = useRouter();
const params = useSearchParams();
const user_code = params.get("user_code");
const [userCode, setUserCode] = useState<string>(user_code ? user_code : "");
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError(null);
startTransition(async () => {
try {
const finalCode = userCode.trim().replaceAll(/-/g, "").toUpperCase();
// Get the device authorization status
const response = await client.device({
query: {
user_code: finalCode,
},
});
if (response.data) {
router.push(`/device/approve?user_code=${finalCode}`);
}
} catch (err: any) {
setError(
err.error?.message || "Invalid code. Please check and try again.",
);
}
});
};
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-md p-6">
<div className="space-y-4">
<div className="text-center">
<h1 className="text-2xl font-bold">Device Authorization</h1>
<p className="text-muted-foreground mt-2">
Enter the code displayed on your device
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="userCode">Device Code</Label>
<Input
id="userCode"
type="text"
placeholder="XXXX-XXXX"
value={userCode}
onChange={(e) => setUserCode(e.target.value)}
className="text-center text-lg font-mono uppercase"
maxLength={9}
disabled={isPending}
required
/>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Verifying...
</>
) : (
"Continue"
)}
</Button>
</form>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,36 @@
"use client";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Check } from "lucide-react";
import Link from "next/link";
export default function DeviceSuccessPage() {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-md p-6">
<div className="space-y-4 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<Check className="h-6 w-6 text-green-600" />
</div>
<div>
<h1 className="text-2xl font-bold">Device Approved</h1>
<p className="text-muted-foreground mt-2">
The device has been successfully authorized to access your
account.
</p>
</div>
<p className="text-sm text-muted-foreground">
You can now return to your device to continue.
</p>
<Button asChild className="w-full">
<Link href="/">Return to Home</Link>
</Button>
</div>
</Card>
</div>
);
}

View File

@@ -29,7 +29,7 @@ export default async function AuthorizePage({
const session = await auth.api.getSession({ const session = await auth.api.getSession({
headers: await headers(), headers: await headers(),
}); });
// @ts-ignore // @ts-expect-error
const clientDetails = await auth.api.getOAuthClient({ const clientDetails = await auth.api.getOAuthClient({
params: { params: {
id: client_id, id: client_id,

View File

@@ -14,10 +14,12 @@ import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { signIn } from "@/lib/auth-client"; import { client, signIn } from "@/lib/auth-client";
import Link from "next/link"; import Link from "next/link";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useRouter } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { toast } from "sonner";
import { getCallbackURL } from "@/lib/shared";
export default function SignIn() { export default function SignIn() {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
@@ -25,6 +27,13 @@ export default function SignIn() {
const [loading, startTransition] = useTransition(); const [loading, startTransition] = useTransition();
const [rememberMe, setRememberMe] = useState(false); const [rememberMe, setRememberMe] = useState(false);
const router = useRouter(); const router = useRouter();
const params = useSearchParams();
const LastUsedIndicator = () => (
<span className="ml-auto absolute top-0 right-0 px-2 py-1 text-xs bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 rounded-md font-medium">
Last Used
</span>
);
return ( return (
<Card className="max-w-md rounded-none"> <Card className="max-w-md rounded-none">
@@ -80,7 +89,7 @@ export default function SignIn() {
<Button <Button
type="submit" type="submit"
className="w-full" className="w-full flex items-center justify-center"
disabled={loading} disabled={loading}
onClick={async () => { onClick={async () => {
startTransition(async () => { startTransition(async () => {
@@ -88,14 +97,24 @@ export default function SignIn() {
{ email, password, rememberMe }, { email, password, rememberMe },
{ {
onSuccess(context) { onSuccess(context) {
router.push("/dashboard"); toast.success("Successfully signed in");
router.push(getCallbackURL(params));
}, },
}, },
); );
}); });
}} }}
> >
{loading ? <Loader2 size={16} className="animate-spin" /> : "Login"} <div className="flex items-center justify-between w-full">
<span className="flex-1">
{loading ? (
<Loader2 size={16} className="animate-spin" />
) : (
"Login"
)}
</span>
{client.isLastUsedLoginMethod("email") && <LastUsedIndicator />}
</div>
</Button> </Button>
<div <div
@@ -106,7 +125,7 @@ export default function SignIn() {
> >
<Button <Button
variant="outline" variant="outline"
className={cn("w-full gap-2")} className={cn("w-full gap-2 flex relative")}
onClick={async () => { onClick={async () => {
await signIn.social({ await signIn.social({
provider: "google", provider: "google",
@@ -137,11 +156,12 @@ export default function SignIn() {
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" 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"
></path> ></path>
</svg> </svg>
Sign in with Google <span>Sign in with Google</span>
{client.isLastUsedLoginMethod("google") && <LastUsedIndicator />}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
className={cn("w-full gap-2")} className={cn("w-full gap-2 flex items-center relative")}
onClick={async () => { onClick={async () => {
await signIn.social({ await signIn.social({
provider: "github", provider: "github",
@@ -160,11 +180,12 @@ export default function SignIn() {
d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2" d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2"
></path> ></path>
</svg> </svg>
Sign in with GitHub <span>Sign in with GitHub</span>
{client.isLastUsedLoginMethod("github") && <LastUsedIndicator />}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
className={cn("w-full gap-2")} className={cn("w-full gap-2 flex items-center relative")}
onClick={async () => { onClick={async () => {
await signIn.social({ await signIn.social({
provider: "microsoft", provider: "microsoft",
@@ -183,7 +204,10 @@ export default function SignIn() {
d="M2 3h9v9H2zm9 19H2v-9h9zM21 3v9h-9V3zm0 19h-9v-9h9z" d="M2 3h9v9H2zm9 19H2v-9h9zM21 3v9h-9V3zm0 19h-9v-9h9z"
></path> ></path>
</svg> </svg>
Sign in with Microsoft <span>Sign in with Microsoft</span>
{client.isLastUsedLoginMethod("microsoft") && (
<LastUsedIndicator />
)}
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -11,13 +11,13 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { useState } from "react"; import { useState, useTransition } from "react";
import Image from "next/image";
import { Loader2, X } from "lucide-react"; import { Loader2, X } from "lucide-react";
import { signUp } from "@/lib/auth-client"; import { signUp } from "@/lib/auth-client";
import { toast } from "sonner"; import { toast } from "sonner";
import { useRouter } from "next/navigation"; import { useSearchParams, useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { getCallbackURL } from "@/lib/shared";
export function SignUp() { export function SignUp() {
const [firstName, setFirstName] = useState(""); const [firstName, setFirstName] = useState("");
@@ -28,17 +28,19 @@ export function SignUp() {
const [image, setImage] = useState<File | null>(null); const [image, setImage] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null); const [imagePreview, setImagePreview] = useState<string | null>(null);
const router = useRouter(); const router = useRouter();
const [loading, setLoading] = useState(false); const params = useSearchParams();
const [loading, startTransition] = useTransition();
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
setImage(file); setImage(file);
const reader = new FileReader(); setImagePreview((preview) => {
reader.onloadend = () => { if (preview) {
setImagePreview(reader.result as string); URL.revokeObjectURL(preview);
}; }
reader.readAsDataURL(file); return URL.createObjectURL(file);
});
} }
}; };
@@ -118,11 +120,10 @@ export function SignUp() {
<div className="flex items-end gap-4"> <div className="flex items-end gap-4">
{imagePreview && ( {imagePreview && (
<div className="relative w-16 h-16 rounded-sm overflow-hidden"> <div className="relative w-16 h-16 rounded-sm overflow-hidden">
<Image <img
src={imagePreview} src={imagePreview}
alt="Profile preview" alt="Profile preview"
layout="fill" className="object-cover w-full h-full"
objectFit="cover"
/> />
</div> </div>
)} )}
@@ -151,26 +152,23 @@ export function SignUp() {
className="w-full" className="w-full"
disabled={loading} disabled={loading}
onClick={async () => { onClick={async () => {
await signUp.email({ startTransition(async () => {
email, await signUp.email({
password, email,
name: `${firstName} ${lastName}`, password,
image: image ? await convertImageToBase64(image) : "", name: `${firstName} ${lastName}`,
callbackURL: "/dashboard", image: image ? await convertImageToBase64(image) : "",
fetchOptions: { callbackURL: "/dashboard",
onResponse: () => { fetchOptions: {
setLoading(false); onError: (ctx) => {
toast.error(ctx.error.message);
},
onSuccess: async () => {
toast.success("Successfully signed up");
router.push(getCallbackURL(params));
},
}, },
onRequest: () => { });
setLoading(true);
},
onError: (ctx) => {
toast.error(ctx.error.message);
},
onSuccess: async () => {
router.push("/dashboard");
},
},
}); });
}} }}
> >

View File

@@ -156,7 +156,7 @@ function toast({ ...props }: Toast) {
...props, ...props,
id, id,
open: true, open: true,
// @ts-ignore // @ts-expect-error
onOpenChange: (open) => { onOpenChange: (open) => {
if (!open) dismiss(); if (!open) dismiss();
}, },

View File

@@ -8,6 +8,8 @@ import {
oneTapClient, oneTapClient,
oidcClient, oidcClient,
genericOAuthClient, genericOAuthClient,
deviceAuthorizationClient,
lastLoginMethodClient,
} from "better-auth/client/plugins"; } from "better-auth/client/plugins";
import { toast } from "sonner"; import { toast } from "sonner";
import { stripeClient } from "@better-auth/stripe/client"; import { stripeClient } from "@better-auth/stripe/client";
@@ -34,6 +36,8 @@ export const client = createAuthClient({
stripeClient({ stripeClient({
subscription: true, subscription: true,
}), }),
deviceAuthorizationClient(),
lastLoginMethodClient(),
], ],
fetchOptions: { fetchOptions: {
onError(e) { onError(e) {

View File

@@ -9,6 +9,8 @@ import {
oAuthProxy, oAuthProxy,
openAPI, openAPI,
customSession, customSession,
deviceAuthorization,
lastLoginMethod,
} from "better-auth/plugins"; } from "better-auth/plugins";
import { reactInvitationEmail } from "./email/invitation"; import { reactInvitationEmail } from "./email/invitation";
import { LibsqlDialect } from "@libsql/kysely-libsql"; import { LibsqlDialect } from "@libsql/kysely-libsql";
@@ -24,33 +26,52 @@ import { Stripe } from "stripe";
const from = process.env.BETTER_AUTH_EMAIL || "delivered@resend.dev"; const from = process.env.BETTER_AUTH_EMAIL || "delivered@resend.dev";
const to = process.env.TEST_EMAIL || ""; const to = process.env.TEST_EMAIL || "";
const libsql = new LibsqlDialect({ const dialect = (() => {
url: process.env.TURSO_DATABASE_URL || "", if (process.env.USE_MYSQL) {
authToken: process.env.TURSO_AUTH_TOKEN || "", if (!process.env.MYSQL_DATABASE_URL) {
}); throw new Error(
"Using MySQL dialect without MYSQL_DATABASE_URL. Please set it in your environment variables.",
const mysql = process.env.USE_MYSQL );
? new MysqlDialect(createPool(process.env.MYSQL_DATABASE_URL || "")) }
: null; return new MysqlDialect(createPool(process.env.MYSQL_DATABASE_URL || ""));
} else {
const dialect = process.env.USE_MYSQL ? mysql : libsql; if (process.env.TURSO_DATABASE_URL && process.env.TURSO_AUTH_TOKEN) {
return new LibsqlDialect({
url: process.env.TURSO_DATABASE_URL,
authToken: process.env.TURSO_AUTH_TOKEN,
});
}
}
return null;
})();
if (!dialect) { if (!dialect) {
throw new Error("No dialect found"); throw new Error("No dialect found");
} }
const PRO_PRICE_ID = { const baseURL: string | undefined =
default: "price_1RoxnRHmTADgihIt4y8c0lVE", process.env.VERCEL === "1"
annual: "price_1RoxnoHmTADgihItzFvVP8KT", ? process.env.VERCEL_ENV === "production"
}; ? process.env.BETTER_AUTH_URL
const PLUS_PRICE_ID = { : process.env.VERCEL_ENV === "preview"
default: "price_1RoxnJHmTADgihIthZTLmrPn", ? `https://${process.env.VERCEL_URL}`
annual: "price_1Roxo5HmTADgihItEbJu5llL", : undefined
}; : undefined;
const cookieDomain: string | undefined =
process.env.VERCEL === "1"
? process.env.VERCEL_ENV === "production"
? ".better-auth.com"
: process.env.VERCEL_ENV === "preview"
? `.${process.env.VERCEL_URL}`
: undefined
: undefined;
export const auth = betterAuth({ export const auth = betterAuth({
appName: "Better Auth Demo", appName: "Better Auth Demo",
baseURL,
database: { database: {
dialect: libsql, dialect,
type: "sqlite", type: "sqlite",
}, },
emailVerification: { emailVerification: {
@@ -112,6 +133,10 @@ export const auth = betterAuth({
clientId: process.env.TWITTER_CLIENT_ID || "", clientId: process.env.TWITTER_CLIENT_ID || "",
clientSecret: process.env.TWITTER_CLIENT_SECRET || "", clientSecret: process.env.TWITTER_CLIENT_SECRET || "",
}, },
paypal: {
clientId: process.env.PAYPAL_CLIENT_ID || "",
clientSecret: process.env.PAYPAL_CLIENT_SECRET || "",
},
}, },
plugins: [ plugins: [
organization({ organization({
@@ -174,32 +199,56 @@ export const auth = betterAuth({
subscription: { subscription: {
enabled: true, enabled: true,
allowReTrialsForDifferentPlans: true, allowReTrialsForDifferentPlans: true,
plans: [ plans: () => {
{ const PRO_PRICE_ID = {
name: "Plus", default:
priceId: PLUS_PRICE_ID.default, process.env.STRIPE_PRO_PRICE_ID ??
annualDiscountPriceId: PLUS_PRICE_ID.annual, "price_1RoxnRHmTADgihIt4y8c0lVE",
freeTrial: { annual:
days: 7, process.env.STRIPE_PRO_ANNUAL_PRICE_ID ??
"price_1RoxnoHmTADgihItzFvVP8KT",
};
const PLUS_PRICE_ID = {
default:
process.env.STRIPE_PLUS_PRICE_ID ??
"price_1RoxnJHmTADgihIthZTLmrPn",
annual:
process.env.STRIPE_PLUS_ANNUAL_PRICE_ID ??
"price_1Roxo5HmTADgihItEbJu5llL",
};
return [
{
name: "Plus",
priceId: PLUS_PRICE_ID.default,
annualDiscountPriceId: PLUS_PRICE_ID.annual,
freeTrial: {
days: 7,
},
}, },
}, {
{ name: "Pro",
name: "Pro", priceId: PRO_PRICE_ID.default,
priceId: PRO_PRICE_ID.default, annualDiscountPriceId: PRO_PRICE_ID.annual,
annualDiscountPriceId: PRO_PRICE_ID.annual, freeTrial: {
freeTrial: { days: 7,
days: 7, },
}, },
}, ];
], },
}, },
}), }),
deviceAuthorization({
expiresIn: "3min",
interval: "5s",
}),
lastLoginMethod(),
], ],
trustedOrigins: ["exp://"], trustedOrigins: ["exp://"],
advanced: { advanced: {
crossSubDomainCookies: { crossSubDomainCookies: {
enabled: process.env.NODE_ENV === "production", enabled: process.env.NODE_ENV === "production",
domain: ".better-auth.com", domain: cookieDomain,
}, },
}, },
}); });

19
demo/nextjs/lib/shared.ts Normal file
View File

@@ -0,0 +1,19 @@
import { ReadonlyURLSearchParams } from "next/navigation";
const allowedCallbackSet: ReadonlySet<string> = new Set([
"/dashboard",
"/device",
]);
export const getCallbackURL = (
queryParams: ReadonlyURLSearchParams,
): string => {
const callbackUrl = queryParams.get("callbackUrl");
if (callbackUrl) {
if (allowedCallbackSet.has(callbackUrl)) {
return callbackUrl;
}
return "/dashboard";
}
return "/dashboard";
};

View File

@@ -1,6 +1,9 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
typescript: {
ignoreBuildErrors: true,
},
webpack: (config) => { webpack: (config) => {
config.externals.push("@libsql/client"); config.externals.push("@libsql/client");
return config; return config;

View File

@@ -13,98 +13,93 @@
"dependencies": { "dependencies": {
"@better-auth/stripe": "workspace:*", "@better-auth/stripe": "workspace:*",
"@better-fetch/fetch": "catalog:", "@better-fetch/fetch": "catalog:",
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^5.2.1",
"@libsql/client": "^0.12.0", "@libsql/client": "^0.15.14",
"@libsql/kysely-libsql": "^0.4.1", "@libsql/kysely-libsql": "^0.4.1",
"@number-flow/react": "^0.5.5", "@number-flow/react": "^0.5.10",
"@prisma/adapter-libsql": "^5.22.0", "@prisma/adapter-libsql": "^5.22.0",
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"@radix-ui/react-accordion": "^1.2.1", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.0", "@radix-ui/react-aspect-ratio": "^1.1.7",
"@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.1", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.2", "@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.2", "@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.0", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-menubar": "^1.1.2", "@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.1", "@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.2.1", "@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.1", "@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.1.2", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.2.1", "@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.1.4", "@radix-ui/react-tooltip": "^1.2.8",
"@react-email/components": "^0.0.25", "@react-email/components": "^0.5.1",
"@react-three/fiber": "^8.17.10", "@react-three/fiber": "^8.18.0",
"@tanstack/react-query": "^5.62.3", "@tanstack/react-query": "^5.85.9",
"@types/better-sqlite3": "^7.6.12", "@types/better-sqlite3": "^7.6.13",
"better-auth": "workspace:*", "better-auth": "workspace:*",
"better-call": "catalog:", "better-call": "catalog:",
"better-sqlite3": "^11.6.0", "better-sqlite3": "^12.2.0",
"canvas-confetti": "^1.9.3", "canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "1.0.0", "cmdk": "1.1.1",
"consola": "^3.2.3", "consola": "^3.4.2",
"crypto": "^1.0.1", "date-fns": "^4.1.0",
"date-fns": "^3.6.0", "embla-carousel-react": "^8.6.0",
"embla-carousel-react": "^8.5.1", "framer-motion": "^12.23.12",
"framer-motion": "^11.13.1", "geist": "^1.4.2",
"geist": "^1.3.1", "input-otp": "^1.4.2",
"input-otp": "^1.4.1", "kysely": "^0.28.5",
"kysely": "^0.28.2", "lucide-react": "^0.542.0",
"lucide-react": "^0.477.0",
"mini-svg-data-uri": "^1.4.4", "mini-svg-data-uri": "^1.4.4",
"mysql2": "^3.11.5", "mysql2": "^3.14.4",
"next": "^15.5.0", "next": "^15.5.2",
"next-themes": "^0.3.0", "next-themes": "^0.4.6",
"prisma": "^5.22.0", "prisma": "^5.22.0",
"react": "^19.0.0", "react": "^19.1.1",
"react-day-picker": "8.10.1", "react-day-picker": "9.9.0",
"react-dom": "^19.0.0", "react-dom": "^19.1.1",
"react-hook-form": "^7.54.0", "react-hook-form": "^7.62.0",
"react-qr-code": "^2.0.15", "react-qr-code": "^2.0.18",
"react-resizable-panels": "^2.1.7", "react-resizable-panels": "^3.0.5",
"recharts": "^2.14.1", "recharts": "^3.1.2",
"resend": "^4.0.1", "resend": "^6.0.2",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"shiki": "^1.24.0", "shiki": "^3.12.2",
"sonner": "^1.7.0", "sonner": "^2.0.7",
"tailwind-merge": "^2.5.5", "tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"three": "^0.168.0", "three": "^0.180.0",
"ua-parser-js": "^0.7.39", "ua-parser-js": "^2.0.4",
"vaul": "^0.9.9", "vaul": "^1.1.2",
"zod": "^3.23.8" "zod": "^4.1.5"
}, },
"devDependencies": { "devDependencies": {
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"@types/node": "^24.3.0", "@types/node": "^24.3.0",
"@types/react": "^19.0.0", "@types/react": "^19.1.12",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.1.9",
"@types/three": "^0.168.0", "@types/three": "^0.179.0",
"@types/ua-parser-js": "^0.7.39", "@types/ua-parser-js": "^0.7.39",
"dotenv": "^16.4.7", "dotenv": "^16.6.1",
"dotenv-cli": "^7.4.4", "dotenv-cli": "^7.4.4",
"eslint-config-next": "15.0.0-canary.149", "eslint-config-next": "15.5.2",
"postcss": "^8.4.49", "postcss": "^8.5.6",
"tailwindcss": "3.4.16", "tailwindcss": "3.4.17"
"typescript": "^5.7.2"
},
"overrides": {
"whatwg-url": "^14.0.0"
} }
} }

26
demo/nextjs/turbo.json Normal file
View File

@@ -0,0 +1,26 @@
{
"$schema": "https://turborepo.org/schema.json",
"extends": ["//"],
"tasks": {
"build": {
"env": [
"TURSO_DATABASE_URL",
"TURSO_AUTH_TOKEN",
"RESEND_API_KEY",
"BETTER_AUTH_EMAIL",
"BETTER_AUTH_SECRET",
"BETTER_AUTH_URL",
"GITHUB_CLIENT_SECRET",
"GITHUB_CLIENT_ID",
"GOOGLE_CLIENT_ID",
"GOOGLE_CLIENT_SECRET",
"DISCORD_CLIENT_ID",
"DISCORD_CLIENT_SECRET",
"MICROSOFT_CLIENT_ID",
"MICROSOFT_CLIENT_SECRET",
"STRIPE_KEY",
"STRIPE_WEBHOOK_SECRET"
]
}
}
}

View File

@@ -1 +1,7 @@
NEXT_PUBLIC_URL=http://localhost:3000 NEXT_PUBLIC_URL=http://localhost:3000
# Orama Search Configuration
ORAMA_PRIVATE_API_KEY=
NEXT_PUBLIC_ORAMA_PUBLIC_API_KEY=
NEXT_PUBLIC_ORAMA_ENDPOINT=
ORAMA_INDEX_ID=

View File

@@ -45,19 +45,22 @@ const ChangelogPage = async () => {
if (line.startsWith("- ")) { if (line.startsWith("- ")) {
const mainContent = line.split(";")[0]; const mainContent = line.split(";")[0];
const context = line.split(";")[2]; const context = line.split(";")[2];
const mentions = context const mentionMatches =
?.split(" ") (context ?? line)?.match(/@([A-Za-z0-9-]+)/g) ?? [];
.filter((word) => word.startsWith("@")) if (mentionMatches.length === 0) {
.map((mention) => { return (mainContent || line).replace(/&nbsp/g, "");
const username = mention.replace("@", "");
const avatarUrl = `https://github.com/${username}.png`;
return `[![${mention}](${avatarUrl})](https://github.com/${username})`;
});
if (!mentions) {
return line;
} }
const mentions = mentionMatches.map((match) => {
const username = match.slice(1);
const avatarUrl = `https://github.com/${username}.png`;
return `[![${match}](${avatarUrl})](https://github.com/${username})`;
});
// Remove &nbsp // Remove &nbsp
return mainContent.replace(/&nbsp/g, "") + " " + mentions.join(" "); return (
(mainContent || line).replace(/&nbsp/g, "") +
" " +
mentions.join(" ")
);
} }
return line; return line;
}); });

View File

@@ -34,7 +34,6 @@ export default async function Page({
const { slug } = await params; const { slug } = await params;
const page = changelogs.getPage(slug); const page = changelogs.getPage(slug);
if (!slug) { if (!slug) {
//@ts-ignore
return <ChangelogPage />; return <ChangelogPage />;
} }
if (!page) { if (!page) {

View File

@@ -45,19 +45,22 @@ const ChangelogPage = async () => {
if (line.trim().startsWith("- ")) { if (line.trim().startsWith("- ")) {
const mainContent = line.split(";")[0]; const mainContent = line.split(";")[0];
const context = line.split(";")[2]; const context = line.split(";")[2];
const mentions = context const mentionMatches =
?.split(" ") (context ?? line)?.match(/@([A-Za-z0-9-]+)/g) ?? [];
.filter((word) => word.startsWith("@")) if (mentionMatches.length === 0) {
.map((mention) => { return (mainContent || line).replace(/&nbsp/g, "");
const username = mention.replace("@", "");
const avatarUrl = `https://github.com/${username}.png`;
return `[![${mention}](${avatarUrl})](https://github.com/${username})`;
});
if (!mentions) {
return line;
} }
const mentions = mentionMatches.map((match) => {
const username = match.slice(1);
const avatarUrl = `https://github.com/${username}.png`;
return `[![${match}](${avatarUrl})](https://github.com/${username})`;
});
// Remove &nbsp // Remove &nbsp
return mainContent.replace(/&nbsp/g, "") + " " + mentions.join(" "); return (
(mainContent || line).replace(/&nbsp/g, "") +
" " +
mentions.join(" ")
);
} }
return line; return line;
}); });

View File

@@ -8,6 +8,250 @@ import { remarkAutoTypeTable } from "fumadocs-typescript";
import { remarkInclude } from "fumadocs-mdx/config"; import { remarkInclude } from "fumadocs-mdx/config";
import { readFile } from "fs/promises"; import { readFile } from "fs/promises";
function extractAPIMethods(rawContent: string): string {
const apiMethodRegex = /<APIMethod\s+([^>]+)>([\s\S]*?)<\/APIMethod>/g;
return rawContent.replace(apiMethodRegex, (match, attributes, content) => {
// Parse attributes by matching
const pathMatch = attributes.match(/path="([^"]+)"/);
const methodMatch = attributes.match(/method="([^"]+)"/);
const requireSessionMatch = attributes.match(/requireSession/);
const isServerOnlyMatch = attributes.match(/isServerOnly/);
const isClientOnlyMatch = attributes.match(/isClientOnly/);
const noResultMatch = attributes.match(/noResult/);
const resultVariableMatch = attributes.match(/resultVariable="([^"]+)"/);
const forceAsBodyMatch = attributes.match(/forceAsBody/);
const forceAsQueryMatch = attributes.match(/forceAsQuery/);
const path = pathMatch ? pathMatch[1] : "";
const method = methodMatch ? methodMatch[1] : "GET";
const requireSession = !!requireSessionMatch;
const isServerOnly = !!isServerOnlyMatch;
const isClientOnly = !!isClientOnlyMatch;
const noResult = !!noResultMatch;
const resultVariable = resultVariableMatch
? resultVariableMatch[1]
: "data";
const forceAsBody = !!forceAsBodyMatch;
const forceAsQuery = !!forceAsQueryMatch;
const typeMatch = content.match(/type\s+(\w+)\s*=\s*\{([\s\S]*?)\}/);
if (!typeMatch) {
return match; // Return original if no type found
}
const functionName = typeMatch[1];
const typeBody = typeMatch[2];
const properties = parseTypeBody(typeBody);
const clientCode = generateClientCode(functionName, properties, path);
const serverCode = generateServerCode(
functionName,
properties,
method,
requireSession,
forceAsBody,
forceAsQuery,
noResult,
resultVariable,
);
return `
### Client Side
\`\`\`ts
${clientCode}
\`\`\`
### Server Side
\`\`\`ts
${serverCode}
\`\`\`
### Type Definition
\`\`\`ts
type ${functionName} = {${typeBody}
}
\`\`\`
`;
});
}
function parseTypeBody(typeBody: string) {
const properties: Array<{
name: string;
type: string;
required: boolean;
description: string;
exampleValue: string;
isServerOnly: boolean;
isClientOnly: boolean;
}> = [];
const lines = typeBody.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("//") || trimmed.startsWith("/*"))
continue;
const propMatch = trimmed.match(
/^(\w+)(\?)?:\s*(.+?)(\s*=\s*["']([^"']+)["'])?(\s*\/\/\s*(.+))?$/,
);
if (propMatch) {
const [, name, optional, type, , exampleValue, , description] = propMatch;
let cleanType = type.trim();
let cleanExampleValue = exampleValue || "";
cleanType = cleanType.replace(/,$/, "");
properties.push({
name,
type: cleanType,
required: !optional,
description: description || "",
exampleValue: cleanExampleValue,
isServerOnly: false,
isClientOnly: false,
});
}
}
return properties;
}
// Generate client code example
function generateClientCode(
functionName: string,
properties: any[],
path: string,
) {
if (!functionName || !path) {
return "// Unable to generate client code - missing function name or path";
}
const clientMethodPath = pathToDotNotation(path);
const body = createClientBody(properties);
return `const { data, error } = await authClient.${clientMethodPath}(${body});`;
}
// Generate server code example
function generateServerCode(
functionName: string,
properties: any[],
method: string,
requireSession: boolean,
forceAsBody: boolean,
forceAsQuery: boolean,
noResult: boolean,
resultVariable: string,
) {
if (!functionName) {
return "// Unable to generate server code - missing function name";
}
const body = createServerBody(
properties,
method,
requireSession,
forceAsBody,
forceAsQuery,
);
return `${noResult ? "" : `const ${resultVariable} = `}await auth.api.${functionName}(${body});`;
}
function pathToDotNotation(input: string): string {
return input
.split("/")
.filter(Boolean)
.map((segment) =>
segment
.split("-")
.map((word, i) =>
i === 0
? word.toLowerCase()
: word.charAt(0).toUpperCase() + word.slice(1),
)
.join(""),
)
.join(".");
}
// Helper function to create client body (simplified version)
function createClientBody(props: any[]) {
if (props.length === 0) return "{}";
let body = "{\n";
for (const prop of props) {
if (prop.isServerOnly) continue;
let comment = "";
if (!prop.required || prop.description) {
const comments = [];
if (!prop.required) comments.push("required");
if (prop.description) comments.push(prop.description);
comment = ` // ${comments.join(", ")}`;
}
body += ` ${prop.name}${prop.exampleValue ? `: ${prop.exampleValue}` : ""}${prop.type === "Object" ? ": {}" : ""},${comment}\n`;
}
body += "}";
return body;
}
function createServerBody(
props: any[],
method: string,
requireSession: boolean,
forceAsBody: boolean,
forceAsQuery: boolean,
) {
const relevantProps = props.filter((x) => !x.isClientOnly);
if (relevantProps.length === 0 && !requireSession) {
return "{}";
}
let serverBody = "{\n";
if (relevantProps.length > 0) {
const bodyKey =
(method === "POST" || forceAsBody) && !forceAsQuery ? "body" : "query";
serverBody += ` ${bodyKey}: {\n`;
for (const prop of relevantProps) {
let comment = "";
if (!prop.required || prop.description) {
const comments = [];
if (!prop.required) comments.push("required");
if (prop.description) comments.push(prop.description);
comment = ` // ${comments.join(", ")}`;
}
serverBody += ` ${prop.name}${prop.exampleValue ? `: ${prop.exampleValue}` : ""}${prop.type === "Object" ? ": {}" : ""},${comment}\n`;
}
serverBody += " }";
}
if (requireSession) {
if (relevantProps.length > 0) serverBody += ",";
serverBody +=
"\n // This endpoint requires session cookies.\n headers: await headers()";
}
serverBody += "\n}";
return serverBody;
}
const processor = remark() const processor = remark()
.use(remarkMdx) .use(remarkMdx)
.use(remarkInclude) .use(remarkInclude)
@@ -23,9 +267,12 @@ export async function getLLMText(docPage: any) {
// Read the raw file content // Read the raw file content
const rawContent = await readFile(docPage.data._file.absolutePath, "utf-8"); const rawContent = await readFile(docPage.data._file.absolutePath, "utf-8");
// Extract APIMethod components & other nested wrapper before processing
const processedContent = extractAPIMethods(rawContent);
const processed = await processor.process({ const processed = await processor.process({
path: docPage.data._file.absolutePath, path: docPage.data._file.absolutePath,
value: rawContent, value: processedContent,
}); });
return `# ${category}: ${docPage.data.title} return `# ${category}: ${docPage.data.title}

View File

@@ -9,6 +9,7 @@ import { baseUrl, createMetadata } from "@/lib/metadata";
import { Analytics } from "@vercel/analytics/react"; import { Analytics } from "@vercel/analytics/react";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { CustomSearchDialog } from "@/components/search-dialog";
export const metadata = createMetadata({ export const metadata = createMetadata({
title: { title: {
@@ -50,6 +51,10 @@ export default function Layout({ children }: { children: ReactNode }) {
enableSystem: true, enableSystem: true,
defaultTheme: "dark", defaultTheme: "dark",
}} }}
search={{
enabled: true,
SearchDialog: CustomSearchDialog,
}}
> >
<NavbarProvider> <NavbarProvider>
<Navbar /> <Navbar />

View File

@@ -0,0 +1,7 @@
import { exportSearchIndexes } from "@/lib/export-search-indexes";
export const revalidate = false;
export async function GET() {
return Response.json(await exportSearchIndexes());
}

View File

@@ -230,6 +230,42 @@ export const socialProviders = {
/> />
</svg>`, </svg>`,
}, },
kakao: {
Icon: (props: SVGProps<any>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 512 512"
{...props}
>
<g>
<path
fill="currentColor"
d="M 511.5,203.5 C 511.5,215.5 511.5,227.5 511.5,239.5C 504.002,286.989 482.002,326.489 445.5,358C 390.216,402.375 326.882,424.209 255.5,423.5C 239.751,423.476 224.085,422.643 208.5,421C 174.34,444.581 140.006,467.914 105.5,491C 95.6667,493.167 91.8333,489.333 94,479.5C 101.833,450.667 109.667,421.833 117.5,393C 85.5639,376.077 58.0639,353.577 35,325.5C 15.8353,299.834 4.00193,271.167 -0.5,239.5C -0.5,227.5 -0.5,215.5 -0.5,203.5C 7.09119,155.407 29.4245,115.574 66.5,84C 121.53,39.9708 184.53,18.4708 255.5,19.5C 326.47,18.4708 389.47,39.9708 444.5,84C 481.575,115.574 503.909,155.407 511.5,203.5 Z"
/>
</g>
</svg>
),
stringIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><g><path fill="currentColor" d="M 511.5,203.5 C 511.5,215.5 511.5,227.5 511.5,239.5C 504.002,286.989 482.002,326.489 445.5,358C 390.216,402.375 326.882,424.209 255.5,423.5C 239.751,423.476 224.085,422.643 208.5,421C 174.34,444.581 140.006,467.914 105.5,491C 95.6667,493.167 91.8333,489.333 94,479.5C 101.833,450.667 109.667,421.833 117.5,393C 85.5639,376.077 58.0639,353.577 35,325.5C 15.8353,299.834 4.00193,271.167 -0.5,239.5C -0.5,227.5 -0.5,215.5 -0.5,203.5C 7.09119,155.407 29.4245,115.574 66.5,84C 121.53,39.9708 184.53,18.4708 255.5,19.5C 326.47,18.4708 389.47,39.9708 444.5,84C 481.575,115.574 503.909,155.407 511.5,203.5 Z"/></g></svg>`,
},
naver: {
Icon: (props: SVGProps<any>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
{...props}
>
<path
fill="currentColor"
d="M16.273 12.845 7.376 0H0v24h7.726V11.156L16.624 24H24V0h-7.727v12.845Z"
/>
</svg>
),
stringIcon: `<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="M16.273 12.845 7.376 0H0v24h7.726V11.156L16.624 24H24V0h-7.727v12.845Z"/></svg>`,
},
linkedin: { linkedin: {
Icon: (props: SVGProps<any>) => ( Icon: (props: SVGProps<any>) => (
<svg <svg

View File

@@ -33,7 +33,6 @@ export const NavbarProvider = ({ children }: { children: React.ReactNode }) => {
const toggleDocsNavbar = () => { const toggleDocsNavbar = () => {
setIsDocsOpen((prevIsOpen) => !prevIsOpen); setIsDocsOpen((prevIsOpen) => !prevIsOpen);
}; };
// @ts-ignore
return ( return (
<NavbarContext.Provider <NavbarContext.Provider
value={{ isOpen, toggleNavbar, isDocsOpen, toggleDocsNavbar }} value={{ isOpen, toggleNavbar, isDocsOpen, toggleDocsNavbar }}

View File

@@ -0,0 +1,59 @@
"use client";
import {
SearchDialog,
SearchDialogClose,
SearchDialogContent,
SearchDialogFooter,
SearchDialogHeader,
SearchDialogIcon,
SearchDialogInput,
SearchDialogList,
SearchDialogOverlay,
type SharedProps,
} from "fumadocs-ui/components/dialog/search";
import { useDocsSearch } from "fumadocs-core/search/client";
import { OramaClient } from "@oramacloud/client";
import { useI18n } from "fumadocs-ui/contexts/i18n";
const client = new OramaClient({
endpoint: process.env.NEXT_PUBLIC_ORAMA_ENDPOINT!,
api_key: process.env.NEXT_PUBLIC_ORAMA_PUBLIC_API_KEY!,
});
export function CustomSearchDialog(props: SharedProps) {
const { locale } = useI18n();
const { search, setSearch, query } = useDocsSearch({
type: "orama-cloud",
client,
locale,
});
return (
<SearchDialog
search={search}
onSearchChange={setSearch}
isLoading={query.isLoading}
{...props}
>
<SearchDialogOverlay />
<SearchDialogContent>
<SearchDialogHeader>
<SearchDialogIcon />
<SearchDialogInput />
<SearchDialogClose />
</SearchDialogHeader>
<SearchDialogList items={query.data !== "empty" ? query.data : null} />
<SearchDialogFooter>
<a
href="https://orama.com"
rel="noreferrer noopener"
className="ms-auto text-xs text-fd-muted-foreground"
>
Search powered by Orama
</a>
</SearchDialogFooter>
</SearchDialogContent>
</SearchDialog>
);
}

View File

@@ -492,6 +492,43 @@ export const contents: Content[] = [
</svg> </svg>
), ),
}, },
{
title: "Atlassian",
href: "/docs/authentication/atlassian",
isNew: true,
icon: () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1.2em"
height="1.2em"
viewBox="0 0 20 20"
>
<path
fill="currentColor"
d="M7.45 10.54c-.15-.26-.44-.26-.59 0L3.12 17.8c-.15.26-.02.47.29.47h3.68c.21 0 .4-.11.49-.29l1.77-3.07c.8-1.38.87-3.04.1-4.37m1.14-6.91c-.8 1.33-.73 2.98.1 4.37l4.84 8.41c.09.18.28.29.49.29h3.68c.31 0 .44-.21.29-.47L8.73 3.34c-.15-.26-.44-.26-.59 0l.45.29z"
/>
</svg>
),
},
{
title: "Cognito",
href: "/docs/authentication/cognito",
isNew: true,
icon: () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1.2em"
height="1.2em"
viewBox="0 0 48 48"
>
<path
fill="currentColor"
d="M24 4L6 14v20l18 10 18-10V14L24 4zm0 4.62l13.6 7.86v15.04L24 39.38 10.4 31.52V16.48L24 8.62z"
/>
<path fill="currentColor" d="M22 14h4v20h-4zM14 22h20v4H14z" />
</svg>
),
},
{ {
title: "Discord", title: "Discord",
href: "/docs/authentication/discord", href: "/docs/authentication/discord",
@@ -526,6 +563,27 @@ export const contents: Content[] = [
</svg> </svg>
), ),
}, },
{
title: "Figma",
href: "/docs/authentication/figma",
isNew: true,
icon: () => (
<svg
width="1.2em"
height="1.2em"
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M7.00005 2.04999H5.52505C4.71043 2.04999 4.05005 2.71037 4.05005 3.52499C4.05005 4.33961 4.71043 4.99999 5.52505 4.99999H7.00005V2.04999ZM7.00005 1.04999H8.00005H9.47505C10.842 1.04999 11.95 2.15808 11.95 3.52499C11.95 4.33163 11.5642 5.04815 10.9669 5.49999C11.5642 5.95184 11.95 6.66836 11.95 7.475C11.95 8.8419 10.842 9.95 9.47505 9.95C8.92236 9.95 8.41198 9.76884 8.00005 9.46266V9.95L8.00005 11.425C8.00005 12.7919 6.89195 13.9 5.52505 13.9C4.15814 13.9 3.05005 12.7919 3.05005 11.425C3.05005 10.6183 3.43593 9.90184 4.03317 9.44999C3.43593 8.99814 3.05005 8.28163 3.05005 7.475C3.05005 6.66836 3.43594 5.95184 4.03319 5.5C3.43594 5.04815 3.05005 4.33163 3.05005 3.52499C3.05005 2.15808 4.15814 1.04999 5.52505 1.04999H7.00005ZM8.00005 2.04999V4.99999H9.47505C10.2897 4.99999 10.95 4.33961 10.95 3.52499C10.95 2.71037 10.2897 2.04999 9.47505 2.04999H8.00005ZM5.52505 8.94998H7.00005L7.00005 7.4788L7.00005 7.475L7.00005 7.4712V6H5.52505C4.71043 6 4.05005 6.66038 4.05005 7.475C4.05005 8.28767 4.70727 8.94684 5.5192 8.94999L5.52505 8.94998ZM4.05005 11.425C4.05005 10.6123 4.70727 9.95315 5.5192 9.94999L5.52505 9.95H7.00005L7.00005 11.425C7.00005 12.2396 6.33967 12.9 5.52505 12.9C4.71043 12.9 4.05005 12.2396 4.05005 11.425ZM8.00005 7.47206C8.00164 6.65879 8.66141 6 9.47505 6C10.2897 6 10.95 6.66038 10.95 7.475C10.95 8.28962 10.2897 8.95 9.47505 8.95C8.66141 8.95 8.00164 8.29121 8.00005 7.47794V7.47206Z"
fill="currentColor"
/>
</svg>
),
},
{ {
title: "GitHub", title: "GitHub",
href: "/docs/authentication/github", href: "/docs/authentication/github",
@@ -563,6 +621,24 @@ export const contents: Content[] = [
</svg> </svg>
), ),
}, },
{
title: "LINE",
href: "/docs/authentication/line",
isNew: true,
icon: () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1.2em"
height="1.2em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M19.365 9.863c.349 0 .63.285.63.631 0 .345-.281.63-.63.63H17.61v1.125h1.755c.349 0 .63.283.63.63 0 .344-.281.629-.63.629h-2.386c-.345 0-.627-.285-.627-.629V8.108c0-.345.282-.63.63-.63h2.386c.346 0 .627.285.627.63 0 .349-.281.63-.63.63H17.61v1.125h1.755zm-3.855 3.016c0 .27-.174.51-.432.596-.064.021-.133.031-.199.031-.211 0-.391-.09-.51-.25l-2.443-3.317v2.94c0 .344-.279.629-.631.629-.346 0-.626-.285-.626-.629V8.108c0-.27.173-.51.43-.595.06-.023.136-.033.194-.033.195 0 .375.104.495.254l2.462 3.33V8.108c0-.345.282-.63.63-.63.345 0 .63.285.63.63v4.771zm-5.741 0c0 .344-.282.629-.631.629-.345 0-.627-.285-.627-.629V8.108c0-.345.282-.63.63-.63.346 0 .628.285.628.63v4.771zm-2.466.629H4.917c-.345 0-.63-.285-.63-.629V8.108c0-.345.285-.63.63-.63.348 0 .63.285.63.63v4.141h1.756c.348 0 .629.283.629.63 0 .344-.282.629-.629.629M24 10.314C24 4.943 18.615.572 12 .572S0 4.943 0 10.314c0 4.811 4.27 8.842 10.035 9.608.391.082.923.258 1.058.59.12.301.079.766.038 1.08l-.164 1.02c-.045.301-.24 1.186 1.049.645 1.291-.539 6.916-4.078 9.436-6.975C23.176 14.393 24 12.458 24 10.314"
/>
</svg>
),
},
{ {
title: "Hugging Face", title: "Hugging Face",
href: "/docs/authentication/huggingface", href: "/docs/authentication/huggingface",
@@ -580,6 +656,26 @@ export const contents: Content[] = [
</svg> </svg>
), ),
}, },
{
title: "Kakao",
isNew: true,
href: "/docs/authentication/kakao",
icon: (props?: SVGProps<any>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1.2em"
height="1.2em"
viewBox="0 0 512 512"
>
<g>
<path
fill="currentColor"
d="M 511.5,203.5 C 511.5,215.5 511.5,227.5 511.5,239.5C 504.002,286.989 482.002,326.489 445.5,358C 390.216,402.375 326.882,424.209 255.5,423.5C 239.751,423.476 224.085,422.643 208.5,421C 174.34,444.581 140.006,467.914 105.5,491C 95.6667,493.167 91.8333,489.333 94,479.5C 101.833,450.667 109.667,421.833 117.5,393C 85.5639,376.077 58.0639,353.577 35,325.5C 15.8353,299.834 4.00193,271.167 -0.5,239.5C -0.5,227.5 -0.5,215.5 -0.5,203.5C 7.09119,155.407 29.4245,115.574 66.5,84C 121.53,39.9708 184.53,18.4708 255.5,19.5C 326.47,18.4708 389.47,39.9708 444.5,84C 481.575,115.574 503.909,155.407 511.5,203.5 Z"
/>
</g>
</svg>
),
},
{ {
title: "Kick", title: "Kick",
href: "/docs/authentication/kick", href: "/docs/authentication/kick",
@@ -614,6 +710,51 @@ export const contents: Content[] = [
</svg> </svg>
), ),
}, },
{
title: "PayPal",
href: "/docs/authentication/paypal",
isNew: true,
icon: () => (
<svg
fill="currentColor"
viewBox="-2 -2 24 24"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMinYMin"
width="1.4em"
height="1.4em"
>
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
<g
id="SVGRepo_tracerCarrier"
strokeLinecap="round"
strokeLinejoin="round"
></g>
<g id="SVGRepo_iconCarrier">
<path d="M4.328 16.127l-.011.07a.899.899 0 0 1-.887.744H.9a.892.892 0 0 1-.88-1.04L2.57.745A.892.892 0 0 1 3.45 0h6.92a4.141 4.141 0 0 1 4.142 4.141c0 .273-.017.54-.05.804a3.629 3.629 0 0 1 1.53 2.962 5.722 5.722 0 0 1-5.72 5.722h-.583c-.653 0-1.211.472-1.32 1.117l-.314 1.87.314-1.87a1.339 1.339 0 0 1 1.32-1.117h.582a5.722 5.722 0 0 0 5.722-5.722 3.629 3.629 0 0 0-1.53-2.962 6.52 6.52 0 0 1-6.47 5.716H6.06a.969.969 0 0 0-.93.701l-1.155 6.862c-.08.48.289.916.775.916h2.214a.786.786 0 0 0 .775-.655l.315-1.87-.315 1.87a.786.786 0 0 1-.775.655H4.751a.782.782 0 0 1-.6-.278.782.782 0 0 1-.175-.638l.352-2.097z"></path>
<path d="M15.45 5.995c.365.567.578 1.242.578 1.967a5.722 5.722 0 0 1-5.722 5.722h-.581c-.654 0-1.212.472-1.32 1.117l-.63 3.739a.786.786 0 0 1-.774.655H4.973l1.15-6.833c.118-.41.495-.7.93-.7h1.932a6.52 6.52 0 0 0 6.464-5.667zm-10.477 13.2h-.187a.786.786 0 0 1-.775-.916l.057-.338h.355a.899.899 0 0 0 .886-.743l.012-.07-.348 2.067z"></path>
</g>
</svg>
),
},
{
title: "Salesforce",
href: "/docs/authentication/salesforce",
isNew: true,
icon: () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="1.2em"
height="1.2em"
>
<path
fill="currentColor"
d="M8.5 3.5c-1.4 0-2.5 1.1-2.5 2.5 0 .4.1.7.2 1-1.7.3-3 1.7-3 3.5 0 2 1.6 3.6 3.6 3.6h10.2c1.6 0 2.9-1.3 2.9-2.9 0-1.2-.7-2.2-1.7-2.6 0-.3 0-.5 0-.8-.3-2-1.9-3.5-4-3.3-.4 0-.7.1-1 .2-.5-.8-1.4-1.3-2.4-1.3-.9 0-1.7.4-2.2 1.1zm7.7 7.1c-.5-.3-1.1-.5-1.7-.5-.6 0-1.2.2-1.7.5-.1-2-1.7-3.6-3.8-3.6-1.3 0-2.4.6-3.1 1.6-.4-.2-.8-.3-1.3-.3-1.8 0-3.3 1.5-3.3 3.3 0 .2 0 .5.1.7-1.6.4-2.7 1.8-2.7 3.5 0 2 1.6 3.6 3.6 3.6h10.6c2 0 3.6-1.6 3.6-3.6 0-1.9-1.4-3.4-3.2-3.5z"
/>
</svg>
),
},
{ {
title: "Slack", title: "Slack",
href: "/docs/authentication/slack", href: "/docs/authentication/slack",
@@ -652,6 +793,24 @@ export const contents: Content[] = [
</svg> </svg>
), ),
}, },
{
title: "Naver",
href: "/docs/authentication/naver",
isNew: true,
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.273 12.845 7.376 0H0v24h7.726V11.156L16.624 24H24V0h-7.727v12.845Z"
/>
</svg>
),
},
{ {
title: "Tiktok", title: "Tiktok",
href: "/docs/authentication/tiktok", href: "/docs/authentication/tiktok",
@@ -726,6 +885,7 @@ export const contents: Content[] = [
{ {
title: "Linear", title: "Linear",
href: "/docs/authentication/linear", href: "/docs/authentication/linear",
isNew: true,
icon: () => ( icon: () => (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -1504,6 +1664,24 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2,
icon: () => <Key className="w-4 h-4" />, icon: () => <Key className="w-4 h-4" />,
href: "/docs/plugins/bearer", href: "/docs/plugins/bearer",
}, },
{
title: "Device Authorization",
icon: () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1.2em"
height="1.2em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M20 18c1.1 0 1.99-.9 1.99-2L22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2H0v2h24v-2zM4 6h16v10H4z"
/>
</svg>
),
href: "/docs/plugins/device-authorization",
isNew: true,
},
{ {
title: "Captcha", title: "Captcha",
href: "/docs/plugins/captcha", href: "/docs/plugins/captcha",
@@ -1526,6 +1704,24 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2,
href: "/docs/plugins/have-i-been-pwned", href: "/docs/plugins/have-i-been-pwned",
icon: () => <p className="text-xs">';--</p>, icon: () => <p className="text-xs">';--</p>,
}, },
{
title: "Last Login Method",
href: "/docs/plugins/last-login-method",
icon: () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1.2em"
height="1.2em"
viewBox="0 0 256 256"
>
<path
fill="currentColor"
d="m141.66 133.66l-40 40A8 8 0 0 1 88 168v-32H24a8 8 0 0 1 0-16h64V88a8 8 0 0 1 13.66-5.66l40 40a8 8 0 0 1 0 11.32M200 32h-64a8 8 0 0 0 0 16h56v160h-56a8 8 0 0 0 0 16h64a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8"
/>
</svg>
),
isNew: true,
},
{ {
title: "Multi Session", title: "Multi Session",
icon: () => ( icon: () => (

View File

@@ -42,6 +42,7 @@ export const SparklesCore = (props: ParticlesProps) => {
const particlesLoaded = async (container?: Container) => { const particlesLoaded = async (container?: Container) => {
if (container) { if (container) {
console.log(container); console.log(container);
// biome-ignore lint/nursery/noFloatingPromises: add error handling is not important
controls.start({ controls.start({
opacity: 1, opacity: 1,
transition: { transition: {

View File

@@ -0,0 +1,59 @@
---
title: Atlassian
description: Atlassian provider setup and usage.
---
<Steps>
<Step>
### Get your Credentials
1. Sign in to your Atlassian account and go to the [Atlassian Developer Console](https://developer.atlassian.com/console/myapps/)
2. Click "Create new app"
3. Fill out the app details
4. Configure your redirect URI (e.g., `https://yourdomain.com/api/auth/callback/atlassian`)
5. Note your Client ID and Client Secret
<Callout type="info">
- The default scope is `read:jira-user` and `offline_access`. For additional scopes, refer to the [Atlassian OAuth documentation](https://developer.atlassian.com/cloud/confluence/oauth-2-3lo-apps/).
</Callout>
Make sure to set the redirect URI to match your application's callback URL. If you change the base path of the auth routes, you should update the redirect URI accordingly.
</Step>
<Step>
### Configure the provider
To configure the provider, you need to import the provider and pass it to the `socialProviders` option of the auth instance.
```ts title="auth.ts"
import { betterAuth } from "better-auth"
export const auth = betterAuth({
socialProviders: {
atlassian: { // [!code highlight]
clientId: process.env.ATLASSIAN_CLIENT_ID as string, // [!code highlight]
clientSecret: process.env.ATLASSIAN_CLIENT_SECRET as string, // [!code highlight]
}, // [!code highlight]
},
})
```
</Step>
<Step>
### Sign In with Atlassian
To sign in with Atlassian, you can use the `signIn.social` function provided by the client. The `signIn` function takes an object with the following properties:
- `provider`: The provider to use. It should be set to `atlassian`.
```ts title="auth-client.ts"
import { createAuthClient } from "better-auth/client"
const authClient = createAuthClient()
const signIn = async () => {
const data = await authClient.signIn.social({
provider: "atlassian"
})
}
```
<Callout type="info">
For more information about Atlassian's OAuth scopes and API capabilities, refer to the [official Atlassian OAuth 2.0 (3LO) apps documentation](https://developer.atlassian.com/cloud/confluence/oauth-2-3lo-apps/).
</Callout>
</Step>
</Steps>

View File

@@ -0,0 +1,78 @@
---
title: Cognito
description: Amazon Cognito provider setup and usage.
---
<Steps>
<Step>
### Get your Cognito Credentials
To integrate with Cognito, you need to set up a **User Pool** and an **App client** in the [Amazon Cognito Console](https://console.aws.amazon.com/cognito/).
Follow these steps:
1. Go to the **Cognito Console** and create a **User Pool**.
2. Under **App clients**, create a new **App client** (note the Client ID and Client Secret if enabled).
3. Go to **Domain** and set a Cognito Hosted UI domain (e.g., `your-app.auth.us-east-1.amazoncognito.com`).
4. In **App client settings**, enable:
- Allowed OAuth flows: `Authorization code grant`
- Allowed OAuth scopes: `openid`, `profile`, `email`
5. Add your callback URL (e.g., `http://localhost:3000/api/auth/callback/cognito`).
<Callout type="info">
- **User Pool is required** for Cognito authentication.
- Make sure the callback URL matches exactly what you configure in Cognito.
</Callout>
</Step>
<Step>
### Configure the provider
Import the Cognito provider and pass it to the `providers` option of your `auth` instance.
```ts title="auth.ts"
import { betterAuth } from "better-auth"
export const auth = betterAuth({
socialProvider: {
cognito({
clientId: process.env.COGNITO_CLIENT_ID as string, // [!code highlight]
clientSecret: process.env.COGNITO_CLIENT_SECRET as string, // [!code highlight]
domain: process.env.COGNITO_DOMAIN as string, // e.g. "your-app.auth.us-east-1.amazoncognito.com" [!code highlight]
region: process.env.COGNITO_REGION as string, // e.g. "us-east-1" [!code highlight]
userPoolId: process.env.COGNITO_USERPOOL_ID as string, // [!code highlight]
}),
},
})
```
</Step>
<Step>
### Sign In with Cognito
To sign in with Cognito, use the `signIn.social` function from the client.
```ts title="auth-client.ts"
import { createAuthClient } from "better-auth/client"
const authClient = createAuthClient()
const signIn = async () => {
const data = await authClient.signIn.social({
provider: "cognito"
})
}
```
### Additional Options:
- `scope`: Additional OAuth2 scopes to request (combined with default permissions).
- Default: `"openid" "profile" "email"`
- Common Cognito scopes:
- `openid`: Required for OpenID Connect authentication
- `profile`: Access to basic profile info
- `email`: Access to users email
- `phone`: Access to users phone number
- `aws.cognito.signin.user.admin`: Grants access to Cognito-specific APIs
- Note: You must configure the scopes in your Cognito App Client settings. [available scopes](https://docs.aws.amazon.com/cognito/latest/developerguide/token-endpoint.html#token-endpoint-userinfo)
- `getUserInfo`: Custom function to retrieve user information from the Cognito UserInfo endpoint.
<Callout type="info">
For more information about Amazon Cognito's scopes and API capabilities, refer to the [official documentation](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-define-resource-servers.html?utm_source).
</Callout>
</Step>
</Steps>

View File

@@ -0,0 +1,60 @@
---
title: Figma
description: Figma provider setup and usage.
---
<Steps>
<Step>
### Get your Credentials
1. Sign in to your Figma account and go to the [Developer Apps page](https://www.figma.com/developers/apps)
2. Click "Create new app"
3. Fill out the app details (name, description, etc.)
4. Configure your redirect URI (e.g., `https://yourdomain.com/api/auth/callback/figma`)
5. Note your Client ID and Client Secret
<Callout type="info">
- The default scope is `file_read`. For additional scopes like `file_write`, refer to the [Figma OAuth documentation](https://www.figma.com/developers/api#oauth2).
</Callout>
Make sure to set the redirect URI to match your application's callback URL. If you change the base path of the auth routes, you should update the redirect URI accordingly.
</Step>
<Step>
### Configure the provider
To configure the provider, you need to import the provider and pass it to the `socialProviders` option of the auth instance.
```ts title="auth.ts"
import { betterAuth } from "better-auth"
export const auth = betterAuth({
socialProviders: {
figma: { // [!code highlight]
clientId: process.env.FIGMA_CLIENT_ID as string, // [!code highlight]
clientSecret: process.env.FIGMA_CLIENT_SECRET as string, // [!code highlight]
clientKey: process.env.FIGMA_CLIENT_KEY as string, // [!code highlight]
}, // [!code highlight]
},
})
```
</Step>
<Step>
### Sign In with Figma
To sign in with Figma, you can use the `signIn.social` function provided by the client. The `signIn` function takes an object with the following properties:
- `provider`: The provider to use. It should be set to `figma`.
```ts title="auth-client.ts"
import { createAuthClient } from "better-auth/client"
const authClient = createAuthClient()
const signIn = async () => {
const data = await authClient.signIn.social({
provider: "figma"
})
}
```
<Callout type="info">
For more information about Figma's OAuth scopes and API capabilities, refer to the [official Figma API documentation](https://www.figma.com/developers/api).
</Callout>
</Step>
</Steps>

View File

@@ -0,0 +1,47 @@
---
title: Kakao
description: Kakao provider setup and usage.
---
<Steps>
<Step>
### Get your Kakao Credentials
To use Kakao sign in, you need a client ID and client secret. You can get them from the [Kakao Developer Portal](https://developers.kakao.com).
Make sure to set the redirect URL to `http://localhost:3000/api/auth/callback/kakao` for local development. For production, you should set it to the URL of your application. If you change the base path of the auth routes, you should update the redirect URL accordingly.
</Step>
<Step>
### Configure the provider
To configure the provider, you need to import the provider and pass it to the `socialProviders` option of the auth instance.
```ts title="auth.ts"
import { betterAuth } from "better-auth"
export const auth = betterAuth({
socialProviders: {
kakao: { // [!code highlight]
clientId: process.env.KAKAO_CLIENT_ID as string, // [!code highlight]
clientSecret: process.env.KAKAO_CLIENT_SECRET as string, // [!code highlight]
}, // [!code highlight]
}
})
```
</Step>
<Step>
### Sign In with Kakao
To sign in with Kakao, you can use the `signIn.social` function provided by the client. The `signIn` function takes an object with the following properties:
- `provider`: The provider to use. It should be set to `kakao`.
```ts title="auth-client.ts"
import { createAuthClient } from "better-auth/client"
const authClient = createAuthClient()
const signIn = async () => {
const data = await authClient.signIn.social({
provider: "kakao"
})
}
```
</Step>
</Steps>

View File

@@ -0,0 +1,77 @@
---
title: LINE
description: LINE provider setup and usage.
---
<Steps>
<Step>
### Get your LINE credentials
1. Create a channel in the LINE Developers Console.
2. Note your Channel ID (client_id) and Channel secret (client_secret).
3. In the channel settings, add your Redirect URI, e.g. `http://localhost:3000/api/auth/callback/line` for local development.
4. Enable required scopes (at least `openid`; add `profile`, `email` if you need name, avatar, email).
See LINE Login v2.1 reference for details: [`https://developers.line.biz/en/reference/line-login/#issue-access-token`]
</Step>
<Step>
### Configure the provider
Add your LINE credentials to `socialProviders.line` in your auth configuration.
```ts title="auth.ts"
import { betterAuth } from "better-auth";
export const auth = betterAuth({
socialProviders: {
line: {
clientId: process.env.LINE_CLIENT_ID as string,
clientSecret: process.env.LINE_CLIENT_SECRET as string,
// Optional: override redirect if needed
// redirectURI: "https://your.app/api/auth/callback/line",
// scopes are prefilled: ["openid","profile","email"]. Append if needed
},
},
});
```
</Step>
</Steps>
## Usage
### Sign In with LINE
Use the client `signIn.social` with `provider: "line"`.
```ts title="auth-client.ts"
import { createAuthClient } from "better-auth/client";
const authClient = createAuthClient();
async function signInWithLINE() {
const res = await authClient.signIn.social({ provider: "line" });
}
```
### Sign In with LINE using ID Token (optional)
If you obtain the LINE ID token on the client, you can sign in directly without redirection.
```ts title="auth-client.ts"
await authClient.signIn.social({
provider: "line",
idToken: {
token: "<LINE_ID_TOKEN>",
accessToken: "<LINE_ACCESS_TOKEN>",
},
});
```
### Notes
- Default scopes include `openid profile email`. Adjust as needed via provider options.
- Verify redirect URI exactly matches the value configured in LINE Developers Console.
- LINE ID token verification uses the official endpoint and checks audience and optional nonce per spec.
Designing a login button? Follow LINE's button [guidelines](https://developers.line.biz/en/docs/line-login/login-button/).

View File

@@ -29,11 +29,15 @@ Enabling OAuth with Microsoft Azure Entra ID (formerly Active Directory) allows
clientSecret: process.env.MICROSOFT_CLIENT_SECRET as string, // [!code highlight] clientSecret: process.env.MICROSOFT_CLIENT_SECRET as string, // [!code highlight]
// Optional // Optional
tenantId: 'common', // [!code highlight] tenantId: 'common', // [!code highlight]
authority: "https://login.microsoftonline.com", // Authentication authority URL // [!code highlight]
prompt: "select_account", // Forces account selection // [!code highlight] prompt: "select_account", // Forces account selection // [!code highlight]
}, // [!code highlight] }, // [!code highlight]
}, },
}) })
``` ```
**Authority URL**: Use the default `https://login.microsoftonline.com` for standard Entra ID scenarios or `https://<tenant-id>.ciamlogin.com` for CIAM (Customer Identity and Access Management) scenarios.
</Step> </Step>
</Steps> </Steps>

View File

@@ -0,0 +1,47 @@
---
title: Naver
description: Naver provider setup and usage.
---
<Steps>
<Step>
### Get your Naver Credentials
To use Naver sign in, you need a client ID and client secret. You can get them from the [Naver Developers](https://developers.naver.com/).
Make sure to set the redirect URL to `http://localhost:3000/api/auth/callback/naver` for local development. For production, you should set it to the URL of your application. If you change the base path of the auth routes, you should update the redirect URL accordingly.
</Step>
<Step>
### Configure the provider
To configure the provider, you need to import the provider and pass it to the `socialProviders` option of the auth instance.
```ts title="auth.ts"
import { betterAuth } from "better-auth"
export const auth = betterAuth({
socialProviders: {
naver: { // [!code highlight]
clientId: process.env.NAVER_CLIENT_ID as string, // [!code highlight]
clientSecret: process.env.NAVER_CLIENT_SECRET as string, // [!code highlight]
}, // [!code highlight]
}
})
```
</Step>
<Step>
### Sign In with Naver
To sign in with Naver, you can use the `signIn.social` function provided by the client. The `signIn` function takes an object with the following properties:
- `provider`: The provider to use. It should be set to `naver`.
```ts title="auth-client.ts"
import { createAuthClient } from "better-auth/client"
const authClient = createAuthClient()
const signIn = async () => {
const data = await authClient.signIn.social({
provider: "naver"
})
}
```
</Step>
</Steps>

View File

@@ -0,0 +1,107 @@
---
title: PayPal
description: Paypal provider setup and usage.
---
<Steps>
<Step>
### Get your PayPal Credentials
To integrate with PayPal, you need to obtain API credentials by creating an application in the [PayPal Developer Portal](https://developer.paypal.com/dashboard).
Follow these steps:
1. Create an account on the PayPal Developer Portal
2. Create a new application, [official docs]( https://developer.paypal.com/developer/applications/)
3. Configure Log in with PayPal under "Other features"
4. Set up your Return URL (redirect URL)
5. Configure user information permissions
6. Note your Client ID and Client Secret
<Callout type="info">
- PayPal has two environments: Sandbox (for testing) and Live (for production)
- For testing, create sandbox test accounts in the Developer Dashboard under "Sandbox" → "Accounts"
- You cannot use your real PayPal account to test in sandbox mode - you must use the generated test accounts
- The Return URL in your PayPal app settings must exactly match your redirect URI
- The PayPal API does not work with localhost. You need to use a public domain for the redirect URL and HTTPS for local testing. You can use [NGROK](https://ngrok.com/) or another similar tool for this.
</Callout>
Make sure to configure "Log in with PayPal" in your app settings:
1. Go to your app in the Developer Dashboard
2. Under "Other features", check "Log in with PayPal"
3. Click "Advanced Settings"
4. Enter your Return URL
5. Select the user information you want to access (email, name, etc.)
6. Enter Privacy Policy and User Agreement URLs
<Callout type="info">
- PayPal doesn't use traditional OAuth2 scopes in the authorization URL. Instead, you configure permissions directly in the Developer Dashboard
- For live apps, PayPal must review and approve your application before it can go live, which typically takes a few weeks
</Callout>
</Step>
<Step>
### Configure the provider
To configure the provider, you need to import the provider and pass it to the `socialProviders` option of the auth instance.
```ts title="auth.ts"
import { betterAuth } from "better-auth"
export const auth = betterAuth({
socialProviders: {
paypal: { // [!code highlight]
clientId: process.env.PAYPAL_CLIENT_ID as string, // [!code highlight]
clientSecret: process.env.PAYPAL_CLIENT_SECRET as string, // [!code highlight]
environment: "sandbox", // or "live" for production //, // [!code highlight]
}, // [!code highlight]
},
})
```
#### Options
The PayPal provider accepts the following options:
- `environment`: `'sandbox' | 'live'` - PayPal environment to use (default: `'sandbox'`)
- `requestShippingAddress`: `boolean` - Whether to request shipping address information (default: `false`)
```ts title="auth.ts"
export const auth = betterAuth({
socialProviders: {
paypal: {
clientId: process.env.PAYPAL_CLIENT_ID as string,
clientSecret: process.env.PAYPAL_CLIENT_SECRET as string,
environment: "live", // Use "live" for production
requestShippingAddress: true, // Request address info
},
},
})
```
</Step>
<Step>
### Sign In with PayPal
To sign in with PayPal, you can use the `signIn.social` function provided by the client. The `signIn` function takes an object with the following properties:
- `provider`: The provider to use. It should be set to `paypal`.
```ts title="auth-client.ts"
import { createAuthClient } from "better-auth/client"
const authClient = createAuthClient()
const signIn = async () => {
const data = await authClient.signIn.social({
provider: "paypal"
})
}
```
### Additional Options:
- `environment`: PayPal environment to use.
- Default: `"sandbox"`
- Options: `"sandbox"` | `"live"`
- `requestShippingAddress`: Whether to request shipping address information.
- Default: `false`
- `scope`: Additional scopes to request (combined with default permissions).
- Default: Configured in PayPal Developer Dashboard
- Note: PayPal doesn't use traditional OAuth2 scopes - permissions are set in the Dashboard
For more details refer to the [Scopes Reference](https://developer.paypal.com/docs/log-in-with-paypal/integrate/reference/#scope-attributes)
- `mapProfileToUser`: Custom function to map PayPal profile data to user object.
- `getUserInfo`: Custom function to retrieve user information.
For more details refer to the [User Reference](https://developer.paypal.com/docs/api/identity/v1/#userinfo_get)
- `verifyIdToken`: Custom ID token verification function.
</Step>
</Steps>

View File

@@ -0,0 +1,153 @@
---
title: Salesforce
description: Salesforce provider setup and usage.
---
<Steps>
<Step>
### Get your Salesforce Credentials
1. Log into your Salesforce org (Production or Developer Edition)
2. Navigate to **Setup > App Manager**
3. Click **New Connected App**
4. Fill in the basic information:
- Connected App Name: Your app name
- API Name: Auto-generated from app name
- Contact Email: Your email address
5. Enable OAuth Settings:
- Check **Enable OAuth Settings**
- Set **Callback URL** to your redirect URI (e.g., `http://localhost:3000/api/auth/callback/salesforce` for development)
- Select Required OAuth Scopes:
- Access your basic information (id)
- Access your identity URL service (openid)
- Access your email address (email)
- Perform requests on your behalf at any time (refresh_token, offline_access)
6. Enable **Require Proof Key for Code Exchange (PKCE)** (required)
7. Save and note your **Consumer Key** (Client ID) and **Consumer Secret** (Client Secret)
<Callout type="info">
- For development, you can use `http://localhost:3000` URLs, but production requires HTTPS
- The callback URL must exactly match what's configured in Better Auth
- PKCE (Proof Key for Code Exchange) is required by Salesforce and is automatically handled by the provider
</Callout>
<Callout type="warning">
For sandbox testing, you can create the Connected App in your sandbox org, or use the same Connected App but specify `environment: "sandbox"` in the provider configuration.
</Callout>
</Step>
<Step>
### Configure the provider
To configure the provider, you need to import the provider and pass it to the `socialProviders` option of the auth instance.
```ts title="auth.ts"
import { betterAuth } from "better-auth"
export const auth = betterAuth({
socialProviders: {
salesforce: { // [!code highlight]
clientId: process.env.SALESFORCE_CLIENT_ID as string, // [!code highlight]
clientSecret: process.env.SALESFORCE_CLIENT_SECRET as string, // [!code highlight]
environment: "production", // or "sandbox" // [!code highlight]
}, // [!code highlight]
},
})
```
#### Configuration Options
- `clientId`: Your Connected App's Consumer Key
- `clientSecret`: Your Connected App's Consumer Secret
- `environment`: `"production"` (default) or `"sandbox"`
- `loginUrl`: Custom My Domain URL (without `https://`) - overrides environment setting
- `redirectURI`: Override the auto-generated redirect URI if needed
#### Advanced Configuration
```ts title="auth.ts"
export const auth = betterAuth({
socialProviders: {
salesforce: {
clientId: process.env.SALESFORCE_CLIENT_ID as string,
clientSecret: process.env.SALESFORCE_CLIENT_SECRET as string,
environment: "sandbox", // [!code highlight]
loginUrl: "mycompany.my.salesforce.com", // Custom My Domain // [!code highlight]
redirectURI: "http://localhost:3000/api/auth/callback/salesforce", // Override if needed // [!code highlight]
},
},
})
```
<Callout type="info">
- Use `environment: "sandbox"` for testing with Salesforce sandbox orgs
- The `loginUrl` option is useful for organizations with My Domain enabled
- The `redirectURI` option helps resolve redirect URI mismatch errors
</Callout>
</Step>
<Step>
### Environment Variables
Add the following environment variables to your `.env.local` file:
```bash title=".env.local"
SALESFORCE_CLIENT_ID=your_consumer_key_here
SALESFORCE_CLIENT_SECRET=your_consumer_secret_here
BETTER_AUTH_URL=http://localhost:3000 # Important for redirect URI generation
```
For production:
```bash title=".env"
SALESFORCE_CLIENT_ID=your_consumer_key_here
SALESFORCE_CLIENT_SECRET=your_consumer_secret_here
BETTER_AUTH_URL=https://yourdomain.com
```
</Step>
<Step>
### Sign In with Salesforce
To sign in with Salesforce, you can use the `signIn.social` function provided by the client. The `signIn` function takes an object with the following properties:
- `provider`: The provider to use. It should be set to `salesforce`.
```ts title="auth-client.ts"
import { createAuthClient } from "better-auth/client"
const authClient = createAuthClient()
const signIn = async () => {
const data = await authClient.signIn.social({
provider: "salesforce"
})
}
```
</Step>
<Step>
### Troubleshooting
#### Redirect URI Mismatch Error
If you encounter a `redirect_uri_mismatch` error:
1. **Check Callback URL**: Ensure the Callback URL in your Salesforce Connected App exactly matches your Better Auth callback URL
2. **Protocol**: Make sure you're using the same protocol (`http://` vs `https://`)
3. **Port**: Verify the port number matches (e.g., `:3000`)
4. **Override if needed**: Use the `redirectURI` option to explicitly set the redirect URI
```ts
salesforce: {
clientId: process.env.SALESFORCE_CLIENT_ID as string,
clientSecret: process.env.SALESFORCE_CLIENT_SECRET as string,
redirectURI: "http://localhost:3000/api/auth/callback/salesforce", // [!code highlight]
}
```
#### Environment Issues
- **Production**: Use `environment: "production"` (default) with `login.salesforce.com`
- **Sandbox**: Use `environment: "sandbox"` with `test.salesforce.com`
- **My Domain**: Use `loginUrl: "yourcompany.my.salesforce.com"` for custom domains
#### PKCE Requirements
Salesforce requires PKCE (Proof Key for Code Exchange) which is automatically handled by this provider. Make sure PKCE is enabled in your Connected App settings.
<Callout type="info">
The default scopes requested are `openid`, `email`, and `profile`. The provider will automatically include the `id` scope for accessing basic user information.
</Callout>
</Step>
</Steps>

View File

@@ -13,7 +13,7 @@ description: TikTok provider setup and usage.
2. Create a new application 2. Create a new application
3. Set up a sandbox environment for testing 3. Set up a sandbox environment for testing
4. Configure your redirect URL (must be HTTPS) 4. Configure your redirect URL (must be HTTPS)
5. Note your Client ID, Client Secret and Client Key 5. Note your Client Secret and Client Key
<Callout type="info"> <Callout type="info">
- The TikTok API does not work with localhost. You need to use a public domain for the redirect URL and HTTPS for local testing. You can use [NGROK](https://ngrok.com/) or another similar tool for this. - The TikTok API does not work with localhost. You need to use a public domain for the redirect URL and HTTPS for local testing. You can use [NGROK](https://ngrok.com/) or another similar tool for this.
@@ -39,7 +39,6 @@ description: TikTok provider setup and usage.
export const auth = betterAuth({ export const auth = betterAuth({
socialProviders: { socialProviders: {
tiktok: { // [!code highlight] tiktok: { // [!code highlight]
clientId: process.env.TIKTOK_CLIENT_ID as string, // [!code highlight]
clientSecret: process.env.TIKTOK_CLIENT_SECRET as string, // [!code highlight] clientSecret: process.env.TIKTOK_CLIENT_SECRET as string, // [!code highlight]
clientKey: process.env.TIKTOK_CLIENT_KEY as string, // [!code highlight] clientKey: process.env.TIKTOK_CLIENT_KEY as string, // [!code highlight]
}, // [!code highlight] }, // [!code highlight]

View File

@@ -3,7 +3,7 @@ title: CLI
description: Built-in CLI for managing your project. description: Built-in CLI for managing your project.
--- ---
Better Auth comes with a built-in CLI to help you manage the database schemas, initialize your project, and generate a secret key for your application. Better Auth comes with a built-in CLI to help you manage the database schemas, initialize your project, generate a secret key for your application, and gather diagnostic information about your setup.
## Generate ## Generate
@@ -15,14 +15,14 @@ npx @better-auth/cli@latest generate
### Options ### Options
- `--output` - Where to save the generated schema. For Prisma, it will be saved in prisma/schema.prisma. For Drizzle, it goes to schema.ts in your project root. For Kysely, its an SQL file saved as schema.sql in your project root. - `--output` - Where to save the generated schema. For Prisma, it will be saved in prisma/schema.prisma. For Drizzle, it goes to schema.ts in your project root. For Kysely, it's an SQL file saved as schema.sql in your project root.
- `--config` - The path to your Better Auth config file. By default, the CLI will search for a auth.ts file in **./**, **./utils**, **./lib**, or any of these directories under `src` directory. - `--config` - The path to your Better Auth config file. By default, the CLI will search for a auth.ts file in **./**, **./utils**, **./lib**, or any of these directories under `src` directory.
- `--yes` - Skip the confirmation prompt and generate the schema directly. - `--yes` - Skip the confirmation prompt and generate the schema directly.
## Migrate ## Migrate
The migrate command applies the Better Auth schema directly to your database. This is available if youre using the built-in Kysely adapter. For other adapters, you'll need to apply the schema using your ORM's migration tool. The migrate command applies the Better Auth schema directly to your database. This is available if you're using the built-in Kysely adapter. For other adapters, you'll need to apply the schema using your ORM's migration tool.
```bash title="Terminal" ```bash title="Terminal"
npx @better-auth/cli@latest migrate npx @better-auth/cli@latest migrate
@@ -49,6 +49,43 @@ npx @better-auth/cli@latest init
- `--database` - The database you want to use. Currently, the only supported database is `sqlite`. - `--database` - The database you want to use. Currently, the only supported database is `sqlite`.
- `--package-manager` - The package manager you want to use. Currently, the only supported package managers are `npm`, `pnpm`, `yarn`, `bun`. (Defaults to the manager you used to initialize the CLI.) - `--package-manager` - The package manager you want to use. Currently, the only supported package managers are `npm`, `pnpm`, `yarn`, `bun`. (Defaults to the manager you used to initialize the CLI.)
## Info
The `info` command provides diagnostic information about your Better Auth setup and environment. Useful for debugging and sharing when seeking support.
```bash title="Terminal"
npx @better-auth/cli@latest info
```
### Output
The command displays:
- **System**: OS, CPU, memory, Node.js version
- **Package Manager**: Detected manager and version
- **Better Auth**: Version and configuration (sensitive data auto-redacted)
- **Frameworks**: Detected frameworks (Next.js, React, Vue, etc.)
- **Databases**: Database clients and ORMs (Prisma, Drizzle, etc.)
### Options
- `--config` - Path to your Better Auth config file
- `--json` - Output as JSON for sharing or programmatic use
### Examples
```bash
# Basic usage
npx @better-auth/cli@latest info
# Custom config path
npx @better-auth/cli@latest info --config ./config/auth.ts
# JSON output
npx @better-auth/cli@latest info --json > auth-info.json
```
Sensitive data like secrets, API keys, and database URLs are automatically replaced with `[REDACTED]` for safe sharing.
## Secret ## Secret
The CLI also provides a way to generate a secret key for your Better Auth instance. The CLI also provides a way to generate a secret key for your Better Auth instance.
@@ -61,6 +98,6 @@ npx @better-auth/cli@latest secret
**Error: Cannot find module X** **Error: Cannot find module X**
If you see this error, it means the CLI cant resolve imported modules in your Better Auth config file. We're working on a fix for many of these issues, but in the meantime, you can try the following: If you see this error, it means the CLI can't resolve imported modules in your Better Auth config file. We're working on a fix for many of these issues, but in the meantime, you can try the following:
- Remove any import aliases in your config file and use relative paths instead. After running the CLI, you can revert to using aliases. - Remove any import aliases in your config file and use relative paths instead. After running the CLI, you can revert to using aliases.

View File

@@ -271,3 +271,25 @@ export const auth = betterAuth({
2. When your server and client code are in separate projects or repositories, and you cannot import the `auth` instance as a type reference, type inference for custom session fields will not work on the client side. 2. When your server and client code are in separate projects or repositories, and you cannot import the `auth` instance as a type reference, type inference for custom session fields will not work on the client side.
3. Session caching, including secondary storage or cookie cache, does not include custom fields. Each time the session is fetched, your custom session function will be called. 3. Session caching, including secondary storage or cookie cache, does not include custom fields. Each time the session is fetched, your custom session function will be called.
**Mutating the list-device-sessions endpoint**
The `/multi-session/list-device-sessions` endpoint from the [multi-session](/docs/plugins/multi-session) plugin is used to list the devices that the user is signed into.
You can mutate the response of this endpoint by passing the `shouldMutateListDeviceSessionsEndpoint` option to the `customSession` plugin.
By default, we do not mutate the response of this endpoint.
```ts title="auth.ts"
import { customSession } from "better-auth/plugins";
export const auth = betterAuth({
plugins: [
customSession(async ({ user, session }, ctx) => {
return {
user,
session
}
}, {}, { shouldMutateListDeviceSessionsEndpoint: true }), // [!code highlight]
],
});
```

View File

@@ -362,7 +362,11 @@ const makeAuthenticatedRequest = async () => {
const headers = { const headers = {
"Cookie": cookies, // [!code highlight] "Cookie": cookies, // [!code highlight]
}; };
const response = await fetch("http://localhost:8081/api/secure-endpoint", { headers }); const response = await fetch("http://localhost:8081/api/secure-endpoint", {
headers,
// 'include' can interfere with the cookies we just set manually in the headers
credentials: "omit" // [!code highlight]
});
const data = await response.json(); const data = await response.json();
return data; return data;
}; };

View File

@@ -0,0 +1,661 @@
---
title: Device Authorization
description: OAuth 2.0 Device Authorization Grant for limited-input devices
---
`RFC 8628` `CLI` `Smart TV` `IoT`
The Device Authorization plugin implements the OAuth 2.0 Device Authorization Grant ([RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628)), enabling authentication for devices with limited input capabilities such as smart TVs, CLI applications, IoT devices, and gaming consoles.
## Try It Out
You can test the device authorization flow right now using the Better Auth CLI:
```bash
npx @better-auth/cli login
```
This will demonstrate the complete device authorization flow by:
1. Requesting a device code from the Better Auth demo server
2. Displaying a user code for you to enter
3. Opening your browser to the verification page
4. Polling for authorization completion
<Callout type="info">
The CLI login command is a demo feature that connects to the Better Auth demo server to showcase the device authorization flow in action.
</Callout>
## Installation
<Steps>
<Step>
### Add the plugin to your auth config
Add the device authorization plugin to your server configuration.
```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { deviceAuthorization } from "better-auth/plugins"; // [!code highlight]
export const auth = betterAuth({
// ... other config
plugins: [ // [!code highlight]
deviceAuthorization({ // [!code highlight]
// Optional configuration
expiresIn: "30m", // Device code expiration time // [!code highlight]
interval: "5s", // Minimum polling interval // [!code highlight]
}), // [!code highlight]
], // [!code highlight]
});
```
</Step>
<Step>
### Migrate the database
Run the migration or generate the schema to add the necessary tables to the database.
<Tabs items={["migrate", "generate"]}>
<Tab value="migrate">
```bash
npx @better-auth/cli migrate
```
</Tab>
<Tab value="generate">
```bash
npx @better-auth/cli generate
```
</Tab>
</Tabs>
See the [Schema](#schema) section to add the fields manually.
</Step>
<Step>
### Add the client plugin
Add the device authorization plugin to your client.
```ts title="auth-client.ts"
import { createAuthClient } from "better-auth/client";
import { deviceAuthorizationClient } from "better-auth/client/plugins"; // [!code highlight]
export const authClient = createAuthClient({
plugins: [ // [!code highlight]
deviceAuthorizationClient(), // [!code highlight]
], // [!code highlight]
});
```
</Step>
</Steps>
## How It Works
The device flow follows these steps:
1. **Device requests codes**: The device requests a device code and user code from the authorization server
2. **User authorizes**: The user visits a verification URL and enters the user code
3. **Device polls for token**: The device polls the server until the user completes authorization
4. **Access granted**: Once authorized, the device receives an access token
## Basic Usage
### Requesting Device Authorization
To initiate device authorization, call `device.code` with the client ID:
<APIMethod
path="/device/code"
method="POST"
>
```ts
type deviceCode = {
/**
* The OAuth client identifier
*/
client_id: string;
/**
* Space-separated list of requested scopes (optional)
*/
scope?: string;
}
```
</APIMethod>
Example usage:
```ts
const { data } = await authClient.device.code({
client_id: "your-client-id",
scope: "openid profile email",
});
if (data) {
console.log(`Please visit: ${data.verification_uri}`);
console.log(`And enter code: ${data.user_code}`);
}
```
### Polling for Token
After displaying the user code, poll for the access token:
<APIMethod
path="/device/token"
method="POST"
>
```ts
type deviceToken = {
/**
* Must be "urn:ietf:params:oauth:grant-type:device_code"
*/
grant_type: string;
/**
* The device code from the initial request
*/
device_code: string;
/**
* The OAuth client identifier
*/
client_id: string;
}
```
</APIMethod>
Example polling implementation:
```ts
let pollingInterval = 5; // Start with 5 seconds
const pollForToken = async () => {
const { data, error } = await authClient.device.token({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code,
client_id: yourClientId,
fetchOptions: {
headers: {
"user-agent": `My CLI`,
},
},
});
if (data?.access_token) {
console.log("Authorization successful!");
} else if (error) {
switch (error.error) {
case "authorization_pending":
// Continue polling
break;
case "slow_down":
pollingInterval += 5;
break;
case "access_denied":
console.error("Access was denied by the user");
return;
case "expired_token":
console.error("The device code has expired. Please try again.");
return;
default:
console.error(`Error: ${error.error_description}`);
return;
}
setTimeout(pollForToken, pollingInterval * 1000);
}
};
pollForToken();
```
### User Authorization Flow
The user authorization flow requires two steps:
1. **Code Verification**: Check if the entered user code is valid
2. **Authorization**: User must be authenticated to approve/deny the device
<Callout type="warn">
Users must be authenticated before they can approve or deny device authorization requests. If not authenticated, redirect them to the login page with a return URL.
</Callout>
Create a page where users can enter their code:
```tsx title="app/device/page.tsx"
export default function DeviceAuthorizationPage() {
const [userCode, setUserCode] = useState("");
const [error, setError] = useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
try {
// Format the code: remove dashes and convert to uppercase
const formattedCode = userCode.trim().replace(/-/g, "").toUpperCase();
// Check if the code is valid using GET /device endpoint
const response = await authClient.device.deviceVerify({
query: { user_code: formattedCode },
});
if (response.data) {
// Redirect to approval page
window.location.href = `/device/approve?user_code=${formattedCode}`;
}
} catch (err) {
setError("Invalid or expired code");
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={userCode}
onChange={(e) => setUserCode(e.target.value)}
placeholder="Enter device code (e.g., ABCD-1234)"
maxLength={12}
/>
<button type="submit">Continue</button>
{error && <p>{error}</p>}
</form>
);
}
```
### Approving or Denying Device
Users must be authenticated to approve or deny device authorization requests:
#### Approve Device
<APIMethod
path="/device/approve"
method="POST"
requireSession
>
```ts
type deviceApprove = {
/**
* The user code to approve
*/
userCode: string;
}
```
</APIMethod>
#### Deny Device
<APIMethod
path="/device/deny"
method="POST"
requireSession
>
```ts
type deviceDeny = {
/**
* The user code to deny
*/
userCode: string;
}
```
</APIMethod>
#### Example Approval Page
```tsx title="app/device/approve/page.tsx"
export default function DeviceApprovalPage() {
const { user } = useAuth(); // Must be authenticated
const searchParams = useSearchParams();
const userCode = searchParams.get("userCode");
const [isProcessing, setIsProcessing] = useState(false);
const handleApprove = async () => {
setIsProcessing(true);
try {
await authClient.device.deviceApprove({
userCode: userCode,
});
// Show success message
alert("Device approved successfully!");
window.location.href = "/";
} catch (error) {
alert("Failed to approve device");
}
setIsProcessing(false);
};
const handleDeny = async () => {
setIsProcessing(true);
try {
await authClient.device.deviceDeny({
userCode: userCode,
});
alert("Device denied");
window.location.href = "/";
} catch (error) {
alert("Failed to deny device");
}
setIsProcessing(false);
};
if (!user) {
// Redirect to login if not authenticated
window.location.href = `/login?redirect=/device/approve?user_code=${userCode}`;
return null;
}
return (
<div>
<h2>Device Authorization Request</h2>
<p>A device is requesting access to your account.</p>
<p>Code: {userCode}</p>
<button onClick={handleApprove} disabled={isProcessing}>
Approve
</button>
<button onClick={handleDeny} disabled={isProcessing}>
Deny
</button>
</div>
);
}
```
## Advanced Configuration
### Client Validation
You can validate client IDs to ensure only authorized applications can use the device flow:
```ts
deviceAuthorization({
validateClient: async (clientId) => {
// Check if client is authorized
const client = await db.oauth_clients.findOne({ id: clientId });
return client && client.allowDeviceFlow;
},
onDeviceAuthRequest: async (clientId, scope) => {
// Log device authorization requests
await logDeviceAuthRequest(clientId, scope);
},
})
```
### Custom Code Generation
Customize how device and user codes are generated:
```ts
deviceAuthorization({
generateDeviceCode: async () => {
// Custom device code generation
return crypto.randomBytes(32).toString("hex");
},
generateUserCode: async () => {
// Custom user code generation
// Default uses: ABCDEFGHJKLMNPQRSTUVWXYZ23456789
// (excludes 0, O, 1, I to avoid confusion)
const charset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let code = "";
for (let i = 0; i < 8; i++) {
code += charset[Math.floor(Math.random() * charset.length)];
}
return code;
},
})
```
## Error Handling
The device flow defines specific error codes:
| Error Code | Description |
|------------|-------------|
| `authorization_pending` | User hasn't approved yet (continue polling) |
| `slow_down` | Polling too frequently (increase interval) |
| `expired_token` | Device code has expired |
| `access_denied` | User denied the authorization |
| `invalid_grant` | Invalid device code or client ID |
## Example: CLI Application
Here's a complete example for a CLI application based on the actual demo:
```ts title="cli-auth.ts"
import { createAuthClient } from "better-auth/client";
import { deviceAuthorizationClient } from "better-auth/client/plugins";
import open from "open";
const authClient = createAuthClient({
baseURL: "http://localhost:3000",
plugins: [deviceAuthorizationClient()],
});
async function authenticateCLI() {
console.log("🔐 Better Auth Device Authorization Demo");
console.log("⏳ Requesting device authorization...");
try {
// Request device code
const { data, error } = await authClient.device.code({
client_id: "demo-cli",
scope: "openid profile email",
});
if (error || !data) {
console.error("❌ Error:", error?.error_description);
process.exit(1);
}
const {
device_code,
user_code,
verification_uri,
verification_uri_complete,
interval = 5,
} = data;
console.log("\n📱 Device Authorization in Progress");
console.log(`Please visit: ${verification_uri}`);
console.log(`Enter code: ${user_code}\n`);
// Open browser with the complete URL
const urlToOpen = verification_uri_complete || verification_uri;
if (urlToOpen) {
console.log("🌐 Opening browser...");
await open(urlToOpen);
}
console.log(`⏳ Waiting for authorization... (polling every ${interval}s)`);
// Poll for token
await pollForToken(device_code, interval);
} catch (err) {
console.error("❌ Error:", err.message);
process.exit(1);
}
}
async function pollForToken(deviceCode: string, interval: number) {
let pollingInterval = interval;
return new Promise<void>((resolve) => {
const poll = async () => {
try {
const { data, error } = await authClient.device.token({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: deviceCode,
client_id: "demo-cli",
});
if (data?.access_token) {
console.log("\n✅ Authorization Successful!");
console.log("Access token received!");
// Get user session
const { data: session } = await authClient.getSession({
fetchOptions: {
headers: {
Authorization: `Bearer ${data.access_token}`,
},
},
});
console.log(`Hello, ${session?.user?.name || "User"}!`);
resolve();
process.exit(0);
} else if (error) {
switch (error.error) {
case "authorization_pending":
// Continue polling silently
break;
case "slow_down":
pollingInterval += 5;
console.log(`⚠️ Slowing down polling to ${pollingInterval}s`);
break;
case "access_denied":
console.error("❌ Access was denied by the user");
process.exit(1);
break;
case "expired_token":
console.error("❌ The device code has expired. Please try again.");
process.exit(1);
break;
default:
console.error("❌ Error:", error.error_description);
process.exit(1);
}
}
} catch (err) {
console.error("❌ Network error:", err.message);
process.exit(1);
}
// Schedule next poll
setTimeout(poll, pollingInterval * 1000);
};
// Start polling
setTimeout(poll, pollingInterval * 1000);
});
}
// Run the authentication flow
authenticateCLI().catch((err) => {
console.error("❌ Fatal error:", err);
process.exit(1);
});
```
## Security Considerations
1. **Rate Limiting**: The plugin enforces polling intervals to prevent abuse
2. **Code Expiration**: Device and user codes expire after the configured time (default: 30 minutes)
3. **Client Validation**: Always validate client IDs in production to prevent unauthorized access
4. **HTTPS Only**: Always use HTTPS in production for device authorization
5. **User Code Format**: User codes use a limited character set (excluding similar-looking characters like 0/O, 1/I) to reduce typing errors
6. **Authentication Required**: Users must be authenticated before they can approve or deny device requests
## Options
### Server
**expiresIn**: The expiration time for device codes. Default: `"30m"` (30 minutes).
**interval**: The minimum polling interval. Default: `"5s"` (5 seconds).
**userCodeLength**: The length of the user code. Default: `8`.
**deviceCodeLength**: The length of the device code. Default: `40`.
**generateDeviceCode**: Custom function to generate device codes. Returns a string or `Promise<string>`.
**generateUserCode**: Custom function to generate user codes. Returns a string or `Promise<string>`.
**validateClient**: Function to validate client IDs. Takes a clientId and returns boolean or `Promise<boolean>`.
**onDeviceAuthRequest**: Hook called when device authorization is requested. Takes clientId and optional scope.
### Client
No client-specific configuration options. The plugin adds the following methods:
- **device.code()**: Request device and user codes
- **device.token()**: Poll for access token
- **device.deviceVerify()**: Verify user code validity
- **device.deviceApprove()**: Approve device (requires authentication)
- **device.deviceDeny()**: Deny device (requires authentication)
## Schema
The plugin requires a new table to store device authorization data.
Table Name: `deviceCode`
<DatabaseTable
fields={[
{
name: "id",
type: "string",
description: "Unique identifier for the device authorization request",
isPrimaryKey: true
},
{
name: "deviceCode",
type: "string",
description: "The device verification code",
},
{
name: "userCode",
type: "string",
description: "The user-friendly code for verification",
},
{
name: "userId",
type: "string",
description: "The ID of the user who approved/denied",
isOptional: true,
isForeignKey: true
},
{
name: "clientId",
type: "string",
description: "The OAuth client identifier",
isOptional: true
},
{
name: "scope",
type: "string",
description: "Requested OAuth scopes",
isOptional: true
},
{
name: "status",
type: "string",
description: "Current status: pending, approved, or denied",
},
{
name: "expiresAt",
type: "Date",
description: "When the device code expires",
},
{
name: "lastPolledAt",
type: "Date",
description: "Last time the device polled for status",
isOptional: true
},
{
name: "pollingInterval",
type: "number",
description: "Minimum seconds between polls",
isOptional: true
},
{
name: "createdAt",
type: "Date",
description: "When the request was created",
},
{
name: "updatedAt",
type: "Date",
description: "When the request was last updated",
}
]}
/>

View File

@@ -66,7 +66,7 @@ To get the token, call the `/token` endpoint. This will return the following:
} }
``` ```
Make sure to include the token in the `Authorization` header of your requests and the `bearer` plugin is added in your auth configuration. Make sure to include the token in the `Authorization` header of your requests if the `bearer` plugin is added in your auth configuration.
```ts ```ts
await fetch("/api/auth/token", { await fetch("/api/auth/token", {

View File

@@ -0,0 +1,354 @@
---
title: Last Login Method
description: Track and display the last authentication method used by users
---
The last login method plugin tracks the most recent authentication method used by users (email, OAuth providers, etc.). This enables you to display helpful indicators on login pages, such as "Last signed in with Google" or prioritize certain login methods based on user preferences.
## Installation
<Steps>
<Step>
### Add the plugin to your auth config
```ts title="auth.ts"
import { betterAuth } from "better-auth"
import { lastLoginMethod } from "better-auth/plugins" // [!code highlight]
export const auth = betterAuth({
// ... other config options
plugins: [
lastLoginMethod() // [!code highlight]
]
})
```
</Step>
<Step>
### Add the client plugin to your auth client
```ts title="auth-client.ts"
import { createAuthClient } from "better-auth/client"
import { lastLoginMethodClient } from "better-auth/client/plugins" // [!code highlight]
export const authClient = createAuthClient({
plugins: [
lastLoginMethodClient() // [!code highlight]
]
})
```
</Step>
</Steps>
## Usage
Once installed, the plugin automatically tracks the last authentication method used by users. You can then retrieve and display this information in your application.
### Getting the Last Used Method
The client plugin provides several methods to work with the last login method:
```ts title="app.tsx"
import { authClient } from "@/lib/auth-client"
// Get the last used login method
const lastMethod = authClient.getLastUsedLoginMethod()
console.log(lastMethod) // "google", "email", "github", etc.
// Check if a specific method was last used
const wasGoogle = authClient.isLastUsedLoginMethod("google")
// Clear the stored method
authClient.clearLastUsedLoginMethod()
```
### UI Integration Example
Here's how to use the plugin to enhance your login page:
```tsx title="sign-in.tsx"
import { authClient } from "@/lib/auth-client"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
export function SignInPage() {
const lastMethod = authClient.getLastUsedLoginMethod()
return (
<div className="space-y-4">
<h1>Sign In</h1>
{/* Email sign in */}
<div className="relative">
<Button
onClick={() => authClient.signIn.email({...})}
variant={lastMethod === "email" ? "default" : "outline"}
className="w-full"
>
Sign in with Email
{lastMethod === "email" && (
<Badge className="ml-2">Last used</Badge>
)}
</Button>
</div>
{/* OAuth providers */}
<div className="relative">
<Button
onClick={() => authClient.signIn.social({ provider: "google" })}
variant={lastMethod === "google" ? "default" : "outline"}
className="w-full"
>
Continue with Google
{lastMethod === "google" && (
<Badge className="ml-2">Last used</Badge>
)}
</Button>
</div>
<div className="relative">
<Button
onClick={() => authClient.signIn.social({ provider: "github" })}
variant={lastMethod === "github" ? "default" : "outline"}
className="w-full"
>
Continue with GitHub
{lastMethod === "github" && (
<Badge className="ml-2">Last used</Badge>
)}
</Button>
</div>
</div>
)
}
```
## Database Persistence
By default, the last login method is stored only in cookies. For more persistent tracking and analytics, you can enable database storage.
<Steps>
<Step>
### Enable database storage
Set `storeInDatabase` to `true` in your plugin configuration:
```ts title="auth.ts"
import { betterAuth } from "better-auth"
import { lastLoginMethod } from "better-auth/plugins"
export const auth = betterAuth({
plugins: [
lastLoginMethod({
storeInDatabase: true // [!code highlight]
})
]
})
```
</Step>
<Step>
### Run database migration
The plugin will automatically add a `lastLoginMethod` field to your user table. Run the migration to apply the changes:
<Tabs items={["migrate", "generate"]}>
<Tab value="migrate">
```bash
npx @better-auth/cli migrate
```
</Tab>
<Tab value="generate">
```bash
npx @better-auth/cli generate
```
</Tab>
</Tabs>
</Step>
<Step>
### Access database field
When database storage is enabled, the `lastLoginMethod` field becomes available in user objects:
```ts title="user-profile.tsx"
import { auth } from "@/lib/auth"
// Server-side access
const session = await auth.api.getSession({ headers })
console.log(session?.user.lastLoginMethod) // "google", "email", etc.
// Client-side access via session
const { data: session } = authClient.useSession()
console.log(session?.user.lastLoginMethod)
```
</Step>
</Steps>
### Database Schema
When `storeInDatabase` is enabled, the plugin adds the following field to the `user` table:
Table: `user`
<DatabaseTable
fields={[
{ name: "lastLoginMethod", type: "string", description: "The last authentication method used by the user", isOptional: true },
]}
/>
### Custom Schema Configuration
You can customize the database field name:
```ts title="auth.ts"
import { betterAuth } from "better-auth"
import { lastLoginMethod } from "better-auth/plugins"
export const auth = betterAuth({
plugins: [
lastLoginMethod({
storeInDatabase: true,
schema: {
user: {
lastLoginMethod: "last_auth_method" // Custom field name
}
}
})
]
})
```
## Configuration Options
The last login method plugin accepts the following options:
### Server Options
```ts title="auth.ts"
import { betterAuth } from "better-auth"
import { lastLoginMethod } from "better-auth/plugins"
export const auth = betterAuth({
plugins: [
lastLoginMethod({
// Cookie configuration
cookieName: "better-auth.last_used_login_method", // Default: "better-auth.last_used_login_method"
maxAge: 60 * 60 * 24 * 30, // Default: 30 days in seconds
// Database persistence
storeInDatabase: false, // Default: false
// Custom method resolution
customResolveMethod: (ctx) => {
// Custom logic to determine the login method
if (ctx.path === "/oauth/callback/custom-provider") {
return "custom-provider"
}
// Return null to use default resolution
return null
},
// Schema customization (when storeInDatabase is true)
schema: {
user: {
lastLoginMethod: "custom_field_name"
}
}
})
]
})
```
**cookieName**: `string`
- The name of the cookie used to store the last login method
- Default: `"better-auth.last_used_login_method"`
**maxAge**: `number`
- Cookie expiration time in seconds
- Default: `2592000` (30 days)
**storeInDatabase**: `boolean`
- Whether to store the last login method in the database
- Default: `false`
- When enabled, adds a `lastLoginMethod` field to the user table
**customResolveMethod**: `(ctx: GenericEndpointContext) => string | null`
- Custom function to determine the login method from the request context
- Return `null` to use the default resolution logic
- Useful for custom OAuth providers or authentication flows
**schema**: `object`
- Customize database field names when `storeInDatabase` is enabled
- Allows mapping the `lastLoginMethod` field to a custom column name
### Client Options
```ts title="auth-client.ts"
import { createAuthClient } from "better-auth/client"
import { lastLoginMethodClient } from "better-auth/client/plugins"
export const authClient = createAuthClient({
plugins: [
lastLoginMethodClient({
cookieName: "better-auth.last_used_login_method" // Default: "better-auth.last_used_login_method"
})
]
})
```
**cookieName**: `string`
- The name of the cookie to read the last login method from
- Must match the server-side `cookieName` configuration
- Default: `"better-auth.last_used_login_method"`
### Default Method Resolution
By default, the plugin tracks these authentication methods:
- **Email authentication**: `"email"`
- **OAuth providers**: Provider ID (e.g., `"google"`, `"github"`, `"discord"`)
- **OAuth2 callbacks**: Provider ID from URL path
- **Sign up methods**: Tracked the same as sign in methods
The plugin automatically detects the method from these endpoints:
- `/callback/:id` - OAuth callback with provider ID
- `/oauth2/callback/:id` - OAuth2 callback with provider ID
- `/sign-in/email` - Email sign in
- `/sign-up/email` - Email sign up
## Advanced Examples
### Custom Provider Tracking
If you have custom OAuth providers or authentication methods, you can use the `customResolveMethod` option:
```ts title="auth.ts"
import { betterAuth } from "better-auth"
import { lastLoginMethod } from "better-auth/plugins"
export const auth = betterAuth({
plugins: [
lastLoginMethod({
customResolveMethod: (ctx) => {
// Track custom SAML provider
if (ctx.path === "/saml/callback") {
return "saml"
}
// Track magic link authentication
if (ctx.path === "/verify-magic-link") {
return "magic-link"
}
// Track phone authentication
if (ctx.path === "/sign-in/phone") {
return "phone"
}
// Return null to use default logic
return null
}
})
]
})
```

View File

@@ -443,7 +443,8 @@ Table Name: `oauthApplication`
name: "userId", name: "userId",
type: "string", type: "string",
description: "ID of the user who owns the client. (optional)", description: "ID of the user who owns the client. (optional)",
isOptional: true isOptional: true,
references: { model: "user", field: "id" }
}, },
{ {
name: "createdAt", name: "createdAt",
@@ -498,7 +499,7 @@ Table Name: `oauthAccessToken`
type: "string", type: "string",
description: "ID of the OAuth client", description: "ID of the OAuth client",
isForeignKey: true, isForeignKey: true,
references: { model: "oauthClient", field: "clientId" } references: { model: "oauthApplication", field: "clientId" }
}, },
{ {
name: "userId", name: "userId",
@@ -550,7 +551,7 @@ Table Name: `oauthConsent`
type: "string", type: "string",
description: "ID of the OAuth client", description: "ID of the OAuth client",
isForeignKey: true, isForeignKey: true,
references: { model: "oauthClient", field: "clientId" } references: { model: "oauthApplication", field: "clientId" }
}, },
{ {
name: "scopes", name: "scopes",
@@ -592,3 +593,5 @@ Table Name: `oauthConsent`
**getAdditionalUserInfoClaim**: `(user: User, scopes: string[], client: Client) => Record<string, any>` - Function to get additional user info claims. **getAdditionalUserInfoClaim**: `(user: User, scopes: string[], client: Client) => Record<string, any>` - Function to get additional user info claims.
**useJWTPlugin**: `boolean` - When `true`, ID tokens are signed using the JWT plugin's asymmetric keys. When `false` (default), ID tokens are signed with HMAC-SHA256 using the application secret. **useJWTPlugin**: `boolean` - When `true`, ID tokens are signed using the JWT plugin's asymmetric keys. When `false` (default), ID tokens are signed with HMAC-SHA256 using the application secret.
**schema**: `AuthPluginSchema` - Customize the OIDC provider schema.

View File

@@ -62,3 +62,5 @@ console.log(openAPISchema)
`path` - The path where the Open API reference is served. Default is `/api/auth/reference`. You can change it to any path you like, but keep in mind that it will be appended to the base path of your auth server. `path` - The path where the Open API reference is served. Default is `/api/auth/reference`. You can change it to any path you like, but keep in mind that it will be appended to the base path of your auth server.
`disableDefaultReference` - If set to `true`, the default Open API reference UI by Scalar will be disabled. Default is `false`. `disableDefaultReference` - If set to `true`, the default Open API reference UI by Scalar will be disabled. Default is `false`.
`theme` - Allows you to change the theme of the OpenAPI reference page. Default is `default`.

File diff suppressed because it is too large Load Diff

View File

@@ -395,6 +395,10 @@ To create a [Stripe billing portal session](https://docs.stripe.com/api/customer
> >
```ts ```ts
type createBillingPortal = { type createBillingPortal = {
/**
* The IETF language tag of the locale customer portal is displayed in. If blank or auto, browser's locale is used.
*/
locale?: string
/** /**
* Reference id of the subscription to upgrade. * Reference id of the subscription to upgrade.
*/ */
@@ -406,6 +410,9 @@ type createBillingPortal = {
} }
``` ```
</APIMethod> </APIMethod>
<Callout type="info" >
For supported locales, see the [IETF language tag documentation](https://docs.stripe.com/js/appendix/supported_locales).
</Callout>
This endpoint creates a Stripe billing portal session and returns a URL in the response as `data.url`. You can redirect users to this URL to allow them to manage their subscription, payment methods, and billing history. This endpoint creates a Stripe billing portal session and returns a URL in the response as `data.url`. You can redirect users to this URL to allow them to manage their subscription, payment methods, and billing history.

View File

@@ -7,7 +7,7 @@ Better Auth collects anonymous usage data to help us improve the project. This i
## Why is telemetry collected? ## Why is telemetry collected?
Since v1.3.5, Better Auth collects anonymous telemetry data about general usage. Since v1.3.5, Better Auth collects anonymous telemetry data about general usage if enabled.
Telemetry data helps us understand how Better Auth is being used across different environments so we can improve performance, prioritize features, and fix issues more effectively. It guides our decisions on performance optimizations, feature development, and bug fixes. All data is collected completely anonymously and with privacy in mind, and users can opt out at any time. We strive to keep what we collect as transparent as possible. Telemetry data helps us understand how Better Auth is being used across different environments so we can improve performance, prioritize features, and fix issues more effectively. It guides our decisions on performance optimizations, feature development, and bug fixes. All data is collected completely anonymously and with privacy in mind, and users can opt out at any time. We strive to keep what we collect as transparent as possible.

View File

@@ -0,0 +1,14 @@
import { source } from "@/lib/source";
import type { OramaDocument } from "fumadocs-core/search/orama-cloud";
export async function exportSearchIndexes() {
return source.getPages().map((page) => {
return {
id: page.url,
structured: page.data.structuredData,
url: page.url,
title: page.data.title,
description: page.data.description,
} satisfies OramaDocument;
});
}

View File

@@ -4,86 +4,88 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "next build", "build": "next build && pnpm run scripts:sync-orama",
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
"start": "next start", "start": "next start",
"postinstall": "fumadocs-mdx", "postinstall": "fumadocs-mdx",
"scripts:endpoint-to-doc": "bun ./scripts/endpoint-to-doc/index.ts" "scripts:endpoint-to-doc": "bun ./scripts/endpoint-to-doc/index.ts",
"scripts:sync-orama": "node ./scripts/sync-orama.ts"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^5.2.1",
"@oramacloud/client": "^2.1.4",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.2", "@radix-ui/react-aspect-ratio": "^1.1.7",
"@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.2", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-menubar": "^1.1.16", "@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.2", "@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@scalar/nextjs-api-reference": "^0.5.15", "@scalar/nextjs-api-reference": "^0.8.17",
"@vercel/analytics": "^1.5.0", "@vercel/analytics": "^1.5.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "1.1.1", "cmdk": "1.1.1",
"date-fns": "^3.6.0", "date-fns": "^4.1.0",
"embla-carousel-react": "^8.5.1", "embla-carousel-react": "^8.6.0",
"foxact": "^0.2.49", "foxact": "^0.2.49",
"fumadocs-core": "15.7.1", "fumadocs-core": "15.7.8",
"fumadocs-docgen": "2.1.0", "fumadocs-docgen": "2.1.0",
"fumadocs-mdx": "11.8.0", "fumadocs-mdx": "11.8.3",
"fumadocs-typescript": "^4.0.6", "fumadocs-typescript": "^4.0.6",
"fumadocs-ui": "15.7.1", "fumadocs-ui": "15.7.8",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"input-otp": "^1.4.1", "input-otp": "^1.4.2",
"jotai": "^2.13.1", "jotai": "^2.13.1",
"js-beautify": "^1.15.4", "js-beautify": "^1.15.4",
"jsrsasign": "^11.1.0", "jsrsasign": "^11.1.0",
"lucide-react": "^0.477.0", "lucide-react": "^0.542.0",
"motion": "^12.23.12", "motion": "^12.23.12",
"next": "15.5.0", "next": "15.5.2",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"prism-react-renderer": "^2.4.1", "prism-react-renderer": "^2.4.1",
"react": "^19.0.0", "react": "^19.1.1",
"react-day-picker": "8.10.1", "react-day-picker": "9.9.0",
"react-dom": "^19.0.0", "react-dom": "^19.1.1",
"react-hook-form": "^7.62.0", "react-hook-form": "^7.62.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-resizable-panels": "^2.1.7", "react-resizable-panels": "^3.0.5",
"recharts": "^2.14.1", "recharts": "^3.1.2",
"rehype-highlight": "^7.0.2", "rehype-highlight": "^7.0.2",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"zod": "^3.24.2" "zod": "^4.1.5"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.0.9", "@tailwindcss/postcss": "^4.1.12",
"@types/jsrsasign": "^10.5.15", "@types/jsrsasign": "^10.5.15",
"@types/mdx": "^2.0.13", "@types/mdx": "^2.0.13",
"@types/node": "^24.3.0", "@types/node": "^24.3.0",
"@types/react": "^19.0.10", "@types/react": "^19.1.12",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.1.9",
"postcss": "^8.5.3", "postcss": "^8.5.6",
"tailwindcss": "^4.0.12", "tailwindcss": "^4.1.12",
"typescript": "^5.8.2" "typescript": "^5.9.2"
} }
} }

View File

@@ -59,7 +59,7 @@ async function generateMDX() {
const functionName = Object.keys(exports)[0]! as string; const functionName = Object.keys(exports)[0]! as string;
const [path, options]: [string, Options] = const [path, options]: [string, Options] =
//@ts-ignore //@ts-expect-error
await exports[Object.keys(exports)[0]!]; await exports[Object.keys(exports)[0]!];
if (!path || !options) return console.error(`No path or options.`); if (!path || !options) return console.error(`No path or options.`);
@@ -194,17 +194,17 @@ function parseZodShape(zod: z.ZodAny, path: string[]) {
{ description: "some descriptiom" }, { description: "some descriptiom" },
).shape; ).shape;
//@ts-ignore //@ts-expect-error
if (zod._def.typeName === "ZodOptional") { if (zod._def.typeName === "ZodOptional") {
isRootOptional = true; isRootOptional = true;
const eg = z.optional(z.object({})); const eg = z.optional(z.object({}));
const x = zod as never as typeof eg; const x = zod as never as typeof eg;
//@ts-ignore //@ts-expect-error
shape = x._def.innerType.shape; shape = x._def.innerType.shape;
} else { } else {
const eg = z.object({}); const eg = z.object({});
const x = zod as never as typeof eg; const x = zod as never as typeof eg;
//@ts-ignore //@ts-expect-error
shape = x.shape; shape = x.shape;
} }
@@ -485,7 +485,7 @@ function pathToDotNotation(input: string): string {
.join("."); .join(".");
} }
async function playSound(name: string = "Ping") { function playSound(name: string = "Ping") {
const path = `/System/Library/Sounds/${name}.aiff`; const path = `/System/Library/Sounds/${name}.aiff`;
await Bun.$`afplay ${path}`; void Bun.$`afplay ${path}`;
} }

View File

@@ -0,0 +1,30 @@
import { sync, type OramaDocument } from "fumadocs-core/search/orama-cloud";
import * as fs from "node:fs/promises";
import { CloudManager } from "@oramacloud/client";
import * as process from "node:process";
import "dotenv/config";
const filePath = ".next/server/app/static.json.body";
async function main() {
const apiKey = process.env.ORAMA_PRIVATE_API_KEY;
if (!apiKey) {
console.log("no api key for Orama found, skipping");
return;
}
const content = await fs.readFile(filePath);
const records = JSON.parse(content.toString()) as OramaDocument[];
const manager = new CloudManager({ api_key: apiKey });
await sync(manager, {
index: process.env.ORAMA_INDEX_ID!,
documents: records,
autoDeploy: true,
});
console.log(`search updated: ${records.length} records`);
}
void main();

15
docs/turbo.json Normal file
View File

@@ -0,0 +1,15 @@
{
"$schema": "https://turborepo.org/schema.json",
"extends": ["//"],
"tasks": {
"build": {
"env": [
"GITHUB_TOKEN",
"ORAMA_PRIVATE_API_KEY",
"ORAMA_INDEX_ID",
"NEXT_PUBLIC_ORAMA_PUBLIC_API_KEY",
"NEXT_PUBLIC_ORAMA_ENDPOINT"
]
}
}
}

View File

@@ -0,0 +1,13 @@
{
"name": "integration",
"scripts": {
"e2e:integration": "playwright test"
},
"dependencies": {
"better-auth": "workspace:*"
},
"devDependencies": {
"@playwright/test": "^1.55.0",
"terminate": "^2.8.0"
}
}

View File

@@ -0,0 +1,20 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testMatch: "**/e2e/**/*.spec.ts",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
use: {
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});

View File

@@ -0,0 +1,55 @@
import { createServer } from "node:http";
import { betterAuth } from "better-auth";
import { toNodeHandler } from "better-auth/node";
import Database from "better-sqlite3";
import { getMigrations } from "better-auth/db";
export async function createAuthServer(
baseURL: string = "http://localhost:3000",
) {
const database = new Database(":memory:");
const auth = betterAuth({
database,
baseURL,
emailAndPassword: {
enabled: true,
},
});
const { runMigrations } = await getMigrations(auth.options);
await runMigrations();
// Create an example user
await auth.api.signUpEmail({
body: {
name: "Test User",
email: "test@test.com",
password: "password123",
},
});
const authHandler = toNodeHandler(auth);
return createServer(async (req, res) => {
res.setHeader("Access-Control-Allow-Origin", req.headers.origin || "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
res.setHeader("Access-Control-Allow-Credentials", "true");
if (req.method === "OPTIONS") {
res.statusCode = 200;
res.end();
return;
}
const isAuthRoute = req.url?.startsWith("/api/auth");
if (isAuthRoute) {
return authHandler(req, res);
}
res.statusCode = 404;
res.end(JSON.stringify({ error: "Not found" }));
});
}

View File

@@ -0,0 +1,40 @@
import { chromium, expect, test } from "@playwright/test";
import { runClient, setup } from "./utils";
const { ref, start, clean } = setup();
test.describe("cross domain", async () => {
test.beforeEach(async () => start());
test.afterEach(async () => clean());
test("should work across domains", async () => {
const browser = await chromium.launch({
args: [`--host-resolver-rules=MAP * localhost`],
});
const page = await browser.newPage();
await page.goto(
`http://test.com:${ref.clientPort}/?port=${ref.serverPort}`,
);
await page.locator("text=Ready").waitFor();
await expect(
runClient(page, ({ client }) => typeof client !== "undefined"),
).resolves.toBe(true);
await expect(
runClient(page, async ({ client }) => client.getSession()),
).resolves.toEqual({ data: null, error: null });
await runClient(page, ({ client }) =>
client.signIn.email({
email: "test@test.com",
password: "password123",
}),
);
// Check that the session is not set because of we didn't set the cookie domain correctly
const cookies = await page.context().cookies();
expect(
cookies.find((c) => c.name === "better-auth.session_token"),
).not.toBeDefined();
});
});

View File

@@ -0,0 +1,36 @@
import { test, expect } from "@playwright/test";
import { runClient, setup } from "./utils";
const { ref, start, clean } = setup();
test.describe("vanilla-node", async () => {
test.beforeEach(async () => start());
test.afterEach(async () => clean());
test("signIn with existing email and password should work", async ({
page,
}) => {
await page.goto(
`http://localhost:${ref.clientPort}/?port=${ref.serverPort}`,
);
await page.locator("text=Ready").waitFor();
await expect(
runClient(page, ({ client }) => typeof client !== "undefined"),
).resolves.toBe(true);
await expect(
runClient(page, async ({ client }) => client.getSession()),
).resolves.toEqual({ data: null, error: null });
await runClient(page, ({ client }) =>
client.signIn.email({
email: "test@test.com",
password: "password123",
}),
);
// Check that the session is now set
const cookies = await page.context().cookies();
expect(
cookies.find((c) => c.name === "better-auth.session_token"),
).toBeDefined();
});
});

View File

@@ -0,0 +1,82 @@
import type { Page } from "@playwright/test";
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { createAuthServer } from "./app";
const terminate = createRequire(import.meta.url)(
// use terminate instead of cp.kill,
// because cp.kill will not kill the child process of the child process
// to avoid the zombie process
"terminate/promise",
) as (pid: number) => Promise<void>;
const root = fileURLToPath(new URL("../", import.meta.url));
export async function runClient<R>(
page: Page,
fn: ({ client }: { client: Window["client"] }) => R,
): Promise<R> {
const client = await page.evaluateHandle<Window["client"]>("window.client");
return page.evaluate(fn, { client });
}
export function setup() {
let server: Awaited<ReturnType<typeof createAuthServer>>;
let clientChild: ChildProcessWithoutNullStreams;
const ref: {
clientPort: number;
serverPort: number;
} = {
clientPort: -1,
serverPort: -1,
};
return {
ref,
start: async () => {
server = await createAuthServer();
clientChild = spawn("pnpm", ["run", "start:client"], {
cwd: root,
stdio: "pipe",
});
clientChild.stderr.on("data", (data) => {
const message = data.toString();
console.log(message);
});
clientChild.stdout.on("data", (data) => {
const message = data.toString();
console.log(message);
});
await Promise.all([
new Promise<void>((resolve) => {
server.listen(0, "0.0.0.0", () => {
const address = server.address();
if (address && typeof address === "object") {
ref.serverPort = address.port;
resolve();
}
});
}),
new Promise<void>((resolve) => {
clientChild.stdout.on("data", (data) => {
const message = data.toString();
// find: http://localhost:5173/
if (message.includes("http://localhost:")) {
const port: string = message
.split("http://localhost:")[1]
.split("/")[0]
.trim();
ref.clientPort = Number(port.replace(/\x1b\[[0-9;]*m/g, ""));
resolve();
}
});
}),
]);
},
clean: async () => {
await terminate(clientChild.pid!);
server.close();
},
};
}

View File

@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Better Auth - Vanilla Node E2E Test</title>
</head>
<body>
<script type="module" src="./src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,15 @@
{
"name": "vanilla-node-e2e",
"private": true,
"type": "module",
"scripts": {
"start:client": "vite"
},
"devDependencies": {
"vite": "^7.1.4"
},
"dependencies": {
"better-auth": "workspace:*",
"better-sqlite3": "^12.2.0"
}
}

View File

@@ -0,0 +1,18 @@
import { createAuthClient } from "better-auth/client";
const search = new URLSearchParams(window.location.search);
const port = search.get("port");
const client = createAuthClient({
baseURL: `http://localhost:${port ?? 3000}`,
});
declare global {
interface Window {
client: typeof client;
}
}
window.client = client;
document.body.innerHTML = "Ready";

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -1,24 +1,21 @@
{ {
"compilerOptions": { "compilerOptions": {
"lib": ["ESNext", "DOM"], "target": "ES2022",
"target": "ESNext", "useDefineForClassFields": true,
"module": "ESNext", "module": "ESNext",
"moduleDetection": "force", "lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx", "skipLibCheck": true,
"allowJs": true,
"declaration": true,
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true, "noEmit": true,
"strict": true, "strict": true,
"skipLibCheck": true, "noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUnusedLocals": false, "noUncheckedSideEffectImports": true
"noUnusedParameters": false, },
"noPropertyAccessFromIndexSignature": false, "include": ["src"]
"paths": {
"@/*": ["./src/*"]
}
}
} }

View File

@@ -0,0 +1,7 @@
import { defineConfig } from "vite";
export default defineConfig({
server: {
allowedHosts: ["test.com", "localhost"],
},
});

13
e2e/smoke/package.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "smoke",
"type": "module",
"dependencies": {
"better-auth": "workspace:*"
},
"devDependencies": {
"@types/node": "^24.3.0"
},
"scripts": {
"e2e:smoke": "node --test ./test/*.spec.ts"
}
}

View File

@@ -0,0 +1,49 @@
import { describe, it } from "node:test";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { join } from "node:path";
import assert from "node:assert";
const fixturesDir = fileURLToPath(new URL("./fixtures", import.meta.url));
describe("(bun) simple server", () => {
it("run server", async (t) => {
const cp = spawn("bun", [join(fixturesDir, "bun-simple.ts")], {
stdio: "pipe",
});
t.after(() => {
cp.kill("SIGINT");
});
cp.stdout.on("data", (data) => {
console.log(data.toString());
});
cp.stderr.on("data", (data) => {
console.error(data.toString());
});
const port = await new Promise<number>((resolve) => {
cp.stdout.once("data", (data) => {
// Bun outputs colored string, we need to remove it
const port = +data.toString().replace(/\u001b\[[0-9;]*m/g, "");
assert.ok(port > 0);
assert.ok(!isNaN(port));
assert.ok(isFinite(port));
resolve(port);
});
});
const response = await fetch(
`http://localhost:${port}/api/auth/sign-up/email`,
{
method: "POST",
body: JSON.stringify({
email: "test-2@test.com",
password: "password",
name: "test-2",
}),
headers: {
"content-type": "application/json",
},
},
);
assert.ok(response.ok);
});
});

View File

@@ -0,0 +1,35 @@
import { describe, it } from "node:test";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { join } from "node:path";
const fixturesDir = fileURLToPath(new URL("./fixtures", import.meta.url));
describe("(cloudflare) simple server", () => {
it("check repo", async (t) => {
const cp = spawn("npm", ["run", "check"], {
cwd: join(fixturesDir, "cloudflare"),
stdio: "pipe",
});
t.after(() => {
cp.kill("SIGINT");
});
cp.stdout.on("data", (data) => {
console.log(data.toString());
});
cp.stderr.on("data", (data) => {
console.error(data.toString());
});
await new Promise<void>((resolve) => {
cp.stdout.on("data", (data) => {
if (data.toString().includes("exiting now.")) {
resolve();
}
});
});
});
});

View File

@@ -0,0 +1,48 @@
import { describe, it } from "node:test";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { join } from "node:path";
import assert from "node:assert";
const fixturesDir = fileURLToPath(new URL("./fixtures", import.meta.url));
describe("(deno) simple server", () => {
it("run server", async (t) => {
const cp = spawn("deno", ["-A", join(fixturesDir, "deno-simple.ts")], {
stdio: "pipe",
});
t.after(() => {
cp.kill("SIGINT");
});
cp.stdout.on("data", (data) => {
console.log(data.toString());
});
cp.stderr.on("data", (data) => {
console.error(data.toString());
});
const port = await new Promise<number>((resolve) => {
cp.stdout.once("data", (data) => {
const port = +data.toString().split(":")[2].split("/")[0];
assert.ok(port > 0);
assert.ok(!isNaN(port));
assert.ok(isFinite(port));
resolve(port);
});
});
const response = await fetch(
`http://localhost:${port}/api/auth/sign-up/email`,
{
method: "POST",
body: JSON.stringify({
email: "test-2@test.com",
password: "password",
name: "test-2",
}),
headers: {
"content-type": "application/json",
},
},
);
assert.ok(response.ok);
});
});

27
e2e/smoke/test/fixtures/bun-simple.ts vendored Normal file
View File

@@ -0,0 +1,27 @@
import { betterAuth } from "better-auth";
import Database from "bun:sqlite";
import { getMigrations } from "better-auth/db";
const database = new Database(":memory:");
export const auth = betterAuth({
baseURL: "http://localhost:4000",
database,
emailAndPassword: {
enabled: true,
},
logger: {
level: "debug",
},
});
const { runMigrations } = await getMigrations(auth.options);
await runMigrations();
const server = Bun.serve({
fetch: auth.handler,
port: 0,
});
console.log(server.port);

View File

@@ -1,105 +1,114 @@
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore # Created by https://www.toptal.com/developers/gitignore/api/node,macos
# Edit at https://www.toptal.com/developers/gitignore?templates=node,macos
### macOS ###
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
### Node ###
# Logs # Logs
logs logs
_.log *.log
npm-debug.log_ npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
lerna-debug.log* lerna-debug.log*
.pnpm-debug.log* .pnpm-debug.log*
# Caches
.cache
# Diagnostic reports (https://nodejs.org/api/report.html) # Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data # Runtime data
pids pids
_.pid *.pid
_.seed *.seed
*.pid.lock *.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover # Directory for instrumented libs generated by jscoverage/JSCover
lib-cov lib-cov
# Coverage directory used by tools like istanbul # Coverage directory used by tools like istanbul
coverage coverage
*.lcov *.lcov
# nyc test coverage # nyc test coverage
.nyc_output .nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt .grunt
# Bower dependency directory (https://bower.io/) # Bower dependency directory (https://bower.io/)
bower_components bower_components
# node-waf configuration # node-waf configuration
.lock-wscript .lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html) # Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release build/Release
# Dependency directories # Dependency directories
node_modules/ node_modules/
jspm_packages/ jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/) # Moved from ./templates for ignoring all locks in templates
templates/**/*-lock.*
templates/**/*.lock
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/ web_modules/
# TypeScript cache # TypeScript cache
*.tsbuildinfo *.tsbuildinfo
# Optional npm cache directory # Optional npm cache directory
.npm .npm
# Optional eslint cache # Optional eslint cache
.eslintcache .eslintcache
# Optional stylelint cache # Optional stylelint cache
.stylelintcache .stylelintcache
# Microbundle cache # Microbundle cache
.rpt2_cache/ .rpt2_cache/
.rts2_cache_cjs/ .rts2_cache_cjs/
.rts2_cache_es/ .rts2_cache_es/
.rts2_cache_umd/ .rts2_cache_umd/
# Optional REPL history # Optional REPL history
.node_repl_history .node_repl_history
# Output of 'npm pack' # Output of 'npm pack'
*.tgz *.tgz
# Yarn Integrity file # Yarn Integrity file
.yarn-integrity .yarn-integrity
# dotenv environment variable files # dotenv environment variable files
.env .env
.env.development.local .env.development.local
.env.test.local .env.test.local
@@ -107,69 +116,73 @@ web_modules/
.env.local .env.local
# parcel-bundler cache (https://parceljs.org/) # parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache .parcel-cache
# Next.js build output # Next.js build output
.next .next
out out
# Nuxt.js build / generate output # Nuxt.js build / generate output
.nuxt .nuxt
dist dist
# Gatsby files # Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js # Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support # https://nextjs.org/blog/next-9-1#public-directory-support
# public # public
# vuepress build output # vuepress build output
.vuepress/dist .vuepress/dist
# vuepress v2.x temp and cache directory # vuepress v2.x temp and cache directory
.temp .temp
# Docusaurus cache and generated files # Docusaurus cache and generated files
.docusaurus .docusaurus
# Serverless directories # Serverless directories
.serverless/ .serverless/
# FuseBox cache # FuseBox cache
.fusebox/ .fusebox/
# DynamoDB Local files # DynamoDB Local files
.dynamodb/ .dynamodb/
# TernJS port file # TernJS port file
.tern-port .tern-port
# Stores VSCode versions used for testing VSCode extensions # Stores VSCode versions used for testing VSCode extensions
.vscode-test .vscode-test
# yarn v2 # yarn v2
.yarn/cache .yarn/cache
.yarn/unplugged .yarn/unplugged
.yarn/build-state.yml .yarn/build-state.yml
.yarn/install-state.gz .yarn/install-state.gz
.pnp.* .pnp.*
# IntelliJ based IDEs ### Node Patch ###
.idea # Serverless Webpack directories
.webpack/
# Finder (MacOS) folder config # Optional stylelint cache
.DS_Store
# SvelteKit build / generate output
.svelte-kit
# End of https://www.toptal.com/developers/gitignore/api/node,macos
# Wrangler output
.wrangler/
build/
# Turbo output
.turbo/
.dev.vars*
!.dev.vars.example
.env*
!.env.example

View File

@@ -0,0 +1,22 @@
{
"name": "cloudflare",
"private": true,
"dependencies": {
"better-auth": "workspace:*",
"drizzle-orm": "^0.44.5",
"hono": "^4.9.6"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.8.69",
"@cloudflare/workers-types": "^4.20250903.0",
"drizzle-kit": "^0.31.4",
"wrangler": "4.33.2"
},
"scripts": {
"check": "tsc && wrangler deploy --dry-run",
"dev": "wrangler dev",
"migrate:local": "wrangler d1 migrations apply db --local",
"cf-typegen": "wrangler types --env-interface CloudflareBindings",
"e2e:smoke": "vitest"
}
}

View File

@@ -1,5 +1,26 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { Auth, createAuth } from "./auth";
import { betterAuth } from "better-auth";
import { createDrizzle } from "./db";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
interface CloudflareBindings {
DB: D1Database;
}
const createAuth = (env: CloudflareBindings) =>
betterAuth({
baseURL: "http://localhost:4000",
database: drizzleAdapter(createDrizzle(env.DB), { provider: "sqlite" }),
emailAndPassword: {
enabled: true,
},
logger: {
level: "debug",
},
});
type Auth = ReturnType<typeof createAuth>;
const app = new Hono<{ const app = new Hono<{
Bindings: CloudflareBindings; Bindings: CloudflareBindings;

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "esnext",
"lib": ["esnext"],
"module": "esnext",
"outDir": "./dist",
"moduleResolution": "bundler",
"types": [
"@cloudflare/workers-types/2023-07-01",
"@cloudflare/workers-types/experimental",
"@cloudflare/vitest-pool-workers"
],
"noEmit": true,
"isolatedModules": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"strict": true
}
}

View File

@@ -14,7 +14,7 @@ export default defineWorkersProject(async () => {
poolOptions: { poolOptions: {
workers: { workers: {
singleWorker: true, singleWorker: true,
wrangler: { configPath: "./wrangler.jsonc" }, wrangler: { configPath: "./wrangler.json" },
miniflare: { miniflare: {
d1Databases: ["DB"], d1Databases: ["DB"],
bindings: { TEST_MIGRATIONS: migrations }, bindings: { TEST_MIGRATIONS: migrations },

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More