feat: allow each user to create API keys

This commit is contained in:
Nguyen Quy Hy
2026-06-08 00:10:27 -04:00
parent a2e621345a
commit b487b096d8
10 changed files with 3934 additions and 5 deletions

View File

@@ -6,6 +6,7 @@ import {
organizationClient,
inferAdditionalFields,
} from "better-auth/client/plugins";
import { apiKeyClient } from "@better-auth/api-key/client";
import { ssoClient } from "@better-auth/sso/client";
import { passkeyClient } from "@better-auth/passkey/client";
import type { auth } from "~/server/lib/auth";
@@ -19,5 +20,6 @@ export const authClient = createAuthClient({
ssoClient(),
twoFactorClient(),
passkeyClient(),
apiKeyClient(),
],
});

View File

@@ -0,0 +1,325 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Copy, KeyRound, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "~/client/components/ui/button";
import { CardContent, CardDescription, CardTitle } from "~/client/components/ui/card";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/client/components/ui/dialog";
import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Alert, AlertDescription, AlertTitle } from "~/client/components/ui/alert";
import { CodeBlock } from "~/client/components/ui/code-block";
import { authClient } from "~/client/lib/auth-client";
import { useOrganizationContext } from "~/client/hooks/use-org-context";
import { useTimeFormat } from "~/client/lib/datetime";
import { logger } from "~/client/lib/logger";
import { cn } from "~/client/lib/utils";
type ApiTokenEntry = {
id: string;
name?: string | null;
start?: string | null;
prefix?: string | null;
createdAt: Date | string;
expiresAt?: Date | string | null;
lastRequest?: Date | string | null;
};
const EXPIRATION_OPTIONS = [
{ value: "30", label: "30 days" },
{ value: "90", label: "90 days" },
{ value: "365", label: "1 year" },
{ value: "never", label: "No expiration" },
] as const;
type ExpirationValue = (typeof EXPIRATION_OPTIONS)[number]["value"];
export function ApiTokensSection() {
const { formatDateTime } = useTimeFormat();
const { activeOrganization } = useOrganizationContext();
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [newTokenName, setNewTokenName] = useState("");
const [newTokenExpiration, setNewTokenExpiration] = useState<ExpirationValue>("365");
const [createdToken, setCreatedToken] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ApiTokenEntry | null>(null);
const { data: tokens, isPending } = useQuery({
queryKey: ["api-tokens"],
queryFn: async (): Promise<ApiTokenEntry[]> => {
const { data, error } = await authClient.apiKey.list();
if (error) throw error;
return data?.apiKeys ?? [];
},
});
const createTokenMutation = useMutation({
mutationFn: async (payload: { name: string; expiresIn: number | null }) => {
const { data, error } = await authClient.apiKey.create({
name: payload.name,
expiresIn: payload.expiresIn ?? undefined,
metadata: { organizationId: activeOrganization.id },
});
if (error) throw error;
return data;
},
onSuccess: (data) => {
toast.success("API token created");
setCreatedToken(data?.key ?? null);
setNewTokenName("");
},
onError: (error: Error) => {
logger.error(error);
toast.error("Failed to create API token", { description: error.message });
},
});
const deleteTokenMutation = useMutation({
mutationFn: async (id: string) => {
const { error } = await authClient.apiKey.delete({ keyId: id });
if (error) throw error;
},
onSuccess: () => {
toast.success("API token revoked");
setDeleteTarget(null);
},
onError: (error: Error) => {
logger.error(error);
toast.error("Failed to revoke API token", { description: error.message });
},
});
const handleCreate = (e: React.SyntheticEvent) => {
e.preventDefault();
const name = newTokenName.trim();
if (!name) {
toast.error("Name is required");
return;
}
const expiresIn = newTokenExpiration === "never" ? null : Number(newTokenExpiration) * 24 * 60 * 60;
createTokenMutation.mutate({ name, expiresIn });
};
const handleCopyToken = async () => {
if (!createdToken) return;
try {
await navigator.clipboard.writeText(createdToken);
toast.success("Copied to clipboard");
} catch (error) {
logger.error(error);
toast.error("Failed to copy");
}
};
const closeCreateDialog = (open: boolean) => {
if (open) {
setCreateDialogOpen(true);
return;
}
setCreateDialogOpen(false);
setCreatedToken(null);
setNewTokenName("");
setNewTokenExpiration("365");
};
const sortedTokens = useMemo(() => {
if (!tokens) return [];
return [...tokens].sort((a, b) => {
const ad = new Date(a.createdAt).getTime();
const bd = new Date(b.createdAt).getTime();
return bd - ad;
});
}, [tokens]);
return (
<>
<div className="border-t border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2">
<KeyRound className="size-5" />
API Tokens
</CardTitle>
<CardDescription className="mt-1.5">
Long-lived tokens for accessing the Zerobyte API programmatically
</CardDescription>
</div>
<CardContent className="p-6 space-y-4">
<div className="flex items-start justify-between gap-4">
<p className="text-xs text-muted-foreground max-w-xl">
Tokens act on your behalf within the current organization. Send them as the{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">x-api-key</code> header on
any API request.
</p>
<Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Create token
</Button>
</div>
<p className={cn("text-sm text-muted-foreground", { hidden: !isPending })}>Loading tokens...</p>
<p
className={cn("text-sm text-muted-foreground", {
hidden: isPending || sortedTokens.length > 0,
})}
>
No API tokens yet. Create one to authenticate against the API.
</p>
<ul
className={cn("divide-y divide-border/50 rounded-md border border-border/50", {
hidden: sortedTokens.length === 0,
})}
>
{sortedTokens.map((token) => (
<li key={token.id} className="flex items-center justify-between gap-4 p-3">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{token.name?.trim() || "Unnamed token"}</p>
<p className="text-xs text-muted-foreground">
Added {formatDateTime(new Date(token.createdAt))}
{token.expiresAt
? ` · Expires ${formatDateTime(new Date(token.expiresAt))}`
: " · No expiration"}
{token.lastRequest
? ` · Last used ${formatDateTime(new Date(token.lastRequest))}`
: ""}
</p>
</div>
<Button
variant="destructive"
size="sm"
aria-label={`Revoke token ${token.name?.trim() || "Unnamed token"}`}
title={`Revoke token ${token.name?.trim() || "Unnamed token"}`}
onClick={() => setDeleteTarget(token)}
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</ul>
</CardContent>
<Dialog open={createDialogOpen} onOpenChange={closeCreateDialog}>
<DialogContent>
{!createdToken ? (
<form onSubmit={handleCreate}>
<DialogHeader>
<DialogTitle>Create API token</DialogTitle>
<DialogDescription>
This token will inherit your permissions in the current organization.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="api-token-name">Name</Label>
<Input
id="api-token-name"
value={newTokenName}
onChange={(e) => setNewTokenName(e.target.value)}
placeholder="API Client Name"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="api-token-expiration">Expiration</Label>
<Select
value={newTokenExpiration}
onValueChange={(value) => setNewTokenExpiration(value as ExpirationValue)}
>
<SelectTrigger id="api-token-expiration">
<SelectValue />
</SelectTrigger>
<SelectContent>
{EXPIRATION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => closeCreateDialog(false)}>
Cancel
</Button>
<Button type="submit" loading={createTokenMutation.isPending}>
<KeyRound className="h-4 w-4 mr-2" />
Create token
</Button>
</DialogFooter>
</form>
) : (
<>
<DialogHeader>
<DialogTitle>Token created</DialogTitle>
<DialogDescription>
Copy this token now. For security reasons it will not be shown again.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<Alert>
<KeyRound className="size-5" />
<AlertTitle>Store it somewhere safe</AlertTitle>
<AlertDescription>
This is the only time you will see the full token value.
</AlertDescription>
</Alert>
<CodeBlock code={createdToken} filename="API token" />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleCopyToken}>
<Copy className="h-4 w-4 mr-2" />
Copy to clipboard
</Button>
<Button type="button" onClick={() => closeCreateDialog(false)}>
Done
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
<AlertDialog open={Boolean(deleteTarget)} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revoke API token?</AlertDialogTitle>
<AlertDialogDescription>
"{deleteTarget?.name?.trim() || "this token"}" will stop working immediately. Any clients
still using it will start receiving 401 responses.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteTokenMutation.isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
if (deleteTarget) deleteTokenMutation.mutate(deleteTarget.id);
}}
disabled={deleteTokenMutation.isPending}
>
Revoke
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -47,6 +47,7 @@ import { parseError } from "~/client/lib/errors";
import { type AppContext } from "~/context";
import { TwoFactorSection } from "../components/two-factor-section";
import { PasskeysSection } from "../components/passkeys-section";
import { ApiTokensSection } from "../components/api-tokens-section";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { SsoSettingsSection } from "~/client/modules/sso/components/sso-settings-section";
import { OrgMembersSection } from "../components/org-members-section";
@@ -478,6 +479,8 @@ export function SettingsPage({
<TwoFactorSection twoFactorEnabled={appContext.user?.twoFactorEnabled} />
<PasskeysSection />
<ApiTokensSection />
</Card>
</TabsContent>

View File

@@ -0,0 +1,26 @@
CREATE TABLE `apikey` (
`id` text PRIMARY KEY,
`config_id` text DEFAULT 'default' NOT NULL,
`name` text,
`start` text,
`prefix` text,
`key` text NOT NULL,
`reference_id` text NOT NULL,
`refill_interval` integer,
`refill_amount` integer,
`last_refill_at` integer,
`enabled` integer,
`rate_limit_enabled` integer,
`rate_limit_time_window` integer,
`rate_limit_max` integer,
`request_count` integer,
`remaining` integer,
`last_request` integer,
`expires_at` integer,
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`permissions` text,
`metadata` text
);
--> statement-breakpoint
CREATE INDEX `apikey_referenceId_idx` ON `apikey` (`reference_id`);

View File

File diff suppressed because it is too large Load Diff

View File

@@ -574,3 +574,42 @@ export const passkey = sqliteTable(
(table) => [index("passkey_userId_idx").on(table.userId), index("passkey_credentialID_idx").on(table.credentialID)],
);
export type Passkey = typeof passkey.$inferSelect;
export type ApiKeyMetadata = {
organizationId?: string;
};
export const apikey = sqliteTable(
"apikey",
{
id: text("id").primaryKey(),
configId: text("config_id").notNull().default("default"),
name: text("name"),
start: text("start"),
prefix: text("prefix"),
key: text("key").notNull(),
referenceId: text("reference_id").notNull(),
refillInterval: integer("refill_interval"),
refillAmount: integer("refill_amount"),
lastRefillAt: integer("last_refill_at", { mode: "timestamp_ms" }),
enabled: integer("enabled", { mode: "boolean" }),
rateLimitEnabled: integer("rate_limit_enabled", { mode: "boolean" }),
rateLimitTimeWindow: integer("rate_limit_time_window"),
rateLimitMax: integer("rate_limit_max"),
requestCount: integer("request_count"),
remaining: integer("remaining"),
lastRequest: integer("last_request", { mode: "timestamp_ms" }),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.notNull()
.default(sql`(unixepoch() * 1000)`),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.notNull()
.$onUpdate(() => new Date())
.default(sql`(unixepoch() * 1000)`),
permissions: text("permissions"),
metadata: text("metadata"),
},
(table) => [index("apikey_referenceId_idx").on(table.referenceId)],
);
export type ApiKey = typeof apikey.$inferSelect;

View File

@@ -8,6 +8,7 @@ import {
import { APIError } from "better-auth/api";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { admin, twoFactor, username, organization, testUtils } from "better-auth/plugins";
import { apiKey } from "@better-auth/api-key";
import { passkey } from "@better-auth/passkey";
import { createAuthMiddleware } from "better-auth/api";
import { config } from "../core/config";
@@ -193,6 +194,13 @@ export const auth = betterAuth({
},
},
}),
apiKey({
defaultPrefix: "zb_",
enableMetadata: true,
rateLimit: {
enabled: !config.flags.disableRateLimiting,
},
}),
tanstackStartCookies(),
...(process.env.NODE_ENV === "test" ? [testUtils()] : []),
],

View File

@@ -2,6 +2,8 @@ import { createMiddleware } from "hono/factory";
import { auth } from "~/server/lib/auth";
import { db } from "~/server/db/db";
import { withContext } from "~/server/core/request-context";
import type { ApiKeyMetadata } from "~/server/db/schema";
import type { Context, Next } from "hono";
declare module "hono" {
interface ContextVariableMap {
@@ -17,11 +19,57 @@ declare module "hono" {
}
}
/**
* Middleware to require authentication
* Verifies the session cookie and attaches user to context
*/
export const requireAuth = createMiddleware(async (c, next) => {
const API_KEY_HEADER = "x-api-key";
const authenticateWithApiKey = async (c: Context<any, string, {}>, next: Next, apiKeyValue: string) => {
const result = await auth.api.verifyApiKey({
body: { key: apiKeyValue },
});
if (!result.valid || !result.key) {
return c.json<unknown>({ message: "Invalid or expired API key" }, 401);
}
const metadata = result.key.metadata as ApiKeyMetadata | null;
const organizationId = metadata?.organizationId;
if (!organizationId) {
return c.json<unknown>({ message: "Invalid organization context" }, 403);
}
const { referenceId: userId } = result.key as { referenceId: string };
if (!userId) {
return c.json<unknown>({ message: "Invalid or expired API key" }, 401);
}
const [user, membership] = await Promise.all([
db.query.usersTable.findFirst({ where: { id: userId } }),
db.query.member.findFirst({
where: {
AND: [{ userId }, { organizationId }],
},
}),
]);
if (!user) {
return c.json<unknown>({ message: "Invalid or expired API key" }, 401);
}
if (!membership) {
return c.json<unknown>({ message: "Invalid organization context" }, 403);
}
c.set("user", user);
c.set("organizationId", organizationId);
c.set("membership", { role: membership.role });
await withContext({ organizationId, userId: user.id }, async () => {
await next();
});
};
const authenticWithSession = async (c: Context<any, string, {}>, next: Next) => {
const sess = await auth.api.getSession({
headers: c.req.raw.headers,
});
@@ -50,6 +98,20 @@ export const requireAuth = createMiddleware(async (c, next) => {
await withContext({ organizationId: activeOrganizationId, userId: user.id }, async () => {
await next();
});
};
/**
* Middleware to require authentication
* Verifies the session cookie or `x-api-key` header and attaches user to context
*/
export const requireAuth = createMiddleware(async (c, next) => {
const apiKeyValue = c.req.header(API_KEY_HEADER);
if (apiKeyValue) {
return authenticateWithApiKey(c, next, apiKeyValue);
}
return authenticWithSession(c, next);
});
/**

View File

@@ -5,6 +5,7 @@
"": {
"name": "zerobyte",
"dependencies": {
"@better-auth/api-key": "^1.6.11",
"@better-auth/passkey": "^1.6.12",
"@better-auth/sso": "^1.6.12",
"@dnd-kit/core": "^6.3.1",
@@ -270,6 +271,8 @@
"@base-ui/utils": ["@base-ui/utils@0.2.9", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-x/PDDCYzoqPpjrdyb3VcyylTI2IjUXEtYDGi5foh7KsnmNJIIaVwA2GLgDH1dps1GgXiJbA60hM+AyuTfQzIvw=="],
"@better-auth/api-key": ["@better-auth/api-key@1.6.14", "", { "dependencies": { "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.6.14", "@better-auth/utils": "0.4.1", "better-auth": "^1.6.14", "better-call": "1.3.5" } }, "sha512-iMLRcjpGyegI5yy375ZIw83HZGSZe6TwjtCKWdFTYy1PQ0bUcD0H61uKcvO82Co4jJmjakI3POR6lDy5W1OOew=="],
"@better-auth/core": ["@better-auth/core@1.6.12", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.5", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-6mXtYSYfo6TvHHCZAZmfjvIQQtBDWzWzwy9iIWPEoede2lP2SuJzkfIQNuTtIGzZcn7a9iuzIm1jWDBzfnBARg=="],
"@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.6.12", "", { "peerDependencies": { "@better-auth/core": "^1.6.12", "@better-auth/utils": "0.4.1", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["drizzle-orm"] }, "sha512-g0sKQstvXHH70s+TjAXo86cNyWV60ahhJm1sow27RyW41U10vfBehOFinU3GPESyxl/fEr9D27rk3jdl6E3l3A=="],

View File

@@ -37,6 +37,7 @@
"test:codegen": "playwright codegen localhost:4096"
},
"dependencies": {
"@better-auth/api-key": "^1.6.11",
"@better-auth/passkey": "^1.6.12",
"@better-auth/sso": "^1.6.12",
"@dnd-kit/core": "^6.3.1",