Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/pwa/src/lib/html.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,13 @@ ${head}
</html>`;
}

export function appBar(user, balance) {
export function appBar(user, balance, csrf = "") {
return `<header class="bar"><div class="wrap bar-inner">
<a class="brand" href="/"><span class="mark">M</span>MOSHCODE<span class="app">app</span></a>
<div class="bar-right">
${user ? `<span class="bal-chip">◆ <b>${balance.toLocaleString()}</b> cr</span>
<a class="btn" href="/settings">Settings</a>
<form method="post" action="/auth/logout" style="margin:0"><button class="btn">Sign out</button></form>`
<form method="post" action="/auth/logout" style="margin:0"><input type="hidden" name="_csrf" value="${esc(csrf)}"><button class="btn">Sign out</button></form>`
: `<a class="btn acid" href="/">Sign in</a>`}
</div>
</div></header>`;
Expand Down
2 changes: 1 addition & 1 deletion apps/pwa/src/routes/approvals.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ approvalsRouter.get("/approve/:id", async (req, res) => {
<div class="label" style="font-size:.6rem">${esc(k)}</div><div class="mono" style="margin-top:3px">${esc(String(v))}</div></div>`).join("");

const t = req.query.t ? `?t=${esc(req.query.t)}` : "";
const body = `${appBar(req.user, req.user ? await balance(req.user.id) : 0)}
const body = `${appBar(req.user, req.user ? await balance(req.user.id) : 0, req.csrfToken)}
<main class="wrap" style="max-width:560px;padding-top:40px">
<div class="card">
<div class="card-head"><span class="h">${esc(a.script || "moshscript")} · ${a.kind}()</span>
Expand Down
4 changes: 2 additions & 2 deletions apps/pwa/src/routes/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ cliRouter.get("/cli/authorize", requireAuth, (req, res) => {
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 || "moshcode cli").slice(0, 40);
const body = `${appBar(req.user, 0)}
const body = `${appBar(req.user, 0, req.csrfToken)}
<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>
Expand Down Expand Up @@ -129,7 +129,7 @@ cliRouter.get("/device", requireAuth, (req, res) => {
const prefill = req.query.code ? normCode(req.query.code) : "";
const done = req.query.done;
const bad = req.query.bad;
const body = `${appBar(req.user, 0)}
const body = `${appBar(req.user, 0, req.csrfToken)}
<main class="wrap" style="max-width:440px;padding-top:8vh">
<div class="card"><div class="card-body" style="text-align:center">
<div style="font-size:2rem">🔑</div>
Expand Down
4 changes: 2 additions & 2 deletions apps/pwa/src/routes/pages.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export async function dashboardHandler(req, res) {
<span style="font-variant-numeric:tabular-nums;color:${l.delta >= 0 ? "var(--acid)" : "var(--warn)"}">${l.delta >= 0 ? "+" : ""}${l.delta}</span>
</div>`).join("") || `<div class="faint mono" style="font-size:.78rem;padding:8px 0">no activity yet</div>`;

const body = `${appBar(req.user, bal)}
const body = `${appBar(req.user, bal, req.csrfToken)}
<div class="strip" style="border-bottom:1px solid var(--line);background:var(--bg-tint)"><div class="wrap" style="display:flex;gap:16px;padding:11px 0;font-family:var(--mono);font-size:.76rem;color:var(--dim);flex-wrap:wrap">
<span style="color:var(--text)"><span class="beat"></span> ${pending.length} waiting on you</span>
<span class="faint">·</span><span>balance ${bal.toLocaleString()} cr</span>
Expand Down Expand Up @@ -130,7 +130,7 @@ pagesRouter.get("/settings", requireAuth, async (req, res) => {
<form method="post" action="/settings/apikeys/${k.id}/delete" style="margin:0">${csrfInput(req)}<button class="btn danger" style="padding:5px 10px;font-size:.72rem">revoke</button></form>
</div>`).join("") : `<div class="faint mono" style="font-size:.78rem;padding:6px 0">no keys yet</div>`;

const body = `${appBar(req.user, bal)}
const body = `${appBar(req.user, bal, req.csrfToken)}
<main class="wrap" style="max-width:720px;padding-top:30px">
<h1 style="font-size:1.5rem;margin-bottom:20px">Settings</h1>
${err ? `<div class="notice err">${esc(err.replace(/-/g, " "))}</div>` : ""}
Expand Down
110 changes: 110 additions & 0 deletions apps/pwa/test/logout-csrf.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// Integration test for signing out from the app bar.
//
// The global csrfGuard requires a matching _csrf field on every browser POST.
// The sign-out form rendered by appBar() must therefore carry the token —
// a form without it always gets a 403 and the user stays logged in.
//
// Boots the real routers against a throwaway libsql file database; skips
// cleanly when the PWA dependencies are not installed.
import assert from "node:assert/strict";
import fs from "node:fs";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { createRequire } from "node:module";
import test from "node:test";

const require = createRequire(import.meta.url);
let deps = null;
try {
deps = { express: require("express"), cookieParser: require("cookie-parser") };
} catch {
deps = null; // pwa dependencies not installed — tests below skip
}

// Point the app at a throwaway database BEFORE importing its modules (config
// reads the environment once, at import time).
const workdir = mkdtempSync(path.join(tmpdir(), "moshcode-pwa-test-"));
process.env.DATABASE_URL = `file:${path.join(workdir, "test.db")}`;
process.env.SESSION_SECRET = "test-secret";

const CSRF = "test-csrf-token";
const SESSION = "test-session-token";

async function boot() {
const { migrate } = await import("../src/migrate.mjs");
await migrate();
const { run, get, db } = await import("../src/db.mjs");
const { sessionMiddleware, csrfGuard } = await import("../src/lib/session.mjs");
const { authRouter } = await import("../src/routes/auth.mjs");
const { pagesRouter } = await import("../src/routes/pages.mjs");

const app = deps.express();
app.use(deps.express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString("utf8"); } }));
app.use(deps.express.urlencoded({ extended: false }));
app.use(deps.cookieParser());
app.use(sessionMiddleware);
app.use(csrfGuard);
app.use(authRouter);
app.use(pagesRouter);
const server = await new Promise((resolve) => {
const s = app.listen(0, "127.0.0.1", () => resolve(s));
});
const base = `http://127.0.0.1:${server.address().port}`;
const cookies = `mc_sess=${SESSION}; mc_csrf=${CSRF}`;

await run(`INSERT INTO users (id, email, display_name, created_at) VALUES ('u1','a@b.c','demo',1)`);
await run(`INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?,?,?,?)`,
[SESSION, "u1", Date.now(), Date.now() + 60_000]);

return { run, get, db, server, base, cookies };
}

let booted = null;
const app = () => (booted ||= boot());

test.after(() => {
if (!booted) return;
booted.then(({ server, db }) => { server.close(); db.close?.(); })
.finally(() => { try { fs.rmSync(workdir, { recursive: true, force: true }); } catch { /* noop */ } });
});

test("sign-out form carries the CSRF token, and submitting it logs out", { skip: !deps && "apps/pwa deps not installed" }, async () => {
const { get, base, cookies } = await app();

const page = await fetch(`${base}/`, { headers: { cookie: cookies } });
assert.equal(page.status, 200);
const html = await page.text();

// The app-bar sign-out form must include the session's CSRF token, or the
// global csrfGuard rejects the POST with 403 and the user can never log out.
const form = /<form method="post" action="\/auth\/logout"[^>]*>([\s\S]*?)<\/form>/.exec(html);
assert.ok(form, "dashboard renders a sign-out form");
const token = /name="_csrf" value="([^"]+)"/.exec(form[1]);
assert.ok(token, "sign-out form must include a hidden _csrf field");
assert.equal(token[1], CSRF);

// Submitting the form as rendered destroys the session.
const res = await fetch(`${base}/auth/logout`, {
method: "POST",
headers: { cookie: cookies, "content-type": "application/x-www-form-urlencoded" },
body: `_csrf=${encodeURIComponent(token[1])}`,
redirect: "manual",
});
assert.equal(res.status, 302);
assert.equal(await get(`SELECT token FROM sessions WHERE token = ?`, [SESSION]), null, "session row must be deleted");
});

test("csrfGuard still rejects a sign-out POST without a token", { skip: !deps && "apps/pwa deps not installed" }, async () => {
const { run, get, base, cookies } = await app();
await run(`INSERT OR REPLACE INTO sessions (token, user_id, created_at, expires_at) VALUES (?,?,?,?)`,
[SESSION, "u1", Date.now(), Date.now() + 60_000]);

const res = await fetch(`${base}/auth/logout`, {
method: "POST",
headers: { cookie: cookies },
redirect: "manual",
});
assert.equal(res.status, 403);
assert.ok(await get(`SELECT token FROM sessions WHERE token = ?`, [SESSION]), "session must survive a rejected POST");
});
Loading