Add API token management for CLI authentication

- Add reviq auth login --token <token> command for CLI authentication
- Create /account/api-tokens page for token management (superuser only)
- Add me.apiTokens endpoints (list, create, delete)
- Require superuser status and trusted session for token creation
- Show API Tokens nav link only for superusers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
igm
2026-01-10 18:58:27 +08:00
parent 42badf3c52
commit a7d6beaf5a
8 changed files with 566 additions and 130 deletions

View File

@@ -0,0 +1,109 @@
/**
* API token management procedures
* Allows users to create and manage API tokens for CLI/programmatic access
*/
import { ORPCError } from "@orpc/server";
import {
generateSecureBase58Token,
hashToken,
TOKEN_PREFIX,
} from "../../utils/crypto.js";
import { authMiddleware, os } from "../base.js";
/** Token expiration: 365 days */
const TOKEN_EXPIRATION_DAYS = 365;
/**
* List all API tokens for the current user
* Returns token metadata (not the actual token values)
*/
export const listApiTokens = os.me.apiTokens.list
.use(authMiddleware)
.handler(async ({ context }) => {
const tokens = await context.db
.selectFrom("api_tokens")
.select(["id", "name", "last_used_at", "created_at", "expires_at"])
.where("user_id", "=", context.user.id)
.orderBy("created_at", "desc")
.execute();
return tokens.map((token) => ({
id: Number(token.id),
name: token.name,
lastUsedAt: token.last_used_at?.toISOString() ?? null,
createdAt: token.created_at.toISOString(),
expiresAt: token.expires_at.toISOString(),
}));
});
/**
* Create a new API token
* Requires superuser status and trusted session
*/
export const createApiToken = os.me.apiTokens.create
.use(authMiddleware)
.handler(async ({ input, context }) => {
// Require superuser status
if (!context.user.isSuperuser) {
throw new ORPCError("FORBIDDEN", {
message: "Only superusers can create API tokens.",
});
}
// Require trusted session for creating API tokens
if (!context.session.trustedMode) {
throw new ORPCError("FORBIDDEN", {
message:
"Creating API tokens requires a trusted session. Please re-authenticate.",
});
}
const { name } = input;
// Generate a new API token
const token = generateSecureBase58Token(TOKEN_PREFIX);
const tokenHash = await hashToken(token);
// Calculate expiration
const expiresAt = new Date(
Date.now() + TOKEN_EXPIRATION_DAYS * 24 * 60 * 60 * 1000,
);
// Insert into api_tokens table
await context.db
.insertInto("api_tokens")
.values({
user_id: context.user.id,
token_hash: tokenHash,
name,
expires_at: expiresAt,
})
.execute();
return {
token,
expiresAt: expiresAt.toISOString(),
};
});
/**
* Delete an API token
*/
export const deleteApiToken = os.me.apiTokens.delete
.use(authMiddleware)
.handler(async ({ input, context }) => {
const result = await context.db
.deleteFrom("api_tokens")
.where("id", "=", String(input.tokenId))
.where("user_id", "=", context.user.id)
.executeTakeFirst();
if (result.numDeletedRows === 0n) {
throw new ORPCError("NOT_FOUND", {
message: "API token not found",
});
}
return { success: true };
});

View File

@@ -15,6 +15,11 @@ import {
loginRequestMiddleware,
os,
} from "./procedures/base.js";
import {
createApiToken,
deleteApiToken,
listApiTokens,
} from "./procedures/me/api-tokens.js";
import { meDelete } from "./procedures/me/delete.js";
import {
getDeviceInfo,
@@ -303,6 +308,11 @@ export const router = os.router({
listTrustedDevices,
untrustDevice,
revokeAllTrustedDevices,
apiTokens: {
list: listApiTokens,
create: createApiToken,
delete: deleteApiToken,
},
},
orgs: {
list: orgsList,

View File

@@ -1,17 +1,22 @@
import type { LocalContext } from "../../context.js";
import { buildCommand } from "@stricli/core";
import { readConfig } from "../../utils/config.js";
import { generateToken, hashToken } from "../../utils/token.js";
import { createApiClient } from "../../utils/api-client.js";
import { readConfig, writeConfig } from "../../utils/config.js";
interface LoginFlags {
email: string;
token: string;
"api-url"?: string;
}
interface LoginStatusOutput {
status: "pending" | "completed" | "expired";
}
/**
* Login to RevIQ with an API token
*
* To get an API token:
* 1. Log in to the web dashboard
* 2. Go to Account Settings > API Tokens
* 3. Create a new token and copy it
* 4. Run: reviq auth login --token <your-token>
*/
async function login(this: LocalContext, flags: LoginFlags): Promise<void> {
const apiUrl = flags["api-url"] ?? "http://localhost:9861";
@@ -23,117 +28,31 @@ async function login(this: LocalContext, flags: LoginFlags): Promise<void> {
return;
}
console.log("Starting login flow...\n");
// Generate a unique callback token for this login request
const callbackToken = generateToken();
const callbackTokenHash = hashToken(callbackToken);
console.log("Validating API token...\n");
try {
// Create login request
const createResponse = await fetch(
`${apiUrl}/api/v1/rpc/auth.createLoginRequest`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: flags.email }),
},
);
// Create a temporary API client with the provided token
const api = createApiClient(apiUrl, flags.token);
if (!createResponse.ok) {
const text = await createResponse.text();
console.error(`Error creating login request: ${text}`);
this.process.exit(1);
}
// Validate the token by fetching the user's auth status
const authStatus = await api.me.authStatus();
// Construct the login URL
const loginUrl = new URL(`${apiUrl}/login`);
loginUrl.searchParams.set("email", flags.email);
loginUrl.searchParams.set("cli_callback", callbackTokenHash);
// Save credentials
await writeConfig({
apiUrl,
token: flags.token,
email: authStatus.user.email,
});
console.log("Opening browser for authentication...");
console.log(`\nIf the browser doesn't open, visit:`);
console.log(` ${loginUrl.toString()}\n`);
// Try to open the browser
const openCommand =
process.platform === "darwin"
? "open"
: process.platform === "win32"
? "start"
: "xdg-open";
try {
const proc = Bun.spawn([openCommand, loginUrl.toString()], {
stdout: "ignore",
stderr: "ignore",
});
await proc.exited;
} catch {
// Ignore errors opening browser - user can use the URL
}
console.log("Waiting for login to complete...");
console.log("(Press Ctrl+C to cancel)\n");
// Poll for completion
const maxAttempts = 120; // 2 minutes at 1 second intervals
let attempts = 0;
while (attempts < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, 1000));
attempts++;
try {
const statusResponse = await fetch(
`${apiUrl}/api/v1/rpc/auth.loginIfRequestIsCompleted`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CLI-Callback-Token": callbackToken,
},
},
);
if (statusResponse.ok) {
const status = (await statusResponse.json()) as LoginStatusOutput;
if (status.status === "completed") {
// Login completed - we should have received a token
// For now, we'll need the API to return the token
console.log("Login completed successfully!");
// TODO: The API needs to return the session token when login completes
// For now, this is a placeholder
console.log(
"\nNote: Browser-based login flow requires API integration.",
);
console.log("Use 'reviq bootstrap' to create initial credentials.");
return;
}
if (status.status === "expired") {
console.error("Login request expired. Please try again.");
this.process.exit(1);
}
}
} catch {
// Ignore polling errors and continue
}
// Show progress indicator
process.stdout.write(".");
}
console.log("\n\nLogin timed out. Please try again.");
this.process.exit(1);
console.log(`Logged in as ${authStatus.user.email}`);
console.log("Credentials saved to ~/.config/reviq/credentials.json");
} catch (error) {
console.error(
"Error:",
"Login failed:",
error instanceof Error ? error.message : String(error),
);
console.log("\nMake sure your API token is valid.");
console.log("You can create a new token at: /account/api-tokens");
this.process.exit(1);
}
}
@@ -142,10 +61,10 @@ export const loginCommand = buildCommand({
func: login,
parameters: {
flags: {
email: {
token: {
kind: "parsed",
parse: String,
brief: "Email address to login with",
brief: "API token from the web dashboard",
},
"api-url": {
kind: "parsed",
@@ -156,8 +75,13 @@ export const loginCommand = buildCommand({
},
},
docs: {
brief: "Login to RevIQ",
fullDescription:
"Opens a browser to complete authentication and stores the credentials locally.",
brief: "Login to RevIQ with an API token",
fullDescription: `Authenticates with RevIQ using an API token.
To get an API token:
1. Log in to the web dashboard at http://localhost:9861
2. Go to Account Settings > API Tokens
3. Create a new token and copy it
4. Run: reviq auth login --token <your-token>`,
},
});

View File

@@ -10,25 +10,48 @@ import { readConfig } from "./config.js";
export type ApiClient = ContractRouterClient<typeof contract>;
/**
* Create an oRPC API client with provided credentials
*/
export function createApiClient(apiUrl: string, token: string): ApiClient;
/**
* Create an oRPC API client with the stored credentials
* Throws an error if not logged in
*/
export const createApiClient = async (): Promise<ApiClient> => {
const config = await readConfig();
if (!config) {
throw new Error(
"Not logged in. Run 'reviq bootstrap' or 'reviq auth login' first.",
);
export function createApiClient(): Promise<ApiClient>;
export function createApiClient(
apiUrl?: string,
token?: string,
): ApiClient | Promise<ApiClient> {
// If both arguments are provided, create client directly
if (apiUrl !== undefined && token !== undefined) {
const link = new RPCLink({
url: `${apiUrl}/api/v1/rpc`,
headers: {
"X-API-Key": token,
},
});
return createORPCClient(link) as unknown as ApiClient;
}
const link = new RPCLink({
url: `${config.apiUrl}/api/v1/rpc`,
headers: {
"X-API-Key": config.token,
},
});
// Otherwise, read from config asynchronously
return (async (): Promise<ApiClient> => {
const config = await readConfig();
if (!config) {
throw new Error(
"Not logged in. Run 'reviq bootstrap' or 'reviq auth login' first.",
);
}
// Cast to ApiClient for type-safe API calls
return createORPCClient(link) as unknown as ApiClient;
};
const link = new RPCLink({
url: `${config.apiUrl}/api/v1/rpc`,
headers: {
"X-API-Key": config.token,
},
});
return createORPCClient(link) as unknown as ApiClient;
})();
}

View File

@@ -1,9 +1,12 @@
<script lang="ts">
import ClockIcon from "@lucide/svelte/icons/clock";
import KeyRoundIcon from "@lucide/svelte/icons/key-round";
import MonitorIcon from "@lucide/svelte/icons/monitor";
import ShieldCheckIcon from "@lucide/svelte/icons/shield-check";
import UserIcon from "@lucide/svelte/icons/user";
import { createQuery } from "@tanstack/svelte-query";
import { page } from "$app/stores";
import { api } from "$lib/api/client";
import { cn } from "$lib/utils.js";
interface Props {
@@ -12,13 +15,33 @@ interface Props {
let { class: className }: Props = $props();
const navItems = [
// Fetch current user to check superuser status
const userQuery = createQuery(() => ({
queryKey: ["me"],
queryFn: () => api.me.get(),
}));
const baseNavItems = [
{ href: "/account", label: "Profile", icon: UserIcon },
{ href: "/account/auth", label: "Authentication", icon: ShieldCheckIcon },
{ href: "/account/devices", label: "Devices", icon: MonitorIcon },
{ href: "/account/sessions", label: "Sessions", icon: ClockIcon },
];
// Add API Tokens link for superusers only
const navItems = $derived(
userQuery.data?.isSuperuser
? [
...baseNavItems,
{
href: "/account/api-tokens",
label: "API Tokens",
icon: KeyRoundIcon,
},
]
: baseNavItems,
);
function isActive(href: string, pathname: string): boolean {
if (href === "/account") {
return pathname === "/account";

View File

@@ -0,0 +1,305 @@
<script lang="ts">
import {
AlertCircle,
Check,
Copy,
KeyRound,
Loader2,
Plus,
Trash2,
} from "@lucide/svelte";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { goto } from "$app/navigation";
import { api } from "$lib/api/client";
import { ConfirmDialog } from "$lib/components/account";
import { Alert, AlertDescription } from "$lib/components/ui/alert";
import { Badge } from "$lib/components/ui/badge";
import { Button } from "$lib/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "$lib/components/ui/card";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
const queryClient = useQueryClient();
// Fetch current user to check superuser status
const userQuery = createQuery(() => ({
queryKey: ["me"],
queryFn: () => api.me.get(),
}));
// Redirect non-superusers
$effect(() => {
if (userQuery.data && !userQuery.data.isSuperuser) {
toast.error("Access denied. Superuser privileges required.");
goto("/account");
}
});
const tokensQuery = createQuery(() => ({
queryKey: ["api-tokens"],
queryFn: () => api.me.apiTokens.list(),
enabled: userQuery.data?.isSuperuser ?? false,
}));
let confirmDialogOpen = $state(false);
let selectedTokenId = $state<number | null>(null);
let isDeleting = $state(false);
// Create token form state
let newTokenName = $state("");
let isCreating = $state(false);
let newlyCreatedToken = $state<string | null>(null);
let tokenCopied = $state(false);
function formatDate(date: Date | string): string {
return new Date(date).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}
function formatRelativeTime(date: Date | string): string {
const diffDays = Math.floor(
(Date.now() - new Date(date).getTime()) / 86400000,
);
if (diffDays === 0) {
return "Today";
}
if (diffDays === 1) {
return "Yesterday";
}
if (diffDays < 7) {
return `${diffDays} days ago`;
}
if (diffDays < 30) {
return `${Math.floor(diffDays / 7)} weeks ago`;
}
return formatDate(date);
}
async function handleCreateToken(e: Event) {
e.preventDefault();
if (!newTokenName.trim() || isCreating) {
return;
}
isCreating = true;
try {
const result = await api.me.apiTokens.create({ name: newTokenName.trim() });
newlyCreatedToken = result.token;
newTokenName = "";
await queryClient.invalidateQueries({ queryKey: ["api-tokens"] });
toast.success("API token created");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to create token");
} finally {
isCreating = false;
}
}
async function copyToken() {
if (!newlyCreatedToken) {
return;
}
try {
await navigator.clipboard.writeText(newlyCreatedToken);
tokenCopied = true;
toast.success("Token copied to clipboard");
setTimeout(() => {
tokenCopied = false;
}, 2000);
} catch {
toast.error("Failed to copy token");
}
}
function dismissNewToken() {
newlyCreatedToken = null;
tokenCopied = false;
}
async function handleDelete() {
if (!selectedTokenId || isDeleting) {
return;
}
isDeleting = true;
try {
await api.me.apiTokens.delete({ tokenId: selectedTokenId });
await queryClient.invalidateQueries({ queryKey: ["api-tokens"] });
toast.success("API token deleted");
confirmDialogOpen = false;
selectedTokenId = null;
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to delete token");
} finally {
isDeleting = false;
}
}
</script>
{#if userQuery.isPending}
<div class="flex items-center justify-center py-12">
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
</div>
{:else if !userQuery.data?.isSuperuser}
<Alert variant="destructive">
<AlertCircle class="h-4 w-4" />
<AlertDescription>Access denied. Superuser privileges required.</AlertDescription>
</Alert>
{:else}
<div class="space-y-6">
<!-- Newly Created Token Banner -->
{#if newlyCreatedToken}
<Alert class="border-green-500 bg-green-50 dark:bg-green-950">
<KeyRound class="h-4 w-4 text-green-600" />
<AlertDescription>
<div class="space-y-2">
<p class="font-medium text-green-800 dark:text-green-200">
Your new API token has been created!
</p>
<p class="text-sm text-green-700 dark:text-green-300">
Copy it now - you won't be able to see it again.
</p>
<div class="flex items-center gap-2">
<code class="flex-1 rounded bg-green-100 px-2 py-1 font-mono text-sm text-green-900 dark:bg-green-900 dark:text-green-100">
{newlyCreatedToken}
</code>
<Button
size="sm"
variant="outline"
onclick={copyToken}
class="shrink-0"
>
{#if tokenCopied}
<Check class="h-4 w-4" />
{:else}
<Copy class="h-4 w-4" />
{/if}
</Button>
</div>
<Button
size="sm"
variant="ghost"
onclick={dismissNewToken}
class="mt-2"
>
I've copied my token
</Button>
</div>
</AlertDescription>
</Alert>
{/if}
<!-- Create Token -->
<Card>
<CardHeader>
<CardTitle>Create API Token</CardTitle>
<CardDescription>
Create a new API token for CLI or programmatic access.
</CardDescription>
</CardHeader>
<CardContent>
<form onsubmit={handleCreateToken} class="flex gap-3">
<div class="flex-1">
<Label for="token-name" class="sr-only">Token name</Label>
<Input
id="token-name"
type="text"
placeholder="Token name (e.g., CLI, CI/CD)"
bind:value={newTokenName}
disabled={isCreating}
/>
</div>
<Button type="submit" disabled={!newTokenName.trim() || isCreating}>
{#if isCreating}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{:else}
<Plus class="mr-2 h-4 w-4" />
{/if}
Create Token
</Button>
</form>
</CardContent>
</Card>
<!-- Existing Tokens -->
<Card>
<CardHeader>
<CardTitle>API Tokens</CardTitle>
<CardDescription>
Manage your API tokens. Use these with the CLI: <code class="rounded bg-muted px-1 py-0.5 text-xs">reviq auth login --token &lt;token&gt;</code>
</CardDescription>
</CardHeader>
<CardContent>
{#if tokensQuery.isPending}
<div class="flex items-center justify-center py-8">
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
</div>
{:else if tokensQuery.error}
<Alert variant="destructive">
<AlertCircle class="h-4 w-4" />
<AlertDescription>Failed to load tokens. Please try again.</AlertDescription>
</Alert>
{:else if tokensQuery.data && tokensQuery.data.length > 0}
<div class="divide-y">
{#each tokensQuery.data as token (token.id)}
<div class="flex items-center justify-between py-3 first:pt-0 last:pb-0">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<KeyRound class="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p class="text-sm font-medium">{token.name}</p>
<p class="text-xs text-muted-foreground">
Created {formatRelativeTime(token.createdAt)}
{#if token.lastUsedAt}
· Last used {formatRelativeTime(token.lastUsedAt)}
{:else}
· Never used
{/if}
</p>
<Badge variant="outline" class="text-xs">
Expires {formatDate(token.expiresAt)}
</Badge>
</div>
</div>
<Button
variant="outline"
size="sm"
onclick={() => { selectedTokenId = token.id; confirmDialogOpen = true; }}
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
{/each}
</div>
{:else}
<div class="flex flex-col items-center justify-center py-8 text-center">
<KeyRound class="mb-2 h-8 w-8 text-muted-foreground/50" />
<p class="text-sm text-muted-foreground">No API tokens yet.</p>
<p class="text-xs text-muted-foreground">Create one to use with the CLI.</p>
</div>
{/if}
</CardContent>
</Card>
</div>
<ConfirmDialog
bind:open={confirmDialogOpen}
title="Delete this API token?"
description="This will immediately revoke access for any applications using this token. This action cannot be undone."
confirmText="Delete token"
variant="destructive"
loading={isDeleting}
onConfirm={handleDelete}
/>
{/if}

View File

@@ -13,6 +13,9 @@ import {
adminUpdateUserInputSchema,
} from "./schemas/admin.js";
import {
apiTokenOutputSchema,
createApiTokenInputSchema,
createApiTokenOutputSchema,
forgotPasswordInputSchema,
loginPasswordInputSchema,
loginRequestInputSchema,
@@ -160,6 +163,17 @@ export const contract = oc.router({
.input(z.object({ deviceId: z.number() }))
.output(successResponseSchema),
revokeAllTrustedDevices: oc.output(successResponseSchema),
// API tokens for CLI/programmatic access
apiTokens: oc.router({
list: oc.output(z.array(apiTokenOutputSchema)),
create: oc
.input(createApiTokenInputSchema)
.output(createApiTokenOutputSchema),
delete: oc
.input(z.object({ tokenId: z.number() }))
.output(successResponseSchema),
}),
}),
orgs: oc.router({

View File

@@ -81,3 +81,31 @@ export const resetPasswordInputSchema = z.object({
token: z.string(),
newPassword: z.string().min(8),
});
/**
* API token creation input schema
* Creates an API token for CLI/programmatic access
*/
export const createApiTokenInputSchema = z.object({
name: z.string().min(1).max(100),
});
/**
* API token creation output schema
* Returns the token (only shown once)
*/
export const createApiTokenOutputSchema = z.object({
token: z.string(),
expiresAt: z.string(),
});
/**
* API token output schema for listing tokens
*/
export const apiTokenOutputSchema = z.object({
id: z.number(),
name: z.string(),
lastUsedAt: z.string().nullable(),
createdAt: z.string(),
expiresAt: z.string(),
});