feat: three-token auth flow (session_id + user_code + device_code)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

- session_id (clm_sess_...) in browser URL — identifies login attempt
- user_code (ABCD-EFGH) visual confirmation — shown in both terminal and browser
- device_code (secret) — CLI polls with this, never displayed
- CLI accepts stdin paste of JWT token while polling (race)
- Web page handles both ?session= and ?code= params

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

View File

@@ -4655,6 +4655,7 @@ async function handleDeviceCodeNew(req: IncomingMessage, res: ServerResponse, st
const dc = generateShortCode(16);
const uc = generateShortCode(4) + "-" + generateShortCode(4);
const sid = "clm_sess_" + generateShortCode(32);
const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
const clientIp = (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() ?? req.socket.remoteAddress ?? "unknown";
@@ -4662,6 +4663,7 @@ async function handleDeviceCodeNew(req: IncomingMessage, res: ServerResponse, st
await db.insert(deviceCodeTable).values({
deviceCode: dc,
userCode: uc,
sessionId: sid,
hostname: body.hostname,
platform: body.platform,
arch: body.arch,
@@ -4674,10 +4676,12 @@ async function handleDeviceCodeNew(req: IncomingMessage, res: ServerResponse, st
writeJson(res, 200, {
device_code: dc,
user_code: uc,
session_id: sid,
expires_at: expiresAt.toISOString(),
verification_url: `${baseUrl}/cli-auth`,
token_url: `${baseUrl}/token`,
});
log.info("device-code", { route: "POST /cli/device-code", user_code: uc, latency_ms: Date.now() - started });
log.info("device-code", { route: "POST /cli/device-code", user_code: uc, session_id: sid, latency_ms: Date.now() - started });
} catch (e) {
log.error("device-code", { error: e instanceof Error ? e.message : String(e) });
writeJson(res, 500, { error: "Failed to create device code" });
@@ -4745,10 +4749,15 @@ async function handleDeviceCodeApprove(req: IncomingMessage, code: string, res:
}
try {
// Find device code by user_code (browser sends user_code, not device_code)
const [entry] = await db.select().from(deviceCodeTable)
.where(and(eq(deviceCodeTable.userCode, code), eq(deviceCodeTable.status, "pending")))
// Find by session_id first (URL param), fall back to user_code (legacy)
let [entry] = await db.select().from(deviceCodeTable)
.where(and(eq(deviceCodeTable.sessionId, code), eq(deviceCodeTable.status, "pending")))
.limit(1);
if (!entry) {
[entry] = await db.select().from(deviceCodeTable)
.where(and(eq(deviceCodeTable.userCode, code), eq(deviceCodeTable.status, "pending")))
.limit(1);
}
if (!entry) {
writeJson(res, 404, { error: "Code not found or expired" });