feat: paste-token auth flow for CLI
Some checks failed
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled

- Broker: POST /cli/token generates a 30-day JWT
- Web: /token page with Generate + Copy button
- Web: /api/auth/cli/token proxies to broker
- CLI: login option 3 "Paste a token" for headless environments

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-13 11:17:38 +01:00
parent 1a42c2ef09
commit ea4e3b03bb
4 changed files with 258 additions and 0 deletions

View File

@@ -669,6 +669,11 @@ function handleHttpRequest(req: IncomingMessage, res: ServerResponse): void {
return;
}
if (req.method === "POST" && req.url === "/cli/token") {
handleCliTokenGenerate(req, res, started);
return;
}
// Telegram connect token (rate-limited: 10 requests/hour per IP)
if (req.method === "POST" && req.url === "/tg/token") {
const clientIp = (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() ?? req.socket.remoteAddress ?? "unknown";
@@ -4809,6 +4814,52 @@ async function handleCliSessionsList(req: IncomingMessage, res: ServerResponse,
}
}
/** POST /cli/token — generate a CLI token for paste-based auth. */
async function handleCliTokenGenerate(req: IncomingMessage, res: ServerResponse, started: number): Promise<void> {
let body: { user_id: string; email: string; name?: string; hostname?: string; platform?: string; arch?: string };
try {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
body = JSON.parse(Buffer.concat(chunks).toString()) as typeof body;
} catch {
writeJson(res, 400, { error: "Invalid body" });
return;
}
if (!body.user_id || !body.email) {
writeJson(res, 400, { error: "user_id and email required" });
return;
}
try {
const now = Math.floor(Date.now() / 1000);
const token = await signCliJwt({
sub: body.user_id,
email: body.email,
name: body.name,
type: "cli-token",
jti: crypto.randomUUID(),
iat: now,
exp: now + 30 * 24 * 60 * 60,
});
// Record session
await db.insert(cliSessionTable).values({
userId: body.user_id,
hostname: body.hostname ?? "paste-token",
platform: body.platform ?? "unknown",
arch: body.arch ?? "unknown",
tokenHash: await hashToken(token),
});
writeJson(res, 200, { token });
log.info("cli-token", { route: "POST /cli/token", user_id: body.user_id, latency_ms: Date.now() - started });
} catch (e) {
log.error("cli-token", { error: e instanceof Error ? e.message : String(e) });
writeJson(res, 500, { error: "Failed to generate token" });
}
}
// ---------------------------------------------------------------------------
// Skip starting the HTTP/WS server when running under vitest — tests import

View File

@@ -0,0 +1,27 @@
import { redirect } from "next/navigation";
import { getSession } from "~/lib/auth/server";
import { getMetadata } from "~/lib/metadata";
import { TokenGenerator } from "./token-generator";
export const generateMetadata = getMetadata({
title: "CLI Token",
description: "Generate a token to sign in to claudemesh CLI.",
});
export default async function TokenPage() {
const { user } = await getSession();
if (!user) {
return redirect(`/auth/login?redirectTo=${encodeURIComponent("/token")}`);
}
return (
<main className="min-h-screen bg-[var(--cm-bg,#0a0a0a)] text-[var(--cm-fg,#fafafa)] antialiased flex items-center justify-center">
<TokenGenerator
userId={user.id}
userEmail={user.email}
userName={user.name ?? user.email}
/>
</main>
);
}

View File

@@ -0,0 +1,152 @@
"use client";
import { useState } from "react";
interface Props {
userId: string;
userEmail: string;
userName: string;
}
const BROKER_URL = process.env.NEXT_PUBLIC_BROKER_HTTP_URL || "https://ic.claudemesh.com";
export function TokenGenerator({ userId, userEmail, userName }: Props) {
const [token, setToken] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [copied, setCopied] = useState(false);
const [error, setError] = useState("");
async function generate() {
setLoading(true);
setError("");
try {
const res = await fetch("/api/auth/cli/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: "Failed" }));
setError((body as { error?: string }).error ?? "Failed to generate token");
return;
}
const { token: t } = (await res.json()) as { token: string };
setToken(t);
} catch (e) {
setError(e instanceof Error ? e.message : "Network error");
} finally {
setLoading(false);
}
}
async function copyToken() {
if (!token) return;
try {
await navigator.clipboard.writeText(token);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback: select the text
const el = document.getElementById("cli-token");
if (el) {
const range = document.createRange();
range.selectNodeContents(el);
window.getSelection()?.removeAllRanges();
window.getSelection()?.addRange(range);
}
}
}
const btnBase = "w-full flex items-center justify-center gap-2 rounded-lg px-4 py-3 text-[15px] font-medium transition-all";
return (
<div className="w-full max-w-[420px] space-y-6 p-8">
{/* Header */}
<div className="text-center space-y-3">
<div className="mx-auto w-14 h-14 rounded-2xl flex items-center justify-center" style={{ background: "var(--cm-clay, #b07a56)" }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="4" r="2" fill="#fff" />
<circle cx="4" cy="12" r="2" fill="#fff" />
<circle cx="20" cy="12" r="2" fill="#fff" />
<circle cx="12" cy="20" r="2" fill="#fff" />
<path d="M12 4L4 12M12 4L20 12M4 12L12 20M20 12L12 20" stroke="#fff" strokeWidth="1.2" opacity="0.5" />
</svg>
</div>
<h1 className="text-[22px] font-bold tracking-tight">CLI Token</h1>
<p className="text-[14px]" style={{ color: "var(--cm-fg-muted, #888)" }}>
Generate a token to sign in to claudemesh CLI.
<br />
Paste it in your terminal when prompted.
</p>
</div>
{/* Signed in as */}
<div className="text-center text-[13px]" style={{ color: "var(--cm-fg-muted, #888)" }}>
Signed in as <strong style={{ color: "var(--cm-fg, #fafafa)" }}>{userName}</strong>
</div>
{!token ? (
<>
<button
onClick={generate}
disabled={loading}
className={btnBase}
style={{ background: "var(--cm-clay, #b07a56)", color: "#fff" }}
>
{loading ? "Generating…" : "Generate CLI token"}
</button>
{error && <p className="text-center text-[13px] text-red-400">{error}</p>}
</>
) : (
<div className="space-y-4">
{/* Token display */}
<div className="relative">
<pre
id="cli-token"
className="w-full overflow-x-auto rounded-lg p-4 text-[12px] leading-relaxed break-all whitespace-pre-wrap"
style={{ background: "var(--cm-bg-elevated, #1a1a1a)", border: "1px solid var(--cm-border, #333)", color: "var(--cm-fg, #fafafa)" }}
>
{token}
</pre>
</div>
{/* Copy button */}
<button
onClick={copyToken}
className={btnBase}
style={{
background: copied ? "#22c55e" : "var(--cm-clay, #b07a56)",
color: "#fff",
}}
>
{copied ? "✓ Copied!" : "Copy to clipboard"}
</button>
{/* Instructions */}
<div className="rounded-lg p-4 text-[13px] space-y-2" style={{ background: "var(--cm-bg-elevated, #1a1a1a)", color: "var(--cm-fg-muted, #888)" }}>
<p className="font-medium" style={{ color: "var(--cm-fg, #fafafa)" }}>Paste in your terminal:</p>
<code className="block text-[12px]" style={{ color: "var(--cm-clay, #b07a56)" }}>
claudemesh login option 3 paste
</code>
</div>
{/* Security note */}
<p className="text-center text-[11px]" style={{ color: "var(--cm-fg-muted, #666)" }}>
This token grants CLI access to your account. Don&apos;t share it.
<br />
Valid for 30 days. Revoke anytime from Dashboard Settings.
</p>
{/* Generate another */}
<button
onClick={() => { setToken(null); setCopied(false); }}
className="w-full text-center text-[13px] underline"
style={{ color: "var(--cm-fg-muted, #888)" }}
>
Generate a new token
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@turbostarter/auth/server";
const BROKER_URL = (process.env.BROKER_HTTP_URL || "https://ic.claudemesh.com").replace(/\/$/, "");
export async function POST() {
const reqHeaders = new Headers(await headers());
reqHeaders.set("x-client-platform", "web-server");
const session = await auth.api.getSession({ headers: reqHeaders });
if (!session?.user) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const brokerRes = await fetch(`${BROKER_URL}/cli/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: session.user.id,
email: session.user.email,
name: session.user.name,
}),
});
const brokerBody = await brokerRes.json().catch(() => ({ error: "Broker error" }));
return NextResponse.json(brokerBody as Record<string, unknown>, { status: brokerRes.status });
}