feat: JWT authentication and RBAC (#6) - #47
Merged
Merged
Conversation
Issue #6. Covers backend internal/auth package, users migration/sqlc queries, router public/protected split, OpenAPI contract updates, and frontend AuthContext/ProtectedRoute/LoginPage wiring. No implementation yet — plan only.
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 short-lived JWT access tokens and two-role RBAC (
operator,admin) across the API and the web app. The/loginplaceholder now signs in againstPOST /login, and every API route except/health/*and/loginrequires a bearer token.Closes #6
Architecture summary
contracts/openapi.yamlgains abearerAuthscheme (global default),POST /login,GET /me, reusableUnauthorized/Forbiddenresponses, and a401on every protected operation./health/*and/loginopt out withsecurity: [].internal/auth(new module, same shape asinternal/alerts): claims + two roles, bcrypt hashing, HS256 issuer/verifier, the login service behind aUserStoreport, andRequireAuth/RequireRolemiddleware.auth/storeis a thin pgx/sqlc adapter;auth/httpapiserves/loginand/me.0004_users(UUID id, unique email, bcrypt hash, roleCHECK (role IN ('operator','admin'))) and sqlc queriesGetUserByEmail,InsertUserIfAbsent.RouterDepsgainsPublicModules,RequireAuth(a plainfunc(http.Handler) http.Handler, sohttpserverstill has no dependency on feature packages) andCORSAllowedOrigins. Protected modules mount inside a chi group behindRequireAuth./loginand sendAuthorization. It reads the existingCORS_ALLOWED_ORIGINS, which was loaded but unused until now.JWT_SIGNING_SECRET(required, at least 32 characters) andJWT_ACCESS_TOKEN_TTL(default15m), validated at startup.api seed-userscreatesoperator@example.com/admin@example.comidempotently.AuthProviderkeeps the token and user in React state only.ProtectedRoutegates the app shell,useApiFetchattaches the bearer token and signs out on a 401, andLoginPageis wired toPOST /login.Changes from the committed plan
The plan in
docs/superpowers/plans/2026-06-19-jwt-auth-rbac.mdwas followed, with these corrections:golang-jwt/jwt/v5v5.3.1 andx/cryptov0.57.0 instead of the pinned v5.2.1 / v0.31.0, which carry High advisories and would fail dependency review.exp/iat) described below.auth/storeintegration test in the same style as the existing store tests.useApiFetchbuilds headers withnew Headers()so caller headers survive.docs/operations/local-development.mdand updated the stale "auth planned" notes.Bearerscheme are case-insensitive, and protected-route 401s carryWWW-AuthenticateVary: OriginSecurity considerations
.env.example/docker-compose.yml.WithValidMethods), soalg: noneand algorithm-confusion tokens are rejected, and tests cover both.expis required and a token withoutiatis rejected.APP_ENVis notdevelopment.DefaultCosthashes. An unknown email and a wrong password return the same401 INVALID_CREDENTIALSand both cost one bcrypt comparison (a dummy hash is used for the unknown email), so neither the response nor its timing reveals which emails exist. Emails are matched case-insensitively./loginbody is capped at 8 KiB.RequireRolematches exactly, so an unknown role is denied (tested).Bearerscheme is accepted case-insensitively, and the auth middleware's 401 responses carryWWW-Authenticate: Bearer.localStorage/sessionStorage).seed-usersfalls back to the example demo passwords only indevelopment. In any other environment bothDEMO_*_PASSWORDvariables are required, and Compose passes them through from.env.seed-userslogs email +created, and the E2E run below found 0 matching log lines.Allow-Credentials, allows onlyGET, POSTwithAuthorization, Content-Type, and sendsVary: Originon every response to a request that has anOriginheader.github.com/golang-jwt/jwt/v5(MIT),golang.org/x/crypto(BSD-3-Clause), and@testing-library/user-event(MIT, dev only).Testing evidence
make verifywith the store integration tests enabled (Postgres 16), on a tree identical to this branch's head:auth/store4 (new),transactions/store2,alerts/store5.sqlc diff,go mod tidy -diffandgit diff --check main...HEADare clean.Forbiddennot being referenced by any route, because no admin-only route exists yet.docker compose:seed-usersis idempotent (created: true, thenfalse)POST /login→ 200 with token + user;Operator@Example.comalso signs in/transactions→ 401UNAUTHORIZEDwithout a token, 200 with one;/me→ 200, also with a lowercasebearerschemeWWW-Authenticate: BearerINVALID_CREDENTIALS{}→ 400INVALID_BODYVary: Originand no CORS headersAPP_ENV=stagingand the example secret, startup fails with a config error that does not print the secret/dashboardsigned out lands on/loginwith nothing in web storageKnown limitations
RequireRoleis implemented and tested but not mounted on any route, because no admin-only endpoint exists yet. The contract'sForbiddenresponse is unused for the same reason./meand the middleware trust the signed claims without a database lookup, so a deleted user or a changed role stays in effect until the token expires (15 minutes by default). RotatingJWT_SIGNING_SECRETand restarting invalidates every token./login. Because each attempt runs bcrypt, the endpoint is both a brute-force target and a CPU cost.docs/threat-model.mdnow calls this out.migrate ... upto get0004_users.Follow-up issues
POST /loginRequireRoleonce an admin-only endpoint existsRequireAuthis not configured/loginChecklist
main; this PR targetsmain.make verifypasses locally.git diff --checkis clean.