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:
89
apps/web/src/app/[locale]/cli-auth/device-code-approval.tsx
Normal file
89
apps/web/src/app/[locale]/cli-auth/device-code-approval.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
33
apps/web/src/app/api/auth/cli/device-code/[code]/route.ts
Normal file
33
apps/web/src/app/api/auth/cli/device-code/[code]/route.ts
Normal 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" });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
65
apps/web/src/app/api/auth/cli/device-code/new/route.ts
Normal file
65
apps/web/src/app/api/auth/cli/device-code/new/route.ts
Normal 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`,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user