Skip to content

feat: JWT authentication and RBAC (#6) - #47

Merged
vianbas merged 29 commits into
mainfrom
feat/6-jwt-auth-rbac
Sep 11, 2026
Merged

vianbas merged 29 commits into
mainfrom
feat/6-jwt-auth-rbac

Conversation

@vianbas

@vianbas vianbas commented Sep 11, 2026

Copy link
Copy Markdown
Owner

Summary

Adds short-lived JWT access tokens and two-role RBAC (operator, admin) across the API and the web app. The /login placeholder now signs in against POST /login, and every API route except /health/* and /login requires a bearer token.

Closes #6

Architecture summary

  • Contract first. contracts/openapi.yaml gains a bearerAuth scheme (global default), POST /login, GET /me, reusable Unauthorized/Forbidden responses, and a 401 on every protected operation. /health/* and /login opt out with security: [].
  • internal/auth (new module, same shape as internal/alerts): claims + two roles, bcrypt hashing, HS256 issuer/verifier, the login service behind a UserStore port, and RequireAuth / RequireRole middleware. auth/store is a thin pgx/sqlc adapter; auth/httpapi serves /login and /me.
  • Data. Migration 0004_users (UUID id, unique email, bcrypt hash, role CHECK (role IN ('operator','admin'))) and sqlc queries GetUserByEmail, InsertUserIfAbsent.
  • Router. RouterDeps gains PublicModules, RequireAuth (a plain func(http.Handler) http.Handler, so httpserver still has no dependency on feature packages) and CORSAllowedOrigins. Protected modules mount inside a chi group behind RequireAuth.
  • CORS. A small allow-list middleware in the top-level chain answers preflights before routing, so the browser can call /login and send Authorization. It reads the existing CORS_ALLOWED_ORIGINS, which was loaded but unused until now.
  • Config. JWT_SIGNING_SECRET (required, at least 32 characters) and JWT_ACCESS_TOKEN_TTL (default 15m), validated at startup.
  • CLI. api seed-users creates operator@example.com / admin@example.com idempotently.
  • Web. AuthProvider keeps the token and user in React state only. ProtectedRoute gates the app shell, useApiFetch attaches the bearer token and signs out on a 401, and LoginPage is wired to POST /login.

Changes from the committed plan

The plan in docs/superpowers/plans/2026-06-19-jwt-auth-rbac.md was followed, with these corrections:

  • golang-jwt/jwt/v5 v5.3.1 and x/crypto v0.57.0 instead of the pinned v5.2.1 / v0.31.0, which carry High advisories and would fail dependency review.
  • The contract change landed first, per CLAUDE.md.
  • The CORS middleware was added. Without it the browser login cannot reach the API.
  • Added the unknown-email timing equalisation, the login body cap, and the stricter token validation (exp/iat) described below.
  • Added an auth/store integration test in the same style as the existing store tests.
  • Fixed two frontend tests in the plan that could not pass as written. useApiFetch builds headers with new Headers() so caller headers survive.
  • Documented auth in docs/operations/local-development.md and updated the stale "auth planned" notes.
  • Fixes from a final whole-branch review:
    • the example signing secret is rejected outside development
    • demo passwords are development-only and pass through Compose
    • emails and the Bearer scheme are case-insensitive, and protected-route 401s carry WWW-Authenticate
    • CORS always sends Vary: Origin
    • new algorithm-confusion and unknown-role tests
    • the setup docs show how to get and send a token

Security considerations

  • All data is synthetic. The only credentials in the repo are the labelled example dev values in .env.example / docker-compose.yml.
  • The verifier accepts HS256 only (WithValidMethods), so alg: none and algorithm-confusion tokens are rejected, and tests cover both. exp is required and a token without iat is rejected.
  • The signing secret comes from the environment, has to be at least 32 characters, and startup fails without it. The published example value is also rejected at startup when APP_ENV is not development.
  • Passwords are stored as bcrypt DefaultCost hashes. An unknown email and a wrong password return the same 401 INVALID_CREDENTIALS and 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.
  • The /login body is capped at 8 KiB.
  • The role is read only from verified token claims, and RequireRole matches exactly, so an unknown role is denied (tested).
  • The Bearer scheme is accepted case-insensitively, and the auth middleware's 401 responses carry WWW-Authenticate: Bearer.
  • The token lives in memory only (no localStorage / sessionStorage).
  • seed-users falls back to the example demo passwords only in development. In any other environment both DEMO_*_PASSWORD variables are required, and Compose passes them through from .env.
  • Logs never include passwords or tokens: seed-users logs email + created, and the E2E run below found 0 matching log lines.
  • CORS uses an exact-origin allow-list, sends no Allow-Credentials, allows only GET, POST with Authorization, Content-Type, and sends Vary: Origin on every response to a request that has an Origin header.
  • New dependencies: 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 verify with the store integration tests enabled (Postgres 16), on a tree identical to this branch's head:

$ FINWATCH_TEST_DATABASE_URL=postgres://finwatch:…@localhost:5433/finwatch_test?sslmode=disable make verify
ok  	github.com/vianbas/finwatch/apps/api/cmd/api
ok  	github.com/vianbas/finwatch/apps/api/internal/alerts
ok  	github.com/vianbas/finwatch/apps/api/internal/alerts/httpapi
ok  	github.com/vianbas/finwatch/apps/api/internal/alerts/store
ok  	github.com/vianbas/finwatch/apps/api/internal/auth
ok  	github.com/vianbas/finwatch/apps/api/internal/auth/httpapi
ok  	github.com/vianbas/finwatch/apps/api/internal/auth/store
ok  	github.com/vianbas/finwatch/apps/api/internal/config
ok  	github.com/vianbas/finwatch/apps/api/internal/platform/httpserver
ok  	github.com/vianbas/finwatch/apps/api/internal/rules
ok  	github.com/vianbas/finwatch/apps/api/internal/transactions
ok  	github.com/vianbas/finwatch/apps/api/internal/transactions/httpapi
ok  	github.com/vianbas/finwatch/apps/api/internal/transactions/store
 Test Files  6 passed (6)
      Tests  17 passed (17)
✓ built in 902ms
  • The store integration tests ran against Postgres: auth/store 4 (new), transactions/store 2, alerts/store 5.
  • sqlc diff, go mod tidy -diff and git diff --check main...HEAD are clean.
  • Spectral: 0 errors and 4 warnings. Three predate this branch. The fourth is Forbidden not being referenced by any route, because no admin-only route exists yet.
  • Manual E2E against docker compose:
    • migrations 0001–0004 apply cleanly
    • seed-users is idempotent (created: true, then false)
    • POST /login → 200 with token + user; Operator@Example.com also signs in
    • /transactions → 401 UNAUTHORIZED without a token, 200 with one; /me → 200, also with a lowercase bearer scheme
    • a tampered token → 401, and protected-route 401s carry WWW-Authenticate: Bearer
    • wrong password and unknown email → both 401 INVALID_CREDENTIALS
    • {} → 400 INVALID_BODY
    • a preflight from the web origin → 204 with the CORS headers; an unknown origin gets Vary: Origin and no CORS headers
    • with APP_ENV=staging and the example secret, startup fails with a config error that does not print the secret
    • in the browser, opening /dashboard signed out lands on /login with nothing in web storage

Known limitations

  • No refresh token. A session ends after 15 minutes or on page reload, and the user signs in again. There is no logout button yet; reloading the page signs you out.
  • Operator and admin currently have the same access. RequireRole is implemented and tested but not mounted on any route, because no admin-only endpoint exists yet. The contract's Forbidden response is unused for the same reason.
  • Tokens can't be revoked individually. /me and 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). Rotating JWT_SIGNING_SECRET and restarting invalidates every token.
  • There is no rate limiting or lockout on /login. Because each attempt runs bcrypt, the endpoint is both a brute-force target and a CPU cost. docs/threat-model.md now calls this out.
  • WebSocket authentication is out of scope (feat: transactional outbox relay and in-process WebSocket hub #5).
  • Migrations are still applied manually with golang-migrate. Existing local databases need migrate ... up to get 0004_users.

Follow-up issues

  • Rate limiting / lockout for POST /login
  • Mount RequireRole once an admin-only endpoint exists
  • Refresh-token / session strategy
  • A logout control in the app shell, and clearing the TanStack Query cache on logout once pages fetch data
  • Make the router fail closed when RequireAuth is not configured
  • Return to the originally requested page after sign-in, and send signed-in users away from /login
  • WebSocket auth (feat: transactional outbox relay and in-process WebSocket hub #5)

Checklist

  • Branch is not main; this PR targets main.
  • Conventional commit messages.
  • make verify passes locally.
  • git diff --check is clean.
  • No secrets, real/personal data, or proprietary references.
  • Contracts updated first where applicable (OpenAPI/AsyncAPI).
  • Docs updated to match the change.

Viko and others added 29 commits June 22, 2026 21:23
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.
@vianbas
vianbas merged commit cbdc2d6 into main Sep 11, 2026
8 checks passed
@vianbas
vianbas deleted the feat/6-jwt-auth-rbac branch September 11, 2026 09:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: authentication (short-lived JWT) and RBAC

1 participant