feat(web): add device-code OAuth API for CLI authentication

New API endpoints:
- POST /api/auth/cli/device-code/new — issue device code + user code
- GET /api/auth/cli/device-code/[code] — poll device code status
- POST /api/auth/cli/device-code/[code]/approve — approve by device code
- POST /api/auth/cli/device-code/approve-by-user-code — approve by user code

Updated cli-auth page to auto-approve on page load after authentication
(no manual "Approve" button click needed).

Enables `claudemesh login` and `claudemesh register` CLI commands.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-13 08:10:09 +01:00
parent d0fbc64e7e
commit ac709dbe92
6 changed files with 367 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
"use client";
import { useEffect, useState, useRef } from "react";
interface Props {
code: string;
userName: string;
}
export function DeviceCodeApproval({ code, userName }: Props) {
const [status, setStatus] = useState<"approving" | "done" | "error">("approving");
const [error, setError] = useState("");
const attempted = useRef(false);
useEffect(() => {
if (attempted.current) return;
attempted.current = true;
// Auto-approve on mount — user is already authenticated
fetch("/api/auth/cli/device-code/approve-by-user-code", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ user_code: code }),
})
.then(async (res) => {
if (res.ok) {
setStatus("done");
} else {
const body = await res.json().catch(() => ({ error: "Unknown error" }));
setError((body as { error?: string }).error ?? `Error ${res.status}`);
setStatus("error");
}
})
.catch((e) => {
setError(e instanceof Error ? e.message : "Network error");
setStatus("error");
});
}, [code]);
return (
<div className="w-full max-w-md text-center space-y-6 p-8">
<div className="mx-auto w-16 h-16 rounded-2xl flex items-center justify-center text-3xl"
style={{ background: "var(--cm-accent, #f97316)" }}>
{status === "done" ? "✓" : status === "error" ? "!" : "⟳"}
</div>
{status === "approving" && (
<>
<h1 className="text-2xl font-bold">Connecting your terminal</h1>
<p className="text-sm" style={{ color: "var(--cm-fg-muted, #888)" }}>
Signing in as {userName}
</p>
</>
)}
{status === "done" && (
<>
<h1 className="text-2xl font-bold">Connected!</h1>
<p style={{ color: "var(--cm-fg-muted, #888)" }}>
Signed in as <strong>{userName}</strong>
</p>
<p className="text-sm" style={{ color: "var(--cm-fg-muted, #888)" }}>
You can close this tab and return to your terminal.
</p>
</>
)}
{status === "error" && (
<>
<h1 className="text-2xl font-bold">Connection failed</h1>
<p style={{ color: "#ef4444" }}>
{error || "Something went wrong."}
</p>
<p className="text-sm" style={{ color: "var(--cm-fg-muted, #888)" }}>
Run <code className="px-1.5 py-0.5 rounded" style={{ background: "var(--cm-bg-muted, #1a1a1a)" }}>claudemesh login</code> again in your terminal.
</p>
</>
)}
<div className="pt-4">
<div className="rounded-lg p-3 font-mono text-sm tracking-wider"
style={{ background: "var(--cm-bg-muted, #1a1a1a)", color: "var(--cm-fg-muted, #888)" }}>
Device code: {code}
</div>
</div>
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { getSession } from "~/lib/auth/server";
import { getMetadata } from "~/lib/metadata";
import { CliAuthFlow } from "./cli-auth-flow";
import { DeviceCodeApproval } from "./device-code-approval";
export const generateMetadata = getMetadata({
title: "Sync with CLI",
@@ -28,6 +29,20 @@ export default async function CliAuthPage({
const { code, port } = await searchParams;
// Device-code flow: code contains "-" (e.g. "ABCD-EFGH"), no port
const isDeviceCode = code && code.includes("-") && !port;
if (isDeviceCode) {
return (
<main className="min-h-screen bg-[var(--cm-bg)] text-[var(--cm-fg)] antialiased flex items-center justify-center">
<DeviceCodeApproval
code={code}
userName={user.name ?? user.email}
/>
</main>
);
}
return (
<main
className="min-h-screen bg-[var(--cm-bg)] text-[var(--cm-fg)] antialiased"

View File

@@ -0,0 +1,77 @@
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@turbostarter/auth/server";
import { deviceCodes } from "../../new/route";
export async function POST(
_request: Request,
{ params }: { params: Promise<{ code: string }> },
) {
const { code } = await params;
// Verify the user is authenticated via Better Auth session
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 entry = deviceCodes.get(code);
if (!entry) {
return NextResponse.json({ error: "Code not found or expired" }, { status: 404 });
}
if (Date.now() > entry.expires_at) {
deviceCodes.delete(code);
return NextResponse.json({ error: "Code expired" }, { status: 410 });
}
if (entry.status !== "pending") {
return NextResponse.json({ error: "Code already used" }, { status: 409 });
}
// Sign a CLI session JWT (same pattern as cli-sync-token)
const secret = process.env.CLI_SYNC_SECRET;
if (!secret) {
return NextResponse.json({ error: "Server not configured" }, { status: 500 });
}
// Create a simple session token for CLI use
const now = Math.floor(Date.now() / 1000);
const payload = {
sub: session.user.id,
email: session.user.email,
name: session.user.name,
type: "cli-session",
jti: crypto.randomUUID(),
iat: now,
exp: now + 30 * 24 * 60 * 60, // 30 days
};
// Sign JWT (inline HS256 — same as cli-sync-token route)
const encoder = new TextEncoder();
const headerB64 = btoa(JSON.stringify({ alg: "HS256", typ: "JWT" }))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const payloadB64 = btoa(JSON.stringify(payload))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const key = await crypto.subtle.importKey(
"raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"],
);
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(`${headerB64}.${payloadB64}`));
const sigB64 = btoa(String.fromCharCode(...new Uint8Array(sig)))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const token = `${headerB64}.${payloadB64}.${sigB64}`;
// Mark device code as approved
entry.status = "approved";
entry.session_token = token;
entry.user = {
id: session.user.id,
display_name: session.user.name ?? session.user.email ?? "User",
email: session.user.email ?? "",
};
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { deviceCodes } from "../new/route";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ code: string }> },
) {
const { code } = await params;
const entry = deviceCodes.get(code);
if (!entry) {
return NextResponse.json({ status: "expired" });
}
if (Date.now() > entry.expires_at) {
entry.status = "expired";
deviceCodes.delete(code);
return NextResponse.json({ status: "expired" });
}
if (entry.status === "approved") {
// Return token once, then clean up
const response = {
status: "approved",
session_token: entry.session_token,
user: entry.user,
};
deviceCodes.delete(code);
return NextResponse.json(response);
}
return NextResponse.json({ status: "pending" });
}

View File

@@ -0,0 +1,88 @@
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@turbostarter/auth/server";
import { deviceCodes } from "../new/route";
export async function POST(request: Request) {
// Verify the user is authenticated
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 });
}
let body: { user_code?: string };
try {
body = (await request.json()) as typeof body;
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
if (!body.user_code) {
return NextResponse.json({ error: "user_code required" }, { status: 400 });
}
// Find the device code entry by user_code
let deviceCode: string | null = null;
let entry: (typeof deviceCodes extends Map<string, infer V> ? V : never) | null = null;
for (const [dc, e] of deviceCodes) {
if (e.user_code === body.user_code && e.status === "pending") {
deviceCode = dc;
entry = e;
break;
}
}
if (!deviceCode || !entry) {
return NextResponse.json({ error: "Code not found or expired" }, { status: 404 });
}
if (Date.now() > entry.expires_at) {
deviceCodes.delete(deviceCode);
return NextResponse.json({ error: "Code expired" }, { status: 410 });
}
// Sign a CLI session JWT
const secret = process.env.CLI_SYNC_SECRET;
if (!secret) {
return NextResponse.json({ error: "Server not configured" }, { status: 500 });
}
const now = Math.floor(Date.now() / 1000);
const payload = {
sub: session.user.id,
email: session.user.email,
name: session.user.name,
type: "cli-session",
jti: crypto.randomUUID(),
iat: now,
exp: now + 30 * 24 * 60 * 60,
};
const encoder = new TextEncoder();
const headerB64 = btoa(JSON.stringify({ alg: "HS256", typ: "JWT" }))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const payloadB64 = btoa(JSON.stringify(payload))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const key = await crypto.subtle.importKey(
"raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"],
);
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(`${headerB64}.${payloadB64}`));
const sigB64 = btoa(String.fromCharCode(...new Uint8Array(sig)))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const token = `${headerB64}.${payloadB64}.${sigB64}`;
// Mark as approved
entry.status = "approved";
entry.session_token = token;
entry.user = {
id: session.user.id,
display_name: session.user.name ?? session.user.email ?? "User",
email: session.user.email ?? "",
};
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,65 @@
import { NextResponse } from "next/server";
// In-memory store for device codes (production would use Redis/DB)
// Exported so poll + approve routes can access it
export const deviceCodes = new Map<
string,
{
user_code: string;
status: "pending" | "approved" | "expired";
session_token?: string;
user?: { id: string; display_name: string; email: string };
hostname: string;
platform: string;
arch: string;
created_at: number;
expires_at: number;
}
>();
function generateCode(len: number): string {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const bytes = new Uint8Array(len);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => chars[b % chars.length]).join("");
}
// Clean expired codes every 5 min
setInterval(() => {
const now = Date.now();
for (const [key, val] of deviceCodes) {
if (now > val.expires_at) deviceCodes.delete(key);
}
}, 5 * 60 * 1000);
export async function POST(request: Request) {
let body: { hostname?: string; platform?: string; arch?: string };
try {
body = (await request.json()) as typeof body;
} catch {
body = {};
}
const device_code = generateCode(16);
const user_code = generateCode(4) + "-" + generateCode(4);
const expires_at = Date.now() + 5 * 60 * 1000;
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://claudemesh.com";
deviceCodes.set(device_code, {
user_code,
status: "pending",
hostname: body.hostname ?? "unknown",
platform: body.platform ?? "unknown",
arch: body.arch ?? "unknown",
created_at: Date.now(),
expires_at,
});
return NextResponse.json({
device_code,
user_code,
expires_at: new Date(expires_at).toISOString(),
verification_url: `${baseUrl}/cli-auth`,
});
}