feat(broker): device-code auth with PostgreSQL persistence
- 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:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user