Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
77edf14
docs(superpowers): add implementation plan for JWT auth and RBAC
Jun 22, 2026
8f92395
docs(contracts): add bearer auth, /login, /me, and 401/403 responses
vianbas Sep 10, 2026
6f9eddb
build(api): add golang-jwt/jwt and x/crypto dependencies
vianbas Sep 10, 2026
84a64cb
feat(api): add users table and sqlc queries for auth
vianbas Sep 10, 2026
78f5128
feat(api): add JWT signing secret and access token TTL to config
vianbas Sep 10, 2026
4eb379d
docs(contracts): document INVALID_BODY example for POST /login
vianbas Sep 10, 2026
a5cbc3b
fix(api): make 0004_users down migration idempotent
vianbas Sep 10, 2026
09d7a0d
feat(api): add JWT claims, bcrypt hashing, and HS256 issue/verify
vianbas Sep 10, 2026
f4e31fa
feat(api): add login service with bcrypt verification
vianbas Sep 10, 2026
1f25876
feat(api): add RequireAuth and RequireRole HTTP middleware
vianbas Sep 10, 2026
8a2227c
feat(api): add Postgres-backed user store
vianbas Sep 10, 2026
15d026e
feat(api): add POST /login and GET /me handlers
vianbas Sep 10, 2026
c318e1b
feat(api): split router into public and auth-protected route groups
vianbas Sep 10, 2026
a2e3388
feat(api): add allow-list CORS middleware for browser clients
vianbas Sep 10, 2026
9fa4120
feat(api): wire JWT auth into the HTTP server and add seed-users command
vianbas Sep 10, 2026
57beda4
chore: add JWT env vars and demo user passwords to local dev config
vianbas Sep 10, 2026
26c5233
docs: document JWT auth for local development
vianbas Sep 10, 2026
2d8a3c5
feat(web): add in-memory AuthContext with login/logout
vianbas Sep 10, 2026
4a39554
feat(web): add ProtectedRoute guard that redirects to /login
vianbas Sep 10, 2026
8d8deac
feat(web): add useApiFetch that clears the token on 401
vianbas Sep 10, 2026
8a0e840
feat(web): wire LoginPage to POST /login
vianbas Sep 10, 2026
4ef3738
feat(web): gate authenticated routes behind ProtectedRoute
vianbas Sep 10, 2026
c9b4a4e
docs(contracts): describe /login and /me; add WWW-Authenticate header…
vianbas Sep 10, 2026
8a220d0
fix(api): reject the example JWT secret outside development
vianbas Sep 10, 2026
e43d4ca
fix(api): require demo passwords outside development and pass them th…
vianbas Sep 10, 2026
07a1ec8
fix(api): accept case-insensitive bearer scheme and email; add WWW-Au…
vianbas Sep 10, 2026
eb8c999
fix(api): always vary CORS responses on Origin
vianbas Sep 10, 2026
21afe2c
test(api): cover algorithm confusion and unknown-role tokens
vianbas Sep 10, 2026
f9c8305
docs: show bearer token usage and seed-users setup in local dev docs
vianbas Sep 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .claude/rules/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
92 changes: 92 additions & 0 deletions apps/api/cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -122,6 +133,76 @@ 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()

// 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
envKey string
fallback string
role auth.Role
}{
{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 {
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
}
_, 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
}

// 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 "", fmt.Errorf("%s is required outside development", key)
}

// healthcheck performs a localhost liveness request against the configured port.
func healthcheck() error {
port := os.Getenv("HTTP_PORT")
Expand Down Expand Up @@ -168,13 +249,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{
Expand Down
63 changes: 63 additions & 0 deletions apps/api/cmd/api/main_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
8 changes: 5 additions & 3 deletions apps/api/go.mod
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
module github.com/vianbas/finwatch/apps/api

go 1.26
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/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/sync v0.23.0 // indirect
golang.org/x/text v0.42.0 // indirect
)
12 changes: 8 additions & 4 deletions apps/api/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -18,10 +20,12 @@ 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/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
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.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
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=
Expand Down
23 changes: 23 additions & 0 deletions apps/api/internal/auth/claims.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading