feat: paste-token auth flow for CLI
- 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:
@@ -669,6 +669,11 @@ function handleHttpRequest(req: IncomingMessage, res: ServerResponse): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && req.url === "/cli/token") {
|
||||||
|
handleCliTokenGenerate(req, res, started);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Telegram connect token (rate-limited: 10 requests/hour per IP)
|
// Telegram connect token (rate-limited: 10 requests/hour per IP)
|
||||||
if (req.method === "POST" && req.url === "/tg/token") {
|
if (req.method === "POST" && req.url === "/tg/token") {
|
||||||
const clientIp = (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() ?? req.socket.remoteAddress ?? "unknown";
|
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
|
// Skip starting the HTTP/WS server when running under vitest — tests import
|
||||||
|
|||||||
27
apps/web/src/app/[locale]/token/page.tsx
Normal file
27
apps/web/src/app/[locale]/token/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
apps/web/src/app/[locale]/token/token-generator.tsx
Normal file
152
apps/web/src/app/[locale]/token/token-generator.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
apps/web/src/app/api/auth/cli/token/route.ts
Normal file
28
apps/web/src/app/api/auth/cli/token/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user