From cf475f0f4cf0043d577ed200a886675f461c4d4a Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 28 Jul 2026 18:07:49 +0000 Subject: [PATCH] fix(cli): point `logicsrc login` at the real app + add device-code login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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:/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) --- .../src/migrations/003_cli_device_codes.sql | 15 ++ apps/pwa/src/routes/cli.mjs | 145 +++++++++++++++++- docs/credential-sharing.md | 21 ++- packages/cli/src/index.ts | 8 +- packages/cli/src/teams.ts | 102 +++++++++++- plugins/credential-sharing/src/identity.ts | 33 +++- plugins/credential-sharing/src/index.ts | 3 + 7 files changed, 311 insertions(+), 16 deletions(-) create mode 100644 apps/pwa/src/migrations/003_cli_device_codes.sql diff --git a/apps/pwa/src/migrations/003_cli_device_codes.sql b/apps/pwa/src/migrations/003_cli_device_codes.sql new file mode 100644 index 0000000..bcd4421 --- /dev/null +++ b/apps/pwa/src/migrations/003_cli_device_codes.sql @@ -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) diff --git a/apps/pwa/src/routes/cli.mjs b/apps/pwa/src/routes/cli.mjs index 46223c3..1445e85 100644 --- a/apps/pwa/src/routes/cli.mjs +++ b/apps/pwa/src/routes/cli.mjs @@ -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)}
${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" }); diff --git a/docs/credential-sharing.md b/docs/credential-sharing.md index 4fc9051..64a48bf 100644 --- a/docs/credential-sharing.md +++ b/docs/credential-sharing.md @@ -179,8 +179,8 @@ re-wraps (seals) it to the new member's public key. The private key lives only i ### CLI ```bash -# One-time: log in by email (registers this device's identity key). -logicsrc login --email you@example.com +# One-time: log in through your browser (registers this device's identity key). +logicsrc login # Owner: create a team, push a local .env into an encrypted vault, invite people. logicsrc teams create acme --name "Acme Inc" @@ -188,7 +188,7 @@ logicsrc teams push acme prod --env .env # encrypt + upload logicsrc teams invite acme teammate@example.com # emails an accept link # Teammate: accept, then get granted, then pull + decrypt locally. -logicsrc login --email teammate@example.com +logicsrc login logicsrc teams accept # …an existing member runs: logicsrc teams grant acme prod teammate@example.com logicsrc teams pull acme prod --env .env # download + decrypt @@ -199,6 +199,21 @@ logicsrc teams members acme logicsrc teams vaults acme ``` +`logicsrc login` picks its flow from the machine it runs on: + +- **Has its own browser** → loopback OAuth-PKCE: a `127.0.0.1` listener catches + the callback. Force it with `--web`. +- **No browser** (SSH, droplet, container, CI) → device authorization: the CLI + prints a short code, you approve it from a browser on any other machine. + Force it with `--device`. A loopback redirect would be useless here — the + browser's `127.0.0.1` is not the CLI's machine. +- **Unattended** → `--token lsk_…` from **Settings ▸ API keys**. + +It talks to the hosted credentials app by default. Point it elsewhere (local dev, +self-hosted) with `LOGICSRC_API=http://localhost:8080 logicsrc login` or +`logicsrc login --api-url …`; the chosen origin is remembered in +`~/.logicsrc/identity.json` once login succeeds. + Because `team` is a normal provider, the generic sync surface works too — e.g. `logicsrc credentials plan --from env --from-path .env --to team --to-project acme --to-config prod`, then `diff`, `sync`, `audit`, and `rollback` behave exactly as diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 87c9f90..2f61bd5 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -80,11 +80,13 @@ program.action(async (options) => { program .command("login") - .option("--api-url ", "LogicSRC app URL", process.env.LOGICSRC_API_URL || process.env.COMMANDBOARD_API_URL) + .option("--api-url ", "LogicSRC credentials app URL (default: $LOGICSRC_API, else the hosted app)") .option("--token ", "Use an existing API key instead of the browser flow (CI)") - .description("Log in via your browser (loopback OAuth) for team credential sharing; registers your device identity key.") + .option("--device", "Force the device-code flow (approve from a browser on another machine)") + .option("--web", "Force the loopback browser flow (needs a browser on THIS machine)") + .description("Log in via your browser for team credential sharing; registers your device identity key.") .action(async (options) => { - await loginAction({ apiUrl: options.apiUrl, token: options.token }); + await loginAction({ apiUrl: options.apiUrl, token: options.token, device: options.device, web: options.web }); }); program.command("logout").description("Clear local auth token (keeps your identity key).").action(async () => { diff --git a/packages/cli/src/teams.ts b/packages/cli/src/teams.ts index 5d95047..9ab3b92 100644 --- a/packages/cli/src/teams.ts +++ b/packages/cli/src/teams.ts @@ -10,6 +10,7 @@ import { updateIdentity, requireAuth, defaultApiUrl, + resolveApiUrl, createCredentialEngine, unwrapVaultKey, wrapVaultKey, @@ -25,12 +26,24 @@ import { print, type OutputFormat } from "./format.js"; function authedClient(): { client: TeamClient; identity: ReturnType } { const identity = requireAuth(); - const client = new TeamClient({ apiUrl: identity.apiUrl || defaultApiUrl(), token: identity.apiToken }); + const client = new TeamClient({ apiUrl: resolveApiUrl(identity), token: identity.apiToken }); return { client, identity }; } const b64url = (buf: Buffer): string => buf.toString("base64url"); +/** + * Can a browser on THIS machine reach a loopback server on THIS machine? + * Over SSH (or in a container/CI) it can't — the human's browser is elsewhere, + * so its 127.0.0.1 is a different machine and the callback never arrives. + */ +function hasLocalBrowser(): boolean { + if (process.env.SSH_CONNECTION || process.env.SSH_TTY || process.env.SSH_CLIENT) return false; + if (process.env.CI) return false; + if (process.platform === "darwin" || process.platform === "win32") return true; + return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY); +} + function openBrowser(url: string): void { const [cmd, args] = process.platform === "darwin" ? ["open", [url]] @@ -102,6 +115,67 @@ function loopbackLogin(apiUrl: string, timeoutMs = 180000): Promise<{ token: str }); } +interface LoginResult { token: string; email: string | null; userId?: string } + +const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); + +/** + * Device-authorization login — for machines with no browser of their own. + * We print a short code; the human approves it from any browser, anywhere. + */ +async function deviceLogin(apiUrl: string, timeoutMs = 600000): Promise { + const base = apiUrl.replace(/\/+$/, ""); + const startRes = await fetch(`${base}/cli/device/code`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: `logicsrc cli @ ${hostname()}` }) + }); + if (startRes.status === 404) throw new DeviceFlowUnsupported(); + if (!startRes.ok) throw new Error(`could not start device login (${startRes.status})`); + const start = (await startRes.json()) as { + device_code: string; user_code: string; verification_uri: string; + verification_uri_complete?: string; expires_in?: number; interval?: number; + }; + + console.error("\n🔑 Authorize the LogicSRC CLI from any browser:"); + console.error(` 1. open ${start.verification_uri}`); + console.error(` 2. enter the code: ${start.user_code}\n`); + if (hasLocalBrowser() && start.verification_uri_complete) openBrowser(start.verification_uri_complete); + + let interval = Math.max(1, start.interval ?? 5); + const deadline = Date.now() + Math.min(timeoutMs, (start.expires_in ?? 600) * 1000); + process.stderr.write(" waiting for approval…"); + try { + while (Date.now() < deadline) { + await sleep(interval * 1000); + const res = await fetch(`${base}/cli/device/token`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ device_code: start.device_code }) + }); + const body = (await res.json().catch(() => ({}))) as { + error?: string; interval?: number; access_token?: string; user?: { email?: string; id?: string }; + }; + if (res.ok && body.access_token) { + return { token: body.access_token, email: body.user?.email ?? null, userId: body.user?.id }; + } + if (body.error === "authorization_pending") { process.stderr.write("."); continue; } + if (body.error === "slow_down") { interval = Math.max(interval + 2, body.interval ?? interval); continue; } + if (body.error === "access_denied") throw new Error("authorization was denied in the browser"); + if (body.error === "expired_token") throw new Error("the code expired — run `logicsrc login` again"); + throw new Error(`device login failed (${body.error || res.status})`); + } + } finally { + process.stderr.write("\n"); + } + throw new Error("login timed out — run `logicsrc login` again"); +} + +/** Thrown when the server predates the device flow, so we can fall back. */ +class DeviceFlowUnsupported extends Error { + constructor() { super("device flow not supported by this server"); } +} + async function resolveVaultId(client: TeamClient, slug: string, vault: string): Promise { const { vaults } = await client.listVaults(slug); const found = vaults.find((v) => v.name === vault); @@ -109,11 +183,12 @@ async function resolveVaultId(client: TeamClient, slug: string, vault: string): return found.id; } -export async function loginAction(options: { apiUrl?: string; token?: string }): Promise { +export async function loginAction(options: { apiUrl?: string; token?: string; device?: boolean; web?: boolean }): Promise { const identity = await loadOrCreateIdentity(); - const apiUrl = (options.apiUrl || identity.apiUrl || defaultApiUrl()).replace(/\/+$/, ""); + const apiUrl = resolveApiUrl(identity, options.apiUrl); - // Loopback browser OAuth-PKCE (like `moshcode login`), or a --token for CI. + // --token for CI; otherwise a browser flow: loopback OAuth-PKCE when this + // machine has its own browser, device-code when it doesn't (SSH, containers). let token = options.token; let email: string | null = null; let userId: string | undefined; @@ -123,7 +198,20 @@ export async function loginAction(options: { apiUrl?: string; token?: string }): email = me.user.email; userId = me.user.id; } else { - const result = await loopbackLogin(apiUrl); + const useDevice = options.device ?? (options.web ? false : !hasLocalBrowser()); + let result: LoginResult; + if (useDevice) { + try { + result = await deviceLogin(apiUrl); + } catch (error) { + if (!(error instanceof DeviceFlowUnsupported)) throw error; + console.error("⚠️ This server has no device flow — falling back to the loopback flow."); + console.error(" If your browser is on another machine, forward the callback port over SSH."); + result = await loopbackLogin(apiUrl); + } + } else { + result = await loopbackLogin(apiUrl); + } token = result.token; email = result.email; userId = result.userId; @@ -145,12 +233,12 @@ export async function logoutAction(): Promise { export async function whoamiAction(format: OutputFormat): Promise { const identity = readIdentity(); if (!identity?.apiToken) { - print({ loggedIn: false, apiUrl: defaultApiUrl(), hint: "Run: logicsrc login --email you@example.com" }, format); + print({ loggedIn: false, apiUrl: defaultApiUrl(), hint: "Run: logicsrc login" }, format); return; } const { client } = authedClient(); const me = await client.me(); - print({ loggedIn: true, email: me.user.email, apiUrl: identity.apiUrl, publicKey: me.user.publicKey, teams: me.teams.map((t) => t.slug) }, format); + print({ loggedIn: true, email: me.user.email, apiUrl: resolveApiUrl(identity), publicKey: me.user.publicKey, teams: me.teams.map((t) => t.slug) }, format); } export async function teamsCreateAction(slug: string, options: { name?: string; format: OutputFormat }): Promise { diff --git a/plugins/credential-sharing/src/identity.ts b/plugins/credential-sharing/src/identity.ts index e3ce6ad..a5f91d9 100644 --- a/plugins/credential-sharing/src/identity.ts +++ b/plugins/credential-sharing/src/identity.ts @@ -38,8 +38,37 @@ export function identityPath(): string { : join(logicsrcHome(), "identity.json"); } +/** + * The hosted LogicSRC credentials app (`apps/pwa`) — where `logicsrc login` + * goes when nothing else is configured. This is deliberately NOT logicsrc.com: + * that origin serves the marketing site and has no /cli routes. + */ +export const DEFAULT_API_URL = "https://logicsrc-credentials-production.up.railway.app"; + +/** An explicitly configured API origin, if any. `LOGICSRC_API` is the documented one. */ +export function envApiUrl(): string | undefined { + return ( + process.env.LOGICSRC_API || + process.env.LOGICSRC_API_URL || + // Legacy: CommandBoard is a different service, so this is the last resort. + process.env.COMMANDBOARD_API_URL || + undefined + ); +} + export function defaultApiUrl(): string { - return process.env.COMMANDBOARD_API_URL || process.env.LOGICSRC_API_URL || "http://localhost:4010"; + return envApiUrl() || DEFAULT_API_URL; +} + +/** + * Resolve the API origin for a command: an explicit `--api-url` wins, then the + * environment, then whatever a *logged-in* identity was registered against. + * A stored URL from an identity that never completed login is ignored — that + * is how machines got stuck pointing at a dev `localhost` server. + */ +export function resolveApiUrl(identity?: Pick, override?: string): string { + const stored = identity?.apiToken ? identity.apiUrl : undefined; + return (override || envApiUrl() || stored || DEFAULT_API_URL).replace(/\/+$/, ""); } function writeSecure(file: string, data: unknown): void { @@ -96,7 +125,7 @@ export async function updateIdentity( export function requireAuth(file = identityPath()): LocalIdentity & { apiToken: string; email: string } { const identity = readIdentity(file); if (!identity?.apiToken || !identity.email) { - throw new Error('Not logged in. Run "logicsrc login --email you@example.com" first.'); + throw new Error('Not logged in. Run "logicsrc login" first.'); } return identity as LocalIdentity & { apiToken: string; email: string }; } diff --git a/plugins/credential-sharing/src/index.ts b/plugins/credential-sharing/src/index.ts index 23e585c..e43bfe9 100644 --- a/plugins/credential-sharing/src/index.ts +++ b/plugins/credential-sharing/src/index.ts @@ -89,6 +89,9 @@ export { identityPath, logicsrcHome, defaultApiUrl, + envApiUrl, + resolveApiUrl, + DEFAULT_API_URL, type LocalIdentity } from "./identity.js"; export {