feat(broker): device-code auth with PostgreSQL persistence
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

- Drizzle schema: device_code + cli_session tables in mesh pgSchema
- Broker endpoints: POST /cli/device-code, GET /cli/device-code/:code,
  POST /cli/device-code/:code/approve, GET /cli/sessions
- Web app API routes now proxy to broker (no in-memory state)
- Tracks devices per user: hostname, platform, arch, last_seen, token_hash
- JWT signed with CLI_SYNC_SECRET, 30-day expiry
- Session revocation support via revokedAt column

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

View File

@@ -1,33 +1,15 @@
import { NextResponse } from "next/server";
import { deviceCodes } from "../new/route";
const BROKER_URL = (process.env.BROKER_HTTP_URL || "https://ic.claudemesh.com").replace(/\/$/, "");
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" });
}
const brokerRes = await fetch(`${BROKER_URL}/cli/device-code/${code}`);
const brokerBody = await brokerRes.json().catch(() => ({ 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" });
return NextResponse.json(brokerBody as Record<string, unknown>, { status: brokerRes.status });
}

View File

@@ -1,10 +1,10 @@
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@turbostarter/auth/server";
import { deviceCodes } from "../new/route";
const BROKER_URL = (process.env.BROKER_HTTP_URL || "https://ic.claudemesh.com").replace(/\/$/, "");
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 });
@@ -24,65 +24,18 @@ export async function POST(request: Request) {
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;
// Proxy approve to the broker
const brokerRes = await fetch(`${BROKER_URL}/cli/device-code/${body.user_code}/approve`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: session.user.id,
email: session.user.email,
name: session.user.name,
}),
});
for (const [dc, e] of deviceCodes) {
if (e.user_code === body.user_code && e.status === "pending") {
deviceCode = dc;
entry = e;
break;
}
}
const brokerBody = await brokerRes.json().catch(() => ({ error: "Broker error" }));
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 });
return NextResponse.json(brokerBody as Record<string, unknown>, { status: brokerRes.status });
}

View File

@@ -1,65 +1,19 @@
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);
const BROKER_URL = (process.env.BROKER_HTTP_URL || "https://ic.claudemesh.com").replace(/\/$/, "");
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 body = await request.text();
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,
const brokerRes = await fetch(`${BROKER_URL}/cli/device-code`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Forwarded-For": request.headers.get("x-forwarded-for") ?? "",
},
body,
});
return NextResponse.json({
device_code,
user_code,
expires_at: new Date(expires_at).toISOString(),
verification_url: `${baseUrl}/cli-auth`,
});
const brokerBody = await brokerRes.json().catch(() => ({ error: "Broker error" }));
return NextResponse.json(brokerBody as Record<string, unknown>, { status: brokerRes.status });
}