|
| 1 | +// `moshcode login` OAuth-style flow (authorization code + PKCE + loopback): |
| 2 | +// GET /cli/authorize browser lands here (login required) → approve page |
| 3 | +// POST /cli/authorize approve → mint a code, redirect to the CLI's loopback |
| 4 | +// POST /cli/token CLI exchanges code + verifier → an API key (bearer) |
| 5 | +// GET /api/me Bearer → who am I (for `moshcode whoami`) |
| 6 | +import { Router } from "express"; |
| 7 | +import crypto from "node:crypto"; |
| 8 | +import { get, run } from "../db.mjs"; |
| 9 | +import { id, token, sha256 } from "../lib/crypto.mjs"; |
| 10 | +import { page, footer, appBar, esc } from "../lib/html.mjs"; |
| 11 | +import { requireAuth, csrfInput } from "../lib/session.mjs"; |
| 12 | +import { createApiKey, bearer, userForApiKey } from "../lib/apikey.mjs"; |
| 13 | +import { balance } from "../lib/credits.mjs"; |
| 14 | + |
| 15 | +export const cliRouter = Router(); |
| 16 | + |
| 17 | +// Only loopback redirect URIs are allowed (the CLI listens on 127.0.0.1). |
| 18 | +function loopbackOk(uri) { |
| 19 | + try { |
| 20 | + const u = new URL(uri); |
| 21 | + return u.protocol === "http:" && (u.hostname === "127.0.0.1" || u.hostname === "localhost"); |
| 22 | + } catch { return false; } |
| 23 | +} |
| 24 | + |
| 25 | +cliRouter.get("/cli/authorize", requireAuth, (req, res) => { |
| 26 | + const { redirect_uri, state, code_challenge } = req.query; |
| 27 | + if (!loopbackOk(redirect_uri) || !state || !code_challenge) { |
| 28 | + 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>` })); |
| 29 | + } |
| 30 | + const name = String(req.query.name || "moshcode cli").slice(0, 40); |
| 31 | + const body = `${appBar(req.user, 0)} |
| 32 | + <main class="wrap" style="max-width:460px;padding-top:8vh"> |
| 33 | + <div class="card"><div class="card-body" style="text-align:center"> |
| 34 | + <div style="font-size:2rem">🔑</div> |
| 35 | + <h1 style="font-size:1.4rem;margin:10px 0">Authorize the moshcode CLI</h1> |
| 36 | + <p class="dim mono" style="font-size:.82rem">Grant <b class="acid">${esc(name)}</b> on this machine access to create & read approvals as <b>${esc(req.user.email || req.user.display_name)}</b>. This is how <span class="acid">notify()</span> / <span class="acid">ask()</span> reach you.</p> |
| 37 | + <form method="post" action="/cli/authorize" style="margin-top:18px"> |
| 38 | + ${csrfInput(req)} |
| 39 | + <input type="hidden" name="redirect_uri" value="${esc(redirect_uri)}"> |
| 40 | + <input type="hidden" name="state" value="${esc(state)}"> |
| 41 | + <input type="hidden" name="code_challenge" value="${esc(code_challenge)}"> |
| 42 | + <input type="hidden" name="name" value="${esc(name)}"> |
| 43 | + <button class="btn acid block" type="submit">Authorize & connect 🤘</button> |
| 44 | + </form> |
| 45 | + <p class="faint mono" style="font-size:.72rem;margin-top:12px">You'll return to your terminal.</p> |
| 46 | + </div></div> |
| 47 | + </main>${footer}`; |
| 48 | + res.type("html").send(page({ title: "moshcode ▸ authorize CLI", body })); |
| 49 | +}); |
| 50 | + |
| 51 | +cliRouter.post("/cli/authorize", requireAuth, async (req, res) => { |
| 52 | + const { redirect_uri, state, code_challenge, name } = req.body; |
| 53 | + if (!loopbackOk(redirect_uri) || !state || !code_challenge) return res.status(400).send("bad request"); |
| 54 | + const code = token(24); |
| 55 | + const now = Date.now(); |
| 56 | + await run( |
| 57 | + `INSERT INTO cli_auth_codes (code,user_id,code_challenge,redirect_uri,name,created_at,expires_at) VALUES (?,?,?,?,?,?,?)`, |
| 58 | + [code, req.user.id, code_challenge, redirect_uri, String(name || "cli").slice(0, 40), now, now + 5 * 60 * 1000] |
| 59 | + ); |
| 60 | + const u = new URL(redirect_uri); |
| 61 | + u.searchParams.set("code", code); |
| 62 | + u.searchParams.set("state", state); |
| 63 | + res.redirect(u.toString()); |
| 64 | +}); |
| 65 | + |
| 66 | +cliRouter.post("/cli/token", async (req, res) => { |
| 67 | + const { code, code_verifier } = req.body || {}; |
| 68 | + if (!code || !code_verifier) return res.status(400).json({ error: "code and code_verifier required" }); |
| 69 | + const row = await get(`SELECT * FROM cli_auth_codes WHERE code = ?`, [code]); |
| 70 | + if (!row || row.used || row.expires_at < Date.now()) return res.status(400).json({ error: "invalid or expired code" }); |
| 71 | + |
| 72 | + // PKCE: base64url(sha256(verifier)) must equal the stored challenge |
| 73 | + const challenge = crypto.createHash("sha256").update(String(code_verifier)).digest("base64url"); |
| 74 | + if (challenge !== row.code_challenge) return res.status(400).json({ error: "PKCE verification failed" }); |
| 75 | + |
| 76 | + await run(`UPDATE cli_auth_codes SET used = 1 WHERE code = ?`, [code]); |
| 77 | + const user = await get(`SELECT * FROM users WHERE id = ?`, [row.user_id]); |
| 78 | + const { plaintext } = await createApiKey(user.id, row.name || "moshcode cli"); |
| 79 | + res.json({ access_token: plaintext, token_type: "bearer", user: { id: user.id, email: user.email || null, name: user.display_name } }); |
| 80 | +}); |
| 81 | + |
| 82 | +cliRouter.get("/api/me", async (req, res) => { |
| 83 | + const user = await userForApiKey(bearer(req)); |
| 84 | + if (!user) return res.status(401).json({ error: "invalid or missing API key" }); |
| 85 | + res.json({ id: user.id, email: user.email || null, name: user.display_name, credits: await balance(user.id) }); |
| 86 | +}); |
0 commit comments