feat(auth): M2 human auth foundation — JWT + GitHub OAuth verifier (#82)#89
Open
viktor-shcherb wants to merge 3 commits intomainfrom
Open
feat(auth): M2 human auth foundation — JWT + GitHub OAuth verifier (#82)#89viktor-shcherb wants to merge 3 commits intomainfrom
viktor-shcherb wants to merge 3 commits intomainfrom
Conversation
…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]>
9 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
`refresh_tokens`, `human_audit`.
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.
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.
rotate inside `BEGIN IMMEDIATE`), `DELETE /auth/session`. All under
`/auth/*`, mounted only when `MURMUR_JWT_SECRET` is supplied.
`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)
Test plan
jwt_auth + bootstrap_auth + race-fix regression)
92.3% funcs (above 85% threshold)
random bytes), verify `POST /auth/exchange` against a real
GitHub access token from `gh auth token`.
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