diff --git a/apps/web/src/app/[locale]/cli-auth/device-code-approval.tsx b/apps/web/src/app/[locale]/cli-auth/device-code-approval.tsx
new file mode 100644
index 0000000..dd4ad93
--- /dev/null
+++ b/apps/web/src/app/[locale]/cli-auth/device-code-approval.tsx
@@ -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 (
+
+
+ {status === "done" ? "✓" : status === "error" ? "!" : "⟳"}
+
+
+ {status === "approving" && (
+ <>
+
Connecting your terminal…
+
+ Signing in as {userName}
+
+ >
+ )}
+
+ {status === "done" && (
+ <>
+
Connected!
+
+ Signed in as {userName}
+
+
+ You can close this tab and return to your terminal.
+
+ >
+ )}
+
+ {status === "error" && (
+ <>
+
Connection failed
+
+ {error || "Something went wrong."}
+
+
+ Run claudemesh login again in your terminal.
+
+ >
+ )}
+
+
+
+ Device code: {code}
+
+
+
+ );
+}
diff --git a/apps/web/src/app/[locale]/cli-auth/page.tsx b/apps/web/src/app/[locale]/cli-auth/page.tsx
index 72778d7..6e2377c 100644
--- a/apps/web/src/app/[locale]/cli-auth/page.tsx
+++ b/apps/web/src/app/[locale]/cli-auth/page.tsx
@@ -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 (
+
+
+
+ );
+ }
+
return (
},
+) {
+ 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 });
+}
diff --git a/apps/web/src/app/api/auth/cli/device-code/[code]/route.ts b/apps/web/src/app/api/auth/cli/device-code/[code]/route.ts
new file mode 100644
index 0000000..a337373
--- /dev/null
+++ b/apps/web/src/app/api/auth/cli/device-code/[code]/route.ts
@@ -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" });
+}
diff --git a/apps/web/src/app/api/auth/cli/device-code/approve-by-user-code/route.ts b/apps/web/src/app/api/auth/cli/device-code/approve-by-user-code/route.ts
new file mode 100644
index 0000000..a02c764
--- /dev/null
+++ b/apps/web/src/app/api/auth/cli/device-code/approve-by-user-code/route.ts
@@ -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 ? 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 });
+}
diff --git a/apps/web/src/app/api/auth/cli/device-code/new/route.ts b/apps/web/src/app/api/auth/cli/device-code/new/route.ts
new file mode 100644
index 0000000..a3eb492
--- /dev/null
+++ b/apps/web/src/app/api/auth/cli/device-code/new/route.ts
@@ -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`,
+ });
+}