logicsrc/apps/pwa/src/routes/cli.mjs
Anthony Ettinger 898d5503b8
fix(pwa): unbreak sign-out, and stop echoing $PUBLIC_ORIGIN in the CLI hint (#108)
Two bugs on the dashboard, both fixed by handing appBar/CLI_HINT the request.

Sign-out was broken for everyone. csrfGuard rejects any POST whose _csrf does
not match the mc_csrf cookie, and /auth/logout is a POST that is not on the
exempt list, but the sign-out form carried no hidden field -- every click
answered 403 "bad csrf token". appBar now takes the request rather than the
user, because it needs the token as well as the identity. The field is written
out instead of reusing csrfInput(): html.mjs is the view layer and imports
nothing, and pulling in session.mjs would drag the database driver with it.

The "Connect the CLI" snippet still printed $PUBLIC_ORIGIN, so users on
app.logicsrc.com were told to point LOGICSRC_API at the generated Railway
hostname. #105 added requestOrigin() for exactly this and fixed the device-flow
URLs; the dashboard hint was missed. It now follows the request too, which is
not a hardcode swap -- the same deployment answering on its Railway hostname
still self-describes correctly.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 10:56:36 -07:00

232 lines
13 KiB
JavaScript

// `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: `<main class="wrap" style="padding-top:12vh"><h1>Bad CLI request</h1><p class="dim mono">missing/invalid redirect_uri, state, or code_challenge.</p></main>` }));
}
const name = String(req.query.name || "logicsrc cli").slice(0, 40);
const body = `${appBar(req)}
<main class="wrap" style="max-width:460px;padding-top:8vh">
<div class="card"><div class="card-body" style="text-align:center">
<div style="font-size:2rem">🔑</div>
<h1 style="font-size:1.4rem;margin:10px 0">Authorize the LogicSRC CLI</h1>
<p class="dim mono" style="font-size:.82rem">Grant <b class="green">${esc(name)}</b> on this machine access to manage teams &amp; encrypted credentials as <b>${esc(req.user.email || req.user.display_name)}</b>.</p>
<form method="post" action="/cli/authorize" style="margin-top:18px">
${csrfInput(req)}
<input type="hidden" name="redirect_uri" value="${esc(redirect_uri)}">
<input type="hidden" name="state" value="${esc(state)}">
<input type="hidden" name="code_challenge" value="${esc(code_challenge)}">
<input type="hidden" name="name" value="${esc(name)}">
<button class="btn acid block" type="submit">Authorize &amp; connect</button>
</form>
<p class="faint mono" style="font-size:.72rem;margin-top:12px">You'll return to your terminal.</p>
</div></div>
</main>${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)}<main class="wrap" style="max-width:460px;padding-top:8vh">${body}</main>${footer}` });
const deviceResult = (req, res, status, heading, detail) =>
res.status(status).type("html").send(devicePage(req, `<div class="card"><div class="card-body" style="text-align:center">
<h1 style="font-size:1.4rem;margin:10px 0">${heading}</h1>
<p class="dim mono" style="font-size:.82rem">${detail}</p>
</div></div>`));
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 <code>logicsrc login</code> again.";
return res.status(code ? 400 : 200).type("html").send(devicePage(req, `<div class="card"><div class="card-body">
<div style="font-size:2rem;text-align:center">🔑</div>
<h1 style="font-size:1.4rem;margin:10px 0;text-align:center">Authorize the LogicSRC CLI</h1>
<p class="dim mono" style="font-size:.82rem;text-align:center">Enter the code shown in your terminal.</p>
${problem ? `<p class="mono" style="font-size:.8rem;color:#c2410c;text-align:center">${problem}</p>` : ""}
<form method="get" action="/cli/device" style="margin-top:18px">
<input name="user_code" value="${esc(code)}" placeholder="XXXX-XXXX" autocomplete="off" autocapitalize="characters" spellcheck="false"
style="width:100%;padding:12px;font-family:ui-monospace,monospace;font-size:1.2rem;letter-spacing:.18em;text-align:center;text-transform:uppercase">
<button class="btn acid block" type="submit" style="margin-top:12px">Continue</button>
</form>
</div></div>`));
}
res.type("html").send(devicePage(req, `<div class="card"><div class="card-body" style="text-align:center">
<div style="font-size:2rem">🔑</div>
<h1 style="font-size:1.4rem;margin:10px 0">Authorize the LogicSRC CLI</h1>
<p class="dim mono" style="font-size:.82rem">Grant <b class="green">${esc(row.name || "logicsrc cli")}</b> access to manage teams &amp; encrypted credentials as <b>${esc(req.user.email || req.user.display_name)}</b>.</p>
<p class="mono" style="font-size:1.2rem;letter-spacing:.18em;margin:14px 0">${esc(row.user_code)}</p>
<p class="faint mono" style="font-size:.72rem">Only approve this if the code matches the one in your terminal.</p>
<form method="post" action="/cli/device" style="margin-top:18px">
${csrfInput(req)}
<input type="hidden" name="user_code" value="${esc(row.user_code)}">
<button class="btn acid block" type="submit" name="action" value="approve">Authorize &amp; connect</button>
<button class="btn block" type="submit" name="action" value="deny" style="margin-top:8px">Deny</button>
</form>
</div></div>`));
});
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 <code>logicsrc login</code> 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, `<span class="green">You're in.</span>`, "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 });
});