jarvis f618098114 M13b W2: Sources CRUD with one-time secrets + rotation 1 month ago
..
README.md d1dc1818df M13a W3: JWT gate on admind + shared NewFromEnv helper 1 month ago
main.go f618098114 M13b W2: Sources CRUD with one-time secrets + rotation 1 month ago
sources.go f618098114 M13b W2: Sources CRUD with one-time secrets + rotation 1 month ago
tenants.go c5e15f76a4 M13b W1: Companies CRUD (backend + UI) 1 month ago

README.md

cmd/authd — multi-tenant auth IdP (port 8804)

In-house JWT issuer + refresh-token store for the M13 admin UI. No third-party auth, no SSO (deferred to v2). The only thing the rest of the system needs to know is: "send a Bearer <access_token> and I'll know who you are".

Endpoints

Method Path Auth Purpose
POST /v1/auth/login email+password → {access_token, refresh_token, ...}
POST /v1/auth/refresh refresh_token → new pair (rotates, kills family on re-use)
POST /v1/auth/logout refresh_token → revoked (idempotent)
POST /v1/auth/magic magic_token + new_password → password set, user active
POST /v1/users/invite bearer (admin) tenant_slug + email + role → new pending user + magic token
GET /v1/users/me bearer current user info
GET /health liveness
GET /metrics prom

Environment

Var Default Notes
BA_AUTHD_HTTP_ADDR :8804
BA_AUTHD_ISSUER broad-announce the iss claim on JWTs
BA_AUTHD_JWT_SECRET (required) HS256 secret, ≥32 bytes. Use sealed-secrets in prod.
BA_AUTHD_ALLOW_GENERATED_SECRET 1 in dev if 1 and no secret in env, generate + persist to BA_AUTHD_SECRET_FILE
BA_AUTHD_SECRET_FILE /var/run/broad-announce/authd.jwt where the generated secret is stored
BA_AUTHD_ACCESS_TTL 15m access JWT TTL
BA_AUTHD_REFRESH_TTL 168h (7d) refresh token TTL
BA_AUTHD_BCRYPT_COST 12 bcrypt work factor (12 ≈ 250ms on modern CPU)
BA_AUTHD_SHUTDOWN_GRACE 10s graceful shutdown timeout
BA_AUTHD_TRUST_FORWARDED 0 if 1, trust X-Forwarded-For for client IP. Only set behind a known reverse proxy.
BA_POSTGRES_DSN (required) e.g. postgres://authd:...@postgres:5432/broad-announce?sslmode=disable
BA_ENV dev dev → allows generated secret, JSON logs. prod → requires BA_AUTHD_JWT_SECRET.

First-run setup

# 1. Apply migration
go run ./cmd/seed

# 2. Generate a JWT secret (≥32 bytes of randomness)
openssl rand -base64 48
# → store in K8s sealed-secret, mount as BA_AUTHD_JWT_SECRET

# 3. Create the first super_admin (psql fallback per M13 decision 2.4)
psql -c "INSERT INTO auth.tenants (...) VALUES (...);"
psql -c "INSERT INTO auth.users (tenant_id, email, role, status, password_hash) \
          VALUES (NULL, 'you@broad-announce.net', 'super_admin', 'active', \
          '\$2a\$12\$...bcrypt-hash...');"

# 4. Run
go run ./cmd/authd

Smoke test

# Login
RESP=$(curl -s -X POST http://localhost:8804/v1/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"you@broad-announce.net","password":"your-password"}')

ACCESS=$(echo "$RESP" | jq -r .access_token)
REFRESH=$(echo "$RESP" | jq -r .refresh_token)

# /me
curl -H "Authorization: Bearer $ACCESS" http://localhost:8804/v1/users/me

# Refresh
curl -X POST http://localhost:8804/v1/auth/refresh \
  -H 'Content-Type: application/json' \
  -d "{\"refresh_token\":\"$REFRESH\"}"

# Re-use the OLD refresh — should be session_killed
curl -X POST http://localhost:8804/v1/auth/refresh \
  -H 'Content-Type: application/json' \
  -d "{\"refresh_token\":\"$REFRESH\"}"
# → {"error":"session_killed","message":"refresh token re-use detected, please log in again"}

Security model

  • Access JWTs are HS256, signed with the shared secret. The verifier (authd.VerifyAccessToken) rejects any non-HMAC alg (the classic alg=none and RS256-confused-as-HS256 attacks).
  • Refresh tokens are 32 bytes of crypto/rand → 64 hex chars. Stored in Postgres as digest(token, 'sha256'). The plaintext is in the httpOnly cookie / mobile secure storage, never in Postgres.
  • Rotation on every refresh. Re-use of a rotated token kills the entire family (re-use-detection is in the SQL function).
  • Audit log records every login, logout, refresh, invite, magic consume. The actor's IP and user-agent are captured for forensics.
  • bcrypt with cost 12 (≈250ms on modern CPU). Adjustable via BA_AUTHD_BCRYPT_COST for tests.

What is NOT here (deferred)

  • JWKS, RS256, key rotation: v1 ships HS256 with a single shared secret. Multi-instance requires moving the secret to a real KMS (v2).
  • SSO / OAuth2 / MFA / password reset email: v2.
  • Rate limiting on /v1/auth/login (brute-force protection): v1.1. For now, the network-level limits in front of authd are the only protection.
  • Per-tenant rate limits on /v1/users/invite: v1.1.

Tests

# Unit tests (no DB)
go test ./internal/authd/

# Integration tests (Postgres required)
TEST_AUTH_DSN=postgres://postgres:testing@127.0.0.1:5432/test_auth?sslmode=disable \
  go test -tags=postgres ./internal/authd/

The integration tests cover login → refresh → re-use-detection → logout end-to-end. The auth schema is created from migrations/009_auth.up.sql if not present, and truncated between tests.

Use as a JWT verifier from other services

Other services (ingestd, admind, etc.) use authd.NewFromEnv() to construct a verifier-only *Authd and then apply the middleware (ad.RequireAuth, ad.RequireRole) to their routes:

import "git3.techno-world.net/lrosales/broad-announce/internal/authd"

func main() {
    if !authd.EnvEnabled() {
        // gate disabled (LAN deploys)
        mux.HandleFunc("GET /v1/...", openHandler)
        return
    }
    ad := authd.MustNewFromEnv()
    mux.Handle("GET /v1/...", ad.RequireAuth(authedHandler))
    mux.Handle("POST /v1/...", ad.RequireRole("super_admin", "tenant_admin")(destructiveHandler))
}

In production, all services that need to verify tokens share the same BA_AUTHD_JWT_SECRET (rotated together). The services that don't issue tokens (admind, ingestd, routerd) can run without a Postgres connection.

The HTTP middleware in internal/authd/middleware.go:

  • RequireAuth(next) — reads Authorization: Bearer <token>, calls VerifyAccessToken, stuffs claims into request context. Rejects alg=none and any non-HMAC signing method.
  • RequireRole(allowed...) — composes on RequireAuth, returns 403 if the role doesn't match.
  • ClaimsFromContext(ctx) — accessor for handlers downstream.

See cmd/admind/main.go::wireDLQRoutes and cmd/ingestd/http.go::RegisterAdminRoutes for example wiring.