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 { getMetadata } from "~/lib/metadata";
|
||||||
|
|
||||||
import { CliAuthFlow } from "./cli-auth-flow";
|
import { CliAuthFlow } from "./cli-auth-flow";
|
||||||
|
import { DeviceCodeApproval } from "./device-code-approval";
|
||||||
|
|
||||||
export const generateMetadata = getMetadata({
|
export const generateMetadata = getMetadata({
|
||||||
title: "Sync with CLI",
|
title: "Sync with CLI",
|
||||||
@@ -28,6 +29,20 @@ export default async function CliAuthPage({
|
|||||||
|
|
||||||
const { code, port } = await searchParams;
|
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 (
|
return (
|
||||||
<main
|
<main
|
||||||
className="min-h-screen bg-[var(--cm-bg)] text-[var(--cm-fg)] antialiased"
|
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