From 77edf141202bfcfbf427cb7d22ae742b285a11b4 Mon Sep 17 00:00:00 2001 From: Viko Date: Mon, 22 Jun 2026 21:23:52 +0700 Subject: [PATCH 01/29] docs(superpowers): add implementation plan for JWT auth and RBAC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../plans/2026-06-19-jwt-auth-rbac.md | 2725 +++++++++++++++++ 1 file changed, 2725 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-19-jwt-auth-rbac.md diff --git a/docs/superpowers/plans/2026-06-19-jwt-auth-rbac.md b/docs/superpowers/plans/2026-06-19-jwt-auth-rbac.md new file mode 100644 index 0000000..1ed50bd --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-jwt-auth-rbac.md @@ -0,0 +1,2725 @@ +# JWT Authentication & RBAC Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add short-lived JWT access-token authentication and two-role RBAC (`operator`, `admin`) to the FinWatch API and wire the frontend login flow and route guard to it. + +**Architecture:** A new `internal/auth` domain package (claims, bcrypt password hashing, HS256 issuer/verifier, login service, HTTP middleware) backed by a new `users` table and a Postgres store, mirroring the existing `internal/alerts` module shape (domain package + `store` subpackage + `httpapi` subpackage). The platform `httpserver` router gains a public/protected route split via a generic middleware hook, with zero new dependency from `httpserver` onto feature packages. The frontend gets a React context holding the access token in memory only, a route guard, and a wired `LoginPage`. + +**Tech Stack:** Go, `github.com/golang-jwt/jwt/v5`, `golang.org/x/crypto/bcrypt`, pgx/sqlc, chi. React + TypeScript, TanStack Query, React Router. + +## Global Constraints + +- Access tokens only; no refresh tokens, OAuth/SAML, or password reset (out of scope). +- Access token TTL: 15 minutes. Signing algorithm: HS256. +- JWT claims: `sub`, `email`, `role`, `iat`, `exp`. +- Roles: `operator`, `admin` only — do not introduce `supervisor`. +- Frontend stores the access token in memory only — never `localStorage` or `sessionStorage`. +- WebSocket auth is issue #5's scope, not this one. +- No changes to alert workflow behavior. +- Money in integer minor units, timestamps RFC 3339 UTC at API boundaries (existing project rule). +- Errors use the canonical envelope `{"error":{"code","message"}}` matching `contracts/openapi.yaml`. +- All synthetic/example data only; demo passwords are clearly-labelled example dev credentials in `.env.example`, never logged in plaintext. +- `make verify` (gofmt, vet, go test, go build, eslint, tsc, vitest, vite build) must pass before considering any task done. + +--- + +## File Structure + +**Backend — new files:** + +``` +apps/api/migrations/0004_users.up.sql +apps/api/migrations/0004_users.down.sql +apps/api/queries/users.sql +apps/api/internal/auth/claims.go # Role, Claims +apps/api/internal/auth/password.go # bcrypt hash/verify +apps/api/internal/auth/password_test.go +apps/api/internal/auth/jwt.go # Issuer, Verifier (HS256) +apps/api/internal/auth/jwt_test.go +apps/api/internal/auth/service.go # UserStore port, Service.Login +apps/api/internal/auth/service_test.go +apps/api/internal/auth/middleware.go # RequireAuth, RequireRole, ClaimsFromContext +apps/api/internal/auth/middleware_test.go +apps/api/internal/auth/store/store.go # Postgres UserStore + seeding helper +apps/api/internal/auth/httpapi/handler.go # POST /login, GET /me +apps/api/internal/auth/httpapi/handler_test.go +``` + +**Backend — modified files:** + +``` +apps/api/go.mod / go.sum # add golang-jwt/jwt/v5, x/crypto +apps/api/sqlc.yaml # unchanged (queries dir already wired) +apps/api/internal/config/config.go # JWTSigningSecret, JWTAccessTokenTTL +apps/api/internal/config/config_test.go # new cases +apps/api/internal/platform/httpserver/router.go # PublicModules, RequireAuth hook, RegistrarFunc +apps/api/internal/platform/httpserver/router_auth_test.go # new: public/protected split behavior +apps/api/cmd/api/main.go # wire auth, seed-users subcommand +apps/api/internal/platform/postgres/db/models.go # sqlc-generated: User +apps/api/internal/platform/postgres/db/queries.sql.go # sqlc-generated: GetUserByEmail, InsertUserIfAbsent +.env.example # JWT_SIGNING_SECRET, JWT_ACCESS_TOKEN_TTL, demo passwords +docker-compose.yml # pass through new env vars to api service +contracts/openapi.yaml # bearerAuth scheme, /login, /me, 401/403 responses +``` + +**Frontend — new files:** + +``` +apps/web/src/app/AuthContext.tsx +apps/web/src/app/AuthContext.test.tsx +apps/web/src/app/ProtectedRoute.tsx +apps/web/src/app/ProtectedRoute.test.tsx +apps/web/src/app/useApiFetch.ts +apps/web/src/app/useApiFetch.test.tsx +``` + +**Frontend — modified files:** + +``` +apps/web/src/app/App.tsx # wrap with AuthProvider +apps/web/src/app/routes.tsx # wrap authenticated routes with ProtectedRoute +apps/web/src/app/routes.test.tsx # update for guard behavior +apps/web/src/pages/LoginPage.tsx # wire to useAuth().login +apps/web/src/pages/LoginPage.test.tsx # new +``` + +--- + +## Task 1: Add JWT and bcrypt dependencies + +**Files:** +- Modify: `apps/api/go.mod`, `apps/api/go.sum` + +**Interfaces:** +- Produces: `github.com/golang-jwt/jwt/v5` and `golang.org/x/crypto/bcrypt` importable in `internal/auth`. + +- [ ] **Step 1: Add the dependencies** + +Run: +```bash +cd apps/api && go get github.com/golang-jwt/jwt/v5@v5.2.1 && go get golang.org/x/crypto@v0.31.0 +``` + +- [ ] **Step 2: Verify the module builds** + +Run: `cd apps/api && go build ./...` +Expected: exits 0 (no source uses the new packages yet, so this only validates `go.mod`/`go.sum`). + +- [ ] **Step 3: Commit** + +```bash +cd apps/api && git add go.mod go.sum +git commit -m "build(api): add golang-jwt/jwt and x/crypto dependencies" +``` + +--- + +## Task 2: Users migration and sqlc query file + +**Files:** +- Create: `apps/api/migrations/0004_users.up.sql` +- Create: `apps/api/migrations/0004_users.down.sql` +- Create: `apps/api/queries/users.sql` + +**Interfaces:** +- Produces: `users` table (`id`, `email`, `password_hash`, `role`, `created_at`); sqlc queries `GetUserByEmail`, `InsertUserIfAbsent` consumed by Task 5 (`internal/auth/store`). + +- [ ] **Step 1: Write the up migration** + +Create `apps/api/migrations/0004_users.up.sql`: +```sql +-- 0004_users: user accounts for JWT authentication and RBAC. +-- +-- Conventions match earlier migrations: UUID ids via gen_random_uuid, +-- TIMESTAMPTZ in UTC, small constrained value sets via CHECK. Roles are +-- intentionally limited to operator and admin for this issue. + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT users_role_valid CHECK (role IN ('operator', 'admin')) +); +``` + +- [ ] **Step 2: Write the down migration** + +Create `apps/api/migrations/0004_users.down.sql`: +```sql +DROP TABLE users; +``` + +- [ ] **Step 3: Write the sqlc query file** + +Create `apps/api/queries/users.sql`: +```sql +-- name: GetUserByEmail :one +SELECT id, email, password_hash, role, created_at +FROM users +WHERE email = $1; + +-- name: InsertUserIfAbsent :one +-- Idempotent seeding: returns no row if the email already exists, so the +-- caller (the seed-users command) can skip logging a duplicate creation. +INSERT INTO users (email, password_hash, role) +VALUES ($1, $2, $3) +ON CONFLICT (email) DO NOTHING +RETURNING id, email, password_hash, role, created_at; +``` + +- [ ] **Step 4: Generate sqlc code** + +sqlc is not preinstalled; install the same version pinned in CI (`.github/workflows/ci.yml`): +```bash +go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1 +cd apps/api && sqlc generate +``` +Expected: `internal/platform/postgres/db/models.go` gains a `User` struct; `internal/platform/postgres/db/queries.sql.go` gains `GetUserByEmail` and `InsertUserIfAbsent` methods on `*Queries`. + +- [ ] **Step 5: Verify generated code matches sources and the module builds** + +Run: +```bash +cd apps/api && sqlc diff && go build ./... +``` +Expected: `sqlc diff` prints nothing (clean) and the build succeeds. + +- [ ] **Step 6: Commit** + +```bash +git add apps/api/migrations/0004_users.up.sql apps/api/migrations/0004_users.down.sql \ + apps/api/queries/users.sql apps/api/internal/platform/postgres/db/models.go \ + apps/api/internal/platform/postgres/db/queries.sql.go +git commit -m "feat(api): add users table and sqlc queries for auth" +``` + +--- + +## Task 3: Config — JWT signing secret and access token TTL + +**Files:** +- Modify: `apps/api/internal/config/config.go` +- Modify: `apps/api/internal/config/config_test.go` + +**Interfaces:** +- Consumes: existing `Load(getenv func(string) string) (*Config, error)` pattern, `firstNonEmpty`, `durationEnv` helpers already in `config.go`. +- Produces: `Config.JWTSigningSecret string`, `Config.JWTAccessTokenTTL time.Duration`, consumed by Task 8 (`cmd/api/main.go` wiring). + +- [ ] **Step 1: Write the failing tests** + +Add to `apps/api/internal/config/config_test.go`, replacing `validEnv()` and adding cases: +```go +func validEnv() map[string]string { + return map[string]string{ + "APP_ENV": "development", + "LOG_LEVEL": "info", + "DATABASE_URL": "postgres://user:pass@localhost:5432/finwatch?sslmode=disable", + "JWT_SIGNING_SECRET": "test-signing-secret-must-be-at-least-32-bytes", + } +} +``` +Add a new test function: +```go +func TestLoad_JWTDefaultsAndOverrides(t *testing.T) { + cfg, err := Load(envFunc(validEnv())) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.JWTAccessTokenTTL != 15*time.Minute { + t.Errorf("JWTAccessTokenTTL = %v, want 15m", cfg.JWTAccessTokenTTL) + } + + env := validEnv() + env["JWT_ACCESS_TOKEN_TTL"] = "5m" + cfg, err = Load(envFunc(env)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.JWTAccessTokenTTL != 5*time.Minute { + t.Errorf("JWTAccessTokenTTL = %v, want 5m", cfg.JWTAccessTokenTTL) + } +} +``` +Extend the `TestLoad_ValidationErrors` table in the same file with: +```go + {"missing jwt signing secret", func(m map[string]string) { delete(m, "JWT_SIGNING_SECRET") }}, + {"short jwt signing secret", func(m map[string]string) { m["JWT_SIGNING_SECRET"] = "too-short" }}, + {"non-duration jwt ttl", func(m map[string]string) { m["JWT_ACCESS_TOKEN_TTL"] = "soon" }}, + {"zero jwt ttl", func(m map[string]string) { m["JWT_ACCESS_TOKEN_TTL"] = "0s" }}, +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd apps/api && go test ./internal/config/... -run TestLoad -v` +Expected: compile error (`Config.JWTSigningSecret` undefined) or failing assertions. + +- [ ] **Step 3: Implement the config fields** + +In `apps/api/internal/config/config.go`, add to the `Config` struct (after `CORSAllowedOrigins`): +```go + // JWTSigningSecret is the HS256 key used to sign and verify access tokens. + JWTSigningSecret string + // JWTAccessTokenTTL bounds how long an issued access token remains valid. + JWTAccessTokenTTL time.Duration +``` +In `Load`, after the `CORSAllowedOrigins` line, add: +```go + cfg.JWTSigningSecret = getenv("JWT_SIGNING_SECRET") + if cfg.JWTAccessTokenTTL, err = durationEnv(getenv, "JWT_ACCESS_TOKEN_TTL", 15*time.Minute); err != nil { + return nil, err + } +``` +In `Validate`, after the `DatabaseURL` prefix check, add: +```go + if len(c.JWTSigningSecret) < 32 { + return fmt.Errorf("config: JWT_SIGNING_SECRET must be at least 32 characters") + } +``` +Add `"JWT_ACCESS_TOKEN_TTL": c.JWTAccessTokenTTL,` to the existing positive-duration validation map. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd apps/api && go test ./internal/config/... -v` +Expected: PASS, all subtests green. + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/internal/config/config.go apps/api/internal/config/config_test.go +git commit -m "feat(api): add JWT signing secret and access token TTL to config" +``` + +--- + +## Task 4: Auth domain — claims, password hashing, JWT issue/verify + +**Files:** +- Create: `apps/api/internal/auth/claims.go` +- Create: `apps/api/internal/auth/password.go` +- Test: `apps/api/internal/auth/password_test.go` +- Create: `apps/api/internal/auth/jwt.go` +- Test: `apps/api/internal/auth/jwt_test.go` + +**Interfaces:** +- Produces: `auth.Role` (`RoleOperator`, `RoleAdmin`), `auth.Claims{UserID, Email, Role, IssuedAt, ExpiresAt}`, `auth.HashPassword(plain string) (string, error)`, `auth.VerifyPassword(hash, plain string) error`, `auth.NewIssuer(secret []byte, ttl time.Duration) *Issuer`, `(*Issuer).Issue(userID, email string, role Role) (string, error)`, `auth.NewVerifier(secret []byte) *Verifier`, `(*Verifier).Verify(token string) (Claims, error)`, `auth.ErrInvalidToken`, `auth.ErrExpiredToken`. Consumed by Task 5 (`Service`), Task 6 (`middleware.go`), Task 8 (`httpapi`). + +- [ ] **Step 1: Write claims.go (no test needed — plain data type)** + +Create `apps/api/internal/auth/claims.go`: +```go +// Package auth implements JWT-based authentication and role-based access +// control: password hashing, token issuance/verification, the login service, +// and HTTP middleware that enforces them. +package auth + +import "time" + +// Role is a RBAC role. Only operator and admin exist in this issue. +type Role string + +const ( + RoleOperator Role = "operator" + RoleAdmin Role = "admin" +) + +// Claims is the decoded, verified content of an access token. +type Claims struct { + UserID string + Email string + Role Role + IssuedAt time.Time + ExpiresAt time.Time +} +``` + +- [ ] **Step 2: Write the failing password test** + +Create `apps/api/internal/auth/password_test.go`: +```go +package auth + +import "testing" + +func TestHashPassword_VerifyRoundTrip(t *testing.T) { + hash, err := HashPassword("correct-password") + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + if hash == "correct-password" { + t.Fatalf("hash must not equal the plaintext") + } + if err := VerifyPassword(hash, "correct-password"); err != nil { + t.Errorf("VerifyPassword with correct password: %v", err) + } +} + +func TestVerifyPassword_WrongPassword(t *testing.T) { + hash, err := HashPassword("correct-password") + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + if err := VerifyPassword(hash, "wrong-password"); err == nil { + t.Errorf("VerifyPassword with wrong password: want error, got nil") + } +} +``` + +- [ ] **Step 3: Run to verify it fails** + +Run: `cd apps/api && go test ./internal/auth/... -run Password -v` +Expected: FAIL — `HashPassword`/`VerifyPassword` undefined. + +- [ ] **Step 4: Implement password.go** + +Create `apps/api/internal/auth/password.go`: +```go +package auth + +import "golang.org/x/crypto/bcrypt" + +// HashPassword bcrypt-hashes a plaintext password for storage. +func HashPassword(plain string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(hash), nil +} + +// VerifyPassword reports whether plain matches the given bcrypt hash. +func VerifyPassword(hash, plain string) error { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) +} +``` + +- [ ] **Step 5: Run to verify it passes** + +Run: `cd apps/api && go test ./internal/auth/... -run Password -v` +Expected: PASS. + +- [ ] **Step 6: Write the failing JWT test** + +Create `apps/api/internal/auth/jwt_test.go`: +```go +package auth + +import ( + "errors" + "testing" + "time" +) + +const testSecret = "test-signing-secret-must-be-at-least-32-bytes" + +func TestIssueAndVerify_RoundTrip(t *testing.T) { + issuer := NewIssuer([]byte(testSecret), 15*time.Minute) + verifier := NewVerifier([]byte(testSecret)) + + token, err := issuer.Issue("user-1", "operator@example.com", RoleOperator) + if err != nil { + t.Fatalf("Issue: %v", err) + } + + claims, err := verifier.Verify(token) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if claims.UserID != "user-1" { + t.Errorf("UserID = %q, want user-1", claims.UserID) + } + if claims.Email != "operator@example.com" { + t.Errorf("Email = %q, want operator@example.com", claims.Email) + } + if claims.Role != RoleOperator { + t.Errorf("Role = %q, want operator", claims.Role) + } + if !claims.ExpiresAt.After(claims.IssuedAt) { + t.Errorf("ExpiresAt %v must be after IssuedAt %v", claims.ExpiresAt, claims.IssuedAt) + } +} + +func TestVerify_ExpiredToken(t *testing.T) { + issuer := NewIssuer([]byte(testSecret), -1*time.Minute) // already expired + verifier := NewVerifier([]byte(testSecret)) + + token, err := issuer.Issue("user-1", "operator@example.com", RoleOperator) + if err != nil { + t.Fatalf("Issue: %v", err) + } + if _, err := verifier.Verify(token); !errors.Is(err, ErrExpiredToken) { + t.Fatalf("got %v, want ErrExpiredToken", err) + } +} + +func TestVerify_WrongSignature(t *testing.T) { + issuer := NewIssuer([]byte(testSecret), 15*time.Minute) + verifier := NewVerifier([]byte("a-completely-different-32-byte-secret!")) + + token, err := issuer.Issue("user-1", "operator@example.com", RoleOperator) + if err != nil { + t.Fatalf("Issue: %v", err) + } + if _, err := verifier.Verify(token); !errors.Is(err, ErrInvalidToken) { + t.Fatalf("got %v, want ErrInvalidToken", err) + } +} + +func TestVerify_MalformedToken(t *testing.T) { + verifier := NewVerifier([]byte(testSecret)) + if _, err := verifier.Verify("not-a-jwt"); !errors.Is(err, ErrInvalidToken) { + t.Fatalf("got %v, want ErrInvalidToken", err) + } +} +``` + +- [ ] **Step 7: Run to verify it fails** + +Run: `cd apps/api && go test ./internal/auth/... -run 'Issue|Verify' -v` +Expected: FAIL — `NewIssuer`/`NewVerifier` undefined. + +- [ ] **Step 8: Implement jwt.go** + +Create `apps/api/internal/auth/jwt.go`: +```go +package auth + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// ErrInvalidToken is returned for malformed tokens or signature mismatches. +var ErrInvalidToken = errors.New("auth: invalid token") + +// ErrExpiredToken is returned when the token's exp claim is in the past. +var ErrExpiredToken = errors.New("auth: token expired") + +// tokenClaims is the on-the-wire JWT claim set: sub/iat/exp via +// RegisteredClaims, plus email and role. +type tokenClaims struct { + Email string `json:"email"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +// Issuer signs short-lived HS256 access tokens. +type Issuer struct { + secret []byte + ttl time.Duration +} + +// NewIssuer constructs an Issuer. secret is the HS256 signing key; ttl is the +// access token lifetime (15 minutes per the architecture decision). +func NewIssuer(secret []byte, ttl time.Duration) *Issuer { + return &Issuer{secret: secret, ttl: ttl} +} + +// Issue signs a new access token for the given user. +func (i *Issuer) Issue(userID, email string, role Role) (string, error) { + now := time.Now().UTC() + claims := tokenClaims{ + Email: email, + Role: string(role), + RegisteredClaims: jwt.RegisteredClaims{ + Subject: userID, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(i.ttl)), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(i.secret) +} + +// Verifier validates HS256 access tokens and extracts their claims. +type Verifier struct { + secret []byte +} + +// NewVerifier constructs a Verifier using the same secret the Issuer signed with. +func NewVerifier(secret []byte) *Verifier { + return &Verifier{secret: secret} +} + +// Verify parses and validates tokenString, returning ErrExpiredToken or +// ErrInvalidToken on failure. +func (v *Verifier) Verify(tokenString string) (Claims, error) { + var claims tokenClaims + _, err := jwt.ParseWithClaims(tokenString, &claims, func(*jwt.Token) (interface{}, error) { + return v.secret, nil + }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) + if errors.Is(err, jwt.ErrTokenExpired) { + return Claims{}, ErrExpiredToken + } + if err != nil { + return Claims{}, ErrInvalidToken + } + return Claims{ + UserID: claims.Subject, + Email: claims.Email, + Role: Role(claims.Role), + IssuedAt: claims.IssuedAt.Time, + ExpiresAt: claims.ExpiresAt.Time, + }, nil +} +``` + +- [ ] **Step 9: Run to verify it passes** + +Run: `cd apps/api && go test ./internal/auth/... -v` +Expected: PASS, all of `password_test.go` and `jwt_test.go`. + +- [ ] **Step 10: Commit** + +```bash +git add apps/api/internal/auth/claims.go apps/api/internal/auth/password.go \ + apps/api/internal/auth/password_test.go apps/api/internal/auth/jwt.go \ + apps/api/internal/auth/jwt_test.go +git commit -m "feat(api): add JWT claims, bcrypt hashing, and HS256 issue/verify" +``` + +--- + +## Task 5: Login service + +**Files:** +- Create: `apps/api/internal/auth/service.go` +- Test: `apps/api/internal/auth/service_test.go` + +**Interfaces:** +- Consumes: `auth.Role`, `auth.Claims` (Task 4), `*Issuer` and its `Issue` method (Task 4). +- Produces: `auth.User{ID, Email, PasswordHash, Role}`, `auth.UserStore` interface (`GetUserByEmail(ctx, email) (User, error)`), `auth.ErrUserNotFound`, `auth.ErrInvalidCredentials`, `auth.NewService(store UserStore, issuer *Issuer) *Service`, `(*Service).Login(ctx, email, password string) (string, User, error)`. Consumed by Task 7 (`store` implements `UserStore`) and Task 8 (`httpapi` calls `Login`). + +- [ ] **Step 1: Write the failing test** + +Create `apps/api/internal/auth/service_test.go`: +```go +package auth_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/vianbas/finwatch/apps/api/internal/auth" +) + +type fakeUserStore struct { + usersByEmail map[string]auth.User +} + +func (f *fakeUserStore) GetUserByEmail(_ context.Context, email string) (auth.User, error) { + u, ok := f.usersByEmail[email] + if !ok { + return auth.User{}, auth.ErrUserNotFound + } + return u, nil +} + +func newTestService(t *testing.T, users ...auth.User) *auth.Service { + t.Helper() + byEmail := make(map[string]auth.User, len(users)) + for _, u := range users { + byEmail[u.Email] = u + } + issuer := auth.NewIssuer([]byte("test-signing-secret-must-be-at-least-32-bytes"), 15*time.Minute) + return auth.NewService(&fakeUserStore{usersByEmail: byEmail}, issuer) +} + +func userWithPassword(t *testing.T, email, password string, role auth.Role) auth.User { + t.Helper() + hash, err := auth.HashPassword(password) + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + return auth.User{ID: "user-1", Email: email, PasswordHash: hash, Role: role} +} + +func TestService_Login_ValidCredentials(t *testing.T) { + user := userWithPassword(t, "operator@example.com", "correct-password", auth.RoleOperator) + svc := newTestService(t, user) + + token, got, err := svc.Login(context.Background(), "operator@example.com", "correct-password") + if err != nil { + t.Fatalf("Login: %v", err) + } + if token == "" { + t.Errorf("want non-empty token") + } + if got.Email != user.Email || got.Role != user.Role { + t.Errorf("got user %+v, want email/role to match %+v", got, user) + } +} + +func TestService_Login_WrongPassword(t *testing.T) { + user := userWithPassword(t, "operator@example.com", "correct-password", auth.RoleOperator) + svc := newTestService(t, user) + + _, _, err := svc.Login(context.Background(), "operator@example.com", "wrong-password") + if !errors.Is(err, auth.ErrInvalidCredentials) { + t.Fatalf("got %v, want ErrInvalidCredentials", err) + } +} + +func TestService_Login_UnknownEmail(t *testing.T) { + svc := newTestService(t) + + _, _, err := svc.Login(context.Background(), "nobody@example.com", "whatever") + if !errors.Is(err, auth.ErrInvalidCredentials) { + t.Fatalf("got %v, want ErrInvalidCredentials", err) + } +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd apps/api && go test ./internal/auth/... -run TestService -v` +Expected: FAIL — `auth.User`, `auth.UserStore`, `auth.NewService` undefined. + +- [ ] **Step 3: Implement service.go** + +Create `apps/api/internal/auth/service.go`: +```go +package auth + +import ( + "context" + "errors" + "fmt" +) + +// ErrUserNotFound is returned by UserStore when no user has the given email. +var ErrUserNotFound = errors.New("auth: user not found") + +// ErrInvalidCredentials is returned by Service.Login for any failure that +// should not reveal whether the email or the password was wrong. +var ErrInvalidCredentials = errors.New("auth: invalid credentials") + +// User is a persisted account used for login. +type User struct { + ID string + Email string + PasswordHash string + Role Role +} + +// UserStore is the persistence port for user accounts. +type UserStore interface { + GetUserByEmail(ctx context.Context, email string) (User, error) +} + +// Service implements the login use case: verify credentials, issue a token. +type Service struct { + store UserStore + issuer *Issuer +} + +// NewService constructs a Service. +func NewService(store UserStore, issuer *Issuer) *Service { + return &Service{store: store, issuer: issuer} +} + +// Login verifies email/password and, on success, returns a signed access +// token and the authenticated user. Unknown email and wrong password both +// return ErrInvalidCredentials so the caller cannot distinguish them. +func (s *Service) Login(ctx context.Context, email, password string) (string, User, error) { + user, err := s.store.GetUserByEmail(ctx, email) + if errors.Is(err, ErrUserNotFound) { + return "", User{}, ErrInvalidCredentials + } + if err != nil { + return "", User{}, fmt.Errorf("auth: get user by email: %w", err) + } + + if err := VerifyPassword(user.PasswordHash, password); err != nil { + return "", User{}, ErrInvalidCredentials + } + + token, err := s.issuer.Issue(user.ID, user.Email, user.Role) + if err != nil { + return "", User{}, fmt.Errorf("auth: issue token: %w", err) + } + return token, user, nil +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd apps/api && go test ./internal/auth/... -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/internal/auth/service.go apps/api/internal/auth/service_test.go +git commit -m "feat(api): add login service with bcrypt verification" +``` + +--- + +## Task 6: Auth HTTP middleware (RequireAuth, RequireRole) + +**Files:** +- Create: `apps/api/internal/auth/middleware.go` +- Test: `apps/api/internal/auth/middleware_test.go` + +**Interfaces:** +- Consumes: `*Verifier` and `Verify` (Task 4), `Claims`, `Role` (Task 4), `web.WriteError` (`internal/platform/web`, existing). +- Produces: `auth.RequireAuth(verifier *Verifier) func(http.Handler) http.Handler`, `auth.RequireRole(role Role) func(http.Handler) http.Handler`, `auth.ClaimsFromContext(ctx) (Claims, bool)`. Consumed by Task 9 (router wiring) and Task 8 (`httpapi.me` reads claims). + +- [ ] **Step 1: Write the failing test** + +Create `apps/api/internal/auth/middleware_test.go`: +```go +package auth_test + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/vianbas/finwatch/apps/api/internal/auth" +) + +const mwTestSecret = "test-signing-secret-must-be-at-least-32-bytes" + +func okHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.ClaimsFromContext(r.Context()) + if ok { + w.Header().Set("X-Role", string(claims.Role)) + } + w.WriteHeader(http.StatusOK) + }) +} + +func issueToken(t *testing.T, ttl time.Duration, role auth.Role) string { + t.Helper() + issuer := auth.NewIssuer([]byte(mwTestSecret), ttl) + token, err := issuer.Issue("user-1", "operator@example.com", role) + if err != nil { + t.Fatalf("Issue: %v", err) + } + return token +} + +func TestRequireAuth_MissingToken(t *testing.T) { + verifier := auth.NewVerifier([]byte(mwTestSecret)) + handler := auth.RequireAuth(verifier)(okHandler()) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} + +func TestRequireAuth_MalformedToken(t *testing.T) { + verifier := auth.NewVerifier([]byte(mwTestSecret)) + handler := auth.RequireAuth(verifier)(okHandler()) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + req.Header.Set("Authorization", "Bearer not-a-jwt") + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} + +func TestRequireAuth_ExpiredToken(t *testing.T) { + verifier := auth.NewVerifier([]byte(mwTestSecret)) + handler := auth.RequireAuth(verifier)(okHandler()) + token := issueToken(t, -1*time.Minute, auth.RoleOperator) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + req.Header.Set("Authorization", "Bearer "+token) + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} + +func TestRequireAuth_ValidToken(t *testing.T) { + verifier := auth.NewVerifier([]byte(mwTestSecret)) + handler := auth.RequireAuth(verifier)(okHandler()) + token := issueToken(t, 15*time.Minute, auth.RoleOperator) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + req.Header.Set("Authorization", "Bearer "+token) + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if got := rec.Header().Get("X-Role"); got != "operator" { + t.Errorf("X-Role = %q, want operator", got) + } +} + +func TestRequireRole_OperatorDeniedFromAdminRoute(t *testing.T) { + verifier := auth.NewVerifier([]byte(mwTestSecret)) + handler := auth.RequireAuth(verifier)(auth.RequireRole(auth.RoleAdmin)(okHandler())) + token := issueToken(t, 15*time.Minute, auth.RoleOperator) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/admin-only", nil) + req.Header.Set("Authorization", "Bearer "+token) + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } +} + +func TestRequireRole_AdminAllowed(t *testing.T) { + verifier := auth.NewVerifier([]byte(mwTestSecret)) + handler := auth.RequireAuth(verifier)(auth.RequireRole(auth.RoleAdmin)(okHandler())) + token := issueToken(t, 15*time.Minute, auth.RoleAdmin) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/admin-only", nil) + req.Header.Set("Authorization", "Bearer "+token) + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd apps/api && go test ./internal/auth/... -run 'TestRequireAuth|TestRequireRole' -v` +Expected: FAIL — `auth.RequireAuth`, `auth.RequireRole`, `auth.ClaimsFromContext` undefined. + +- [ ] **Step 3: Implement middleware.go** + +Create `apps/api/internal/auth/middleware.go`: +```go +package auth + +import ( + "context" + "net/http" + "strings" + + "github.com/vianbas/finwatch/apps/api/internal/platform/web" +) + +type contextKey string + +const claimsContextKey contextKey = "auth_claims" + +// RequireAuth rejects requests without a valid, unexpired bearer token with +// 401, and otherwise stores the decoded Claims on the request context. +func RequireAuth(verifier *Verifier) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token, ok := bearerToken(r.Header.Get("Authorization")) + if !ok { + web.WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "missing bearer token") + return + } + claims, err := verifier.Verify(token) + if err != nil { + web.WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid or expired token") + return + } + ctx := context.WithValue(r.Context(), claimsContextKey, claims) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// RequireRole rejects requests whose authenticated Claims do not carry the +// given role with 403. It must run behind RequireAuth. +func RequireRole(role Role) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, ok := ClaimsFromContext(r.Context()) + if !ok || claims.Role != role { + web.WriteError(w, http.StatusForbidden, "FORBIDDEN", "insufficient role") + return + } + next.ServeHTTP(w, r) + }) + } +} + +// ClaimsFromContext returns the Claims stored by RequireAuth, if any. +func ClaimsFromContext(ctx context.Context) (Claims, bool) { + claims, ok := ctx.Value(claimsContextKey).(Claims) + return claims, ok +} + +func bearerToken(header string) (string, bool) { + const prefix = "Bearer " + if !strings.HasPrefix(header, prefix) { + return "", false + } + token := strings.TrimSpace(strings.TrimPrefix(header, prefix)) + if token == "" { + return "", false + } + return token, true +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd apps/api && go test ./internal/auth/... -v` +Expected: PASS, all tests in the package. + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/internal/auth/middleware.go apps/api/internal/auth/middleware_test.go +git commit -m "feat(api): add RequireAuth and RequireRole HTTP middleware" +``` + +--- + +## Task 7: Postgres user store + +**Files:** +- Create: `apps/api/internal/auth/store/store.go` + +**Interfaces:** +- Consumes: `db.New(pool).GetUserByEmail` / `InsertUserIfAbsent` (sqlc-generated, Task 2), `pgconv.UUIDString` (existing `internal/platform/postgres/pgconv`), `auth.User`, `auth.Role`, `auth.ErrUserNotFound` (Task 5). +- Produces: `store.New(pool *pgxpool.Pool) *Store` implementing `auth.UserStore`; `(*Store).InsertUserIfAbsent(ctx, email, passwordHash string, role auth.Role) (auth.User, bool, error)` consumed by Task 10 (`seed-users` command). + +This task has no unit test of its own — it is a thin Postgres adapter exercised indirectly through `make verify`'s build/vet step and, if a database is available locally, manually via the `seed-users` command added in Task 10. This matches the existing `internal/alerts/store` package, which is also untested in isolation (its behavior is covered through the domain `Service` tests with a fake `Repository`). + +- [ ] **Step 1: Implement store.go** + +Create `apps/api/internal/auth/store/store.go`: +```go +// Package store is the PostgreSQL implementation of auth.UserStore. +package store + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/vianbas/finwatch/apps/api/internal/auth" + "github.com/vianbas/finwatch/apps/api/internal/platform/postgres/db" + "github.com/vianbas/finwatch/apps/api/internal/platform/postgres/pgconv" +) + +// Store persists users in PostgreSQL. +type Store struct { + pool *pgxpool.Pool +} + +// New constructs a Store. +func New(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +// GetUserByEmail implements auth.UserStore. +func (s *Store) GetUserByEmail(ctx context.Context, email string) (auth.User, error) { + row, err := db.New(s.pool).GetUserByEmail(ctx, email) + if errors.Is(err, pgx.ErrNoRows) { + return auth.User{}, auth.ErrUserNotFound + } + if err != nil { + return auth.User{}, fmt.Errorf("store: get user by email: %w", err) + } + return toDomain(row), nil +} + +// InsertUserIfAbsent creates a user with an already-hashed password. created +// is false (with a nil error) if the email already exists, so the +// seed-users command can skip logging a duplicate. +func (s *Store) InsertUserIfAbsent(ctx context.Context, email, passwordHash string, role auth.Role) (auth.User, bool, error) { + row, err := db.New(s.pool).InsertUserIfAbsent(ctx, db.InsertUserIfAbsentParams{ + Email: email, + PasswordHash: passwordHash, + Role: string(role), + }) + if errors.Is(err, pgx.ErrNoRows) { + return auth.User{}, false, nil + } + if err != nil { + return auth.User{}, false, fmt.Errorf("store: insert user: %w", err) + } + return toDomain(row), true, nil +} + +func toDomain(r db.User) auth.User { + return auth.User{ + ID: pgconv.UUIDString(r.ID), + Email: r.Email, + PasswordHash: r.PasswordHash, + Role: auth.Role(r.Role), + } +} +``` + +- [ ] **Step 2: Verify it builds and vets cleanly** + +Run: `cd apps/api && go build ./... && go vet ./...` +Expected: exits 0. (Field names `db.InsertUserIfAbsentParams{Email, PasswordHash, Role}` must match what sqlc generated in Task 2 — if `go build` reports a field mismatch, open `internal/platform/postgres/db/queries.sql.go` and adjust this file's field names to match exactly.) + +- [ ] **Step 3: Commit** + +```bash +git add apps/api/internal/auth/store/store.go +git commit -m "feat(api): add Postgres-backed user store" +``` + +--- + +## Task 8: Auth HTTP handlers (POST /login, GET /me) + +**Files:** +- Create: `apps/api/internal/auth/httpapi/handler.go` +- Test: `apps/api/internal/auth/httpapi/handler_test.go` + +**Interfaces:** +- Consumes: `auth.NewService`, `*auth.Service.Login` (Task 5), `auth.ClaimsFromContext` (Task 6), `auth.ErrInvalidCredentials` (Task 5), `web.WriteJSON`/`web.WriteError` (existing), `chi.Router`. +- Produces: `httpapi.NewHandler(svc *auth.Service, log *slog.Logger) *Handler`; `(*Handler).RegisterPublicRoutes(r chi.Router)` mounting `POST /login`; `(*Handler).RegisterProtectedRoutes(r chi.Router)` mounting `GET /me`. Consumed by Task 10 (`cmd/api/main.go` wiring) via Task 9's `RegistrarFunc` adapter. + +- [ ] **Step 1: Write the failing test** + +Create `apps/api/internal/auth/httpapi/handler_test.go`: +```go +package httpapi_test + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/vianbas/finwatch/apps/api/internal/auth" + "github.com/vianbas/finwatch/apps/api/internal/auth/httpapi" +) + +const handlerTestSecret = "test-signing-secret-must-be-at-least-32-bytes" + +type fakeUserStore struct { + usersByEmail map[string]auth.User +} + +func (f *fakeUserStore) GetUserByEmail(_ context.Context, email string) (auth.User, error) { + u, ok := f.usersByEmail[email] + if !ok { + return auth.User{}, auth.ErrUserNotFound + } + return u, nil +} + +func newRouter(t *testing.T, users ...auth.User) http.Handler { + t.Helper() + byEmail := make(map[string]auth.User, len(users)) + for _, u := range users { + byEmail[u.Email] = u + } + issuer := auth.NewIssuer([]byte(handlerTestSecret), 15*time.Minute) + verifier := auth.NewVerifier([]byte(handlerTestSecret)) + svc := auth.NewService(&fakeUserStore{usersByEmail: byEmail}, issuer) + h := httpapi.NewHandler(svc, slog.Default()) + + r := chi.NewRouter() + h.RegisterPublicRoutes(r) + r.Group(func(pr chi.Router) { + pr.Use(auth.RequireAuth(verifier)) + h.RegisterProtectedRoutes(pr) + }) + return r +} + +func userWithPassword(t *testing.T, email, password string, role auth.Role) auth.User { + t.Helper() + hash, err := auth.HashPassword(password) + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + return auth.User{ID: "user-1", Email: email, PasswordHash: hash, Role: role} +} + +func decode(t *testing.T, body []byte) map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal(body, &m); err != nil { + t.Fatalf("decode body: %v\nbody: %s", err, body) + } + return m +} + +func TestLogin_ValidCredentials(t *testing.T) { + user := userWithPassword(t, "operator@example.com", "correct-password", auth.RoleOperator) + router := newRouter(t, user) + + body, _ := json.Marshal(map[string]string{"email": "operator@example.com", "password": "correct-password"}) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewReader(body)) + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body = %s", rec.Code, rec.Body.String()) + } + resp := decode(t, rec.Body.Bytes()) + if resp["accessToken"] == "" || resp["accessToken"] == nil { + t.Errorf("want non-empty accessToken, got %v", resp["accessToken"]) + } + user_, ok := resp["user"].(map[string]any) + if !ok || user_["email"] != "operator@example.com" || user_["role"] != "operator" { + t.Errorf("user field = %v, want email/role operator@example.com/operator", resp["user"]) + } +} + +func TestLogin_WrongPassword(t *testing.T) { + user := userWithPassword(t, "operator@example.com", "correct-password", auth.RoleOperator) + router := newRouter(t, user) + + body, _ := json.Marshal(map[string]string{"email": "operator@example.com", "password": "wrong-password"}) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewReader(body)) + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} + +func TestLogin_MissingFields(t *testing.T) { + router := newRouter(t) + + body, _ := json.Marshal(map[string]string{"email": "", "password": ""}) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewReader(body)) + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } +} + +func TestMe_ValidToken_ReturnsClaims(t *testing.T) { + user := userWithPassword(t, "operator@example.com", "correct-password", auth.RoleOperator) + router := newRouter(t, user) + + loginBody, _ := json.Marshal(map[string]string{"email": "operator@example.com", "password": "correct-password"}) + loginRec := httptest.NewRecorder() + router.ServeHTTP(loginRec, httptest.NewRequest(http.MethodPost, "/login", bytes.NewReader(loginBody))) + token := decode(t, loginRec.Body.Bytes())["accessToken"].(string) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/me", nil) + req.Header.Set("Authorization", "Bearer "+token) + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body = %s", rec.Code, rec.Body.String()) + } + resp := decode(t, rec.Body.Bytes()) + if resp["email"] != "operator@example.com" || resp["role"] != "operator" { + t.Errorf("got %v, want email/role operator@example.com/operator", resp) + } +} + +func TestMe_NoToken_Returns401(t *testing.T) { + router := newRouter(t) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/me", nil) + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd apps/api && go test ./internal/auth/httpapi/... -v` +Expected: FAIL — package `httpapi` and `NewHandler` undefined. + +- [ ] **Step 3: Implement handler.go** + +Create `apps/api/internal/auth/httpapi/handler.go`: +```go +// Package httpapi exposes the auth module over HTTP: login and the current +// user. It validates input at the boundary and holds no business logic. +package httpapi + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "github.com/vianbas/finwatch/apps/api/internal/auth" + "github.com/vianbas/finwatch/apps/api/internal/platform/web" +) + +// Handler serves the auth HTTP endpoints. +type Handler struct { + svc *auth.Service + log *slog.Logger +} + +// NewHandler constructs a Handler. +func NewHandler(svc *auth.Service, log *slog.Logger) *Handler { + return &Handler{svc: svc, log: log} +} + +// RegisterPublicRoutes mounts routes that do not require authentication. +func (h *Handler) RegisterPublicRoutes(r chi.Router) { + r.Post("/login", h.login) +} + +// RegisterProtectedRoutes mounts routes that require a valid bearer token. +// The caller is responsible for applying auth.RequireAuth to this router. +func (h *Handler) RegisterProtectedRoutes(r chi.Router) { + r.Get("/me", h.me) +} + +type loginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type userDTO struct { + ID string `json:"id"` + Email string `json:"email"` + Role string `json:"role"` +} + +type loginResponse struct { + AccessToken string `json:"accessToken"` + User userDTO `json:"user"` +} + +func (h *Handler) login(w http.ResponseWriter, r *http.Request) { + var req loginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + web.WriteError(w, http.StatusBadRequest, "INVALID_BODY", "request body must be valid JSON") + return + } + req.Email = strings.TrimSpace(req.Email) + if req.Email == "" || req.Password == "" { + web.WriteError(w, http.StatusBadRequest, "INVALID_BODY", "email and password are required") + return + } + + token, user, err := h.svc.Login(r.Context(), req.Email, req.Password) + if err != nil { + if errors.Is(err, auth.ErrInvalidCredentials) { + web.WriteError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "email or password is incorrect") + return + } + h.log.ErrorContext(r.Context(), "login failed", slog.String("error", err.Error())) + web.WriteError(w, http.StatusInternalServerError, "INTERNAL", "unexpected error") + return + } + web.WriteJSON(w, http.StatusOK, loginResponse{AccessToken: token, User: toUserDTO(user)}) +} + +func (h *Handler) me(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.ClaimsFromContext(r.Context()) + if !ok { + web.WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "missing bearer token") + return + } + web.WriteJSON(w, http.StatusOK, userDTO{ID: claims.UserID, Email: claims.Email, Role: string(claims.Role)}) +} + +func toUserDTO(u auth.User) userDTO { + return userDTO{ID: u.ID, Email: u.Email, Role: string(u.Role)} +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd apps/api && go test ./internal/auth/httpapi/... -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/internal/auth/httpapi/handler.go apps/api/internal/auth/httpapi/handler_test.go +git commit -m "feat(api): add POST /login and GET /me handlers" +``` + +--- + +## Task 9: Router — public/protected route split + +**Files:** +- Modify: `apps/api/internal/platform/httpserver/router.go` +- Create: `apps/api/internal/platform/httpserver/router_auth_test.go` + +**Interfaces:** +- Consumes: nothing new from feature packages — `RouterDeps.RequireAuth` is a plain `func(http.Handler) http.Handler`, keeping `httpserver` free of any dependency on `internal/auth`. +- Produces: `RouterDeps.PublicModules []RouteRegistrar`, `RouterDeps.RequireAuth func(http.Handler) http.Handler`, `httpserver.RegistrarFunc` (adapter type). Consumed by Task 10 (`cmd/api/main.go` passes `auth.RequireAuth(verifier)` and wraps handler methods in `RegistrarFunc`). + +This task changes router behavior without breaking the existing `TestRouter_NotFound` test in `health_test.go` (`RouterDeps{Logger: testLogger(), Health: h}` still compiles — the new fields default to nil/empty and are no-ops). + +- [ ] **Step 1: Write the failing test** + +Create `apps/api/internal/platform/httpserver/router_auth_test.go`: +```go +package httpserver + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" +) + +func registrar(register func(chi.Router)) RouteRegistrar { + return RegistrarFunc(register) +} + +func TestRouter_PublicModuleRunsWithoutAuthMiddleware(t *testing.T) { + h := NewHealthHandler(fakePinger{}) + router := NewRouter(RouterDeps{ + Logger: testLogger(), + Health: h, + PublicModules: []RouteRegistrar{ + registrar(func(r chi.Router) { + r.Get("/public", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) + }), + }, + RequireAuth: func(http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) // would reject everything if applied + }) + }, + }) + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/public", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (public route must bypass RequireAuth)", rec.Code) + } +} + +func TestRouter_ProtectedModuleRunsBehindAuthMiddleware(t *testing.T) { + h := NewHealthHandler(fakePinger{}) + router := NewRouter(RouterDeps{ + Logger: testLogger(), + Health: h, + Modules: []RouteRegistrar{ + registrar(func(r chi.Router) { + r.Get("/protected", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) + }), + }, + RequireAuth: func(http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }) + }, + }) + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/protected", nil)) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401 (protected route must run behind RequireAuth)", rec.Code) + } +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd apps/api && go test ./internal/platform/httpserver/... -run TestRouter -v` +Expected: FAIL — `RouterDeps.PublicModules`, `RouterDeps.RequireAuth`, `RegistrarFunc` undefined. + +- [ ] **Step 3: Implement the router changes** + +In `apps/api/internal/platform/httpserver/router.go`, add after the `RouteRegistrar` interface: +```go +// RegistrarFunc adapts a plain function to RouteRegistrar, the same pattern +// as http.HandlerFunc. +type RegistrarFunc func(r chi.Router) + +// RegisterRoutes implements RouteRegistrar. +func (f RegistrarFunc) RegisterRoutes(r chi.Router) { f(r) } +``` +Replace the `RouterDeps` struct with: +```go +// RouterDeps are the dependencies required to build the HTTP router. +type RouterDeps struct { + Logger *slog.Logger + Health *HealthHandler + // PublicModules mount routes that do not require authentication (e.g. login). + PublicModules []RouteRegistrar + // Modules mount routes that require a valid bearer token when RequireAuth + // is set. + Modules []RouteRegistrar + // RequireAuth, if non-nil, wraps Modules' routes. It is a plain middleware + // function so this package has no dependency on the auth feature package. + RequireAuth func(http.Handler) http.Handler +} +``` +Replace the route-mounting body of `NewRouter` (the two comment blocks plus the `for _, m := range deps.Modules` loop) with: +```go + // Operational endpoints. These are intentionally unauthenticated. + r.Get("/health/live", deps.Health.Live) + r.Get("/health/ready", deps.Health.Ready) + + // Public feature routes (e.g. login) mount without auth middleware. + for _, m := range deps.PublicModules { + if m != nil { + m.RegisterRoutes(r) + } + } + + // Protected feature routes run behind RequireAuth when configured. + r.Group(func(pr chi.Router) { + if deps.RequireAuth != nil { + pr.Use(deps.RequireAuth) + } + for _, m := range deps.Modules { + if m != nil { + m.RegisterRoutes(pr) + } + } + }) +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd apps/api && go test ./internal/platform/httpserver/... -v` +Expected: PASS, including the pre-existing `TestRouter_NotFound` and all `TestHealth_*` tests (no regression). + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/internal/platform/httpserver/router.go apps/api/internal/platform/httpserver/router_auth_test.go +git commit -m "feat(api): split router into public and auth-protected route groups" +``` + +--- + +## Task 10: Wire auth into cmd/api, add seed-users command + +**Files:** +- Modify: `apps/api/cmd/api/main.go` + +**Interfaces:** +- Consumes: `config.Config.JWTSigningSecret`/`JWTAccessTokenTTL` (Task 3), `auth.NewIssuer`/`NewVerifier`/`NewService`/`RequireAuth`/`HashPassword`/`RoleOperator`/`RoleAdmin` (Tasks 4-6), `authstore.New`/`InsertUserIfAbsent` (Task 7), `authhttp.NewHandler` (Task 8), `httpserver.RegistrarFunc` (Task 9). +- Produces: the wired `/login`, `/me` routes and protected `/transactions`, `/alerts*` routes in the running server; a `seed-users` CLI subcommand. + +- [ ] **Step 1: Add imports** + +In `apps/api/cmd/api/main.go`, add to the import block: +```go + "github.com/vianbas/finwatch/apps/api/internal/auth" + authstore "github.com/vianbas/finwatch/apps/api/internal/auth/store" + authhttp "github.com/vianbas/finwatch/apps/api/internal/auth/httpapi" +``` + +- [ ] **Step 2: Add the seed-users subcommand dispatch** + +In `main()`, after the existing `seed` subcommand block, add: +```go + // `api seed-users` creates the demo operator/admin accounts and exits. + if len(os.Args) > 1 && os.Args[1] == "seed-users" { + if err := runSeedUsers(); err != nil { + os.Exit(1) + } + return + } +``` + +- [ ] **Step 3: Implement runSeedUsers** + +Add a new function near `runSeed`: +```go +// runSeedUsers creates the demo operator and admin accounts used for local +// development and manual testing. It is idempotent: existing emails are left +// untouched. Passwords are never logged. +func runSeedUsers() error { + cfg, err := config.Load(os.Getenv) + logger := newLogger(cfg, err) + if err != nil { + logger.Error("invalid configuration", slog.String("error", err.Error())) + return err + } + + ctx := context.Background() + pool, err := postgres.NewPool(ctx, cfg.DatabaseURL) + if err != nil { + logger.Error("failed to initialise database pool", slog.String("error", err.Error())) + return err + } + defer pool.Close() + + demoUsers := []struct { + email string + password string + role auth.Role + }{ + {email: "operator@example.com", password: demoPassword("DEMO_OPERATOR_PASSWORD", "operator_dev_password"), role: auth.RoleOperator}, + {email: "admin@example.com", password: demoPassword("DEMO_ADMIN_PASSWORD", "admin_dev_password"), role: auth.RoleAdmin}, + } + + repo := authstore.New(pool) + for _, u := range demoUsers { + hash, err := auth.HashPassword(u.password) + if err != nil { + logger.Error("failed to hash demo password", slog.String("error", err.Error())) + return err + } + _, created, err := repo.InsertUserIfAbsent(ctx, u.email, hash, u.role) + if err != nil { + logger.Error("failed to seed demo user", slog.String("email", u.email), slog.String("error", err.Error())) + return err + } + logger.Info("seed user", slog.String("email", u.email), slog.Bool("created", created)) + } + return nil +} + +func demoPassword(envKey, fallback string) string { + if v := os.Getenv(envKey); v != "" { + return v + } + return fallback +} +``` + +- [ ] **Step 4: Wire auth into the running server** + +In `run()`, after `svcs := buildServices(pool, logger)`, add: +```go + issuer := auth.NewIssuer([]byte(cfg.JWTSigningSecret), cfg.JWTAccessTokenTTL) + verifier := auth.NewVerifier([]byte(cfg.JWTSigningSecret)) + authSvc := auth.NewService(authstore.New(pool), issuer) + authHandler := authhttp.NewHandler(authSvc, logger) +``` +Replace the `httpserver.NewRouter` call with: +```go + router := httpserver.NewRouter(httpserver.RouterDeps{ + Logger: logger, + Health: httpserver.NewHealthHandler(pool), + PublicModules: []httpserver.RouteRegistrar{ + httpserver.RegistrarFunc(authHandler.RegisterPublicRoutes), + }, + Modules: []httpserver.RouteRegistrar{ + httpserver.RegistrarFunc(authHandler.RegisterProtectedRoutes), + txhttp.NewHandler(svcs.transactions, logger), + alerthttp.NewHandler(svcs.alerts, logger), + }, + RequireAuth: auth.RequireAuth(verifier), + }) +``` + +- [ ] **Step 5: Verify the binary builds and existing tests still pass** + +Run: `cd apps/api && go build ./... && go vet ./... && go test ./...` +Expected: build succeeds; all tests pass (including the auth package tests from Tasks 4-9). + +- [ ] **Step 6: Commit** + +```bash +git add apps/api/cmd/api/main.go +git commit -m "feat(api): wire JWT auth into the HTTP server and add seed-users command" +``` + +--- + +## Task 11: OpenAPI contract — bearerAuth, /login, /me, 401/403 + +**Files:** +- Modify: `contracts/openapi.yaml` + +**Interfaces:** +- Produces: the committed contract that Task 8's handlers must match (`POST /login`, `GET /me`, error codes `INVALID_BODY`, `INVALID_CREDENTIALS`, `UNAUTHORIZED`, `FORBIDDEN`). + +- [ ] **Step 1: Add the `auth` tag** + +In `contracts/openapi.yaml`, add to the `tags` list (after `health`): +```yaml + - name: auth + description: Login and the current authenticated user. +``` + +- [ ] **Step 2: Add the security scheme and a global default** + +Add a top-level `security` key right after the `servers` block: +```yaml +security: + - bearerAuth: [] +``` +In `components`, before `schemas:`, add: +```yaml + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: Short-lived (15 minute) HS256 access token returned by POST /login. +``` + +- [ ] **Step 3: Mark health and login as public** + +Add `security: []` to the `get:` operation under `/health/live` and under `/health/ready` (each gets its own line directly under its `operationId`/`summary` block, e.g. right after `summary: Liveness probe`): +```yaml + security: [] +``` +(Apply the same one-line addition to the `/health/ready` `get:` operation.) + +- [ ] **Step 4: Add the /login and /me paths** + +Insert after the `/health/ready` block and before `/transactions`: +```yaml + /login: + post: + tags: [auth] + operationId: login + summary: Authenticate with email and password + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/LoginRequest" + responses: + "200": + description: Authenticated; returns a short-lived access token. + content: + application/json: + schema: + $ref: "#/components/schemas/LoginResponse" + "400": + description: Missing or malformed email/password. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Email or password is incorrect. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: INVALID_CREDENTIALS + message: email or password is incorrect + /me: + get: + tags: [auth] + operationId: getCurrentUser + summary: Get the authenticated user + responses: + "200": + description: The authenticated user. + content: + application/json: + schema: + $ref: "#/components/schemas/User" + "401": + $ref: "#/components/responses/Unauthorized" +``` + +- [ ] **Step 5: Add 401/403 responses to protected feature endpoints** + +Add a `"401": { $ref: "#/components/responses/Unauthorized" }` entry to the `responses:` map of each of these existing operations: `GET /transactions`, `GET /alerts`, `GET /alerts/{id}`, `POST /alerts/{id}/acknowledge`, `POST /alerts/{id}/resolve`. For example, `/transactions`'s `get.responses` becomes: +```yaml + responses: + "200": + description: A page of transactions. + content: + application/json: + schema: + $ref: "#/components/schemas/TransactionPage" + "400": + description: Invalid query parameters. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: INVALID_QUERY + message: limit must be between 1 and 200 + "401": + $ref: "#/components/responses/Unauthorized" +``` +(Apply the same single added `"401"` entry to the other four operations listed above, leaving their existing `200`/`400`/`404`/`409` responses untouched.) + +- [ ] **Step 6: Add the new schemas** + +In `components.schemas`, add (alongside the existing `Error` schema): +```yaml + LoginRequest: + type: object + required: [email, password] + properties: + email: + type: string + format: email + password: + type: string + format: password + User: + type: object + required: [id, email, role] + properties: + id: + type: string + format: uuid + email: + type: string + format: email + role: + type: string + enum: [operator, admin] + LoginResponse: + type: object + required: [accessToken, user] + properties: + accessToken: + type: string + description: Short-lived (15 minute) HS256 JWT access token. + user: + $ref: "#/components/schemas/User" +``` + +- [ ] **Step 7: Add the reusable 401/403 responses** + +In `components.responses`, add alongside the existing `NotFound`: +```yaml + Unauthorized: + description: Missing, malformed, or expired bearer token. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: UNAUTHORIZED + message: missing bearer token + Forbidden: + description: The authenticated user's role does not permit this action. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: FORBIDDEN + message: insufficient role +``` + +- [ ] **Step 8: Validate the contract** + +Run (use whichever OpenAPI validator the project's contract-lint CI step uses; check `.github/workflows/` for the exact tool/command first): +```bash +grep -rn "openapi" /Users/viko/Documents/dev/code/finwatch/.github/workflows/*.yml +``` +Then run that same validation command locally against `contracts/openapi.yaml` and confirm it reports no errors. + +- [ ] **Step 9: Commit** + +```bash +git add contracts/openapi.yaml +git commit -m "docs(contracts): add bearer auth, /login, /me, and 401/403 responses" +``` + +--- + +## Task 12: Environment and Compose wiring + +**Files:** +- Modify: `.env.example` +- Modify: `docker-compose.yml` + +**Interfaces:** +- Produces: `JWT_SIGNING_SECRET`, `JWT_ACCESS_TOKEN_TTL`, `DEMO_OPERATOR_PASSWORD`, `DEMO_ADMIN_PASSWORD` available to local `docker compose up` and to anyone copying `.env.example` to `.env`. + +- [ ] **Step 1: Add example values to .env.example** + +In `.env.example`, add to the `# --- API ---------------------------------------------------------------` section (after `CORS_ALLOWED_ORIGINS`): +``` +# JWT signing secret: a SAFE example dev value only, at least 32 characters. +# Never reuse this value outside local development. +JWT_SIGNING_SECRET=dev_only_example_secret_change_me_32+chars +JWT_ACCESS_TOKEN_TTL=15m + +# Demo account passwords for `go run ./cmd/api seed-users` (operator@example.com, +# admin@example.com). SAFE example dev values only. +DEMO_OPERATOR_PASSWORD=operator_dev_password +DEMO_ADMIN_PASSWORD=admin_dev_password +``` + +- [ ] **Step 2: Pass the new variables through to the api service in docker-compose.yml** + +In `docker-compose.yml`, in the `api` service's `environment:` block, add after `CORS_ALLOWED_ORIGINS`: +```yaml + JWT_SIGNING_SECRET: ${JWT_SIGNING_SECRET:-dev_only_example_secret_change_me_32+chars} + JWT_ACCESS_TOKEN_TTL: ${JWT_ACCESS_TOKEN_TTL:-15m} +``` + +- [ ] **Step 3: Validate Compose config** + +Run: `docker compose config -q && echo ok` +Expected: prints `ok` with no errors. + +- [ ] **Step 4: Commit** + +```bash +git add .env.example docker-compose.yml +git commit -m "chore: add JWT env vars and demo user passwords to local dev config" +``` + +--- + +## Task 13: AuthContext — in-memory token store + +**Files:** +- Create: `apps/web/src/app/AuthContext.tsx` +- Test: `apps/web/src/app/AuthContext.test.tsx` + +**Interfaces:** +- Consumes: `env.apiUrl` from `apps/web/src/lib/env.ts` (existing). +- Produces: `AuthUser{id, email, role}`, `AuthProvider` component, `useAuth()` hook returning `{accessToken: string | null, user: AuthUser | null, login(email, password): Promise, logout(): void}`. Consumed by Task 14 (`ProtectedRoute`), Task 15 (`useApiFetch`), Task 16 (`LoginPage`), Task 17 (`App.tsx`). + +- [ ] **Step 1: Write the failing test** + +Create `apps/web/src/app/AuthContext.test.tsx`: +```tsx +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { AuthProvider, useAuth } from "@/app/AuthContext"; + +function Probe() { + const { accessToken, user, login, logout } = useAuth(); + return ( +
+ {accessToken ?? "none"} + {user?.role ?? "none"} + + +
+ ); +} + +describe("AuthContext", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("stores the access token and user after a successful login", async () => { + (fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => ({ + accessToken: "token-123", + user: { id: "u1", email: "operator@example.com", role: "operator" }, + }), + }); + + render(); + await userEvent.click(screen.getByText("login")); + + await waitFor(() => expect(screen.getByTestId("token")).toHaveTextContent("token-123")); + expect(screen.getByTestId("role")).toHaveTextContent("operator"); + }); + + it("throws and leaves state empty when login fails", async () => { + (fetch as ReturnType).mockResolvedValue({ + ok: false, + json: async () => ({ error: { code: "INVALID_CREDENTIALS", message: "email or password is incorrect" } }), + }); + + render(); + await userEvent.click(screen.getByText("login")); + + await waitFor(() => expect(screen.getByTestId("token")).toHaveTextContent("none")); + }); + + it("clears the token and user on logout", async () => { + (fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => ({ + accessToken: "token-123", + user: { id: "u1", email: "operator@example.com", role: "operator" }, + }), + }); + + render(); + await userEvent.click(screen.getByText("login")); + await waitFor(() => expect(screen.getByTestId("token")).toHaveTextContent("token-123")); + + await userEvent.click(screen.getByText("logout")); + expect(screen.getByTestId("token")).toHaveTextContent("none"); + expect(screen.getByTestId("role")).toHaveTextContent("none"); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd apps/web && npx vitest run src/app/AuthContext.test.tsx` +Expected: FAIL — module `@/app/AuthContext` not found. (If `@testing-library/user-event` is not yet a dependency, the test fails to resolve it; add it in this step: `npm install -D @testing-library/user-event@^14`.) + +- [ ] **Step 3: Implement AuthContext.tsx** + +Create `apps/web/src/app/AuthContext.tsx`: +```tsx +import { createContext, useCallback, useContext, useState, type ReactNode } from "react"; +import { env } from "@/lib/env"; + +export interface AuthUser { + id: string; + email: string; + role: "operator" | "admin"; +} + +interface AuthContextValue { + /** Held in memory only — never written to localStorage or sessionStorage. */ + accessToken: string | null; + user: AuthUser | null; + login: (email: string, password: string) => Promise; + logout: () => void; +} + +const AuthContext = createContext(undefined); + +/** AuthProvider holds the access token and current user in memory only. */ +export function AuthProvider({ children }: { children: ReactNode }) { + const [accessToken, setAccessToken] = useState(null); + const [user, setUser] = useState(null); + + const login = useCallback(async (email: string, password: string) => { + const res = await fetch(`${env.apiUrl}/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error(body?.error?.message ?? "Login failed"); + } + const data = await res.json(); + setAccessToken(data.accessToken); + setUser(data.user); + }, []); + + const logout = useCallback(() => { + setAccessToken(null); + setUser(null); + }, []); + + return ( + + {children} + + ); +} + +/** useAuth reads the auth state; must be used within an AuthProvider. */ +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return ctx; +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd apps/web && npx vitest run src/app/AuthContext.test.tsx` +Expected: PASS, 3 tests. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/app/AuthContext.tsx apps/web/src/app/AuthContext.test.tsx apps/web/package.json apps/web/package-lock.json +git commit -m "feat(web): add in-memory AuthContext with login/logout" +``` + +--- + +## Task 14: ProtectedRoute guard + +**Files:** +- Create: `apps/web/src/app/ProtectedRoute.tsx` +- Test: `apps/web/src/app/ProtectedRoute.test.tsx` + +**Interfaces:** +- Consumes: `useAuth()` (Task 13). +- Produces: `ProtectedRoute` component (a route-element wrapper rendering `` when authenticated, `` otherwise). Consumed by Task 17 (`routes.tsx`). + +- [ ] **Step 1: Write the failing test** + +Create `apps/web/src/app/ProtectedRoute.test.tsx`: +```tsx +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { AuthProvider } from "@/app/AuthContext"; +import { ProtectedRoute } from "@/app/ProtectedRoute"; + +function renderProtected(initialPath: string) { + return render( + + + + login page} /> + }> + dashboard page} /> + + + + , + ); +} + +describe("ProtectedRoute", () => { + it("redirects to /login when there is no access token", () => { + renderProtected("/dashboard"); + expect(screen.getByText("login page")).toBeInTheDocument(); + expect(screen.queryByText("dashboard page")).not.toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd apps/web && npx vitest run src/app/ProtectedRoute.test.tsx` +Expected: FAIL — module `@/app/ProtectedRoute` not found. + +- [ ] **Step 3: Implement ProtectedRoute.tsx** + +Create `apps/web/src/app/ProtectedRoute.tsx`: +```tsx +import { Navigate, Outlet } from "react-router-dom"; +import { useAuth } from "@/app/AuthContext"; + +/** + * ProtectedRoute renders its nested routes only when an access token is + * present; otherwise it redirects to /login. Reactivity comes from + * useAuth() — clearing the token (e.g. on a 401) re-renders this guard. + */ +export function ProtectedRoute() { + const { accessToken } = useAuth(); + if (!accessToken) { + return ; + } + return ; +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd apps/web && npx vitest run src/app/ProtectedRoute.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/app/ProtectedRoute.tsx apps/web/src/app/ProtectedRoute.test.tsx +git commit -m "feat(web): add ProtectedRoute guard that redirects to /login" +``` + +--- + +## Task 15: useApiFetch — attach token, clear on 401 + +**Files:** +- Create: `apps/web/src/app/useApiFetch.ts` +- Test: `apps/web/src/app/useApiFetch.test.tsx` + +**Interfaces:** +- Consumes: `useAuth()` (Task 13), `env.apiUrl`. +- Produces: `useApiFetch()` hook returning `(path: string, init?: RequestInit) => Promise` that attaches `Authorization: Bearer ` when present and calls `logout()` on a 401 response. This is the mechanism that makes `ProtectedRoute` (Task 14) redirect to `/login` after a 401: clearing the token via `logout()` triggers a re-render of the guard. Available for future protected-data-fetching pages; not consumed by any page in this issue beyond its own test. + +- [ ] **Step 1: Write the failing test** + +Create `apps/web/src/app/useApiFetch.test.tsx`: +```tsx +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { AuthProvider, useAuth } from "@/app/AuthContext"; +import { useApiFetch } from "@/app/useApiFetch"; +import type { ReactNode } from "react"; + +function wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +describe("useApiFetch", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("attaches the bearer token when one is present", async () => { + (fetch as ReturnType).mockResolvedValue({ ok: true, status: 200, json: async () => ({}) }); + const { result } = renderHook( + () => ({ auth: useAuth(), apiFetch: useApiFetch() }), + { wrapper }, + ); + + (fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: async () => ({ accessToken: "token-123", user: { id: "u1", email: "a@example.com", role: "operator" } }), + }); + await act(async () => { + await result.current.auth.login("a@example.com", "correct-password"); + }); + + await act(async () => { + await result.current.apiFetch("/transactions"); + }); + + const lastCall = (fetch as ReturnType).mock.calls.at(-1); + expect(lastCall?.[1]?.headers).toMatchObject({ Authorization: "Bearer token-123" }); + }); + + it("clears the token when a request returns 401", async () => { + (fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: async () => ({ accessToken: "token-123", user: { id: "u1", email: "a@example.com", role: "operator" } }), + }); + const { result } = renderHook( + () => ({ auth: useAuth(), apiFetch: useApiFetch() }), + { wrapper }, + ); + await act(async () => { + await result.current.auth.login("a@example.com", "correct-password"); + }); + expect(result.current.auth.accessToken).toBe("token-123"); + + (fetch as ReturnType).mockResolvedValueOnce({ ok: false, status: 401, json: async () => ({}) }); + await act(async () => { + await result.current.apiFetch("/transactions"); + }); + + expect(result.current.auth.accessToken).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd apps/web && npx vitest run src/app/useApiFetch.test.tsx` +Expected: FAIL — module `@/app/useApiFetch` not found. + +- [ ] **Step 3: Implement useApiFetch.ts** + +Create `apps/web/src/app/useApiFetch.ts`: +```ts +import { useCallback } from "react"; +import { useAuth } from "@/app/AuthContext"; +import { env } from "@/lib/env"; + +/** + * useApiFetch wraps fetch with the current access token and, on a 401 + * response, logs out — which clears the in-memory token and lets + * ProtectedRoute redirect to /login on the next render. + */ +export function useApiFetch() { + const { accessToken, logout } = useAuth(); + + return useCallback( + async (path: string, init: RequestInit = {}) => { + const headers = { + ...init.headers, + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + }; + const res = await fetch(`${env.apiUrl}${path}`, { ...init, headers }); + if (res.status === 401) { + logout(); + } + return res; + }, + [accessToken, logout], + ); +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd apps/web && npx vitest run src/app/useApiFetch.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/app/useApiFetch.ts apps/web/src/app/useApiFetch.test.tsx +git commit -m "feat(web): add useApiFetch that clears the token on 401" +``` + +--- + +## Task 16: Wire LoginPage to POST /login + +**Files:** +- Modify: `apps/web/src/pages/LoginPage.tsx` +- Create: `apps/web/src/pages/LoginPage.test.tsx` + +**Interfaces:** +- Consumes: `useAuth()` (Task 13), `useNavigate` (react-router-dom, existing dependency). + +- [ ] **Step 1: Write the failing test** + +Create `apps/web/src/pages/LoginPage.test.tsx`: +```tsx +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { AuthProvider } from "@/app/AuthContext"; +import { LoginPage } from "@/pages/LoginPage"; + +function renderLoginPage() { + return render( + + + + } /> + dashboard page} /> + + + , + ); +} + +describe("LoginPage", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("navigates to /dashboard on successful submit", async () => { + (fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => ({ + accessToken: "token-123", + user: { id: "u1", email: "operator@example.com", role: "operator" }, + }), + }); + + renderLoginPage(); + await userEvent.type(screen.getByLabelText(/email/i), "operator@example.com"); + await userEvent.type(screen.getByLabelText(/password/i), "correct-password"); + await userEvent.click(screen.getByRole("button", { name: /sign in/i })); + + await waitFor(() => expect(screen.getByText("dashboard page")).toBeInTheDocument()); + }); + + it("shows an error message on invalid credentials", async () => { + (fetch as ReturnType).mockResolvedValue({ + ok: false, + json: async () => ({ error: { code: "INVALID_CREDENTIALS", message: "email or password is incorrect" } }), + }); + + renderLoginPage(); + await userEvent.type(screen.getByLabelText(/email/i), "operator@example.com"); + await userEvent.type(screen.getByLabelText(/password/i), "wrong-password"); + await userEvent.click(screen.getByRole("button", { name: /sign in/i })); + + await waitFor(() => expect(screen.getByText(/email or password is incorrect/i)).toBeInTheDocument()); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd apps/web && npx vitest run src/pages/LoginPage.test.tsx` +Expected: FAIL — the form fields are `disabled` and submit does nothing, so neither assertion is met. + +- [ ] **Step 3: Implement the wired LoginPage** + +Replace the contents of `apps/web/src/pages/LoginPage.tsx`: +```tsx +import { useState, type FormEvent } from "react"; +import { useNavigate } from "react-router-dom"; +import { useAuth } from "@/app/AuthContext"; +import { Button } from "@/components/ui/button"; + +/** LoginPage authenticates against POST /login and stores the token in memory. */ +export function LoginPage() { + const { login } = useAuth(); + const navigate = useNavigate(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + setSubmitting(true); + try { + await login(email, password); + navigate("/dashboard", { replace: true }); + } catch (err) { + setError(err instanceof Error ? err.message : "Login failed"); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+

Sign in

+

FinWatch operator access.

+
+
+
+ + setEmail(event.target.value)} + required + /> +
+
+ + setPassword(event.target.value)} + required + /> +
+ {error && ( +

+ {error} +

+ )} + +
+
+ ); +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd apps/web && npx vitest run src/pages/LoginPage.test.tsx` +Expected: PASS, 2 tests. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/pages/LoginPage.tsx apps/web/src/pages/LoginPage.test.tsx +git commit -m "feat(web): wire LoginPage to POST /login" +``` + +--- + +## Task 17: Wrap the app in AuthProvider and gate authenticated routes + +**Files:** +- Modify: `apps/web/src/app/App.tsx` +- Modify: `apps/web/src/app/routes.tsx` +- Modify: `apps/web/src/app/routes.test.tsx` + +**Interfaces:** +- Consumes: `AuthProvider` (Task 13), `ProtectedRoute` (Task 14). + +- [ ] **Step 1: Update the existing routes test to authenticate before asserting protected pages render** + +Replace the contents of `apps/web/src/app/routes.test.tsx`: +```tsx +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { AuthProvider, useAuth } from "@/app/AuthContext"; +import { AppRoutes } from "@/app/routes"; + +function renderAt(path: string) { + return render( + + + + + , + ); +} + +describe("AppRoutes", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("redirects unauthenticated requests for the dashboard to /login", () => { + renderAt("/dashboard"); + expect(screen.getByRole("heading", { name: /sign in/i })).toBeInTheDocument(); + }); + + it("renders the dashboard with primary navigation once authenticated", async () => { + (fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => ({ + accessToken: "token-123", + user: { id: "u1", email: "operator@example.com", role: "operator" }, + }), + }); + + function Authed() { + const { login } = useAuth(); + return ( + + ); + } + + render( + + + + + + , + ); + + screen.getByText("do-login").click(); + await waitFor(() => + expect(screen.getByRole("heading", { name: /dashboard/i })).toBeInTheDocument(), + ); + expect(screen.getByRole("navigation", { name: /primary/i })).toBeInTheDocument(); + }); + + it("renders a not-found page for unknown routes", () => { + renderAt("/nope"); + expect(screen.getByText("404")).toBeInTheDocument(); + }); + + it("renders the login page outside the app shell", () => { + renderAt("/login"); + expect(screen.getByRole("heading", { name: /sign in/i })).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd apps/web && npx vitest run src/app/routes.test.tsx` +Expected: FAIL — `/dashboard` currently renders the dashboard unconditionally (no guard yet), so the first new assertion (redirect to sign in) fails. + +- [ ] **Step 3: Add ProtectedRoute to the route table** + +In `apps/web/src/app/routes.tsx`, add the import and wrap the `AppShell` route: +```tsx +import { Navigate, Route, Routes } from "react-router-dom"; +import { AppShell } from "@/app/AppShell"; +import { ProtectedRoute } from "@/app/ProtectedRoute"; +import { DashboardPage } from "@/pages/DashboardPage"; +import { AlertsPage } from "@/pages/AlertsPage"; +import { OpsPage } from "@/pages/OpsPage"; +import { LoginPage } from "@/pages/LoginPage"; +import { NotFoundPage } from "@/pages/NotFoundPage"; + +export function AppRoutes() { + return ( + + } /> + }> + }> + } /> + } /> + } /> + } /> + + + } /> + + ); +} +``` + +- [ ] **Step 4: Wrap App.tsx in AuthProvider** + +In `apps/web/src/app/App.tsx`: +```tsx +import { BrowserRouter } from "react-router-dom"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { ErrorBoundary } from "@/components/ErrorBoundary"; +import { queryClient } from "@/lib/queryClient"; +import { AuthProvider } from "@/app/AuthContext"; +import { AppRoutes } from "@/app/routes"; + +/** App composes global providers, the error boundary, and the router. */ +export function App() { + return ( + + + + + + + + + + ); +} +``` + +- [ ] **Step 5: Run to verify it passes** + +Run: `cd apps/web && npx vitest run src/app/routes.test.tsx` +Expected: PASS, all 4 tests. + +- [ ] **Step 6: Run the full frontend test suite and type-check** + +Run: `cd apps/web && npm run test && npm run typecheck && npm run lint` +Expected: all green — this exercises every test added in Tasks 13-17 together plus the previously existing suite. + +- [ ] **Step 7: Commit** + +```bash +git add apps/web/src/app/App.tsx apps/web/src/app/routes.tsx apps/web/src/app/routes.test.tsx +git commit -m "feat(web): gate authenticated routes behind ProtectedRoute" +``` + +--- + +## Test Strategy Summary + +| Layer | File | Cases | +|---|---|---| +| Password hashing | `internal/auth/password_test.go` | round-trip, wrong password | +| JWT issue/verify | `internal/auth/jwt_test.go` | round-trip, expired, wrong signature, malformed | +| Login service | `internal/auth/service_test.go` | valid login, wrong password, unknown email | +| Middleware | `internal/auth/middleware_test.go` | missing/malformed/expired/valid token, operator denied from admin route (403), admin allowed | +| Config | `internal/config/config_test.go` | missing secret, short secret, non-duration TTL, zero TTL, default 15m, override | +| HTTP handlers | `internal/auth/httpapi/handler_test.go` | login success/wrong-password/missing-fields, /me with valid token, /me without token | +| Router | `internal/platform/httpserver/router_auth_test.go` | public module bypasses RequireAuth, protected module runs behind it | +| Frontend context | `app/AuthContext.test.tsx` | login stores token/user, failed login leaves state empty, logout clears state | +| Frontend guard | `app/ProtectedRoute.test.tsx` | unauthenticated redirect to /login | +| Frontend fetch wrapper | `app/useApiFetch.test.tsx` | attaches bearer token, clears token on 401 | +| Frontend login page | `pages/LoginPage.test.tsx` | successful submit navigates to /dashboard, invalid login shows error | +| Frontend routing | `app/routes.test.tsx` | unauthenticated redirect, authenticated dashboard render, 404, public login page | + +No test against a live Postgres database is included: `internal/auth/store` is a thin adapter exercised through `go build`/`go vet` and, optionally, manual verification with `make dev` + `go run ./cmd/api seed-users` + a real `curl` login (see Verification Commands). This mirrors the existing `internal/alerts/store` package, which has no dedicated test file either. + +## Security Risks and Mitigations + +- **Signing secret strength.** `JWT_SIGNING_SECRET` must be at least 32 characters (enforced in `Config.Validate`); the example value in `.env.example` is clearly labelled as dev-only and must never be reused in any real deployment. +- **Credential enumeration.** `Service.Login` returns the same `ErrInvalidCredentials` for both "unknown email" and "wrong password" so the API cannot be used to enumerate registered emails. +- **Password storage.** Passwords are bcrypt-hashed (`bcrypt.DefaultCost`) before storage; plaintext passwords are never logged (`runSeedUsers` logs only email and a `created` boolean). +- **Token lifetime.** A 15-minute access-token TTL bounds the exposure window of a leaked token; there is no refresh token in this issue, so a logged-in session simply expires after 15 minutes (acceptable per the explicit non-goals — refresh is future work). +- **Token storage on the frontend.** The access token lives only in a React context's in-memory state; it is never written to `localStorage` or `sessionStorage`, so it does not survive a page reload and is not readable by a separately-injected script that only has DOM/storage access (XSS via direct memory access is a different, harder threat that this design does not claim to fully close). +- **Algorithm confusion.** `Verifier.Verify` pins `jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})`, rejecting tokens signed with `none` or an asymmetric algorithm. +- **Role escalation.** Role is only ever read from a verified JWT's claims (`ClaimsFromContext`), never from client-supplied request bodies or query parameters. + +## Known Limitations / Non-Goals (carried into follow-up issues) + +- No refresh tokens — a 401 after 15 minutes requires logging in again. (Explicitly out of scope.) +- No OAuth/SAML, password reset, or production user-management UI. (Explicitly out of scope.) +- `RequireRole` is implemented and unit-tested but not yet mounted on any concrete business route, since no admin-only endpoint exists in the current API surface (adding one is alert/transaction-workflow scope, which this issue does not touch). +- WebSocket authentication is issue #5's scope. +- `sessionStorage` is also not used in this issue, per the explicit instruction — token loss on page refresh is accepted behavior for now. + +## Verification Commands + +Run after every task, and definitely before the final commit: +```bash +cd apps/api && gofmt -l . && go vet ./... && go test -p 1 ./... && go build ./... +cd apps/web && npm run lint && npm run typecheck && npm run test && npm run build +``` +Or, from the repo root: +```bash +make verify +``` + +Manual end-to-end check (requires Docker): +```bash +make dev +go run ./apps/api/cmd/api seed-users # or: docker compose exec api /app/api seed-users +curl -s -X POST http://localhost:8080/login \ + -H 'Content-Type: application/json' \ + -d '{"email":"operator@example.com","password":"operator_dev_password"}' +# Expect: {"accessToken":"...","user":{"id":"...","email":"operator@example.com","role":"operator"}} +curl -s http://localhost:8080/transactions +# Expect: 401 Unauthorized envelope (no token) +curl -s http://localhost:8080/transactions -H "Authorization: Bearer " +# Expect: 200 with a transaction page +``` + +## Step-by-Step Checklist + +- [ ] Task 1: Add JWT and bcrypt dependencies +- [ ] Task 2: Users migration and sqlc query file +- [ ] Task 3: Config — JWT signing secret and access token TTL +- [ ] Task 4: Auth domain — claims, password hashing, JWT issue/verify +- [ ] Task 5: Login service +- [ ] Task 6: Auth HTTP middleware (RequireAuth, RequireRole) +- [ ] Task 7: Postgres user store +- [ ] Task 8: Auth HTTP handlers (POST /login, GET /me) +- [ ] Task 9: Router — public/protected route split +- [ ] Task 10: Wire auth into cmd/api, add seed-users command +- [ ] Task 11: OpenAPI contract — bearerAuth, /login, /me, 401/403 +- [ ] Task 12: Environment and Compose wiring +- [ ] Task 13: AuthContext — in-memory token store +- [ ] Task 14: ProtectedRoute guard +- [ ] Task 15: useApiFetch — attach token, clear on 401 +- [ ] Task 16: Wire LoginPage to POST /login +- [ ] Task 17: Wrap the app in AuthProvider and gate authenticated routes +- [ ] Final: run `make verify` end-to-end; manually verify login/me/protected-route flow with `make dev` From 8f923952034a4d5a185f094f792507fca81028b4 Mon Sep 17 00:00:00 2001 From: vikoabastian <11003051+vianbas@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:12:16 +0700 Subject: [PATCH 02/29] docs(contracts): add bearer auth, /login, /me, and 401/403 responses --- contracts/openapi.yaml | 124 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/contracts/openapi.yaml b/contracts/openapi.yaml index 5d29b2d..4d85857 100644 --- a/contracts/openapi.yaml +++ b/contracts/openapi.yaml @@ -21,9 +21,14 @@ servers: - url: http://localhost:8080 description: Local development +security: + - bearerAuth: [] + tags: - name: health description: Operational liveness and readiness probes. + - name: auth + description: Login and the current authenticated user. - name: transactions description: Synthetic transaction records. - name: alerts @@ -35,6 +40,7 @@ paths: tags: [health] operationId: getLiveness summary: Liveness probe + security: [] description: Returns 200 while the process is running. No dependencies are checked. responses: "200": @@ -50,6 +56,7 @@ paths: tags: [health] operationId: getReadiness summary: Readiness probe + security: [] description: Returns 200 only when all downstream dependencies are reachable. responses: "200": @@ -70,6 +77,55 @@ paths: error: code: NOT_READY message: database unreachable + /login: + post: + tags: [auth] + operationId: login + summary: Authenticate with email and password + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/LoginRequest" + responses: + "200": + description: Authenticated; returns a short-lived access token. + content: + application/json: + schema: + $ref: "#/components/schemas/LoginResponse" + "400": + description: Missing or malformed email/password. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Email or password is incorrect. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: INVALID_CREDENTIALS + message: email or password is incorrect + /me: + get: + tags: [auth] + operationId: getCurrentUser + summary: Get the authenticated user + responses: + "200": + description: The authenticated user. + content: + application/json: + schema: + $ref: "#/components/schemas/User" + "401": + $ref: "#/components/responses/Unauthorized" /transactions: get: tags: [transactions] @@ -111,6 +167,8 @@ paths: error: code: INVALID_QUERY message: limit must be between 1 and 200 + "401": + $ref: "#/components/responses/Unauthorized" /alerts: get: tags: [alerts] @@ -154,6 +212,8 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + "401": + $ref: "#/components/responses/Unauthorized" /alerts/{id}: get: tags: [alerts] @@ -174,6 +234,8 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + "401": + $ref: "#/components/responses/Unauthorized" /alerts/{id}/acknowledge: post: tags: [alerts] @@ -205,6 +267,8 @@ paths: error: code: INVALID_TRANSITION message: alert is not in a state that allows this transition + "401": + $ref: "#/components/responses/Unauthorized" /alerts/{id}/resolve: post: tags: [alerts] @@ -232,8 +296,16 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + "401": + $ref: "#/components/responses/Unauthorized" components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: Short-lived (15 minute) HS256 access token returned by POST /login. schemas: LivenessStatus: type: object @@ -340,6 +412,38 @@ components: message: type: string description: Human-readable description; safe to display. + LoginRequest: + type: object + required: [email, password] + properties: + email: + type: string + format: email + password: + type: string + format: password + User: + type: object + required: [id, email, role] + properties: + id: + type: string + format: uuid + email: + type: string + format: email + role: + type: string + enum: [operator, admin] + LoginResponse: + type: object + required: [accessToken, user] + properties: + accessToken: + type: string + description: Short-lived (15 minute) HS256 JWT access token. + user: + $ref: "#/components/schemas/User" parameters: AlertId: name: id @@ -360,3 +464,23 @@ components: error: code: NOT_FOUND message: resource not found + Unauthorized: + description: Missing, malformed, or expired bearer token. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: UNAUTHORIZED + message: missing bearer token + Forbidden: + description: The authenticated user's role does not permit this action. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + error: + code: FORBIDDEN + message: insufficient role From 6f9eddbc428c6ca173ac8be07c51f6d1adc4992c Mon Sep 17 00:00:00 2001 From: vikoabastian <11003051+vianbas@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:14:37 +0700 Subject: [PATCH 03/29] build(api): add golang-jwt/jwt and x/crypto dependencies --- apps/api/go.mod | 8 +++++--- apps/api/go.sum | 8 ++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/api/go.mod b/apps/api/go.mod index 60de6c1..4618338 100644 --- a/apps/api/go.mod +++ b/apps/api/go.mod @@ -1,6 +1,6 @@ module github.com/vianbas/finwatch/apps/api -go 1.26 +go 1.26.0 require ( github.com/go-chi/chi/v5 v5.3.0 @@ -8,9 +8,11 @@ require ( ) require ( + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/text v0.29.0 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/text v0.42.0 // indirect ) diff --git a/apps/api/go.sum b/apps/api/go.sum index f80318d..42d23e0 100644 --- a/apps/api/go.sum +++ b/apps/api/go.sum @@ -3,6 +3,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -18,10 +20,16 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= From 84a64cbfcd17ea70b09e34ba500eeac4170eba10 Mon Sep 17 00:00:00 2001 From: vikoabastian <11003051+vianbas@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:16:39 +0700 Subject: [PATCH 04/29] feat(api): add users table and sqlc queries for auth --- .../internal/platform/postgres/db/models.go | 8 +++ .../platform/postgres/db/users.sql.go | 57 +++++++++++++++++++ apps/api/migrations/0004_users.down.sql | 1 + apps/api/migrations/0004_users.up.sql | 15 +++++ apps/api/queries/users.sql | 12 ++++ 5 files changed, 93 insertions(+) create mode 100644 apps/api/internal/platform/postgres/db/users.sql.go create mode 100644 apps/api/migrations/0004_users.down.sql create mode 100644 apps/api/migrations/0004_users.up.sql create mode 100644 apps/api/queries/users.sql diff --git a/apps/api/internal/platform/postgres/db/models.go b/apps/api/internal/platform/postgres/db/models.go index 59aef40..bac1b1a 100644 --- a/apps/api/internal/platform/postgres/db/models.go +++ b/apps/api/internal/platform/postgres/db/models.go @@ -48,3 +48,11 @@ type Transaction struct { OccurredAt pgtype.Timestamptz `json:"occurred_at"` CreatedAt pgtype.Timestamptz `json:"created_at"` } + +type User struct { + ID pgtype.UUID `json:"id"` + Email string `json:"email"` + PasswordHash string `json:"password_hash"` + Role string `json:"role"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} diff --git a/apps/api/internal/platform/postgres/db/users.sql.go b/apps/api/internal/platform/postgres/db/users.sql.go new file mode 100644 index 0000000..e4f7a13 --- /dev/null +++ b/apps/api/internal/platform/postgres/db/users.sql.go @@ -0,0 +1,57 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: users.sql + +package db + +import ( + "context" +) + +const getUserByEmail = `-- name: GetUserByEmail :one +SELECT id, email, password_hash, role, created_at +FROM users +WHERE email = $1 +` + +func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) { + row := q.db.QueryRow(ctx, getUserByEmail, email) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.Role, + &i.CreatedAt, + ) + return i, err +} + +const insertUserIfAbsent = `-- name: InsertUserIfAbsent :one +INSERT INTO users (email, password_hash, role) +VALUES ($1, $2, $3) +ON CONFLICT (email) DO NOTHING +RETURNING id, email, password_hash, role, created_at +` + +type InsertUserIfAbsentParams struct { + Email string `json:"email"` + PasswordHash string `json:"password_hash"` + Role string `json:"role"` +} + +// Idempotent seeding: returns no row if the email already exists, so the +// caller (the seed-users command) can skip logging a duplicate creation. +func (q *Queries) InsertUserIfAbsent(ctx context.Context, arg InsertUserIfAbsentParams) (User, error) { + row := q.db.QueryRow(ctx, insertUserIfAbsent, arg.Email, arg.PasswordHash, arg.Role) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.Role, + &i.CreatedAt, + ) + return i, err +} diff --git a/apps/api/migrations/0004_users.down.sql b/apps/api/migrations/0004_users.down.sql new file mode 100644 index 0000000..cc1f647 --- /dev/null +++ b/apps/api/migrations/0004_users.down.sql @@ -0,0 +1 @@ +DROP TABLE users; diff --git a/apps/api/migrations/0004_users.up.sql b/apps/api/migrations/0004_users.up.sql new file mode 100644 index 0000000..f01f142 --- /dev/null +++ b/apps/api/migrations/0004_users.up.sql @@ -0,0 +1,15 @@ +-- 0004_users: user accounts for JWT authentication and RBAC. +-- +-- Conventions match earlier migrations: UUID ids via gen_random_uuid, +-- TIMESTAMPTZ in UTC, small constrained value sets via CHECK. Roles are +-- intentionally limited to operator and admin for this issue. + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT users_role_valid CHECK (role IN ('operator', 'admin')) +); diff --git a/apps/api/queries/users.sql b/apps/api/queries/users.sql new file mode 100644 index 0000000..b6c6e66 --- /dev/null +++ b/apps/api/queries/users.sql @@ -0,0 +1,12 @@ +-- name: GetUserByEmail :one +SELECT id, email, password_hash, role, created_at +FROM users +WHERE email = $1; + +-- name: InsertUserIfAbsent :one +-- Idempotent seeding: returns no row if the email already exists, so the +-- caller (the seed-users command) can skip logging a duplicate creation. +INSERT INTO users (email, password_hash, role) +VALUES ($1, $2, $3) +ON CONFLICT (email) DO NOTHING +RETURNING id, email, password_hash, role, created_at; From 78f5128da4412a3a29eb96611f664804d9673ce6 Mon Sep 17 00:00:00 2001 From: vikoabastian <11003051+vianbas@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:19:11 +0700 Subject: [PATCH 05/29] feat(api): add JWT signing secret and access token TTL to config --- apps/api/internal/config/config.go | 14 +++++++++++ apps/api/internal/config/config_test.go | 31 ++++++++++++++++++++++--- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/apps/api/internal/config/config.go b/apps/api/internal/config/config.go index bb403d7..9d05dad 100644 --- a/apps/api/internal/config/config.go +++ b/apps/api/internal/config/config.go @@ -39,6 +39,11 @@ type Config struct { // CORSAllowedOrigins is the list of browser origins permitted to call the API. CORSAllowedOrigins []string + + // JWTSigningSecret is the HS256 key used to sign and verify access tokens. + JWTSigningSecret string + // JWTAccessTokenTTL bounds how long an issued access token remains valid. + JWTAccessTokenTTL time.Duration } // Addr returns the host:port the HTTP server should bind to. @@ -86,6 +91,11 @@ func Load(getenv func(string) string) (*Config, error) { cfg.CORSAllowedOrigins = splitAndTrim(firstNonEmpty(getenv("CORS_ALLOWED_ORIGINS"), "http://localhost:5173")) + cfg.JWTSigningSecret = getenv("JWT_SIGNING_SECRET") + if cfg.JWTAccessTokenTTL, err = durationEnv(getenv, "JWT_ACCESS_TOKEN_TTL", 15*time.Minute); err != nil { + return nil, err + } + if err := cfg.Validate(); err != nil { return nil, err } @@ -112,12 +122,16 @@ func (c Config) Validate() error { if !strings.HasPrefix(c.DatabaseURL, "postgres://") && !strings.HasPrefix(c.DatabaseURL, "postgresql://") { return fmt.Errorf("config: DATABASE_URL must be a postgres:// or postgresql:// connection string") } + if len(c.JWTSigningSecret) < 32 { + return fmt.Errorf("config: JWT_SIGNING_SECRET must be at least 32 characters") + } for name, d := range map[string]time.Duration{ "HTTP_READ_HEADER_TIMEOUT": c.ReadHeaderTimeout, "HTTP_READ_TIMEOUT": c.ReadTimeout, "HTTP_WRITE_TIMEOUT": c.WriteTimeout, "HTTP_IDLE_TIMEOUT": c.IdleTimeout, "HTTP_SHUTDOWN_TIMEOUT": c.ShutdownTimeout, + "JWT_ACCESS_TOKEN_TTL": c.JWTAccessTokenTTL, } { if d <= 0 { return fmt.Errorf("config: %s must be a positive duration", name) diff --git a/apps/api/internal/config/config_test.go b/apps/api/internal/config/config_test.go index 7a0c428..dbbd6fb 100644 --- a/apps/api/internal/config/config_test.go +++ b/apps/api/internal/config/config_test.go @@ -12,9 +12,10 @@ func envFunc(m map[string]string) func(string) string { func validEnv() map[string]string { return map[string]string{ - "APP_ENV": "development", - "LOG_LEVEL": "info", - "DATABASE_URL": "postgres://user:pass@localhost:5432/finwatch?sslmode=disable", + "APP_ENV": "development", + "LOG_LEVEL": "info", + "DATABASE_URL": "postgres://user:pass@localhost:5432/finwatch?sslmode=disable", + "JWT_SIGNING_SECRET": "test-signing-secret-must-be-at-least-32-bytes", } } @@ -57,6 +58,26 @@ func TestLoad_OverridesParsed(t *testing.T) { } } +func TestLoad_JWTDefaultsAndOverrides(t *testing.T) { + cfg, err := Load(envFunc(validEnv())) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.JWTAccessTokenTTL != 15*time.Minute { + t.Errorf("JWTAccessTokenTTL = %v, want 15m", cfg.JWTAccessTokenTTL) + } + + env := validEnv() + env["JWT_ACCESS_TOKEN_TTL"] = "5m" + cfg, err = Load(envFunc(env)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.JWTAccessTokenTTL != 5*time.Minute { + t.Errorf("JWTAccessTokenTTL = %v, want 5m", cfg.JWTAccessTokenTTL) + } +} + func TestLoad_ValidationErrors(t *testing.T) { tests := []struct { name string @@ -70,6 +91,10 @@ func TestLoad_ValidationErrors(t *testing.T) { {"non-integer port", func(m map[string]string) { m["HTTP_PORT"] = "abc" }}, {"non-duration timeout", func(m map[string]string) { m["HTTP_READ_TIMEOUT"] = "soon" }}, {"zero timeout", func(m map[string]string) { m["HTTP_IDLE_TIMEOUT"] = "0s" }}, + {"missing jwt signing secret", func(m map[string]string) { delete(m, "JWT_SIGNING_SECRET") }}, + {"short jwt signing secret", func(m map[string]string) { m["JWT_SIGNING_SECRET"] = "too-short" }}, + {"non-duration jwt ttl", func(m map[string]string) { m["JWT_ACCESS_TOKEN_TTL"] = "soon" }}, + {"zero jwt ttl", func(m map[string]string) { m["JWT_ACCESS_TOKEN_TTL"] = "0s" }}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From 4eb379d6922c673ffb17cc5feb9a1b6e6eb65bad Mon Sep 17 00:00:00 2001 From: vikoabastian <11003051+vianbas@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:20:45 +0700 Subject: [PATCH 06/29] docs(contracts): document INVALID_BODY example for POST /login --- contracts/openapi.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contracts/openapi.yaml b/contracts/openapi.yaml index 4d85857..489bdac 100644 --- a/contracts/openapi.yaml +++ b/contracts/openapi.yaml @@ -102,6 +102,10 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + example: + error: + code: INVALID_BODY + message: email and password are required "401": description: Email or password is incorrect. content: From a5cbc3ba2a4a17aa4b770358a8c153811a5c3efc Mon Sep 17 00:00:00 2001 From: vikoabastian <11003051+vianbas@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:21:10 +0700 Subject: [PATCH 07/29] fix(api): make 0004_users down migration idempotent --- apps/api/migrations/0004_users.down.sql | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/api/migrations/0004_users.down.sql b/apps/api/migrations/0004_users.down.sql index cc1f647..0178c29 100644 --- a/apps/api/migrations/0004_users.down.sql +++ b/apps/api/migrations/0004_users.down.sql @@ -1 +1,2 @@ -DROP TABLE users; +-- Reverse of 0004_users. +DROP TABLE IF EXISTS users; From 09d7a0d86a655c15f614f540ffa2e2cf00b50643 Mon Sep 17 00:00:00 2001 From: vikoabastian <11003051+vianbas@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:23:47 +0700 Subject: [PATCH 08/29] feat(api): add JWT claims, bcrypt hashing, and HS256 issue/verify --- apps/api/go.mod | 4 +- apps/api/go.sum | 4 - apps/api/internal/auth/claims.go | 23 +++++ apps/api/internal/auth/jwt.go | 85 +++++++++++++++++ apps/api/internal/auth/jwt_test.go | 117 ++++++++++++++++++++++++ apps/api/internal/auth/password.go | 17 ++++ apps/api/internal/auth/password_test.go | 26 ++++++ 7 files changed, 270 insertions(+), 6 deletions(-) create mode 100644 apps/api/internal/auth/claims.go create mode 100644 apps/api/internal/auth/jwt.go create mode 100644 apps/api/internal/auth/jwt_test.go create mode 100644 apps/api/internal/auth/password.go create mode 100644 apps/api/internal/auth/password_test.go diff --git a/apps/api/go.mod b/apps/api/go.mod index 4618338..29e1666 100644 --- a/apps/api/go.mod +++ b/apps/api/go.mod @@ -4,15 +4,15 @@ go 1.26.0 require ( github.com/go-chi/chi/v5 v5.3.0 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/jackc/pgx/v5 v5.10.0 + golang.org/x/crypto v0.57.0 ) require ( - github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - golang.org/x/crypto v0.57.0 // indirect golang.org/x/sync v0.23.0 // indirect golang.org/x/text v0.42.0 // indirect ) diff --git a/apps/api/go.sum b/apps/api/go.sum index 42d23e0..b0ed1e0 100644 --- a/apps/api/go.sum +++ b/apps/api/go.sum @@ -22,12 +22,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/apps/api/internal/auth/claims.go b/apps/api/internal/auth/claims.go new file mode 100644 index 0000000..2631e24 --- /dev/null +++ b/apps/api/internal/auth/claims.go @@ -0,0 +1,23 @@ +// Package auth implements JWT-based authentication and role-based access +// control: password hashing, token issuance/verification, the login service, +// and HTTP middleware that enforces them. +package auth + +import "time" + +// Role is a RBAC role. Only operator and admin exist in this issue. +type Role string + +const ( + RoleOperator Role = "operator" + RoleAdmin Role = "admin" +) + +// Claims is the decoded, verified content of an access token. +type Claims struct { + UserID string + Email string + Role Role + IssuedAt time.Time + ExpiresAt time.Time +} diff --git a/apps/api/internal/auth/jwt.go b/apps/api/internal/auth/jwt.go new file mode 100644 index 0000000..0cccce5 --- /dev/null +++ b/apps/api/internal/auth/jwt.go @@ -0,0 +1,85 @@ +package auth + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// ErrInvalidToken is returned for malformed tokens or signature mismatches. +var ErrInvalidToken = errors.New("auth: invalid token") + +// ErrExpiredToken is returned when the token's exp claim is in the past. +var ErrExpiredToken = errors.New("auth: token expired") + +// tokenClaims is the on-the-wire JWT claim set: sub/iat/exp via +// RegisteredClaims, plus email and role. +type tokenClaims struct { + Email string `json:"email"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +// Issuer signs short-lived HS256 access tokens. +type Issuer struct { + secret []byte + ttl time.Duration +} + +// NewIssuer constructs an Issuer. secret is the HS256 signing key; ttl is the +// access token lifetime (15 minutes per the architecture decision). +func NewIssuer(secret []byte, ttl time.Duration) *Issuer { + return &Issuer{secret: secret, ttl: ttl} +} + +// Issue signs a new access token for the given user. +func (i *Issuer) Issue(userID, email string, role Role) (string, error) { + now := time.Now().UTC() + claims := tokenClaims{ + Email: email, + Role: string(role), + RegisteredClaims: jwt.RegisteredClaims{ + Subject: userID, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(i.ttl)), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(i.secret) +} + +// Verifier validates HS256 access tokens and extracts their claims. +type Verifier struct { + secret []byte +} + +// NewVerifier constructs a Verifier using the same secret the Issuer signed with. +func NewVerifier(secret []byte) *Verifier { + return &Verifier{secret: secret} +} + +// Verify parses and validates tokenString, returning ErrExpiredToken or +// ErrInvalidToken on failure. +func (v *Verifier) Verify(tokenString string) (Claims, error) { + var claims tokenClaims + _, err := jwt.ParseWithClaims(tokenString, &claims, func(*jwt.Token) (interface{}, error) { + return v.secret, nil + }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}), jwt.WithExpirationRequired()) + if errors.Is(err, jwt.ErrTokenExpired) { + return Claims{}, ErrExpiredToken + } + if err != nil { + return Claims{}, ErrInvalidToken + } + if claims.IssuedAt == nil { + return Claims{}, ErrInvalidToken + } + return Claims{ + UserID: claims.Subject, + Email: claims.Email, + Role: Role(claims.Role), + IssuedAt: claims.IssuedAt.Time, + ExpiresAt: claims.ExpiresAt.Time, + }, nil +} diff --git a/apps/api/internal/auth/jwt_test.go b/apps/api/internal/auth/jwt_test.go new file mode 100644 index 0000000..2f8ee6d --- /dev/null +++ b/apps/api/internal/auth/jwt_test.go @@ -0,0 +1,117 @@ +package auth + +import ( + "errors" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const testSecret = "test-signing-secret-must-be-at-least-32-bytes" + +func TestIssueAndVerify_RoundTrip(t *testing.T) { + issuer := NewIssuer([]byte(testSecret), 15*time.Minute) + verifier := NewVerifier([]byte(testSecret)) + + token, err := issuer.Issue("user-1", "operator@example.com", RoleOperator) + if err != nil { + t.Fatalf("Issue: %v", err) + } + + claims, err := verifier.Verify(token) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if claims.UserID != "user-1" { + t.Errorf("UserID = %q, want user-1", claims.UserID) + } + if claims.Email != "operator@example.com" { + t.Errorf("Email = %q, want operator@example.com", claims.Email) + } + if claims.Role != RoleOperator { + t.Errorf("Role = %q, want operator", claims.Role) + } + if !claims.ExpiresAt.After(claims.IssuedAt) { + t.Errorf("ExpiresAt %v must be after IssuedAt %v", claims.ExpiresAt, claims.IssuedAt) + } +} + +func TestVerify_ExpiredToken(t *testing.T) { + issuer := NewIssuer([]byte(testSecret), -1*time.Minute) // already expired + verifier := NewVerifier([]byte(testSecret)) + + token, err := issuer.Issue("user-1", "operator@example.com", RoleOperator) + if err != nil { + t.Fatalf("Issue: %v", err) + } + if _, err := verifier.Verify(token); !errors.Is(err, ErrExpiredToken) { + t.Fatalf("got %v, want ErrExpiredToken", err) + } +} + +func TestVerify_WrongSignature(t *testing.T) { + issuer := NewIssuer([]byte(testSecret), 15*time.Minute) + verifier := NewVerifier([]byte("a-completely-different-32-byte-secret!")) + + token, err := issuer.Issue("user-1", "operator@example.com", RoleOperator) + if err != nil { + t.Fatalf("Issue: %v", err) + } + if _, err := verifier.Verify(token); !errors.Is(err, ErrInvalidToken) { + t.Fatalf("got %v, want ErrInvalidToken", err) + } +} + +func TestVerify_MalformedToken(t *testing.T) { + verifier := NewVerifier([]byte(testSecret)) + if _, err := verifier.Verify("not-a-jwt"); !errors.Is(err, ErrInvalidToken) { + t.Fatalf("got %v, want ErrInvalidToken", err) + } +} + +func TestVerify_MissingExpiration(t *testing.T) { + verifier := NewVerifier([]byte(testSecret)) + + claims := tokenClaims{ + Email: "operator@example.com", + Role: string(RoleOperator), + RegisteredClaims: jwt.RegisteredClaims{ + Subject: "user-1", + IssuedAt: jwt.NewNumericDate(time.Now().UTC()), + // ExpiresAt intentionally omitted. + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := token.SignedString([]byte(testSecret)) + if err != nil { + t.Fatalf("SignedString: %v", err) + } + + if _, err := verifier.Verify(signed); !errors.Is(err, ErrInvalidToken) { + t.Fatalf("got %v, want ErrInvalidToken", err) + } +} + +func TestVerify_MissingIssuedAt(t *testing.T) { + verifier := NewVerifier([]byte(testSecret)) + + claims := tokenClaims{ + Email: "operator@example.com", + Role: string(RoleOperator), + RegisteredClaims: jwt.RegisteredClaims{ + Subject: "user-1", + ExpiresAt: jwt.NewNumericDate(time.Now().UTC().Add(15 * time.Minute)), + // IssuedAt intentionally omitted. + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := token.SignedString([]byte(testSecret)) + if err != nil { + t.Fatalf("SignedString: %v", err) + } + + if _, err := verifier.Verify(signed); !errors.Is(err, ErrInvalidToken) { + t.Fatalf("got %v, want ErrInvalidToken", err) + } +} diff --git a/apps/api/internal/auth/password.go b/apps/api/internal/auth/password.go new file mode 100644 index 0000000..100d659 --- /dev/null +++ b/apps/api/internal/auth/password.go @@ -0,0 +1,17 @@ +package auth + +import "golang.org/x/crypto/bcrypt" + +// HashPassword bcrypt-hashes a plaintext password for storage. +func HashPassword(plain string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(hash), nil +} + +// VerifyPassword reports whether plain matches the given bcrypt hash. +func VerifyPassword(hash, plain string) error { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) +} diff --git a/apps/api/internal/auth/password_test.go b/apps/api/internal/auth/password_test.go new file mode 100644 index 0000000..da9dfd2 --- /dev/null +++ b/apps/api/internal/auth/password_test.go @@ -0,0 +1,26 @@ +package auth + +import "testing" + +func TestHashPassword_VerifyRoundTrip(t *testing.T) { + hash, err := HashPassword("correct-password") + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + if hash == "correct-password" { + t.Fatalf("hash must not equal the plaintext") + } + if err := VerifyPassword(hash, "correct-password"); err != nil { + t.Errorf("VerifyPassword with correct password: %v", err) + } +} + +func TestVerifyPassword_WrongPassword(t *testing.T) { + hash, err := HashPassword("correct-password") + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + if err := VerifyPassword(hash, "wrong-password"); err == nil { + t.Errorf("VerifyPassword with wrong password: want error, got nil") + } +} From f4e31fac9b4b2ddbf1f499d910e7250c83027cc5 Mon Sep 17 00:00:00 2001 From: vikoabastian <11003051+vianbas@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:26:07 +0700 Subject: [PATCH 09/29] feat(api): add login service with bcrypt verification --- apps/api/internal/auth/service.go | 81 ++++++++++++++++++++++++++ apps/api/internal/auth/service_test.go | 76 ++++++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 apps/api/internal/auth/service.go create mode 100644 apps/api/internal/auth/service_test.go diff --git a/apps/api/internal/auth/service.go b/apps/api/internal/auth/service.go new file mode 100644 index 0000000..3c824f2 --- /dev/null +++ b/apps/api/internal/auth/service.go @@ -0,0 +1,81 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "sync" +) + +// ErrUserNotFound is returned by UserStore when no user has the given email. +var ErrUserNotFound = errors.New("auth: user not found") + +// ErrInvalidCredentials is returned by Service.Login for any failure that +// should not reveal whether the email or the password was wrong. +var ErrInvalidCredentials = errors.New("auth: invalid credentials") + +// User is a persisted account used for login. +type User struct { + ID string + Email string + PasswordHash string + Role Role +} + +// UserStore is the persistence port for user accounts. +type UserStore interface { + GetUserByEmail(ctx context.Context, email string) (User, error) +} + +// Service implements the login use case: verify credentials, issue a token. +type Service struct { + store UserStore + issuer *Issuer +} + +// NewService constructs a Service. +func NewService(store UserStore, issuer *Issuer) *Service { + return &Service{store: store, issuer: issuer} +} + +// dummyPasswordHash is a bcrypt hash of a fixed, non-secret string, computed +// once on first use. Login compares against it for unknown emails so that an +// unknown-email attempt costs the same bcrypt comparison as a wrong-password +// attempt on a real account, closing a timing oracle that would otherwise let +// an attacker enumerate registered emails. +var dummyPasswordHash = sync.OnceValue(func() string { + hash, err := HashPassword("auth-dummy-password-for-constant-time-comparison") + if err != nil { + // HashPassword only fails on bcrypt-internal errors (e.g. an + // unreasonably long input); the fixed string above can never + // trigger that, so this is unreachable in practice. + panic(fmt.Sprintf("auth: hash dummy password: %v", err)) + } + return hash +}) + +// Login verifies email/password and, on success, returns a signed access +// token and the authenticated user. Unknown email and wrong password both +// return ErrInvalidCredentials so the caller cannot distinguish them. +func (s *Service) Login(ctx context.Context, email, password string) (string, User, error) { + user, err := s.store.GetUserByEmail(ctx, email) + if errors.Is(err, ErrUserNotFound) { + // Run a bcrypt comparison against a dummy hash so this path costs + // the same as a wrong-password match below, not a timing oracle. + _ = VerifyPassword(dummyPasswordHash(), password) + return "", User{}, ErrInvalidCredentials + } + if err != nil { + return "", User{}, fmt.Errorf("auth: get user by email: %w", err) + } + + if err := VerifyPassword(user.PasswordHash, password); err != nil { + return "", User{}, ErrInvalidCredentials + } + + token, err := s.issuer.Issue(user.ID, user.Email, user.Role) + if err != nil { + return "", User{}, fmt.Errorf("auth: issue token: %w", err) + } + return token, user, nil +} diff --git a/apps/api/internal/auth/service_test.go b/apps/api/internal/auth/service_test.go new file mode 100644 index 0000000..e47f885 --- /dev/null +++ b/apps/api/internal/auth/service_test.go @@ -0,0 +1,76 @@ +package auth_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/vianbas/finwatch/apps/api/internal/auth" +) + +type fakeUserStore struct { + usersByEmail map[string]auth.User +} + +func (f *fakeUserStore) GetUserByEmail(_ context.Context, email string) (auth.User, error) { + u, ok := f.usersByEmail[email] + if !ok { + return auth.User{}, auth.ErrUserNotFound + } + return u, nil +} + +func newTestService(t *testing.T, users ...auth.User) *auth.Service { + t.Helper() + byEmail := make(map[string]auth.User, len(users)) + for _, u := range users { + byEmail[u.Email] = u + } + issuer := auth.NewIssuer([]byte("test-signing-secret-must-be-at-least-32-bytes"), 15*time.Minute) + return auth.NewService(&fakeUserStore{usersByEmail: byEmail}, issuer) +} + +func userWithPassword(t *testing.T, email, password string, role auth.Role) auth.User { + t.Helper() + hash, err := auth.HashPassword(password) + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + return auth.User{ID: "user-1", Email: email, PasswordHash: hash, Role: role} +} + +func TestService_Login_ValidCredentials(t *testing.T) { + user := userWithPassword(t, "operator@example.com", "correct-password", auth.RoleOperator) + svc := newTestService(t, user) + + token, got, err := svc.Login(context.Background(), "operator@example.com", "correct-password") + if err != nil { + t.Fatalf("Login: %v", err) + } + if token == "" { + t.Errorf("want non-empty token") + } + if got.Email != user.Email || got.Role != user.Role { + t.Errorf("got user %+v, want email/role to match %+v", got, user) + } +} + +func TestService_Login_WrongPassword(t *testing.T) { + user := userWithPassword(t, "operator@example.com", "correct-password", auth.RoleOperator) + svc := newTestService(t, user) + + _, _, err := svc.Login(context.Background(), "operator@example.com", "wrong-password") + if !errors.Is(err, auth.ErrInvalidCredentials) { + t.Fatalf("got %v, want ErrInvalidCredentials", err) + } +} + +func TestService_Login_UnknownEmail(t *testing.T) { + svc := newTestService(t) + + _, _, err := svc.Login(context.Background(), "nobody@example.com", "whatever") + if !errors.Is(err, auth.ErrInvalidCredentials) { + t.Fatalf("got %v, want ErrInvalidCredentials", err) + } +} From 1f2587625f46abf3741103747e6f47af3fdfd0b2 Mon Sep 17 00:00:00 2001 From: vikoabastian <11003051+vianbas@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:28:17 +0700 Subject: [PATCH 10/29] feat(api): add RequireAuth and RequireRole HTTP middleware --- apps/api/internal/auth/middleware.go | 67 ++++++++++++ apps/api/internal/auth/middleware_test.go | 122 ++++++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 apps/api/internal/auth/middleware.go create mode 100644 apps/api/internal/auth/middleware_test.go diff --git a/apps/api/internal/auth/middleware.go b/apps/api/internal/auth/middleware.go new file mode 100644 index 0000000..f9f4ed2 --- /dev/null +++ b/apps/api/internal/auth/middleware.go @@ -0,0 +1,67 @@ +package auth + +import ( + "context" + "net/http" + "strings" + + "github.com/vianbas/finwatch/apps/api/internal/platform/web" +) + +type contextKey string + +const claimsContextKey contextKey = "auth_claims" + +// RequireAuth rejects requests without a valid, unexpired bearer token with +// 401, and otherwise stores the decoded Claims on the request context. +func RequireAuth(verifier *Verifier) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token, ok := bearerToken(r.Header.Get("Authorization")) + if !ok { + web.WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "missing bearer token") + return + } + claims, err := verifier.Verify(token) + if err != nil { + web.WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid or expired token") + return + } + ctx := context.WithValue(r.Context(), claimsContextKey, claims) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// RequireRole rejects requests whose authenticated Claims do not carry the +// given role with 403. It must run behind RequireAuth. +func RequireRole(role Role) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, ok := ClaimsFromContext(r.Context()) + if !ok || claims.Role != role { + web.WriteError(w, http.StatusForbidden, "FORBIDDEN", "insufficient role") + return + } + next.ServeHTTP(w, r) + }) + } +} + +// ClaimsFromContext returns the Claims stored by RequireAuth, if any. +func ClaimsFromContext(ctx context.Context) (Claims, bool) { + claims, ok := ctx.Value(claimsContextKey).(Claims) + return claims, ok +} + +func bearerToken(header string) (string, bool) { + const prefix = "Bearer " + if !strings.HasPrefix(header, prefix) { + return "", false + } + token := strings.TrimSpace(strings.TrimPrefix(header, prefix)) + if token == "" { + return "", false + } + return token, true +} diff --git a/apps/api/internal/auth/middleware_test.go b/apps/api/internal/auth/middleware_test.go new file mode 100644 index 0000000..422013f --- /dev/null +++ b/apps/api/internal/auth/middleware_test.go @@ -0,0 +1,122 @@ +package auth_test + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/vianbas/finwatch/apps/api/internal/auth" +) + +const mwTestSecret = "test-signing-secret-must-be-at-least-32-bytes" + +func okHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.ClaimsFromContext(r.Context()) + if ok { + w.Header().Set("X-Role", string(claims.Role)) + } + w.WriteHeader(http.StatusOK) + }) +} + +func issueToken(t *testing.T, ttl time.Duration, role auth.Role) string { + t.Helper() + issuer := auth.NewIssuer([]byte(mwTestSecret), ttl) + token, err := issuer.Issue("user-1", "operator@example.com", role) + if err != nil { + t.Fatalf("Issue: %v", err) + } + return token +} + +func TestRequireAuth_MissingToken(t *testing.T) { + verifier := auth.NewVerifier([]byte(mwTestSecret)) + handler := auth.RequireAuth(verifier)(okHandler()) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} + +func TestRequireAuth_MalformedToken(t *testing.T) { + verifier := auth.NewVerifier([]byte(mwTestSecret)) + handler := auth.RequireAuth(verifier)(okHandler()) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + req.Header.Set("Authorization", "Bearer not-a-jwt") + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} + +func TestRequireAuth_ExpiredToken(t *testing.T) { + verifier := auth.NewVerifier([]byte(mwTestSecret)) + handler := auth.RequireAuth(verifier)(okHandler()) + token := issueToken(t, -1*time.Minute, auth.RoleOperator) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + req.Header.Set("Authorization", "Bearer "+token) + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} + +func TestRequireAuth_ValidToken(t *testing.T) { + verifier := auth.NewVerifier([]byte(mwTestSecret)) + handler := auth.RequireAuth(verifier)(okHandler()) + token := issueToken(t, 15*time.Minute, auth.RoleOperator) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + req.Header.Set("Authorization", "Bearer "+token) + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if got := rec.Header().Get("X-Role"); got != "operator" { + t.Errorf("X-Role = %q, want operator", got) + } +} + +func TestRequireRole_OperatorDeniedFromAdminRoute(t *testing.T) { + verifier := auth.NewVerifier([]byte(mwTestSecret)) + handler := auth.RequireAuth(verifier)(auth.RequireRole(auth.RoleAdmin)(okHandler())) + token := issueToken(t, 15*time.Minute, auth.RoleOperator) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/admin-only", nil) + req.Header.Set("Authorization", "Bearer "+token) + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } +} + +func TestRequireRole_AdminAllowed(t *testing.T) { + verifier := auth.NewVerifier([]byte(mwTestSecret)) + handler := auth.RequireAuth(verifier)(auth.RequireRole(auth.RoleAdmin)(okHandler())) + token := issueToken(t, 15*time.Minute, auth.RoleAdmin) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/admin-only", nil) + req.Header.Set("Authorization", "Bearer "+token) + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } +} From 8a2227cd5954cacde2139bc35b6850a85dea31b5 Mon Sep 17 00:00:00 2001 From: vikoabastian <11003051+vianbas@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:30:53 +0700 Subject: [PATCH 11/29] feat(api): add Postgres-backed user store --- apps/api/internal/auth/store/store.go | 64 +++++++ .../auth/store/store_integration_test.go | 173 ++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 apps/api/internal/auth/store/store.go create mode 100644 apps/api/internal/auth/store/store_integration_test.go diff --git a/apps/api/internal/auth/store/store.go b/apps/api/internal/auth/store/store.go new file mode 100644 index 0000000..52dbe92 --- /dev/null +++ b/apps/api/internal/auth/store/store.go @@ -0,0 +1,64 @@ +// Package store is the PostgreSQL implementation of auth.UserStore. +package store + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/vianbas/finwatch/apps/api/internal/auth" + "github.com/vianbas/finwatch/apps/api/internal/platform/postgres/db" + "github.com/vianbas/finwatch/apps/api/internal/platform/postgres/pgconv" +) + +// Store persists users in PostgreSQL. +type Store struct { + pool *pgxpool.Pool +} + +// New constructs a Store. +func New(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +// GetUserByEmail implements auth.UserStore. +func (s *Store) GetUserByEmail(ctx context.Context, email string) (auth.User, error) { + row, err := db.New(s.pool).GetUserByEmail(ctx, email) + if errors.Is(err, pgx.ErrNoRows) { + return auth.User{}, auth.ErrUserNotFound + } + if err != nil { + return auth.User{}, fmt.Errorf("store: get user by email: %w", err) + } + return toDomain(row), nil +} + +// InsertUserIfAbsent creates a user with an already-hashed password. created +// is false (with a nil error) if the email already exists, so the +// seed-users command can skip logging a duplicate. +func (s *Store) InsertUserIfAbsent(ctx context.Context, email, passwordHash string, role auth.Role) (auth.User, bool, error) { + row, err := db.New(s.pool).InsertUserIfAbsent(ctx, db.InsertUserIfAbsentParams{ + Email: email, + PasswordHash: passwordHash, + Role: string(role), + }) + if errors.Is(err, pgx.ErrNoRows) { + return auth.User{}, false, nil + } + if err != nil { + return auth.User{}, false, fmt.Errorf("store: insert user: %w", err) + } + return toDomain(row), true, nil +} + +func toDomain(r db.User) auth.User { + return auth.User{ + ID: pgconv.UUIDString(r.ID), + Email: r.Email, + PasswordHash: r.PasswordHash, + Role: auth.Role(r.Role), + } +} diff --git a/apps/api/internal/auth/store/store_integration_test.go b/apps/api/internal/auth/store/store_integration_test.go new file mode 100644 index 0000000..46e2787 --- /dev/null +++ b/apps/api/internal/auth/store/store_integration_test.go @@ -0,0 +1,173 @@ +package store + +import ( + "context" + "errors" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/vianbas/finwatch/apps/api/internal/auth" +) + +// These tests exercise real PostgreSQL behaviour. They run only when +// FINWATCH_TEST_DATABASE_URL points at a disposable database, e.g.: +// +// make dev # starts Postgres on localhost:5432 +// FINWATCH_TEST_DATABASE_URL='postgres://finwatch:finwatch_dev_password@localhost:5432/finwatch?sslmode=disable' \ +// go test ./internal/auth/store/... +// +// Without the env var they skip, so `make verify` stays green without a DB. + +func newTestPool(t *testing.T) *pgxpool.Pool { + t.Helper() + url := os.Getenv("FINWATCH_TEST_DATABASE_URL") + if url == "" { + t.Skip("set FINWATCH_TEST_DATABASE_URL to run store integration tests") + } + pool, err := pgxpool.New(context.Background(), url) + if err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(pool.Close) + resetSchema(t, pool) + return pool +} + +func resetSchema(t *testing.T, pool *pgxpool.Pool) { + t.Helper() + ctx := context.Background() + if _, err := pool.Exec(ctx, `DROP SCHEMA public CASCADE; CREATE SCHEMA public`); err != nil { + t.Fatalf("reset schema: %v", err) + } + dir := filepath.Join("..", "..", "..", "migrations") + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("read migrations dir: %v", err) + } + var ups []string + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".up.sql") { + ups = append(ups, e.Name()) + } + } + sort.Strings(ups) + for _, name := range ups { + sql, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + if _, err := pool.Exec(ctx, string(sql)); err != nil { + t.Fatalf("apply %s: %v", name, err) + } + } +} + +func TestStore_InsertUserIfAbsent_CreatesUser(t *testing.T) { + pool := newTestPool(t) + s := New(pool) + ctx := context.Background() + + hash, err := auth.HashPassword("correct horse battery staple") + if err != nil { + t.Fatalf("hash password: %v", err) + } + + got, created, err := s.InsertUserIfAbsent(ctx, "insert-user@example.com", hash, auth.RoleOperator) + if err != nil { + t.Fatalf("insert user: %v", err) + } + if !created { + t.Fatal("want created=true on first insert") + } + if got.ID == "" { + t.Fatal("want a generated user id") + } + if got.Email != "insert-user@example.com" { + t.Errorf("email=%q, want insert-user@example.com", got.Email) + } + if got.PasswordHash != hash { + t.Errorf("password hash did not round-trip") + } + if got.Role != auth.RoleOperator { + t.Errorf("role=%q, want operator", got.Role) + } +} + +func TestStore_InsertUserIfAbsent_DuplicateEmailNotCreated(t *testing.T) { + pool := newTestPool(t) + s := New(pool) + ctx := context.Background() + + hash, err := auth.HashPassword("correct horse battery staple") + if err != nil { + t.Fatalf("hash password: %v", err) + } + + if _, _, err := s.InsertUserIfAbsent(ctx, "dup-user@example.com", hash, auth.RoleOperator); err != nil { + t.Fatalf("first insert: %v", err) + } + + _, created, err := s.InsertUserIfAbsent(ctx, "dup-user@example.com", hash, auth.RoleAdmin) + if err != nil { + t.Fatalf("second insert: %v", err) + } + if created { + t.Fatal("want created=false on duplicate email") + } + + var count int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM users WHERE email = 'dup-user@example.com'`).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("users with that email: got %d, want 1", count) + } +} + +func TestStore_GetUserByEmail_ReturnsStoredUser(t *testing.T) { + pool := newTestPool(t) + s := New(pool) + ctx := context.Background() + + hash, err := auth.HashPassword("correct horse battery staple") + if err != nil { + t.Fatalf("hash password: %v", err) + } + + inserted, _, err := s.InsertUserIfAbsent(ctx, "get-user@example.com", hash, auth.RoleAdmin) + if err != nil { + t.Fatalf("insert user: %v", err) + } + + got, err := s.GetUserByEmail(ctx, "get-user@example.com") + if err != nil { + t.Fatalf("get user by email: %v", err) + } + if got.ID != inserted.ID { + t.Errorf("id=%q, want %q", got.ID, inserted.ID) + } + if got.Email != "get-user@example.com" { + t.Errorf("email=%q, want get-user@example.com", got.Email) + } + if got.PasswordHash != hash { + t.Errorf("password hash did not round-trip") + } + if got.Role != auth.RoleAdmin { + t.Errorf("role=%q, want admin", got.Role) + } +} + +func TestStore_GetUserByEmail_UnknownEmailReturnsErrUserNotFound(t *testing.T) { + pool := newTestPool(t) + s := New(pool) + + _, err := s.GetUserByEmail(context.Background(), "unknown-user@example.com") + if !errors.Is(err, auth.ErrUserNotFound) { + t.Fatalf("got %v, want ErrUserNotFound", err) + } +} From 15d026e9da707ba06f359f68a5a1e23d58a019cb Mon Sep 17 00:00:00 2001 From: vikoabastian <11003051+vianbas@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:33:18 +0700 Subject: [PATCH 12/29] feat(api): add POST /login and GET /me handlers --- apps/api/internal/auth/httpapi/handler.go | 100 ++++++++++ .../api/internal/auth/httpapi/handler_test.go | 183 ++++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 apps/api/internal/auth/httpapi/handler.go create mode 100644 apps/api/internal/auth/httpapi/handler_test.go diff --git a/apps/api/internal/auth/httpapi/handler.go b/apps/api/internal/auth/httpapi/handler.go new file mode 100644 index 0000000..d622ac3 --- /dev/null +++ b/apps/api/internal/auth/httpapi/handler.go @@ -0,0 +1,100 @@ +// Package httpapi exposes the auth module over HTTP: login and the current +// user. It validates input at the boundary and holds no business logic. +package httpapi + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "github.com/vianbas/finwatch/apps/api/internal/auth" + "github.com/vianbas/finwatch/apps/api/internal/platform/web" +) + +// maxLoginBodyBytes bounds the size of the POST /login request body. /login +// is public and unauthenticated, so an unbounded body would let an anonymous +// caller exhaust server memory; 8 KiB comfortably covers any real +// email/password payload. +const maxLoginBodyBytes = 8 << 10 // 8 KiB + +// Handler serves the auth HTTP endpoints. +type Handler struct { + svc *auth.Service + log *slog.Logger +} + +// NewHandler constructs a Handler. +func NewHandler(svc *auth.Service, log *slog.Logger) *Handler { + return &Handler{svc: svc, log: log} +} + +// RegisterPublicRoutes mounts routes that do not require authentication. +func (h *Handler) RegisterPublicRoutes(r chi.Router) { + r.Post("/login", h.login) +} + +// RegisterProtectedRoutes mounts routes that require a valid bearer token. +// The caller is responsible for applying auth.RequireAuth to this router. +func (h *Handler) RegisterProtectedRoutes(r chi.Router) { + r.Get("/me", h.me) +} + +type loginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type userDTO struct { + ID string `json:"id"` + Email string `json:"email"` + Role string `json:"role"` +} + +type loginResponse struct { + AccessToken string `json:"accessToken"` + User userDTO `json:"user"` +} + +func (h *Handler) login(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodyBytes) + + var req loginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + web.WriteError(w, http.StatusBadRequest, "INVALID_BODY", "request body must be valid JSON") + return + } + req.Email = strings.TrimSpace(req.Email) + if req.Email == "" || req.Password == "" { + web.WriteError(w, http.StatusBadRequest, "INVALID_BODY", "email and password are required") + return + } + + token, user, err := h.svc.Login(r.Context(), req.Email, req.Password) + if err != nil { + if errors.Is(err, auth.ErrInvalidCredentials) { + web.WriteError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "email or password is incorrect") + return + } + h.log.ErrorContext(r.Context(), "login failed", slog.String("error", err.Error())) + web.WriteError(w, http.StatusInternalServerError, "INTERNAL", "unexpected error") + return + } + web.WriteJSON(w, http.StatusOK, loginResponse{AccessToken: token, User: toUserDTO(user)}) +} + +func (h *Handler) me(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.ClaimsFromContext(r.Context()) + if !ok { + web.WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "missing bearer token") + return + } + web.WriteJSON(w, http.StatusOK, userDTO{ID: claims.UserID, Email: claims.Email, Role: string(claims.Role)}) +} + +func toUserDTO(u auth.User) userDTO { + return userDTO{ID: u.ID, Email: u.Email, Role: string(u.Role)} +} diff --git a/apps/api/internal/auth/httpapi/handler_test.go b/apps/api/internal/auth/httpapi/handler_test.go new file mode 100644 index 0000000..007f0e7 --- /dev/null +++ b/apps/api/internal/auth/httpapi/handler_test.go @@ -0,0 +1,183 @@ +package httpapi_test + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/vianbas/finwatch/apps/api/internal/auth" + "github.com/vianbas/finwatch/apps/api/internal/auth/httpapi" +) + +const handlerTestSecret = "test-signing-secret-must-be-at-least-32-bytes" + +type fakeUserStore struct { + usersByEmail map[string]auth.User +} + +func (f *fakeUserStore) GetUserByEmail(_ context.Context, email string) (auth.User, error) { + u, ok := f.usersByEmail[email] + if !ok { + return auth.User{}, auth.ErrUserNotFound + } + return u, nil +} + +func newRouter(t *testing.T, users ...auth.User) http.Handler { + t.Helper() + byEmail := make(map[string]auth.User, len(users)) + for _, u := range users { + byEmail[u.Email] = u + } + issuer := auth.NewIssuer([]byte(handlerTestSecret), 15*time.Minute) + verifier := auth.NewVerifier([]byte(handlerTestSecret)) + svc := auth.NewService(&fakeUserStore{usersByEmail: byEmail}, issuer) + h := httpapi.NewHandler(svc, slog.Default()) + + r := chi.NewRouter() + h.RegisterPublicRoutes(r) + r.Group(func(pr chi.Router) { + pr.Use(auth.RequireAuth(verifier)) + h.RegisterProtectedRoutes(pr) + }) + return r +} + +func userWithPassword(t *testing.T, email, password string, role auth.Role) auth.User { + t.Helper() + hash, err := auth.HashPassword(password) + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + return auth.User{ID: "user-1", Email: email, PasswordHash: hash, Role: role} +} + +func decode(t *testing.T, body []byte) map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal(body, &m); err != nil { + t.Fatalf("decode body: %v\nbody: %s", err, body) + } + return m +} + +func TestLogin_ValidCredentials(t *testing.T) { + user := userWithPassword(t, "operator@example.com", "correct-password", auth.RoleOperator) + router := newRouter(t, user) + + body, _ := json.Marshal(map[string]string{"email": "operator@example.com", "password": "correct-password"}) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewReader(body)) + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body = %s", rec.Code, rec.Body.String()) + } + resp := decode(t, rec.Body.Bytes()) + if resp["accessToken"] == "" || resp["accessToken"] == nil { + t.Errorf("want non-empty accessToken, got %v", resp["accessToken"]) + } + user_, ok := resp["user"].(map[string]any) + if !ok || user_["email"] != "operator@example.com" || user_["role"] != "operator" { + t.Errorf("user field = %v, want email/role operator@example.com/operator", resp["user"]) + } +} + +func TestLogin_WrongPassword(t *testing.T) { + user := userWithPassword(t, "operator@example.com", "correct-password", auth.RoleOperator) + router := newRouter(t, user) + + body, _ := json.Marshal(map[string]string{"email": "operator@example.com", "password": "wrong-password"}) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewReader(body)) + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} + +func TestLogin_MissingFields(t *testing.T) { + router := newRouter(t) + + body, _ := json.Marshal(map[string]string{"email": "", "password": ""}) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewReader(body)) + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } +} + +func TestLogin_BodyTooLarge(t *testing.T) { + router := newRouter(t) + + // Build a >8 KiB JSON body: a valid-shaped payload padded out with a + // filler field so it still decodes as JSON up to the point the reader + // cuts it off. + padding := bytes.Repeat([]byte("a"), 9<<10) + body, _ := json.Marshal(map[string]string{ + "email": "operator@example.com", + "password": "correct-password", + "padding": string(padding), + }) + if len(body) <= 8<<10 { + t.Fatalf("test body must exceed 8 KiB, got %d bytes", len(body)) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewReader(body)) + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String()) + } + resp := decode(t, rec.Body.Bytes()) + errObj, ok := resp["error"].(map[string]any) + if !ok || errObj["code"] != "INVALID_BODY" { + t.Errorf("error = %v, want code INVALID_BODY", resp["error"]) + } +} + +func TestMe_ValidToken_ReturnsClaims(t *testing.T) { + user := userWithPassword(t, "operator@example.com", "correct-password", auth.RoleOperator) + router := newRouter(t, user) + + loginBody, _ := json.Marshal(map[string]string{"email": "operator@example.com", "password": "correct-password"}) + loginRec := httptest.NewRecorder() + router.ServeHTTP(loginRec, httptest.NewRequest(http.MethodPost, "/login", bytes.NewReader(loginBody))) + token := decode(t, loginRec.Body.Bytes())["accessToken"].(string) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/me", nil) + req.Header.Set("Authorization", "Bearer "+token) + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body = %s", rec.Code, rec.Body.String()) + } + resp := decode(t, rec.Body.Bytes()) + if resp["email"] != "operator@example.com" || resp["role"] != "operator" { + t.Errorf("got %v, want email/role operator@example.com/operator", resp) + } +} + +func TestMe_NoToken_Returns401(t *testing.T) { + router := newRouter(t) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/me", nil) + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} From c318e1bd551b276b06e32d68f93093fa1aad844f Mon Sep 17 00:00:00 2001 From: vikoabastian <11003051+vianbas@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:35:16 +0700 Subject: [PATCH 13/29] feat(api): split router into public and auth-protected route groups --- .../internal/platform/httpserver/router.go | 34 ++++++++-- .../platform/httpserver/router_auth_test.go | 63 +++++++++++++++++++ 2 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 apps/api/internal/platform/httpserver/router_auth_test.go diff --git a/apps/api/internal/platform/httpserver/router.go b/apps/api/internal/platform/httpserver/router.go index 648f766..0e21d9f 100644 --- a/apps/api/internal/platform/httpserver/router.go +++ b/apps/api/internal/platform/httpserver/router.go @@ -16,11 +16,25 @@ type RouteRegistrar interface { RegisterRoutes(r chi.Router) } +// RegistrarFunc adapts a plain function to RouteRegistrar, the same pattern +// as http.HandlerFunc. +type RegistrarFunc func(r chi.Router) + +// RegisterRoutes implements RouteRegistrar. +func (f RegistrarFunc) RegisterRoutes(r chi.Router) { f(r) } + // RouterDeps are the dependencies required to build the HTTP router. type RouterDeps struct { - Logger *slog.Logger - Health *HealthHandler + Logger *slog.Logger + Health *HealthHandler + // PublicModules mount routes that do not require authentication (e.g. login). + PublicModules []RouteRegistrar + // Modules mount routes that require a valid bearer token when RequireAuth + // is set. Modules []RouteRegistrar + // RequireAuth, if non-nil, wraps Modules' routes. It is a plain middleware + // function so this package has no dependency on the auth feature package. + RequireAuth func(http.Handler) http.Handler } // NewRouter builds the application's HTTP handler with the standard middleware @@ -37,13 +51,25 @@ func NewRouter(deps RouterDeps) http.Handler { r.Get("/health/live", deps.Health.Live) r.Get("/health/ready", deps.Health.Ready) - // Feature modules mount their own routes. - for _, m := range deps.Modules { + // Public feature routes (e.g. login) mount without auth middleware. + for _, m := range deps.PublicModules { if m != nil { m.RegisterRoutes(r) } } + // Protected feature routes run behind RequireAuth when configured. + r.Group(func(pr chi.Router) { + if deps.RequireAuth != nil { + pr.Use(deps.RequireAuth) + } + for _, m := range deps.Modules { + if m != nil { + m.RegisterRoutes(pr) + } + } + }) + r.NotFound(func(w http.ResponseWriter, _ *http.Request) { web.WriteError(w, http.StatusNotFound, "NOT_FOUND", "resource not found") }) diff --git a/apps/api/internal/platform/httpserver/router_auth_test.go b/apps/api/internal/platform/httpserver/router_auth_test.go new file mode 100644 index 0000000..cf403e8 --- /dev/null +++ b/apps/api/internal/platform/httpserver/router_auth_test.go @@ -0,0 +1,63 @@ +package httpserver + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" +) + +func registrar(register func(chi.Router)) RouteRegistrar { + return RegistrarFunc(register) +} + +func TestRouter_PublicModuleRunsWithoutAuthMiddleware(t *testing.T) { + h := NewHealthHandler(fakePinger{}) + router := NewRouter(RouterDeps{ + Logger: testLogger(), + Health: h, + PublicModules: []RouteRegistrar{ + registrar(func(r chi.Router) { + r.Get("/public", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) + }), + }, + RequireAuth: func(http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) // would reject everything if applied + }) + }, + }) + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/public", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (public route must bypass RequireAuth)", rec.Code) + } +} + +func TestRouter_ProtectedModuleRunsBehindAuthMiddleware(t *testing.T) { + h := NewHealthHandler(fakePinger{}) + router := NewRouter(RouterDeps{ + Logger: testLogger(), + Health: h, + Modules: []RouteRegistrar{ + registrar(func(r chi.Router) { + r.Get("/protected", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) + }), + }, + RequireAuth: func(http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }) + }, + }) + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/protected", nil)) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401 (protected route must run behind RequireAuth)", rec.Code) + } +} From a2e338815ac4aa62984ad20c1c6a17637b3c75c7 Mon Sep 17 00:00:00 2001 From: vikoabastian <11003051+vianbas@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:37:39 +0700 Subject: [PATCH 14/29] feat(api): add allow-list CORS middleware for browser clients --- .../internal/platform/httpserver/cors_test.go | 165 ++++++++++++++++++ .../platform/httpserver/middleware.go | 48 +++++ .../internal/platform/httpserver/router.go | 7 + 3 files changed, 220 insertions(+) create mode 100644 apps/api/internal/platform/httpserver/cors_test.go diff --git a/apps/api/internal/platform/httpserver/cors_test.go b/apps/api/internal/platform/httpserver/cors_test.go new file mode 100644 index 0000000..f31eb0f --- /dev/null +++ b/apps/api/internal/platform/httpserver/cors_test.go @@ -0,0 +1,165 @@ +package httpserver + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" +) + +func TestCORS_PreflightFromAllowedOrigin(t *testing.T) { + nextCalled := false + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + nextCalled = true + w.WriteHeader(http.StatusOK) + }) + + handler := CORS([]string{"http://localhost:5173"})(next) + + req := httptest.NewRequest(http.MethodOptions, "/login", nil) + req.Header.Set("Origin", "http://localhost:5173") + req.Header.Set("Access-Control-Request-Method", "POST") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204", rec.Code) + } + if nextCalled { + t.Error("next handler was called for a preflight request, want it skipped") + } + + h := rec.Header() + if got := h.Get("Access-Control-Allow-Origin"); got != "http://localhost:5173" { + t.Errorf("Access-Control-Allow-Origin = %q, want %q", got, "http://localhost:5173") + } + if got := h.Get("Access-Control-Allow-Methods"); got != "GET, POST" { + t.Errorf("Access-Control-Allow-Methods = %q, want %q", got, "GET, POST") + } + if got := h.Get("Access-Control-Allow-Headers"); got != "Authorization, Content-Type" { + t.Errorf("Access-Control-Allow-Headers = %q, want %q", got, "Authorization, Content-Type") + } + if got := h.Get("Access-Control-Max-Age"); got != "600" { + t.Errorf("Access-Control-Max-Age = %q, want %q", got, "600") + } + if got := h.Values("Vary"); len(got) != 1 || got[0] != "Origin" { + t.Errorf("Vary = %v, want [Origin]", got) + } + if got := h.Get("Access-Control-Allow-Credentials"); got != "" { + t.Errorf("Access-Control-Allow-Credentials = %q, want unset", got) + } +} + +func TestCORS_PreflightFromDisallowedOrigin(t *testing.T) { + nextCalled := false + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + nextCalled = true + w.WriteHeader(http.StatusOK) + }) + + handler := CORS([]string{"http://localhost:5173"})(next) + + req := httptest.NewRequest(http.MethodOptions, "/login", nil) + req.Header.Set("Origin", "http://evil.example.com") + req.Header.Set("Access-Control-Request-Method", "POST") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Errorf("Access-Control-Allow-Origin = %q, want unset for disallowed origin", got) + } + if !nextCalled { + t.Error("next handler was not called, want request to pass through untouched") + } +} + +func TestCORS_SimpleRequestFromAllowedOrigin(t *testing.T) { + nextCalled := false + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + nextCalled = true + w.WriteHeader(http.StatusOK) + }) + + handler := CORS([]string{"http://localhost:5173"})(next) + + req := httptest.NewRequest(http.MethodGet, "/transactions", nil) + req.Header.Set("Origin", "http://localhost:5173") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if !nextCalled { + t.Error("next handler was not called for a simple request") + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:5173" { + t.Errorf("Access-Control-Allow-Origin = %q, want %q", got, "http://localhost:5173") + } + varyValues := rec.Header().Values("Vary") + found := false + for _, v := range varyValues { + if v == "Origin" { + found = true + } + } + if !found { + t.Errorf("Vary = %v, want it to contain Origin", varyValues) + } +} + +func TestCORS_NoOriginHeader(t *testing.T) { + nextCalled := false + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + nextCalled = true + w.WriteHeader(http.StatusOK) + }) + + handler := CORS([]string{"http://localhost:5173"})(next) + + req := httptest.NewRequest(http.MethodGet, "/transactions", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if !nextCalled { + t.Error("next handler was not called for a request with no Origin header") + } + h := rec.Header() + for _, name := range []string{"Access-Control-Allow-Origin", "Access-Control-Allow-Methods", "Access-Control-Allow-Headers", "Access-Control-Max-Age", "Vary"} { + if got := h.Get(name); got != "" { + t.Errorf("%s = %q, want unset when no Origin header is present", name, got) + } + } +} + +func TestRouter_CORSPreflightAnswersBeforeRequireAuth(t *testing.T) { + h := NewHealthHandler(fakePinger{}) + router := NewRouter(RouterDeps{ + Logger: testLogger(), + Health: h, + CORSAllowedOrigins: []string{"http://localhost:5173"}, + Modules: []RouteRegistrar{ + registrar(func(r chi.Router) { + r.Post("/anything-protected", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) + }), + }, + RequireAuth: func(http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) // would reject the preflight if reached + }) + }, + }) + + req := httptest.NewRequest(http.MethodOptions, "/anything-protected", nil) + req.Header.Set("Origin", "http://localhost:5173") + req.Header.Set("Access-Control-Request-Method", "POST") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204 (CORS must answer preflight before RequireAuth runs)", rec.Code) + } +} diff --git a/apps/api/internal/platform/httpserver/middleware.go b/apps/api/internal/platform/httpserver/middleware.go index 2ee8d8f..c518e66 100644 --- a/apps/api/internal/platform/httpserver/middleware.go +++ b/apps/api/internal/platform/httpserver/middleware.go @@ -85,6 +85,54 @@ func (s *statusRecorder) WriteHeader(code int) { s.ResponseWriter.WriteHeader(code) } +// CORS returns a middleware that answers cross-origin requests from an +// explicit allow-list of origins, using exact string comparison (no +// wildcards, no suffix matching). It exists to let the web app (served from a +// different origin than the API) complete a JSON POST /login and send +// requests carrying an Authorization header without the browser blocking +// them. +// +// Requests with no Origin header, or an Origin not on the allow-list, pass +// through untouched: no CORS headers are added. A preflight request (method +// OPTIONS with an Access-Control-Request-Method header) from an allowed +// origin is answered directly with 204 and is never forwarded to next. Any +// other request from an allowed origin is annotated with +// Access-Control-Allow-Origin and Vary: Origin before being forwarded. +// +// Access-Control-Allow-Credentials is never set: tokens travel in the +// Authorization header, not cookies. +func CORS(allowedOrigins []string) func(http.Handler) http.Handler { + allowed := make(map[string]bool, len(allowedOrigins)) + for _, o := range allowedOrigins { + allowed[o] = true + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin == "" || !allowed[origin] { + next.ServeHTTP(w, r) + return + } + + if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" { + h := w.Header() + h.Set("Access-Control-Allow-Origin", origin) + h.Set("Access-Control-Allow-Methods", "GET, POST") + h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type") + h.Set("Access-Control-Max-Age", "600") + h.Add("Vary", "Origin") + w.WriteHeader(http.StatusNoContent) + return + } + + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Add("Vary", "Origin") + next.ServeHTTP(w, r) + }) + } +} + // AccessLog emits one structured JSON line per request with method, path, // status and latency. It is the only request-scoped logging in the skeleton. func AccessLog(logger *slog.Logger) func(http.Handler) http.Handler { diff --git a/apps/api/internal/platform/httpserver/router.go b/apps/api/internal/platform/httpserver/router.go index 0e21d9f..94ecc59 100644 --- a/apps/api/internal/platform/httpserver/router.go +++ b/apps/api/internal/platform/httpserver/router.go @@ -35,6 +35,10 @@ type RouterDeps struct { // RequireAuth, if non-nil, wraps Modules' routes. It is a plain middleware // function so this package has no dependency on the auth feature package. RequireAuth func(http.Handler) http.Handler + // CORSAllowedOrigins is the exact-match allow-list for cross-origin + // browser requests (e.g. the web app's origin). When empty or nil, the + // CORS middleware is not applied. + CORSAllowedOrigins []string } // NewRouter builds the application's HTTP handler with the standard middleware @@ -46,6 +50,9 @@ func NewRouter(deps RouterDeps) http.Handler { r.Use(RequestID) r.Use(Recoverer(deps.Logger)) r.Use(AccessLog(deps.Logger)) + if len(deps.CORSAllowedOrigins) > 0 { + r.Use(CORS(deps.CORSAllowedOrigins)) + } // Operational endpoints. These are intentionally unauthenticated. r.Get("/health/live", deps.Health.Live) From 9fa41200a800865cdbda149a1202dd37fb3ad276 Mon Sep 17 00:00:00 2001 From: vikoabastian <11003051+vianbas@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:39:53 +0700 Subject: [PATCH 15/29] feat(api): wire JWT auth into the HTTP server and add seed-users command --- apps/api/cmd/api/main.go | 74 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/apps/api/cmd/api/main.go b/apps/api/cmd/api/main.go index f7fb63a..73bdf36 100644 --- a/apps/api/cmd/api/main.go +++ b/apps/api/cmd/api/main.go @@ -23,6 +23,9 @@ import ( "github.com/vianbas/finwatch/apps/api/internal/alerts" alerthttp "github.com/vianbas/finwatch/apps/api/internal/alerts/httpapi" alertstore "github.com/vianbas/finwatch/apps/api/internal/alerts/store" + "github.com/vianbas/finwatch/apps/api/internal/auth" + authhttp "github.com/vianbas/finwatch/apps/api/internal/auth/httpapi" + authstore "github.com/vianbas/finwatch/apps/api/internal/auth/store" "github.com/vianbas/finwatch/apps/api/internal/config" "github.com/vianbas/finwatch/apps/api/internal/platform/httpserver" "github.com/vianbas/finwatch/apps/api/internal/platform/postgres" @@ -52,6 +55,14 @@ func main() { return } + // `api seed-users` creates the demo operator/admin accounts and exits. + if len(os.Args) > 1 && os.Args[1] == "seed-users" { + if err := runSeedUsers(); err != nil { + os.Exit(1) + } + return + } + if err := run(); err != nil { // run already logged the cause; this is the final, fatal exit. os.Exit(1) @@ -122,6 +133,58 @@ func runSeed(args []string) error { return nil } +// runSeedUsers creates the demo operator and admin accounts used for local +// development and manual testing. It is idempotent: existing emails are left +// untouched. Passwords are never logged. +func runSeedUsers() error { + cfg, err := config.Load(os.Getenv) + logger := newLogger(cfg, err) + if err != nil { + logger.Error("invalid configuration", slog.String("error", err.Error())) + return err + } + + ctx := context.Background() + pool, err := postgres.NewPool(ctx, cfg.DatabaseURL) + if err != nil { + logger.Error("failed to initialise database pool", slog.String("error", err.Error())) + return err + } + defer pool.Close() + + demoUsers := []struct { + email string + password string + role auth.Role + }{ + {email: "operator@example.com", password: demoPassword("DEMO_OPERATOR_PASSWORD", "operator_dev_password"), role: auth.RoleOperator}, + {email: "admin@example.com", password: demoPassword("DEMO_ADMIN_PASSWORD", "admin_dev_password"), role: auth.RoleAdmin}, + } + + repo := authstore.New(pool) + for _, u := range demoUsers { + hash, err := auth.HashPassword(u.password) + if err != nil { + logger.Error("failed to hash demo password", slog.String("error", err.Error())) + return err + } + _, created, err := repo.InsertUserIfAbsent(ctx, u.email, hash, u.role) + if err != nil { + logger.Error("failed to seed demo user", slog.String("email", u.email), slog.String("error", err.Error())) + return err + } + logger.Info("seed user", slog.String("email", u.email), slog.Bool("created", created)) + } + return nil +} + +func demoPassword(envKey, fallback string) string { + if v := os.Getenv(envKey); v != "" { + return v + } + return fallback +} + // healthcheck performs a localhost liveness request against the configured port. func healthcheck() error { port := os.Getenv("HTTP_PORT") @@ -168,13 +231,24 @@ func run() error { svcs := buildServices(pool, logger) + issuer := auth.NewIssuer([]byte(cfg.JWTSigningSecret), cfg.JWTAccessTokenTTL) + verifier := auth.NewVerifier([]byte(cfg.JWTSigningSecret)) + authSvc := auth.NewService(authstore.New(pool), issuer) + authHandler := authhttp.NewHandler(authSvc, logger) + router := httpserver.NewRouter(httpserver.RouterDeps{ Logger: logger, Health: httpserver.NewHealthHandler(pool), + PublicModules: []httpserver.RouteRegistrar{ + httpserver.RegistrarFunc(authHandler.RegisterPublicRoutes), + }, Modules: []httpserver.RouteRegistrar{ + httpserver.RegistrarFunc(authHandler.RegisterProtectedRoutes), txhttp.NewHandler(svcs.transactions, logger), alerthttp.NewHandler(svcs.alerts, logger), }, + RequireAuth: auth.RequireAuth(verifier), + CORSAllowedOrigins: cfg.CORSAllowedOrigins, }) srv := httpserver.New(httpserver.Options{ From 57beda4994c88a76fb8b513808a998f7495911b9 Mon Sep 17 00:00:00 2001 From: vikoabastian <11003051+vianbas@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:41:27 +0700 Subject: [PATCH 16/29] chore: add JWT env vars and demo user passwords to local dev config --- .env.example | 10 ++++++++++ docker-compose.yml | 2 ++ 2 files changed, 12 insertions(+) diff --git a/.env.example b/.env.example index 28f2356..1f019c8 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,16 @@ HTTP_IDLE_TIMEOUT=60s HTTP_SHUTDOWN_TIMEOUT=15s CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:8081 +# JWT signing secret: a SAFE example dev value only, at least 32 characters. +# Never reuse this value outside local development. +JWT_SIGNING_SECRET=dev_only_example_secret_change_me_32+chars +JWT_ACCESS_TOKEN_TTL=15m + +# Demo account passwords for `go run ./cmd/api seed-users` (operator@example.com, +# admin@example.com). SAFE example dev values only. +DEMO_OPERATOR_PASSWORD=operator_dev_password +DEMO_ADMIN_PASSWORD=admin_dev_password + # --- Database (example dev values only) -------------------------------- POSTGRES_USER=finwatch POSTGRES_PASSWORD=finwatch_dev_password diff --git a/docker-compose.yml b/docker-compose.yml index be9c243..766a89f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,8 @@ services: HTTP_PORT: "8080" DATABASE_URL: postgres://${POSTGRES_USER:-finwatch}:${POSTGRES_PASSWORD:-finwatch_dev_password}@db:5432/${POSTGRES_DB:-finwatch}?sslmode=disable CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:8081,http://localhost:5173} + JWT_SIGNING_SECRET: ${JWT_SIGNING_SECRET:-dev_only_example_secret_change_me_32+chars} + JWT_ACCESS_TOKEN_TTL: ${JWT_ACCESS_TOKEN_TTL:-15m} ports: - "8080:8080" depends_on: From 26c52336ac255ea546eedef5b927b245fc5dca19 Mon Sep 17 00:00:00 2001 From: vikoabastian <11003051+vianbas@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:43:54 +0700 Subject: [PATCH 17/29] docs: document JWT auth for local development --- .claude/rules/security.md | 4 +++- CLAUDE.md | 5 +++-- README.md | 2 +- docs/architecture/container-design.md | 2 +- docs/architecture/system-context.md | 4 ++-- docs/operations/local-development.md | 30 +++++++++++++++++++++++++++ docs/threat-model.md | 6 +++--- 7 files changed, 43 insertions(+), 10 deletions(-) diff --git a/.claude/rules/security.md b/.claude/rules/security.md index 937afe8..9de25ce 100644 --- a/.claude/rules/security.md +++ b/.claude/rules/security.md @@ -29,7 +29,9 @@ - Bounded HTTP timeouts and graceful shutdown by default. - Panic recovery prevents a single request from crashing the process. - Structured logs must never contain secrets or raw personal/financial data. -- Planned auth: short-lived JWT access tokens + RBAC (not in the bootstrap). +- Auth: short-lived HS256 JWT access tokens (`JWT_SIGNING_SECRET`, at least 32 + characters) + two roles (operator, admin); `RequireRole` exists but is not + yet mounted on any route. ## Reporting diff --git a/CLAUDE.md b/CLAUDE.md index 6a115f6..a67dd4d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,8 +30,9 @@ product, and must never be represented as one. components. Data fetching via TanStack Query. Charts via Recharts. - **Real-time:** PostgreSQL transactional **outbox** + an in-process WebSocket hub. Do **not** add Kafka, Redis, NATS, or RabbitMQ. -- **Auth (future):** short-lived JWT access tokens + RBAC. Not implemented in - the bootstrap. +- **Auth:** short-lived (15m default) HS256 JWT access tokens via `POST + /login`; two roles (operator, admin). `RequireRole` exists but is not yet + mounted on any route. ## Contract-first diff --git a/README.md b/README.md index 19def9f..e148ba8 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ near-real-time monitoring and alerting platform: This repository is the **bootstrap**: structure, skeletons, contracts, docs, local environment, and CI. Business features (transactions, alerts, rule -evaluation, auth, live streaming) arrive in later issues. +evaluation, live streaming) arrive in later issues. ## Repository layout diff --git a/docs/architecture/container-design.md b/docs/architecture/container-design.md index 8aa187b..995ae18 100644 --- a/docs/architecture/container-design.md +++ b/docs/architecture/container-design.md @@ -61,4 +61,4 @@ command (`make dev`) brings the stack up. | Logging | Structured JSON via `log/slog`. | | Money | Integer minor units (`BIGINT` / `int64`). | | Time | UTC internally; RFC 3339 at API/event boundaries. | -| Auth (future) | Short-lived JWT access tokens + RBAC. | +| Auth | Short-lived HS256 JWT access tokens; two roles (operator, admin); role-scoped route enforcement (`RequireRole`) not yet mounted. | diff --git a/docs/architecture/system-context.md b/docs/architecture/system-context.md index da0b16a..1f3d66d 100644 --- a/docs/architecture/system-context.md +++ b/docs/architecture/system-context.md @@ -49,5 +49,5 @@ integrations. Everything runs locally via Docker Compose. ## Out of scope (this bootstrap) -Transaction ingestion, rule evaluation, alerting, authentication/RBAC, and live -WebSocket streaming. These are tracked as follow-up issues. +Transaction ingestion, rule evaluation, alerting, RBAC route enforcement, and +live WebSocket streaming. These are tracked as follow-up issues. diff --git a/docs/operations/local-development.md b/docs/operations/local-development.md index 40cbb50..a1b6785 100644 --- a/docs/operations/local-development.md +++ b/docs/operations/local-development.md @@ -42,6 +42,36 @@ Stop the stack: make stop ``` +## Authentication (local development) + +The API requires `Authorization: Bearer ` on every route except +`/login` and `/health/*`. Create the two demo accounts (idempotent) before +logging in: + +```sh +cd apps/api && go run ./cmd/api seed-users +# or, against the compose stack: +docker compose exec api /app/api seed-users +``` + +This creates `operator@example.com` and `admin@example.com`, with passwords +taken from `DEMO_OPERATOR_PASSWORD` / `DEMO_ADMIN_PASSWORD` (see +`.env.example` for the example dev values). Then log in: + +```sh +curl -s -X POST http://localhost:8080/login \ + -H 'Content-Type: application/json' \ + -d '{"email":"operator@example.com","password":"operator_dev_password"}' +``` + +Access tokens are short-lived (`JWT_ACCESS_TOKEN_TTL`, default 15 minutes) and +there is no refresh token — once a token expires, sign in again; refresh is +future work. The web app keeps the token in memory only, never in +`localStorage`/`sessionStorage`, so reloading the page signs you out. + +Rotating `JWT_SIGNING_SECRET` (at least 32 characters) and restarting the API +invalidates every outstanding token. + ## Running pieces directly Backend: diff --git a/docs/threat-model.md b/docs/threat-model.md index 33472e8..7743d48 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -23,12 +23,12 @@ and contribute to. | Threat | Example | Current mitigation | Planned | | ------ | ------- | ------------------ | ------- | -| **Spoofing** | Unauthenticated access to endpoints | Only public health endpoints exist today | JWT access tokens + RBAC | +| **Spoofing** | Unauthenticated access to endpoints | JWT bearer-token auth on `/me`, `/transactions`, `/alerts*`; `/login` and `/health/*` stay public | RBAC enforcement (`RequireRole`) mounted on routes | | **Tampering** | Malformed/oversized requests | Bounded HTTP timeouts; input validated at handlers (per-feature) | Schema validation against OpenAPI | | **Repudiation** | No trace of actions | Per-request IDs + structured access logs | Audit logging for state changes | | **Information disclosure** | Secrets/PII leakage | No secrets in repo; synthetic data only; logs exclude secrets/PII | Secret scanning, log review | | **Denial of service** | Slow-client / resource exhaustion | Read/write/idle timeouts; panic recovery; graceful shutdown | Rate limiting, connection caps | -| **Elevation of privilege** | Acting beyond role | N/A (no auth yet) | RBAC enforced server-side | +| **Elevation of privilege** | Acting beyond role | N/A (`RequireRole` exists but is not yet mounted on any route) | RBAC enforced server-side | ## Supply chain @@ -46,5 +46,5 @@ and contribute to. ## Explicitly out of scope (bootstrap) -Authentication/authorization, transaction ingestion, rule evaluation, alerting, +RBAC route enforcement, transaction ingestion, rule evaluation, alerting, and live streaming — each will extend this model when implemented. From 2d8a3c5d2da6e02df14b7af067fcbd8b0e5a4949 Mon Sep 17 00:00:00 2001 From: vikoabastian <11003051+vianbas@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:46:35 +0700 Subject: [PATCH 18/29] feat(web): add in-memory AuthContext with login/logout --- apps/web/package-lock.json | 15 +++++ apps/web/package.json | 1 + apps/web/src/app/AuthContext.test.tsx | 81 +++++++++++++++++++++++++++ apps/web/src/app/AuthContext.tsx | 60 ++++++++++++++++++++ 4 files changed, 157 insertions(+) create mode 100644 apps/web/src/app/AuthContext.test.tsx create mode 100644 apps/web/src/app/AuthContext.tsx diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index b14811e..fb89e5e 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -23,6 +23,7 @@ "@eslint/js": "^10.0.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", + "@testing-library/user-event": "^14.6.7", "@types/node": "^25.9.3", "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", @@ -1720,6 +1721,20 @@ } } }, + "node_modules/@testing-library/user-event": { + "version": "14.6.7", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz", + "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", diff --git a/apps/web/package.json b/apps/web/package.json index 62fe395..caf2680 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -28,6 +28,7 @@ "@eslint/js": "^10.0.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", + "@testing-library/user-event": "^14.6.7", "@types/node": "^25.9.3", "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", diff --git a/apps/web/src/app/AuthContext.test.tsx b/apps/web/src/app/AuthContext.test.tsx new file mode 100644 index 0000000..3b3859c --- /dev/null +++ b/apps/web/src/app/AuthContext.test.tsx @@ -0,0 +1,81 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { useState } from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { AuthProvider, useAuth } from "@/app/AuthContext"; + +function Probe() { + const { accessToken, user, login, logout } = useAuth(); + const [error, setError] = useState(null); + return ( +
+ {accessToken ?? "none"} + {user?.role ?? "none"} + {error ?? "none"} + + +
+ ); +} + +describe("AuthContext", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("stores the access token and user after a successful login", async () => { + (fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => ({ + accessToken: "token-123", + user: { id: "u1", email: "operator@example.com", role: "operator" }, + }), + }); + + render(); + await userEvent.click(screen.getByText("login")); + + await waitFor(() => expect(screen.getByTestId("token")).toHaveTextContent("token-123")); + expect(screen.getByTestId("role")).toHaveTextContent("operator"); + }); + + it("throws and leaves state empty when login fails", async () => { + (fetch as ReturnType).mockResolvedValue({ + ok: false, + json: async () => ({ error: { code: "INVALID_CREDENTIALS", message: "email or password is incorrect" } }), + }); + + render(); + await userEvent.click(screen.getByText("login")); + + await waitFor(() => expect(screen.getByTestId("token")).toHaveTextContent("none")); + await waitFor(() => expect(screen.getByTestId("error")).toHaveTextContent("email or password is incorrect")); + }); + + it("clears the token and user on logout", async () => { + (fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => ({ + accessToken: "token-123", + user: { id: "u1", email: "operator@example.com", role: "operator" }, + }), + }); + + render(); + await userEvent.click(screen.getByText("login")); + await waitFor(() => expect(screen.getByTestId("token")).toHaveTextContent("token-123")); + + await userEvent.click(screen.getByText("logout")); + expect(screen.getByTestId("token")).toHaveTextContent("none"); + expect(screen.getByTestId("role")).toHaveTextContent("none"); + }); +}); diff --git a/apps/web/src/app/AuthContext.tsx b/apps/web/src/app/AuthContext.tsx new file mode 100644 index 0000000..ed56a99 --- /dev/null +++ b/apps/web/src/app/AuthContext.tsx @@ -0,0 +1,60 @@ +import { createContext, useCallback, useContext, useState, type ReactNode } from "react"; +import { env } from "@/lib/env"; + +export interface AuthUser { + id: string; + email: string; + role: "operator" | "admin"; +} + +interface AuthContextValue { + /** Held in memory only — never written to localStorage or sessionStorage. */ + accessToken: string | null; + user: AuthUser | null; + login: (email: string, password: string) => Promise; + logout: () => void; +} + +const AuthContext = createContext(undefined); + +/** AuthProvider holds the access token and current user in memory only. */ +export function AuthProvider({ children }: { children: ReactNode }) { + const [accessToken, setAccessToken] = useState(null); + const [user, setUser] = useState(null); + + const login = useCallback(async (email: string, password: string) => { + const res = await fetch(`${env.apiUrl}/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error(body?.error?.message ?? "Login failed"); + } + const data = await res.json(); + setAccessToken(data.accessToken); + setUser(data.user); + }, []); + + const logout = useCallback(() => { + setAccessToken(null); + setUser(null); + }, []); + + return ( + + {children} + + ); +} + +/** useAuth reads the auth state; must be used within an AuthProvider. */ +// eslint-disable-next-line react-refresh/only-export-components +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return ctx; +} From 4a39554364088c31644800ac409143a7687899f8 Mon Sep 17 00:00:00 2001 From: vikoabastian <11003051+vianbas@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:48:21 +0700 Subject: [PATCH 19/29] feat(web): add ProtectedRoute guard that redirects to /login --- apps/web/src/app/ProtectedRoute.test.tsx | 28 ++++++++++++++++++++++++ apps/web/src/app/ProtectedRoute.tsx | 15 +++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 apps/web/src/app/ProtectedRoute.test.tsx create mode 100644 apps/web/src/app/ProtectedRoute.tsx diff --git a/apps/web/src/app/ProtectedRoute.test.tsx b/apps/web/src/app/ProtectedRoute.test.tsx new file mode 100644 index 0000000..030109d --- /dev/null +++ b/apps/web/src/app/ProtectedRoute.test.tsx @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { AuthProvider } from "@/app/AuthContext"; +import { ProtectedRoute } from "@/app/ProtectedRoute"; + +function renderProtected(initialPath: string) { + return render( + + + + login page} /> + }> + dashboard page} /> + + + + , + ); +} + +describe("ProtectedRoute", () => { + it("redirects to /login when there is no access token", () => { + renderProtected("/dashboard"); + expect(screen.getByText("login page")).toBeInTheDocument(); + expect(screen.queryByText("dashboard page")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/app/ProtectedRoute.tsx b/apps/web/src/app/ProtectedRoute.tsx new file mode 100644 index 0000000..ea8a302 --- /dev/null +++ b/apps/web/src/app/ProtectedRoute.tsx @@ -0,0 +1,15 @@ +import { Navigate, Outlet } from "react-router-dom"; +import { useAuth } from "@/app/AuthContext"; + +/** + * ProtectedRoute renders its nested routes only when an access token is + * present; otherwise it redirects to /login. Reactivity comes from + * useAuth() — clearing the token (e.g. on a 401) re-renders this guard. + */ +export function ProtectedRoute() { + const { accessToken } = useAuth(); + if (!accessToken) { + return ; + } + return ; +} From 8d8deac66d939d29fd67b91910dc1cbf7a7bbef6 Mon Sep 17 00:00:00 2001 From: vikoabastian <11003051+vianbas@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:50:07 +0700 Subject: [PATCH 20/29] feat(web): add useApiFetch that clears the token on 401 --- apps/web/src/app/useApiFetch.test.tsx | 88 +++++++++++++++++++++++++++ apps/web/src/app/useApiFetch.ts | 27 ++++++++ 2 files changed, 115 insertions(+) create mode 100644 apps/web/src/app/useApiFetch.test.tsx create mode 100644 apps/web/src/app/useApiFetch.ts diff --git a/apps/web/src/app/useApiFetch.test.tsx b/apps/web/src/app/useApiFetch.test.tsx new file mode 100644 index 0000000..39dae7e --- /dev/null +++ b/apps/web/src/app/useApiFetch.test.tsx @@ -0,0 +1,88 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { AuthProvider, useAuth } from "@/app/AuthContext"; +import { useApiFetch } from "@/app/useApiFetch"; +import type { ReactNode } from "react"; + +function wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +describe("useApiFetch", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("attaches the bearer token when one is present", async () => { + (fetch as ReturnType).mockResolvedValue({ ok: true, status: 200, json: async () => ({}) }); + const { result } = renderHook( + () => ({ auth: useAuth(), apiFetch: useApiFetch() }), + { wrapper }, + ); + + (fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: async () => ({ accessToken: "token-123", user: { id: "u1", email: "a@example.com", role: "operator" } }), + }); + await act(async () => { + await result.current.auth.login("a@example.com", "correct-password"); + }); + + await act(async () => { + await result.current.apiFetch("/transactions"); + }); + + const lastCall = (fetch as ReturnType).mock.calls.at(-1); + expect(new Headers(lastCall?.[1]?.headers).get("Authorization")).toBe("Bearer token-123"); + }); + + it("preserves caller-supplied headers alongside the bearer token", async () => { + (fetch as ReturnType).mockResolvedValue({ ok: true, status: 200, json: async () => ({}) }); + const { result } = renderHook( + () => ({ auth: useAuth(), apiFetch: useApiFetch() }), + { wrapper }, + ); + + (fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: async () => ({ accessToken: "token-123", user: { id: "u1", email: "a@example.com", role: "operator" } }), + }); + await act(async () => { + await result.current.auth.login("a@example.com", "correct-password"); + }); + + await act(async () => { + await result.current.apiFetch("/transactions", { headers: new Headers({ "X-Test": "1" }) }); + }); + + const lastCall = (fetch as ReturnType).mock.calls.at(-1); + const headers = new Headers(lastCall?.[1]?.headers); + expect(headers.get("Authorization")).toBe("Bearer token-123"); + expect(headers.get("X-Test")).toBe("1"); + }); + + it("clears the token when a request returns 401", async () => { + (fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: async () => ({ accessToken: "token-123", user: { id: "u1", email: "a@example.com", role: "operator" } }), + }); + const { result } = renderHook( + () => ({ auth: useAuth(), apiFetch: useApiFetch() }), + { wrapper }, + ); + await act(async () => { + await result.current.auth.login("a@example.com", "correct-password"); + }); + expect(result.current.auth.accessToken).toBe("token-123"); + + (fetch as ReturnType).mockResolvedValueOnce({ ok: false, status: 401, json: async () => ({}) }); + await act(async () => { + await result.current.apiFetch("/transactions"); + }); + + expect(result.current.auth.accessToken).toBeNull(); + }); +}); diff --git a/apps/web/src/app/useApiFetch.ts b/apps/web/src/app/useApiFetch.ts new file mode 100644 index 0000000..57bc414 --- /dev/null +++ b/apps/web/src/app/useApiFetch.ts @@ -0,0 +1,27 @@ +import { useCallback } from "react"; +import { useAuth } from "@/app/AuthContext"; +import { env } from "@/lib/env"; + +/** + * useApiFetch wraps fetch with the current access token and, on a 401 + * response, logs out — which clears the in-memory token and lets + * ProtectedRoute redirect to /login on the next render. + */ +export function useApiFetch() { + const { accessToken, logout } = useAuth(); + + return useCallback( + async (path: string, init: RequestInit = {}) => { + const headers = new Headers(init.headers); + if (accessToken) { + headers.set("Authorization", `Bearer ${accessToken}`); + } + const res = await fetch(`${env.apiUrl}${path}`, { ...init, headers }); + if (res.status === 401) { + logout(); + } + return res; + }, + [accessToken, logout], + ); +} From 8a0e840e6d462b4b2d6056d1568305e9ba5173f0 Mon Sep 17 00:00:00 2001 From: vikoabastian <11003051+vianbas@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:52:34 +0700 Subject: [PATCH 21/29] feat(web): wire LoginPage to POST /login --- apps/web/src/app/routes.test.tsx | 9 ++-- apps/web/src/pages/LoginPage.test.tsx | 59 +++++++++++++++++++++++++++ apps/web/src/pages/LoginPage.tsx | 56 +++++++++++++++++-------- 3 files changed, 105 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/pages/LoginPage.test.tsx diff --git a/apps/web/src/app/routes.test.tsx b/apps/web/src/app/routes.test.tsx index 218a8fe..39379bf 100644 --- a/apps/web/src/app/routes.test.tsx +++ b/apps/web/src/app/routes.test.tsx @@ -1,13 +1,16 @@ import { describe, expect, it } from "vitest"; import { render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; +import { AuthProvider } from "@/app/AuthContext"; import { AppRoutes } from "@/app/routes"; function renderAt(path: string) { return render( - - - , + + + + + , ); } diff --git a/apps/web/src/pages/LoginPage.test.tsx b/apps/web/src/pages/LoginPage.test.tsx new file mode 100644 index 0000000..979b3c2 --- /dev/null +++ b/apps/web/src/pages/LoginPage.test.tsx @@ -0,0 +1,59 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { AuthProvider } from "@/app/AuthContext"; +import { LoginPage } from "@/pages/LoginPage"; + +function renderLoginPage() { + return render( + + + + } /> + dashboard page} /> + + + , + ); +} + +describe("LoginPage", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("navigates to /dashboard on successful submit", async () => { + (fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => ({ + accessToken: "token-123", + user: { id: "u1", email: "operator@example.com", role: "operator" }, + }), + }); + + renderLoginPage(); + await userEvent.type(screen.getByLabelText(/email/i), "operator@example.com"); + await userEvent.type(screen.getByLabelText(/password/i), "correct-password"); + await userEvent.click(screen.getByRole("button", { name: /sign in/i })); + + await waitFor(() => expect(screen.getByText("dashboard page")).toBeInTheDocument()); + }); + + it("shows an error message on invalid credentials", async () => { + (fetch as ReturnType).mockResolvedValue({ + ok: false, + json: async () => ({ error: { code: "INVALID_CREDENTIALS", message: "email or password is incorrect" } }), + }); + + renderLoginPage(); + await userEvent.type(screen.getByLabelText(/email/i), "operator@example.com"); + await userEvent.type(screen.getByLabelText(/password/i), "wrong-password"); + await userEvent.click(screen.getByRole("button", { name: /sign in/i })); + + await waitFor(() => expect(screen.getByText(/email or password is incorrect/i)).toBeInTheDocument()); + }); +}); diff --git a/apps/web/src/pages/LoginPage.tsx b/apps/web/src/pages/LoginPage.tsx index 470f8c5..b819f1f 100644 --- a/apps/web/src/pages/LoginPage.tsx +++ b/apps/web/src/pages/LoginPage.tsx @@ -1,23 +1,38 @@ +import { useState, type FormEvent } from "react"; +import { useNavigate } from "react-router-dom"; +import { useAuth } from "@/app/AuthContext"; import { Button } from "@/components/ui/button"; -/** - * LoginPage is a placeholder. Authentication (short-lived JWT + RBAC) is - * implemented in a later issue; the form here is non-functional. - */ +/** LoginPage authenticates against POST /login and stores the token in memory. */ export function LoginPage() { + const { login } = useAuth(); + const navigate = useNavigate(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + setSubmitting(true); + try { + await login(email, password); + navigate("/dashboard", { replace: true }); + } catch (err) { + setError(err instanceof Error ? err.message : "Login failed"); + } finally { + setSubmitting(false); + } + } + return (

Sign in

-

- FinWatch operator access (placeholder). -

+

FinWatch operator access.

-
event.preventDefault()} - aria-label="Sign in" - > +
@@ -40,11 +57,18 @@ export function LoginPage() { type="password" autoComplete="current-password" className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm" - disabled + value={password} + onChange={(event) => setPassword(event.target.value)} + required />
-
From 4ef3738333774b767eaf2946ff547865a0418943 Mon Sep 17 00:00:00 2001 From: vikoabastian <11003051+vianbas@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:54:49 +0700 Subject: [PATCH 22/29] feat(web): gate authenticated routes behind ProtectedRoute --- apps/web/src/app/App.tsx | 13 ++++--- apps/web/src/app/routes.test.tsx | 60 +++++++++++++++++++++++++++----- apps/web/src/app/routes.tsx | 13 ++++--- 3 files changed, 67 insertions(+), 19 deletions(-) diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index cca09e7..322764d 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -2,17 +2,20 @@ import { BrowserRouter } from "react-router-dom"; import { QueryClientProvider } from "@tanstack/react-query"; import { ErrorBoundary } from "@/components/ErrorBoundary"; import { queryClient } from "@/lib/queryClient"; +import { AuthProvider } from "@/app/AuthContext"; import { AppRoutes } from "@/app/routes"; /** App composes global providers, the error boundary, and the router. */ export function App() { return ( - - - - - + + + + + + + ); } diff --git a/apps/web/src/app/routes.test.tsx b/apps/web/src/app/routes.test.tsx index 39379bf..15b2969 100644 --- a/apps/web/src/app/routes.test.tsx +++ b/apps/web/src/app/routes.test.tsx @@ -1,7 +1,8 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; -import { AuthProvider } from "@/app/AuthContext"; +import { AuthProvider, useAuth } from "@/app/AuthContext"; import { AppRoutes } from "@/app/routes"; function renderAt(path: string) { @@ -15,14 +16,55 @@ function renderAt(path: string) { } describe("AppRoutes", () => { - it("renders the dashboard with primary navigation", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("redirects unauthenticated requests for the dashboard to /login", () => { renderAt("/dashboard"); - expect( - screen.getByRole("heading", { name: /dashboard/i }), - ).toBeInTheDocument(); - expect( - screen.getByRole("navigation", { name: /primary/i }), - ).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: /sign in/i })).toBeInTheDocument(); + }); + + it("renders the dashboard with primary navigation once authenticated", async () => { + (fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => ({ + accessToken: "token-123", + user: { id: "u1", email: "operator@example.com", role: "operator" }, + }), + }); + + function LoginThenRoutes() { + const { accessToken, login } = useAuth(); + if (!accessToken) { + return ( + + ); + } + return ( + + + + ); + } + + render( + + + , + ); + await userEvent.click(screen.getByText("do-login")); + expect(await screen.findByRole("heading", { name: /dashboard/i })).toBeInTheDocument(); + expect(screen.getByRole("navigation", { name: /primary/i })).toBeInTheDocument(); }); it("renders a not-found page for unknown routes", () => { diff --git a/apps/web/src/app/routes.tsx b/apps/web/src/app/routes.tsx index 300b04a..6cf725c 100644 --- a/apps/web/src/app/routes.tsx +++ b/apps/web/src/app/routes.tsx @@ -1,5 +1,6 @@ import { Navigate, Route, Routes } from "react-router-dom"; import { AppShell } from "@/app/AppShell"; +import { ProtectedRoute } from "@/app/ProtectedRoute"; import { DashboardPage } from "@/pages/DashboardPage"; import { AlertsPage } from "@/pages/AlertsPage"; import { OpsPage } from "@/pages/OpsPage"; @@ -15,11 +16,13 @@ export function AppRoutes() { return ( } /> - }> - } /> - } /> - } /> - } /> + }> + }> + } /> + } /> + } /> + } /> + } /> From c9b4a4e71cd9f5a210a9fb8e6f900550ad29b5a3 Mon Sep 17 00:00:00 2001 From: vikoabastian <11003051+vianbas@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:06:47 +0700 Subject: [PATCH 23/29] docs(contracts): describe /login and /me; add WWW-Authenticate header to Unauthorized --- contracts/openapi.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/contracts/openapi.yaml b/contracts/openapi.yaml index 489bdac..ab410cf 100644 --- a/contracts/openapi.yaml +++ b/contracts/openapi.yaml @@ -82,6 +82,7 @@ paths: tags: [auth] operationId: login summary: Authenticate with email and password + description: Verifies email and password and, on success, returns a short-lived HS256 access token. security: [] requestBody: required: true @@ -121,6 +122,7 @@ paths: tags: [auth] operationId: getCurrentUser summary: Get the authenticated user + description: Returns the identity and role encoded in the caller's bearer token. responses: "200": description: The authenticated user. @@ -470,6 +472,11 @@ components: message: resource not found Unauthorized: description: Missing, malformed, or expired bearer token. + headers: + WWW-Authenticate: + description: Bearer + schema: + type: string content: application/json: schema: From 8a220d0cc62e5590edd323f5c88dbfd06e9b2209 Mon Sep 17 00:00:00 2001 From: vikoabastian <11003051+vianbas@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:07:20 +0700 Subject: [PATCH 24/29] fix(api): reject the example JWT secret outside development --- apps/api/internal/config/config.go | 9 +++++++++ apps/api/internal/config/config_test.go | 17 +++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/apps/api/internal/config/config.go b/apps/api/internal/config/config.go index 9d05dad..c2b634f 100644 --- a/apps/api/internal/config/config.go +++ b/apps/api/internal/config/config.go @@ -57,6 +57,12 @@ var ( validLevels = map[string]bool{"debug": true, "info": true, "warn": true, "error": true} ) +// publishedExampleJWTSigningSecret is the JWT_SIGNING_SECRET value published +// in .env.example and used as the docker-compose.yml default. It is safe for +// local development only: anyone who has read the repository can forge an +// admin token with it, so Validate rejects it outside development. +const publishedExampleJWTSigningSecret = "dev_only_example_secret_change_me_32+chars" + // Load reads configuration using the provided getenv function (typically // os.Getenv) and validates it. Passing getenv explicitly keeps Load pure and // testable without mutating process environment. @@ -125,6 +131,9 @@ func (c Config) Validate() error { if len(c.JWTSigningSecret) < 32 { return fmt.Errorf("config: JWT_SIGNING_SECRET must be at least 32 characters") } + if c.AppEnv != "development" && c.JWTSigningSecret == publishedExampleJWTSigningSecret { + return fmt.Errorf("config: JWT_SIGNING_SECRET must not be the published example value outside development") + } for name, d := range map[string]time.Duration{ "HTTP_READ_HEADER_TIMEOUT": c.ReadHeaderTimeout, "HTTP_READ_TIMEOUT": c.ReadTimeout, diff --git a/apps/api/internal/config/config_test.go b/apps/api/internal/config/config_test.go index dbbd6fb..0ff077d 100644 --- a/apps/api/internal/config/config_test.go +++ b/apps/api/internal/config/config_test.go @@ -95,6 +95,14 @@ func TestLoad_ValidationErrors(t *testing.T) { {"short jwt signing secret", func(m map[string]string) { m["JWT_SIGNING_SECRET"] = "too-short" }}, {"non-duration jwt ttl", func(m map[string]string) { m["JWT_ACCESS_TOKEN_TTL"] = "soon" }}, {"zero jwt ttl", func(m map[string]string) { m["JWT_ACCESS_TOKEN_TTL"] = "0s" }}, + {"published example secret in staging", func(m map[string]string) { + m["APP_ENV"] = "staging" + m["JWT_SIGNING_SECRET"] = "dev_only_example_secret_change_me_32+chars" + }}, + {"published example secret in production", func(m map[string]string) { + m["APP_ENV"] = "production" + m["JWT_SIGNING_SECRET"] = "dev_only_example_secret_change_me_32+chars" + }}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -106,3 +114,12 @@ func TestLoad_ValidationErrors(t *testing.T) { }) } } + +func TestLoad_ExampleSecretAllowedInDevelopment(t *testing.T) { + env := validEnv() + env["APP_ENV"] = "development" + env["JWT_SIGNING_SECRET"] = "dev_only_example_secret_change_me_32+chars" + if _, err := Load(envFunc(env)); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} From e43d4ca6824e371fbc2f60560de43f1d863306a4 Mon Sep 17 00:00:00 2001 From: vikoabastian <11003051+vianbas@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:08:19 +0700 Subject: [PATCH 25/29] fix(api): require demo passwords outside development and pass them through compose --- apps/api/cmd/api/main.go | 34 ++++++++++++++----- apps/api/cmd/api/main_test.go | 63 +++++++++++++++++++++++++++++++++++ docker-compose.yml | 2 ++ 3 files changed, 91 insertions(+), 8 deletions(-) create mode 100644 apps/api/cmd/api/main_test.go diff --git a/apps/api/cmd/api/main.go b/apps/api/cmd/api/main.go index 73bdf36..9e08485 100644 --- a/apps/api/cmd/api/main.go +++ b/apps/api/cmd/api/main.go @@ -152,18 +152,27 @@ func runSeedUsers() error { } defer pool.Close() + // Fallback literals below are example development credentials only (also + // published in .env.example / docker-compose.yml); demoPassword refuses + // to use them outside development. demoUsers := []struct { email string - password string + envKey string + fallback string role auth.Role }{ - {email: "operator@example.com", password: demoPassword("DEMO_OPERATOR_PASSWORD", "operator_dev_password"), role: auth.RoleOperator}, - {email: "admin@example.com", password: demoPassword("DEMO_ADMIN_PASSWORD", "admin_dev_password"), role: auth.RoleAdmin}, + {email: "operator@example.com", envKey: "DEMO_OPERATOR_PASSWORD", fallback: "operator_dev_password", role: auth.RoleOperator}, + {email: "admin@example.com", envKey: "DEMO_ADMIN_PASSWORD", fallback: "admin_dev_password", role: auth.RoleAdmin}, } repo := authstore.New(pool) for _, u := range demoUsers { - hash, err := auth.HashPassword(u.password) + password, err := demoPassword(os.Getenv, cfg.AppEnv, u.envKey, u.fallback) + if err != nil { + logger.Error("failed to resolve demo password", slog.String("email", u.email), slog.String("error", err.Error())) + return err + } + hash, err := auth.HashPassword(password) if err != nil { logger.Error("failed to hash demo password", slog.String("error", err.Error())) return err @@ -178,11 +187,20 @@ func runSeedUsers() error { return nil } -func demoPassword(envKey, fallback string) string { - if v := os.Getenv(envKey); v != "" { - return v +// demoPassword resolves a demo account's password: the value of the env var +// named by key if set; otherwise the fallback, but only when appEnv is +// "development". Outside development a missing override is a fatal +// misconfiguration rather than a silent fallback to a password published in +// .env.example / docker-compose.yml — the error names the missing variable, +// never a password. +func demoPassword(getenv func(string) string, appEnv, key, fallback string) (string, error) { + if v := getenv(key); v != "" { + return v, nil + } + if appEnv == "development" { + return fallback, nil } - return fallback + return "", fmt.Errorf("%s is required outside development", key) } // healthcheck performs a localhost liveness request against the configured port. diff --git a/apps/api/cmd/api/main_test.go b/apps/api/cmd/api/main_test.go new file mode 100644 index 0000000..b916d6e --- /dev/null +++ b/apps/api/cmd/api/main_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "strings" + "testing" +) + +func TestDemoPassword(t *testing.T) { + tests := []struct { + name string + getenv func(string) string + appEnv string + key string + fallback string + want string + wantErr bool + }{ + { + name: "env set returns env value", + getenv: func(string) string { return "from-env-value" }, + appEnv: "production", + key: "DEMO_OPERATOR_PASSWORD", + fallback: "operator_dev_password", + want: "from-env-value", + }, + { + name: "unset in development returns fallback", + getenv: func(string) string { return "" }, + appEnv: "development", + key: "DEMO_OPERATOR_PASSWORD", + fallback: "operator_dev_password", + want: "operator_dev_password", + }, + { + name: "unset in staging returns error", + getenv: func(string) string { return "" }, + appEnv: "staging", + key: "DEMO_OPERATOR_PASSWORD", + fallback: "operator_dev_password", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := demoPassword(tt.getenv, tt.appEnv, tt.key, tt.fallback) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + if strings.Contains(err.Error(), tt.fallback) { + t.Errorf("error message %q must not contain the fallback password", err.Error()) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("demoPassword() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 766a89f..54890b5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,6 +37,8 @@ services: CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:8081,http://localhost:5173} JWT_SIGNING_SECRET: ${JWT_SIGNING_SECRET:-dev_only_example_secret_change_me_32+chars} JWT_ACCESS_TOKEN_TTL: ${JWT_ACCESS_TOKEN_TTL:-15m} + DEMO_OPERATOR_PASSWORD: ${DEMO_OPERATOR_PASSWORD:-operator_dev_password} + DEMO_ADMIN_PASSWORD: ${DEMO_ADMIN_PASSWORD:-admin_dev_password} ports: - "8080:8080" depends_on: From 07a1ec84c5f54cb2b0fc73e3e58cb48131c24fa1 Mon Sep 17 00:00:00 2001 From: vikoabastian <11003051+vianbas@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:09:20 +0700 Subject: [PATCH 26/29] fix(api): accept case-insensitive bearer scheme and email; add WWW-Authenticate --- apps/api/internal/auth/middleware.go | 10 +++++--- apps/api/internal/auth/middleware_test.go | 31 +++++++++++++++++++++++ apps/api/internal/auth/service.go | 2 ++ apps/api/internal/auth/service_test.go | 16 ++++++++++++ 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/apps/api/internal/auth/middleware.go b/apps/api/internal/auth/middleware.go index f9f4ed2..e09927a 100644 --- a/apps/api/internal/auth/middleware.go +++ b/apps/api/internal/auth/middleware.go @@ -19,11 +19,13 @@ func RequireAuth(verifier *Verifier) func(http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token, ok := bearerToken(r.Header.Get("Authorization")) if !ok { + w.Header().Set("WWW-Authenticate", "Bearer") web.WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "missing bearer token") return } claims, err := verifier.Verify(token) if err != nil { + w.Header().Set("WWW-Authenticate", "Bearer") web.WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid or expired token") return } @@ -54,12 +56,14 @@ func ClaimsFromContext(ctx context.Context) (Claims, bool) { return claims, ok } +// bearerToken extracts the token from an Authorization header. The auth +// scheme ("Bearer") is matched case-insensitively per RFC 7235 section 2.1. func bearerToken(header string) (string, bool) { - const prefix = "Bearer " - if !strings.HasPrefix(header, prefix) { + scheme, token, found := strings.Cut(header, " ") + if !found || !strings.EqualFold(scheme, "Bearer") { return "", false } - token := strings.TrimSpace(strings.TrimPrefix(header, prefix)) + token = strings.TrimSpace(token) if token == "" { return "", false } diff --git a/apps/api/internal/auth/middleware_test.go b/apps/api/internal/auth/middleware_test.go index 422013f..34fdaab 100644 --- a/apps/api/internal/auth/middleware_test.go +++ b/apps/api/internal/auth/middleware_test.go @@ -73,6 +73,37 @@ func TestRequireAuth_ExpiredToken(t *testing.T) { } } +func TestRequireAuth_LowercaseBearerScheme(t *testing.T) { + verifier := auth.NewVerifier([]byte(mwTestSecret)) + handler := auth.RequireAuth(verifier)(okHandler()) + token := issueToken(t, 15*time.Minute, auth.RoleOperator) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + req.Header.Set("Authorization", "bearer "+token) + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } +} + +func TestRequireAuth_MissingTokenSetsWWWAuthenticate(t *testing.T) { + verifier := auth.NewVerifier([]byte(mwTestSecret)) + handler := auth.RequireAuth(verifier)(okHandler()) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + if got := rec.Header().Get("WWW-Authenticate"); got != "Bearer" { + t.Errorf("WWW-Authenticate = %q, want %q", got, "Bearer") + } +} + func TestRequireAuth_ValidToken(t *testing.T) { verifier := auth.NewVerifier([]byte(mwTestSecret)) handler := auth.RequireAuth(verifier)(okHandler()) diff --git a/apps/api/internal/auth/service.go b/apps/api/internal/auth/service.go index 3c824f2..c7dd268 100644 --- a/apps/api/internal/auth/service.go +++ b/apps/api/internal/auth/service.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "sync" ) @@ -58,6 +59,7 @@ var dummyPasswordHash = sync.OnceValue(func() string { // token and the authenticated user. Unknown email and wrong password both // return ErrInvalidCredentials so the caller cannot distinguish them. func (s *Service) Login(ctx context.Context, email, password string) (string, User, error) { + email = strings.ToLower(strings.TrimSpace(email)) user, err := s.store.GetUserByEmail(ctx, email) if errors.Is(err, ErrUserNotFound) { // Run a bcrypt comparison against a dummy hash so this path costs diff --git a/apps/api/internal/auth/service_test.go b/apps/api/internal/auth/service_test.go index e47f885..2f93f18 100644 --- a/apps/api/internal/auth/service_test.go +++ b/apps/api/internal/auth/service_test.go @@ -66,6 +66,22 @@ func TestService_Login_WrongPassword(t *testing.T) { } } +func TestService_Login_EmailIsCaseInsensitive(t *testing.T) { + user := userWithPassword(t, "operator@example.com", "correct-password", auth.RoleOperator) + svc := newTestService(t, user) + + token, got, err := svc.Login(context.Background(), "Operator@Example.com", "correct-password") + if err != nil { + t.Fatalf("Login: %v", err) + } + if token == "" { + t.Errorf("want non-empty token") + } + if got.Email != user.Email { + t.Errorf("got email %q, want %q", got.Email, user.Email) + } +} + func TestService_Login_UnknownEmail(t *testing.T) { svc := newTestService(t) From eb8c99945922ab4884654f9d0bde8285f27111a4 Mon Sep 17 00:00:00 2001 From: vikoabastian <11003051+vianbas@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:10:00 +0700 Subject: [PATCH 27/29] fix(api): always vary CORS responses on Origin --- .../internal/platform/httpserver/cors_test.go | 17 ++++++++++++-- .../platform/httpserver/middleware.go | 23 +++++++++++-------- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/apps/api/internal/platform/httpserver/cors_test.go b/apps/api/internal/platform/httpserver/cors_test.go index f31eb0f..a00592c 100644 --- a/apps/api/internal/platform/httpserver/cors_test.go +++ b/apps/api/internal/platform/httpserver/cors_test.go @@ -68,8 +68,21 @@ func TestCORS_PreflightFromDisallowedOrigin(t *testing.T) { handler.ServeHTTP(rec, req) - if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { - t.Errorf("Access-Control-Allow-Origin = %q, want unset for disallowed origin", got) + h := rec.Header() + for _, name := range []string{"Access-Control-Allow-Origin", "Access-Control-Allow-Methods", "Access-Control-Allow-Headers", "Access-Control-Max-Age"} { + if got := h.Get(name); got != "" { + t.Errorf("%s = %q, want unset for disallowed origin", name, got) + } + } + varyValues := h.Values("Vary") + found := false + for _, v := range varyValues { + if v == "Origin" { + found = true + } + } + if !found { + t.Errorf("Vary = %v, want it to contain Origin even for a disallowed origin", varyValues) } if !nextCalled { t.Error("next handler was not called, want request to pass through untouched") diff --git a/apps/api/internal/platform/httpserver/middleware.go b/apps/api/internal/platform/httpserver/middleware.go index c518e66..42e3950 100644 --- a/apps/api/internal/platform/httpserver/middleware.go +++ b/apps/api/internal/platform/httpserver/middleware.go @@ -92,12 +92,14 @@ func (s *statusRecorder) WriteHeader(code int) { // requests carrying an Authorization header without the browser blocking // them. // -// Requests with no Origin header, or an Origin not on the allow-list, pass -// through untouched: no CORS headers are added. A preflight request (method -// OPTIONS with an Access-Control-Request-Method header) from an allowed -// origin is answered directly with 204 and is never forwarded to next. Any -// other request from an allowed origin is annotated with -// Access-Control-Allow-Origin and Vary: Origin before being forwarded. +// Requests with no Origin header pass through with no CORS headers added at +// all. A request that does carry an Origin header always gets Vary: Origin, +// even when that origin is not on the allow-list, so that shared caches never +// reuse a response computed for one origin when serving another. A preflight +// request (method OPTIONS with an Access-Control-Request-Method header) from +// an allowed origin is answered directly with 204 and is never forwarded to +// next. Any other request from an allowed origin is additionally annotated +// with Access-Control-Allow-Origin before being forwarded. // // Access-Control-Allow-Credentials is never set: tokens travel in the // Authorization header, not cookies. @@ -110,7 +112,12 @@ func CORS(allowedOrigins []string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") - if origin == "" || !allowed[origin] { + if origin == "" { + next.ServeHTTP(w, r) + return + } + w.Header().Add("Vary", "Origin") + if !allowed[origin] { next.ServeHTTP(w, r) return } @@ -121,13 +128,11 @@ func CORS(allowedOrigins []string) func(http.Handler) http.Handler { h.Set("Access-Control-Allow-Methods", "GET, POST") h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type") h.Set("Access-Control-Max-Age", "600") - h.Add("Vary", "Origin") w.WriteHeader(http.StatusNoContent) return } w.Header().Set("Access-Control-Allow-Origin", origin) - w.Header().Add("Vary", "Origin") next.ServeHTTP(w, r) }) } From 21afe2cffac7751662aa38a48a99c5c4fb69ec29 Mon Sep 17 00:00:00 2001 From: vikoabastian <11003051+vianbas@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:10:45 +0700 Subject: [PATCH 28/29] test(api): cover algorithm confusion and unknown-role tokens --- apps/api/internal/auth/jwt_test.go | 46 +++++++++++++++++++++++ apps/api/internal/auth/middleware_test.go | 46 +++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/apps/api/internal/auth/jwt_test.go b/apps/api/internal/auth/jwt_test.go index 2f8ee6d..0993bbd 100644 --- a/apps/api/internal/auth/jwt_test.go +++ b/apps/api/internal/auth/jwt_test.go @@ -93,6 +93,52 @@ func TestVerify_MissingExpiration(t *testing.T) { } } +func TestVerify_RejectsHS512Signature(t *testing.T) { + verifier := NewVerifier([]byte(testSecret)) + + claims := tokenClaims{ + Email: "operator@example.com", + Role: string(RoleOperator), + RegisteredClaims: jwt.RegisteredClaims{ + Subject: "user-1", + IssuedAt: jwt.NewNumericDate(time.Now().UTC()), + ExpiresAt: jwt.NewNumericDate(time.Now().UTC().Add(15 * time.Minute)), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS512, claims) + signed, err := token.SignedString([]byte(testSecret)) + if err != nil { + t.Fatalf("SignedString: %v", err) + } + + if _, err := verifier.Verify(signed); !errors.Is(err, ErrInvalidToken) { + t.Fatalf("got %v, want ErrInvalidToken", err) + } +} + +func TestVerify_RejectsAlgNone(t *testing.T) { + verifier := NewVerifier([]byte(testSecret)) + + claims := tokenClaims{ + Email: "operator@example.com", + Role: string(RoleAdmin), + RegisteredClaims: jwt.RegisteredClaims{ + Subject: "user-1", + IssuedAt: jwt.NewNumericDate(time.Now().UTC()), + ExpiresAt: jwt.NewNumericDate(time.Now().UTC().Add(15 * time.Minute)), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodNone, claims) + signed, err := token.SignedString(jwt.UnsafeAllowNoneSignatureType) + if err != nil { + t.Fatalf("SignedString: %v", err) + } + + if _, err := verifier.Verify(signed); !errors.Is(err, ErrInvalidToken) { + t.Fatalf("got %v, want ErrInvalidToken", err) + } +} + func TestVerify_MissingIssuedAt(t *testing.T) { verifier := NewVerifier([]byte(testSecret)) diff --git a/apps/api/internal/auth/middleware_test.go b/apps/api/internal/auth/middleware_test.go index 34fdaab..f5eb77c 100644 --- a/apps/api/internal/auth/middleware_test.go +++ b/apps/api/internal/auth/middleware_test.go @@ -6,6 +6,8 @@ import ( "testing" "time" + "github.com/golang-jwt/jwt/v5" + "github.com/vianbas/finwatch/apps/api/internal/auth" ) @@ -122,6 +124,50 @@ func TestRequireAuth_ValidToken(t *testing.T) { } } +// rawClaims mirrors the on-the-wire shape of auth's unexported tokenClaims, +// letting this external test package build a validly-signed token carrying +// an arbitrary role string that Role's constants do not cover. +type rawClaims struct { + Email string `json:"email"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +func issueTokenWithRawRole(t *testing.T, role string) string { + t.Helper() + now := time.Now().UTC() + claims := rawClaims{ + Email: "operator@example.com", + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: "user-1", + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := token.SignedString([]byte(mwTestSecret)) + if err != nil { + t.Fatalf("SignedString: %v", err) + } + return signed +} + +func TestRequireRole_UnknownRoleDenied(t *testing.T) { + verifier := auth.NewVerifier([]byte(mwTestSecret)) + handler := auth.RequireAuth(verifier)(auth.RequireRole(auth.RoleAdmin)(okHandler())) + token := issueTokenWithRawRole(t, "supervisor") + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/admin-only", nil) + req.Header.Set("Authorization", "Bearer "+token) + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } +} + func TestRequireRole_OperatorDeniedFromAdminRoute(t *testing.T) { verifier := auth.NewVerifier([]byte(mwTestSecret)) handler := auth.RequireAuth(verifier)(auth.RequireRole(auth.RoleAdmin)(okHandler())) From f9c830519a45ab3bd8b9d1dd58240776bbfd92aa Mon Sep 17 00:00:00 2001 From: vikoabastian <11003051+vianbas@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:11:44 +0700 Subject: [PATCH 29/29] docs: show bearer token usage and seed-users setup in local dev docs --- Makefile | 2 +- README.md | 6 ++++++ docs/operations/local-development.md | 30 ++++++++++++++++++---------- docs/threat-model.md | 2 +- 4 files changed, 27 insertions(+), 13 deletions(-) diff --git a/Makefile b/Makefile index 40a1869..7e6eace 100644 --- a/Makefile +++ b/Makefile @@ -68,5 +68,5 @@ sqlc: ## Regenerate type-safe DB code from SQL (requires sqlc on PATH) sqlc-check: ## Verify generated DB code matches SQL sources (requires sqlc on PATH) cd $(API_DIR) && sqlc diff -seed: ## Ingest N synthetic transactions (make seed N=100); needs DATABASE_URL +seed: ## Ingest N synthetic transactions (make seed N=100); needs DATABASE_URL, JWT_SIGNING_SECRET cd $(API_DIR) && go run ./cmd/api seed -n $(N) diff --git a/README.md b/README.md index e148ba8..fc95d89 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,12 @@ Then: - Transactions — http://localhost:8080/transactions (seed first: `make seed N=100`) - Web app — http://localhost:8081 +Every API route except `/health/*` and `POST /login` needs a bearer token. +Apply migrations, then `seed-users`, then sign in at the web app as +`operator@example.com` — see the +[local development Authentication section](docs/operations/local-development.md#authentication-local-development) +for the full sequence and example credentials. + Stop the stack with `make stop`. See [docs/operations/local-development.md](docs/operations/local-development.md) diff --git a/docs/operations/local-development.md b/docs/operations/local-development.md index a1b6785..5674421 100644 --- a/docs/operations/local-development.md +++ b/docs/operations/local-development.md @@ -45,8 +45,10 @@ make stop ## Authentication (local development) The API requires `Authorization: Bearer ` on every route except -`/login` and `/health/*`. Create the two demo accounts (idempotent) before -logging in: +`/login` and `/health/*`. Migrations (including `0004_users`, which creates +the accounts table) must be applied — see +[Database migrations](#database-migrations) below — before `seed-users` can +run. Create the two demo accounts (idempotent) before logging in: ```sh cd apps/api && go run ./cmd/api seed-users @@ -54,14 +56,19 @@ cd apps/api && go run ./cmd/api seed-users docker compose exec api /app/api seed-users ``` -This creates `operator@example.com` and `admin@example.com`, with passwords -taken from `DEMO_OPERATOR_PASSWORD` / `DEMO_ADMIN_PASSWORD` (see -`.env.example` for the example dev values). Then log in: +This creates `operator@example.com` and `admin@example.com`. Passwords come +from `DEMO_OPERATOR_PASSWORD` / `DEMO_ADMIN_PASSWORD` (see `.env.example` for +the example dev values); `docker compose` passes these through from `.env` +automatically, and outside `development` (`APP_ENV=staging` or `production`) +both variables are required — `seed-users` exits with an error naming the +missing variable rather than falling back to a published password. Then log +in and use the token on every protected request: ```sh -curl -s -X POST http://localhost:8080/login \ - -H 'Content-Type: application/json' \ - -d '{"email":"operator@example.com","password":"operator_dev_password"}' +TOKEN=$(curl -s -X POST http://localhost:8080/login -H 'Content-Type: application/json' \ + -d '{"email":"operator@example.com","password":"operator_dev_password"}' \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["accessToken"])') +curl -s -H "Authorization: Bearer $TOKEN" 'http://localhost:8080/transactions?limit=5' ``` Access tokens are short-lived (`JWT_ACCESS_TOKEN_TTL`, default 15 minutes) and @@ -111,12 +118,13 @@ make seed N=100 # or: cd apps/api && go run ./cmd/api seed -n 100 ``` Each insert writes a `transaction.observed` row to the outbox in the same -database transaction. List the results (most-recent first, cursor-paginated): +database transaction. List the results (most-recent first, cursor-paginated; +`$TOKEN` is a bearer token from [Authentication](#authentication-local-development)): ```sh -curl 'http://localhost:8080/transactions?limit=20' +curl -s -H "Authorization: Bearer $TOKEN" 'http://localhost:8080/transactions?limit=20' # follow nextCursor for the next page: -curl 'http://localhost:8080/transactions?limit=20&cursor=' +curl -s -H "Authorization: Bearer $TOKEN" 'http://localhost:8080/transactions?limit=20&cursor=' ``` ## Database integration tests diff --git a/docs/threat-model.md b/docs/threat-model.md index 7743d48..8722004 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -27,7 +27,7 @@ and contribute to. | **Tampering** | Malformed/oversized requests | Bounded HTTP timeouts; input validated at handlers (per-feature) | Schema validation against OpenAPI | | **Repudiation** | No trace of actions | Per-request IDs + structured access logs | Audit logging for state changes | | **Information disclosure** | Secrets/PII leakage | No secrets in repo; synthetic data only; logs exclude secrets/PII | Secret scanning, log review | -| **Denial of service** | Slow-client / resource exhaustion | Read/write/idle timeouts; panic recovery; graceful shutdown | Rate limiting, connection caps | +| **Denial of service** | Slow-client / resource exhaustion | Read/write/idle timeouts; panic recovery; graceful shutdown | Rate limiting on `POST /login` (bcrypt cost makes it both a brute-force target and a CPU sink), connection caps | | **Elevation of privilege** | Acting beyond role | N/A (`RequireRole` exists but is not yet mounted on any route) | RBAC enforced server-side | ## Supply chain