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`, + }); +}