Skip to content

feat(auth): M2 human auth foundation — JWT + GitHub OAuth verifier (#82)#89

Open
viktor-shcherb wants to merge 3 commits intomainfrom
dev/82-human-auth-foundation
Open

feat(auth): M2 human auth foundation — JWT + GitHub OAuth verifier (#82)#89
viktor-shcherb wants to merge 3 commits intomainfrom
dev/82-human-auth-foundation

Conversation

@viktor-shcherb
Copy link
Copy Markdown
Member

@viktor-shcherb viktor-shcherb commented May 7, 2026

Summary

Adds the human-plane auth layer the dashboard (M4) and HITL flow (M3)
will consume. The dashboard runs the GitHub OAuth code flow client-side
(no Murmur-side OAuth app needed); Murmur receives the resulting
access_token and exchanges it for a session JWT.

  • Schema (migration 0003): `users`, `publisher_members`,
    `refresh_tokens`, `human_audit`.
  • JWT (HS256) — hand-rolled in `src/auth/jwt.ts` (node:crypto only,
    no library dep). Rejects `alg=none` and asymmetric algs. Constant-
    time signature compare. Carries memberships snapshot at issue time;
    soft-disable check on every request.
  • GitHub OAuth verifier — `src/auth/oauth_github.ts`. Murmur is
    the verifier, NOT the OAuth client — the dashboard's OAuth app issues
    the access_token; Murmur just calls `/user` (+ `/user/emails` for
    private primaries) to validate.
  • Endpoints — `POST /auth/exchange`, `POST /auth/refresh` (atomic
    rotate inside `BEGIN IMMEDIATE`), `DELETE /auth/session`. All under
    `/auth/*`, mounted only when `MURMUR_JWT_SECRET` is supplied.
  • Audit — sign-in success/failure, refresh, revoke all written to
    `human_audit`.

Also closes #88 (migration race deploy false-failure)

While in flight, this PR also bundles the fix for the migration-race
bug that surfaced as the M1 deploy false-failure — `runMigrations`
re-checks `_migrations` under each migration's `BEGIN IMMEDIATE`
instead of pre-reading once outside the loop. The deploy chain
(`scripts/deploy.sh`'s `pnpm migrate` step + the container's startup
`runMigrations`) used to race when a new migration was pending; the
loser hit "table publishers already exists" and crashed. Post-fix, the
loser sees the freshly-committed row inside the lock and skips cleanly.
Regression test pins the invariant.

DoD coverage (issue #82)

  • User + publisher_member tables with migrations
  • GitHub OAuth provider integration + exchange/refresh/logout endpoints
  • Audit log writes from every human-plane action
  • Phase 2 plan documented (Google OAuth — adds rows; no schema change)
  • Documentation in `docs/auth.md`
  • Role enforcement on every human-plane API — there's no human-plane consumer in this PR; `requireRole` lands alongside M3 HITL routes. Tracked as a follow-up.
  • Member-management CLI / API — no consumer yet; lands with M3 (HITL needs reviewers to invite). Tracked as a follow-up.

Test plan

  • `pnpm test:unit` — 450 tests pass (jwt + auth + oauth_github +
    jwt_auth + bootstrap_auth + race-fix regression)
  • `pnpm typecheck`, `pnpm lint`, `pnpm grep:all` — green
  • Coverage for src/auth/**/*.ts: 87.43% lines / 86.51% branches /
    92.3% funcs (above 85% threshold)
  • Post-merge: deploy to Hetzner, set `MURMUR_JWT_SECRET` (32
    random bytes), verify `POST /auth/exchange` against a real
    GitHub access token from `gh auth token`.
  • Verify `/auth/*` 404s when `MURMUR_JWT_SECRET` is unset.

Operator setup

ONE env var required:

```bash

On the deploy host:

export MURMUR_JWT_SECRET="$(openssl rand -hex 32)"
echo "MURMUR_JWT_SECRET=$MURMUR_JWT_SECRET" >> /etc/murmur.env
systemctl restart murmur
```

Without it, the `/auth/*` routes 404 and the rest of the server is
unaffected (this PR's deploy is safe to merge before the env var lands).

Closes #82
Closes #88
🤖 Generated with Claude Code

viktor-shcherb and others added 3 commits May 7, 2026 16:10
…ifier, role model

Adds the human-plane auth layer the dashboard (M4) and HITL flow (M3)
will consume. The dashboard runs the GitHub OAuth code flow client-side
(no Murmur-side OAuth app needed); Murmur receives the resulting
access_token and exchanges it for a session JWT.

Schema (migration 0003):
- `users` — global identity, one per OAuth identity, soft-disable via
  `disabled_at`
- `publisher_members` — per-publisher role grants (admin/reviewer/viewer)
- `refresh_tokens` — opaque tokens, SHA-256 hashed at rest, atomic rotate
- `human_audit` — append-only log of human-plane actions

JWT (HS256, hand-rolled — node:crypto only, no library dep):
- Verifies `alg=HS256` literal-only (rejects `none` + asymmetric algs)
- Constant-time signature compare via `timingSafeEqual`
- Carries memberships snapshot at issue time; soft-disable check
  consults `users.disabled_at` on every request

GitHub OAuth verifier:
- `verifyGitHubAccessToken` introspects `api.github.com/user` (and
  `/user/emails` if primary is private)
- Murmur is the verifier, NOT the OAuth client — the dashboard's OAuth
  app issues the access_token; Murmur just validates it
- No Murmur-side OAuth app required; only `MURMUR_JWT_SECRET` env var

Endpoints (all under `/auth/*`, mounted only when MURMUR_JWT_SECRET set):
- `POST /auth/exchange { provider, oauth_access_token }` — verify OAuth
  token, upsert user, mint JWT + refresh token
- `POST /auth/refresh { refresh_token }` — atomic rotate inside BEGIN
  IMMEDIATE; reloads memberships fresh
- `DELETE /auth/session` (jwtAuth-gated) — revoke all active refresh
  tokens for the user

Audit:
- Every sign-in writes one row tagged sign_in_success /
  sign_in_no_membership / sign_in_oauth_failed / sign_in_disabled
- Refresh + revoke also audited

Server wiring:
- `MURMUR_JWT_SECRET` is OPTIONAL — without it, /auth/* 404s and the
  rest of the server is unaffected. Operator setup is one env var.

Tests:
- jwt.test.ts: 12 cases (sign/verify round-trip, alg=none rejection,
  signature tampering, expiry, issuer mismatch, claim shape validation,
  refresh token mint)
- auth.test.ts: 11 cases (exchange happy path, repeat sign-in updates
  user, memberships in JWT, OAuth failure, disabled user, refresh
  rotation, session revoke)

Member-management API + CLI deliberately deferred to M2.5 — there's no
consumer until M3 (HITL) needs reviewers. Schema ships now so M3 can
build on it. Phase 2 (Google OAuth) adds rows; no schema change.

DoD coverage:
- [x] User + publisher_member tables with migrations
- [x] GitHub OAuth provider integration + exchange/refresh/logout
- [x] Audit log writes from every human-plane action
- [x] Phase 2 plan documented (Google OAuth via same shape)
- [x] Documentation in `docs/auth.md`
- [ ] Role enforcement on every human-plane API — no human-plane
  consumer in this PR; lands with M3 HITL routes
- [ ] Member-management CLI / API — deferred to M2.5 (no consumer yet)

Closes #82 (modulo the two deferred items above
which are tracked in the PR description as follow-ups).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…strap_auth

CI's coverage gate flagged src/auth/**/*.ts at 70.46% lines /
70.46% statements / 84.61% functions, all below the 85% threshold.

Adds three test files lifting coverage to 87.43% / 87.43% / 92.3%:
- oauth_github.test.ts: 16 cases (happy paths + every named failure
  reason — invalid token, transport error, 5xx, malformed responses,
  missing fields, /user/emails primary-verified resolution)
- jwt_auth.test.ts: 10 cases (happy path + missing/malformed bearer,
  expired JWT, unknown user, soft-disabled user, wrong-secret tampering)
- bootstrap_auth.test.ts: 9 cases (admits correct bearer, rejects
  wrong-length / same-length-wrong-value / empty / no-prefix /
  no-header; readBootstrapTokenFromEnv set/unset/empty)

Also fixes a real bug surfaced by the new oauth_github tests: the
`fetchPrimaryEmail` status-range check used contradictory conditions
(`!(<200) && !(>200) && !(<400)` is always false) that made the 200
fall-through unreachable. Replaced with explicit range tests
(`status > 199 && status < 300`) — same `grep-no-naked-eq-in-auth`
constraint, correct semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…nside BEGIN IMMEDIATE

The previous "is idempotent" test exercised the same path indirectly,
but didn't pin the post-#88-fix invariant explicitly. New test passes
an explicit migration set on the second call to defeat any caller-side
caching, asserting the per-migration `IS_APPLIED_SQL` check inside the
RESERVED-lock txn is what skips already-applied versions cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant