mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-13 14:37:26 +00:00
fix(cli): point logicsrc login at the real app + add device-code login
`logicsrc login` defaulted to http://localhost:4010 — a dev origin that doesn't exist on an installed machine, so the printed authorize URL went nowhere. It now defaults to the hosted credentials app (apps/pwa), reads the documented $LOGICSRC_API, and only reuses a stored apiUrl once that identity has actually completed a login (which is how machines got stuck pointing at localhost). Note logicsrc.com is the marketing site and has no /cli routes. The loopback flow is also unusable over SSH: redirect_uri is http://127.0.0.1:<port>/callback, which resolves to the *browser's* machine, not the CLI's. Added a device-authorization flow — the CLI prints a short user_code, the human approves it from any browser: POST /cli/device/code mint device_code + user_code (10 min TTL) GET /cli/device approve page (login required; typo-tolerant) POST /cli/device approve/deny (CSRF-guarded browser form) POST /cli/device/token CLI polls -> lsk_ API key device_code is stored sha256-hashed, single-use, with authorization_pending / slow_down / access_denied / expired_token poll semantics. The CLI picks the flow automatically (SSH/CI/no-DISPLAY -> device), with --device/--web to force it and a fallback to loopback against servers without /cli/device. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
eaf0a6162b
commit
cf475f0f4c
7 changed files with 311 additions and 16 deletions
15
apps/pwa/src/migrations/003_cli_device_codes.sql
Normal file
15
apps/pwa/src/migrations/003_cli_device_codes.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
-- Device-authorization codes for `logicsrc login` on machines with no browser
|
||||
-- (SSH sessions, droplets, containers). The CLI polls /cli/device/token with the
|
||||
-- device_code while the human approves the short user_code in a browser anywhere.
|
||||
CREATE TABLE IF NOT EXISTS cli_device_codes (
|
||||
device_code_hash TEXT PRIMARY KEY, -- sha256 of the CLI's secret device_code
|
||||
user_code TEXT NOT NULL UNIQUE, -- short human-typed code (XXXX-XXXX)
|
||||
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending | approved | denied | used
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
last_polled_at INTEGER
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cli_device_user_code ON cli_device_codes(user_code)
|
||||
|
|
@ -3,13 +3,21 @@
|
|||
// 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 } from "../lib/crypto.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 { config } from "../config.mjs";
|
||||
|
||||
export const cliRouter = Router();
|
||||
|
||||
|
|
@ -78,6 +86,141 @@ cliRouter.post("/cli/token", async (req, res) => {
|
|||
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]
|
||||
);
|
||||
res.json({
|
||||
device_code: deviceCode,
|
||||
user_code: code,
|
||||
verification_uri: `${config.origin}/cli/device`,
|
||||
verification_uri_complete: `${config.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.user)}<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 & 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 & 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" });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue