// `logicsrc login` OAuth-style flow (authorization code + PKCE + loopback): // GET /cli/authorize browser lands here (login required) → approve page // POST /cli/authorize approve → mint a code, redirect to the CLI's loopback // POST /cli/token CLI exchanges code + verifier → an lsk_ API key (bearer) // GET /api/me Bearer → who am I (for `logicsrc whoami`) // // …and the device-authorization flow, for CLIs on a machine with no browser // (SSH, droplets, containers) where a 127.0.0.1 redirect_uri is unreachable: // POST /cli/device/code CLI asks for a device_code + short user_code // GET /cli/device human opens this anywhere, types/confirms the code // POST /cli/device approve (or deny) the pending code // POST /cli/device/token CLI polls with device_code → an lsk_ API key import { Router } from "express"; import crypto from "node:crypto"; import { get, run } from "../db.mjs"; import { token, sha256 } from "../lib/crypto.mjs"; import { page, footer, appBar, esc } from "../lib/html.mjs"; import { requireAuth, csrfInput } from "../lib/session.mjs"; import { createApiKey, bearer, userForApiKey } from "../lib/apikey.mjs"; import { requestOrigin } from "../lib/origin.mjs"; import { config } from "../config.mjs"; export const cliRouter = Router(); // Only loopback redirect URIs are allowed (the CLI listens on 127.0.0.1). function loopbackOk(uri) { try { const u = new URL(uri); return u.protocol === "http:" && (u.hostname === "127.0.0.1" || u.hostname === "localhost"); } catch { return false; } } cliRouter.get("/cli/authorize", requireAuth, (req, res) => { const { redirect_uri, state, code_challenge } = req.query; if (!loopbackOk(redirect_uri) || !state || !code_challenge) { return res.status(400).type("html").send(page({ body: `

Bad CLI request

missing/invalid redirect_uri, state, or code_challenge.

` })); } const name = String(req.query.name || "logicsrc cli").slice(0, 40); const body = `${appBar(req)}
🔑

Authorize the LogicSRC CLI

Grant ${esc(name)} on this machine access to manage teams & encrypted credentials as ${esc(req.user.email || req.user.display_name)}.

${csrfInput(req)}

You'll return to your terminal.

${footer}`; res.type("html").send(page({ title: "LogicSRC ▸ authorize CLI", body })); }); cliRouter.post("/cli/authorize", requireAuth, async (req, res) => { const { redirect_uri, state, code_challenge, name } = req.body; if (!loopbackOk(redirect_uri) || !state || !code_challenge) return res.status(400).send("bad request"); const code = token(24); const now = Date.now(); await run( `INSERT INTO cli_auth_codes (code,user_id,code_challenge,redirect_uri,name,created_at,expires_at) VALUES (?,?,?,?,?,?,?)`, [code, req.user.id, code_challenge, redirect_uri, String(name || "cli").slice(0, 40), now, now + 5 * 60 * 1000] ); const u = new URL(redirect_uri); u.searchParams.set("code", code); u.searchParams.set("state", state); res.redirect(u.toString()); }); cliRouter.post("/cli/token", async (req, res) => { const { code, code_verifier } = req.body || {}; if (!code || !code_verifier) return res.status(400).json({ error: "code and code_verifier required" }); const row = await get(`SELECT * FROM cli_auth_codes WHERE code = ?`, [code]); if (!row || row.used || row.expires_at < Date.now()) return res.status(400).json({ error: "invalid or expired code" }); // PKCE: base64url(sha256(verifier)) must equal the stored challenge const challenge = crypto.createHash("sha256").update(String(code_verifier)).digest("base64url"); if (challenge !== row.code_challenge) return res.status(400).json({ error: "PKCE verification failed" }); await run(`UPDATE cli_auth_codes SET used = 1 WHERE code = ?`, [code]); const user = await get(`SELECT * FROM users WHERE id = ?`, [row.user_id]); const { plaintext } = await createApiKey(user.id, row.name || "logicsrc cli"); res.json({ access_token: plaintext, token_type: "bearer", user: { id: user.id, email: user.email || null, name: user.display_name } }); }); // ---- device authorization (no browser on the CLI's machine) ---- const DEVICE_TTL_MS = 10 * 60 * 1000; const DEVICE_POLL_SECONDS = 5; // Unambiguous alphabet — no 0/O, 1/I/L, U/V confusion when read off a screen. const CODE_ALPHABET = "BCDFGHJKMNPQRSTWXYZ23456789"; function userCode() { const bytes = crypto.randomBytes(8); let out = ""; for (let i = 0; i < 8; i++) { out += CODE_ALPHABET[bytes[i] % CODE_ALPHABET.length]; if (i === 3) out += "-"; } return out; } /** Normalize whatever the human typed (spaces, lowercase, missing dash). */ function normalizeUserCode(input) { const raw = String(input || "").toUpperCase().replace(/[^A-Z0-9]/g, ""); return raw.length === 8 ? `${raw.slice(0, 4)}-${raw.slice(4)}` : raw; } cliRouter.post("/cli/device/code", async (req, res) => { const name = String(req.body?.name || "logicsrc cli").slice(0, 40); const deviceCode = token(32); const now = Date.now(); // Retry on the (vanishingly unlikely) user_code collision. let code; for (let attempt = 0; attempt < 5 && !code; attempt++) { const candidate = userCode(); const clash = await get(`SELECT user_code FROM cli_device_codes WHERE user_code = ? AND expires_at > ?`, [candidate, now]); if (!clash) code = candidate; } if (!code) return res.status(503).json({ error: "could not allocate a user code — try again" }); await run( `INSERT INTO cli_device_codes (device_code_hash,user_code,name,status,created_at,expires_at) VALUES (?,?,?,'pending',?,?)`, [sha256(deviceCode), code, name, now, now + DEVICE_TTL_MS] ); // Echo back the host the CLI actually called us on, not $PUBLIC_ORIGIN — the // user is told to open this link, and it has to be a domain they can reach. const origin = requestOrigin(req, config.origin); res.json({ device_code: deviceCode, user_code: code, verification_uri: `${origin}/cli/device`, verification_uri_complete: `${origin}/cli/device?user_code=${encodeURIComponent(code)}`, expires_in: Math.floor(DEVICE_TTL_MS / 1000), interval: DEVICE_POLL_SECONDS }); }); const devicePage = (req, body) => page({ title: "LogicSRC ▸ authorize CLI", body: `${appBar(req)}
${body}
${footer}` }); const deviceResult = (req, res, status, heading, detail) => res.status(status).type("html").send(devicePage(req, `

${heading}

${detail}

`)); cliRouter.get("/cli/device", requireAuth, async (req, res) => { const code = normalizeUserCode(req.query.user_code); const row = code ? await get(`SELECT * FROM cli_device_codes WHERE user_code = ?`, [code]) : null; const pending = row && row.status === "pending" && row.expires_at > Date.now(); // No (or an unusable) code in the URL → ask the human to type the one their terminal is showing. if (!pending) { const problem = !code ? "" : !row ? "That code doesn't exist — check for typos." : row.status !== "pending" ? "That code was already used." : "That code expired — run logicsrc login again."; return res.status(code ? 400 : 200).type("html").send(devicePage(req, `
🔑

Authorize the LogicSRC CLI

Enter the code shown in your terminal.

${problem ? `

${problem}

` : ""}
`)); } res.type("html").send(devicePage(req, `
🔑

Authorize the LogicSRC CLI

Grant ${esc(row.name || "logicsrc cli")} access to manage teams & encrypted credentials as ${esc(req.user.email || req.user.display_name)}.

${esc(row.user_code)}

Only approve this if the code matches the one in your terminal.

${csrfInput(req)}
`)); }); cliRouter.post("/cli/device", requireAuth, async (req, res) => { const code = normalizeUserCode(req.body?.user_code); const deny = req.body?.action === "deny"; const row = code ? await get(`SELECT * FROM cli_device_codes WHERE user_code = ?`, [code]) : null; if (!row) return deviceResult(req, res, 400, "Unknown code", "That code doesn't exist — check for typos."); if (row.status !== "pending") return deviceResult(req, res, 400, "Already used", "That code was already approved or denied."); if (row.expires_at < Date.now()) return deviceResult(req, res, 400, "Code expired", "Run logicsrc login again for a fresh code."); await run(`UPDATE cli_device_codes SET status = ?, user_id = ? WHERE user_code = ?`, [deny ? "denied" : "approved", req.user.id, code]); return deny ? deviceResult(req, res, 200, "Denied", "Nothing was granted. You can close this tab.") : deviceResult(req, res, 200, `You're in.`, "Return to your terminal — you can close this tab."); }); cliRouter.post("/cli/device/token", async (req, res) => { const deviceCode = req.body?.device_code; if (!deviceCode) return res.status(400).json({ error: "invalid_request" }); const row = await get(`SELECT * FROM cli_device_codes WHERE device_code_hash = ?`, [sha256(String(deviceCode))]); if (!row) return res.status(400).json({ error: "invalid_grant" }); const now = Date.now(); // Rate-limit impatient pollers, per the device-flow convention. const tooSoon = row.last_polled_at && now - row.last_polled_at < (DEVICE_POLL_SECONDS - 1) * 1000; await run(`UPDATE cli_device_codes SET last_polled_at = ? WHERE device_code_hash = ?`, [now, row.device_code_hash]); if (tooSoon) return res.status(400).json({ error: "slow_down", interval: DEVICE_POLL_SECONDS }); if (row.status === "used") return res.status(400).json({ error: "invalid_grant" }); if (row.status === "denied") return res.status(400).json({ error: "access_denied" }); if (row.expires_at < now) return res.status(400).json({ error: "expired_token" }); if (row.status !== "approved") return res.status(400).json({ error: "authorization_pending", interval: DEVICE_POLL_SECONDS }); await run(`UPDATE cli_device_codes SET status = 'used' WHERE device_code_hash = ?`, [row.device_code_hash]); const user = await get(`SELECT * FROM users WHERE id = ?`, [row.user_id]); if (!user) return res.status(400).json({ error: "invalid_grant" }); const { plaintext } = await createApiKey(user.id, row.name || "logicsrc cli"); res.json({ access_token: plaintext, token_type: "bearer", user: { id: user.id, email: user.email || null, name: user.display_name } }); }); cliRouter.get("/api/me", async (req, res) => { const user = await userForApiKey(bearer(req)); if (!user) return res.status(401).json({ error: "invalid or missing API key" }); res.json({ id: user.id, email: user.email || null, name: user.display_name }); });