瀏覽代碼

M13a W1: authd IdP (in-house JWT + refresh-token store)

Multi-tenant auth service for the M13 admin UI. No third-party
auth, no SSO (deferred to v2). The only thing the rest of the
system needs is 'send a Bearer <access_token>'.

What ships:

  cmd/authd/main.go        — HTTP server (port 8804) with 6 endpoints:
                             /v1/auth/{login,refresh,logout,magic}
                             /v1/users/{invite,me}
                             /health, /metrics
                             Env-driven config (BA_AUTHD_*).
                             Secret auto-gen in dev only.

  cmd/authd/README.md      — usage, env vars, smoke test, security model.

  internal/authd/authd.go  — core service: JWT (HS256, alg-confusion
                             rejected), bcrypt, magic-link gen/consume,
                             sessions, invite flow, audit hooks.
                             VerifyAccessToken is the public surface
                             other services will use to authorize requests.

  internal/authd/store.go  — pgx-based data access. All SQL lives here.
                             Delegates refresh-token CRUD to the SQL
                             functions in 009_auth.up.sql.

  migrations/009_auth.up.sql — schema 'auth' with:
                               tenants, users, magic_links,
                               refresh_tokens, sessions, audit_log.
                               4 SQL functions:
                                 generate_magic_link_token
                                 issue_refresh_token
                                 rotate_refresh_token  (with re-use
                                                        detection + family kill)
                                 revoke_refresh_token
                               pgcrypto extension (gen_random_bytes, digest).
                               Indexes + check constraints + updated_at triggers.

  migrations/009_auth.down.sql — DROP SCHEMA CASCADE.

  internal/authd/authd_test.go — 13 unit tests (no DB):
                                  bcrypt round-trip, JWT round-trip,
                                  bad-secret rejection, expired token,
                                  alg=none attack rejection,
                                  newJTI uniqueness/shape,
                                  defaults, magic-link input validation.

  internal/authd/store_test.go — 6 integration tests (build tag 'postgres'):
                                  create+get user, duplicate email rejected,
                                  magic-link issue+consume,
                                  login+refresh+re-use-kill+logout,
                                  invite+setpassword, audit log write.

Test results: 19/19 pass (13 unit + 6 integration with real Postgres).

Bugs found and fixed during development (worth knowing):

  SQL:
  1. gen_random_bytes requires CREATE EXTENSION pgcrypto (not
     built-in on PG 13-).
  2. digest(hex_string, 'sha256') != digest(decode(hex_string,'hex'),
     'sha256'). Hash over raw bytes, not the hex-encoded string.
  3. PL/pgSQL RETURNING id INTO v_id is ambiguous when there's a
     RETURNS TABLE(id ...) — qualify with schema.table.column.
  4. PL/pgSQL functions need SET search_path = auth, public so
     gen_random_bytes and digest are findable when called externally.

  Go:
  5. INET columns don't accept 'host:port' — strip the port from
     r.RemoteAddr before storing.
  6. golang-jwt/v5: must explicitly reject non-HMAC signing methods
     in the keyfunc (alg=none + RS256 confused-deputy attacks).

Co-Authored-By: Jarvis <jarvis@techno-world.net>
Jarvis 1 月之前
父節點
當前提交
8f221518b5
共有 11 個文件被更改,包括 2558 次插入5 次删除
  1. 3 0
      .gitignore
  2. 125 0
      cmd/authd/README.md
  3. 610 0
      cmd/authd/main.go
  4. 6 5
      go.mod
  5. 12 0
      go.sum
  6. 456 0
      internal/authd/authd.go
  7. 247 0
      internal/authd/authd_test.go
  8. 375 0
      internal/authd/store.go
  9. 323 0
      internal/authd/store_test.go
  10. 4 0
      migrations/009_auth.down.sql
  11. 397 0
      migrations/009_auth.up.sql

+ 3 - 0
.gitignore

@@ -29,3 +29,6 @@ docker-compose.override.yml
 /seed
 /fakefcmd
 loadgen/grpc
+
+# Local authd binary
+/authd

+ 125 - 0
cmd/authd/README.md

@@ -0,0 +1,125 @@
+# 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
+
+```bash
+# 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
+
+```bash
+# 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
+
+```bash
+# 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.

+ 610 - 0
cmd/authd/main.go

@@ -0,0 +1,610 @@
+// Command authd is the in-house multi-tenant auth IdP that powers
+// the M13 admin UI. It exposes:
+//
+//	POST /v1/auth/login        — email + password → access JWT + refresh token
+//	POST /v1/auth/refresh      — refresh token → new pair
+//	POST /v1/auth/logout       — refresh token → revoke
+//	POST /v1/auth/magic        — magic-link token + new password → session
+//	POST /v1/users/invite      — super/tenant-admin → magic link (email side-effect lives in the caller)
+//	GET  /v1/users/me          — current user info
+//	GET  /health, /metrics     — observability
+//
+// All endpoints are unauthenticated except /v1/users/me and
+// /v1/users/invite (which require a valid access JWT). The auth
+// path is /v1/auth/* (no JWT required to log in, naturally).
+//
+// Configuration: env vars only. See authdEnv() below.
+package main
+
+import (
+	"context"
+	cryptorand "crypto/rand"
+	"encoding/json"
+	"errors"
+	"log/slog"
+	"net/http"
+	"os"
+	"os/signal"
+	"strconv"
+	"syscall"
+	"time"
+
+	"git3.techno-world.net/lrosales/broad-announce/internal/authd"
+	"git3.techno-world.net/lrosales/broad-announce/internal/httpserver"
+	"git3.techno-world.net/lrosales/broad-announce/internal/observability"
+	"git3.techno-world.net/lrosales/broad-announce/internal/postgres"
+)
+
+func main() {
+	logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
+	if err := run(logger); err != nil {
+		logger.Error("authd exited with error", "err", err)
+		os.Exit(1)
+	}
+}
+
+func run(logger *slog.Logger) error {
+	cfg, err := authdEnv()
+	if err != nil {
+		return err
+	}
+	logger.Info("authd starting",
+		"env", cfg.Env, "addr", cfg.HTTPAddr,
+		"issuer", cfg.Issuer, "access_ttl", cfg.AuthdConfig.AccessTokenTTL)
+
+	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+	defer stop()
+
+	pool, err := postgres.Connect(ctx, cfg.PostgresDSN)
+	if err != nil {
+		return err
+	}
+	defer pool.Close()
+
+	// Verify the migration is applied (graceful if not — first boot
+	// may need to run `go run ./cmd/seed` first).
+	if err := pingSchema(ctx, pool); err != nil {
+		logger.Warn("auth schema not yet present, login will fail", "err", err)
+	}
+
+	// Generate a JWT secret if not provided. The first-run case
+	// (no env) gets a random secret written to a file so restarts
+	// produce the same tokens. This is dev-only behavior; in
+	// production the secret comes from K8s sealed-secrets.
+	if len(cfg.JWTSecret) < 32 {
+		if !cfg.AllowGeneratedSecret {
+			return errors.New("BA_AUTHD_JWT_SECRET must be at least 32 bytes (set in env or sealed-secret)")
+		}
+		sec, err := loadOrCreateSecret(cfg.SecretFile, logger)
+		if err != nil {
+			return err
+		}
+		cfg.JWTSecret = sec
+	}
+
+	ad, err := authd.New(pool, cfg.AuthdConfig)
+	if err != nil {
+		return err
+	}
+
+	reg, _ := observability.NewRegistry("authd")
+	srv := httpserver.New(httpserver.Config{
+		Addr:        cfg.HTTPAddr,
+		ServiceName: "authd",
+	}, logger, observability.MetricsHandler(reg))
+
+	mux := srv.Mux()
+	mux.HandleFunc("POST /v1/auth/login", loginHandler(ad, logger))
+	mux.HandleFunc("POST /v1/auth/refresh", refreshHandler(ad, logger))
+	mux.HandleFunc("POST /v1/auth/logout", logoutHandler(ad, logger))
+	mux.HandleFunc("POST /v1/auth/magic", magicConsumeHandler(ad, logger))
+	mux.HandleFunc("POST /v1/users/invite", inviteHandler(ad, logger))
+	mux.HandleFunc("GET /v1/users/me", meHandler(ad, logger))
+
+	// Start in background, wait for signal, then graceful shutdown.
+	errCh := make(chan error, 1)
+	go func() { errCh <- srv.Start() }()
+
+	select {
+	case err := <-errCh:
+		return err
+	case <-ctx.Done():
+		logger.Info("shutdown signal received")
+		shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownGrace)
+		defer cancel()
+		return srv.Shutdown(shutdownCtx)
+	}
+}
+
+// ---------------------------------------------------------------------------
+// Env loading
+// ---------------------------------------------------------------------------
+
+type env struct {
+	Env       string
+	HTTPAddr  string
+	Issuer    string
+	AuthdConfig authd.Config
+
+	PostgresDSN  string
+	ShutdownGrace time.Duration
+
+	// JWT secret
+	JWTSecret         []byte
+	AllowGeneratedSecret bool
+	SecretFile        string
+}
+
+func authdEnv() (env, error) {
+	e := env{
+		Env:        getenvDefault("BA_ENV", "dev"),
+		HTTPAddr:   getenvDefault("BA_AUTHD_HTTP_ADDR", ":8804"),
+		Issuer:     getenvDefault("BA_AUTHD_ISSUER", "broad-announce"),
+		PostgresDSN: os.Getenv("BA_POSTGRES_DSN"),
+	}
+	if s := os.Getenv("BA_AUTHD_JWT_SECRET"); s != "" {
+		e.JWTSecret = []byte(s)
+	}
+	e.AllowGeneratedSecret = getenvDefault("BA_AUTHD_ALLOW_GENERATED_SECRET", "") == "1" ||
+		os.Getenv("BA_ENV") == "dev"
+	e.SecretFile = getenvDefault("BA_AUTHD_SECRET_FILE", "/var/run/broad-announce/authd.jwt")
+	if t := os.Getenv("BA_AUTHD_ACCESS_TTL"); t != "" {
+		d, err := time.ParseDuration(t)
+		if err != nil {
+			return e, err
+		}
+		e.AuthdConfig.AccessTokenTTL = d
+	}
+	if t := os.Getenv("BA_AUTHD_REFRESH_TTL"); t != "" {
+		d, err := time.ParseDuration(t)
+		if err != nil {
+			return e, err
+		}
+		e.AuthdConfig.RefreshTokenTTL = d
+	}
+	if c := os.Getenv("BA_AUTHD_BCRYPT_COST"); c != "" {
+		n, err := strconv.Atoi(c)
+		if err != nil {
+			return e, err
+		}
+		e.AuthdConfig.BcryptCost = n
+	}
+	e.AuthdConfig.JWTSecret = e.JWTSecret // may be empty; New() will reject
+	e.AuthdConfig.Issuer = e.Issuer
+	if e.PostgresDSN == "" {
+		return e, errors.New("BA_POSTGRES_DSN must be set")
+	}
+	grace := 10 * time.Second
+	if g := os.Getenv("BA_AUTHD_SHUTDOWN_GRACE"); g != "" {
+		d, err := time.ParseDuration(g)
+		if err != nil {
+			return e, err
+		}
+		grace = d
+	}
+	e.ShutdownGrace = grace
+	return e, nil
+}
+
+func getenvDefault(k, def string) string {
+	if v := os.Getenv(k); v != "" {
+		return v
+	}
+	return def
+}
+
+// pingSchema is a best-effort check that the auth schema is migrated.
+// Returns nil if the audit_log table exists, an error otherwise.
+func pingSchema(ctx context.Context, pool *postgres.Pool) error {
+	row := pool.QueryRow(ctx,
+		`SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='auth' AND table_name='users')`)
+	var ok bool
+	if err := row.Scan(&ok); err != nil {
+		return err
+	}
+	if !ok {
+		return errors.New("auth.users not found; run migrations first")
+	}
+	return nil
+}
+
+// loadOrCreateSecret reads the JWT secret from disk, or generates a
+// random one and writes it. The file is chmod 600.
+func loadOrCreateSecret(path string, logger *slog.Logger) ([]byte, error) {
+	if data, err := os.ReadFile(path); err == nil && len(data) >= 32 {
+		return data, nil
+	}
+	logger.Warn("generating new JWT secret (dev-only)", "file", path)
+	if err := os.MkdirAll(parentDir(path), 0o700); err != nil {
+		return nil, err
+	}
+	secret := make([]byte, 48)
+	if _, err := readFull(secret); err != nil {
+		return nil, err
+	}
+	if err := os.WriteFile(path, secret, 0o600); err != nil {
+		return nil, err
+	}
+	return secret, nil
+}
+
+// readFull fills b with cryptographically random bytes.
+func readFull(b []byte) (int, error) {
+	return randomRead(b)
+}
+
+func parentDir(p string) string {
+	for i := len(p) - 1; i >= 0; i-- {
+		if p[i] == '/' {
+			return p[:i]
+		}
+	}
+	return "."
+}
+
+// randomRead is split out so tests can stub it; default uses crypto/rand.
+var randomRead = func(b []byte) (int, error) {
+	return cryptorand.Read(b)
+}
+
+// ---------------------------------------------------------------------------
+// HTTP handlers
+// ---------------------------------------------------------------------------
+
+// clientIP pulls the IP from r.RemoteAddr, respecting X-Forwarded-For
+// when BA_AUTHD_TRUST_FORWARDED=1. Returns the bare IP (no port)
+// because the audit_log columns are INET.
+func clientIP(r *http.Request) string {
+	raw := r.RemoteAddr
+	if os.Getenv("BA_AUTHD_TRUST_FORWARDED") == "1" {
+		if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
+			// X-Forwarded-For is a list; first is the client
+			if i := indexByte(xff, ','); i >= 0 {
+				raw = xff[:i]
+			} else {
+				raw = xff
+			}
+		}
+	}
+	// Strip :port if present (host:port)
+	if i := lastByte(raw, ':'); i >= 0 {
+		// Make sure it's not part of an IPv6 address
+		if !containsByte(raw, ']') || i > indexByte(raw, ']') {
+			raw = raw[:i]
+		}
+	}
+	return raw
+}
+
+func indexByte(s string, c byte) int {
+	for i := 0; i < len(s); i++ {
+		if s[i] == c {
+			return i
+		}
+	}
+	return -1
+}
+
+func lastByte(s string, c byte) int {
+	for i := len(s) - 1; i >= 0; i-- {
+		if s[i] == c {
+			return i
+		}
+	}
+	return -1
+}
+
+func containsByte(s string, c byte) bool {
+	return indexByte(s, c) >= 0
+}
+
+// writeJSON writes status + JSON body. Sets Content-Type.
+func writeJSON(w http.ResponseWriter, status int, body any) {
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(status)
+	_ = json.NewEncoder(w).Encode(body)
+}
+
+// writeErr writes a JSON error response.
+func writeErr(w http.ResponseWriter, status int, code, msg string) {
+	writeJSON(w, status, map[string]string{"error": code, "message": msg})
+}
+
+// --- /v1/auth/login -------------------------------------------------------
+
+type loginReq struct {
+	Email    string `json:"email"`
+	Password string `json:"password"`
+}
+
+type loginResp struct {
+	AccessToken  string    `json:"access_token"`
+	RefreshToken string    `json:"refresh_token"`
+	TokenType    string    `json:"token_type"`
+	ExpiresAt    time.Time `json:"expires_at"`
+	UserID       string    `json:"user_id"`
+	TenantID     string    `json:"tenant_id,omitempty"`
+	Role         string    `json:"role"`
+}
+
+func loginHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		var req loginReq
+		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+			writeErr(w, http.StatusBadRequest, "bad_request", "invalid json")
+			return
+		}
+		if req.Email == "" || req.Password == "" {
+			writeErr(w, http.StatusBadRequest, "bad_request", "email and password required")
+			return
+		}
+		res, err := ad.Login(r.Context(), req.Email, req.Password, clientIP(r), r.UserAgent())
+		if err != nil {
+			if errors.Is(err, authd.ErrInvalidCredentials) || errors.Is(err, authd.ErrUserDisabled) {
+				writeErr(w, http.StatusUnauthorized, "unauthorized", "invalid credentials")
+				return
+			}
+			logger.Error("login internal error", "err", err)
+			writeErr(w, http.StatusInternalServerError, "internal", "internal error")
+			return
+		}
+		writeJSON(w, http.StatusOK, loginResp{
+			AccessToken:  res.AccessToken,
+			RefreshToken: res.RefreshToken,
+			TokenType:    "Bearer",
+			ExpiresAt:    res.ExpiresAt,
+			UserID:       res.UserID,
+			TenantID:     res.TenantID,
+			Role:         res.Role,
+		})
+	}
+}
+
+// --- /v1/auth/refresh -----------------------------------------------------
+
+type refreshReq struct {
+	RefreshToken string `json:"refresh_token"`
+}
+
+func refreshHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		var req refreshReq
+		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+			writeErr(w, http.StatusBadRequest, "bad_request", "invalid json")
+			return
+		}
+		if req.RefreshToken == "" {
+			writeErr(w, http.StatusBadRequest, "bad_request", "refresh_token required")
+			return
+		}
+		res, err := ad.Refresh(r.Context(), req.RefreshToken, clientIP(r), r.UserAgent())
+		if err != nil {
+			if errors.Is(err, authd.ErrTokenReuse) {
+				writeErr(w, http.StatusUnauthorized, "session_killed", "refresh token re-use detected, please log in again")
+				return
+			}
+			if errors.Is(err, authd.ErrUserDisabled) {
+				writeErr(w, http.StatusUnauthorized, "unauthorized", "user disabled")
+				return
+			}
+			logger.Error("refresh internal error", "err", err)
+			writeErr(w, http.StatusInternalServerError, "internal", "internal error")
+			return
+		}
+		writeJSON(w, http.StatusOK, loginResp{
+			AccessToken:  res.AccessToken,
+			RefreshToken: res.RefreshToken,
+			TokenType:    "Bearer",
+			ExpiresAt:    res.ExpiresAt,
+			UserID:       res.UserID,
+			TenantID:     res.TenantID,
+			Role:         res.Role,
+		})
+	}
+}
+
+// --- /v1/auth/logout ------------------------------------------------------
+
+type logoutReq struct {
+	RefreshToken string `json:"refresh_token"`
+}
+
+func logoutHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		var req logoutReq
+		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+			writeErr(w, http.StatusBadRequest, "bad_request", "invalid json")
+			return
+		}
+		// Resolve user id from the access JWT in the Authorization
+		// header (best effort) so we can write the audit row.
+		uid, _ := userIDFromAuthHeader(ad, r)
+		if err := ad.Logout(r.Context(), req.RefreshToken, uid, clientIP(r), r.UserAgent()); err != nil {
+			logger.Error("logout internal error", "err", err)
+			writeErr(w, http.StatusInternalServerError, "internal", "internal error")
+			return
+		}
+		writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
+	}
+}
+
+// --- /v1/auth/magic -------------------------------------------------------
+
+type magicReq struct {
+	Token       string `json:"token"`
+	NewPassword string `json:"new_password"`
+}
+
+func magicConsumeHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		var req magicReq
+		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+			writeErr(w, http.StatusBadRequest, "bad_request", "invalid json")
+			return
+		}
+		if req.Token == "" || req.NewPassword == "" {
+			writeErr(w, http.StatusBadRequest, "bad_request", "token and new_password required")
+			return
+		}
+		if len(req.NewPassword) < 12 {
+			writeErr(w, http.StatusBadRequest, "weak_password", "password must be at least 12 characters")
+			return
+		}
+		uid, err := ad.ConsumeMagicLink(r.Context(), req.Token, clientIP(r), r.UserAgent())
+		if err != nil {
+			if errors.Is(err, authd.ErrMagicLinkInvalid) {
+				writeErr(w, http.StatusUnauthorized, "invalid_link", "magic link invalid or expired")
+				return
+			}
+			logger.Error("magic consume error", "err", err)
+			writeErr(w, http.StatusInternalServerError, "internal", "internal error")
+			return
+		}
+		if err := ad.SetPassword(r.Context(), uid, req.NewPassword); err != nil {
+			logger.Error("set password error", "err", err)
+			writeErr(w, http.StatusInternalServerError, "internal", "internal error")
+			return
+		}
+		// Auto-login: return a session so the user lands on the
+		// dashboard without a separate login round-trip.
+		user, err := ad.Store().GetUserByID(r.Context(), uid)
+		if err != nil {
+			writeErr(w, http.StatusInternalServerError, "internal", "internal error")
+			return
+		}
+		// We don't have the plaintext password to call Login(). The
+		// simpler path: sign a session directly using a helper.
+		// For now, return success and require the user to log in
+		// normally — UX is one extra click, security is cleaner.
+		_ = user
+		writeJSON(w, http.StatusOK, map[string]any{
+			"status":  "ok",
+			"user_id": uid,
+			"message": "password set. Please log in.",
+		})
+	}
+}
+
+// --- /v1/users/invite -----------------------------------------------------
+
+type inviteReq struct {
+	TenantSlug string `json:"tenant_slug"`
+	Email      string `json:"email"`
+	Role       string `json:"role"`
+}
+
+type inviteResp struct {
+	UserID         string    `json:"user_id"`
+	MagicLinkToken string    `json:"magic_link_token"`
+	ExpiresAt      time.Time `json:"expires_at"`
+}
+
+func inviteHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		claims, err := userClaimsFromAuthHeader(ad, r)
+		if err != nil {
+			writeErr(w, http.StatusUnauthorized, "unauthorized", "valid access token required")
+			return
+		}
+		if claims.Role != "super_admin" && claims.Role != "tenant_admin" {
+			writeErr(w, http.StatusForbidden, "forbidden", "invite requires admin role")
+			return
+		}
+		var req inviteReq
+		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+			writeErr(w, http.StatusBadRequest, "bad_request", "invalid json")
+			return
+		}
+		if req.Email == "" || req.Role == "" {
+			writeErr(w, http.StatusBadRequest, "bad_request", "email and role required")
+			return
+		}
+		// Resolve tenant: super_admin can target any tenant by slug;
+		// tenant_admin can only target their own tenant.
+		var tenantID string
+		if claims.Role == "super_admin" {
+			if req.TenantSlug == "" {
+				writeErr(w, http.StatusBadRequest, "bad_request", "tenant_slug required for super_admin")
+				return
+			}
+			tenantID, err = ad.Store().TenantIDBySlug(r.Context(), req.TenantSlug)
+			if err != nil {
+				writeErr(w, http.StatusBadRequest, "unknown_tenant", "tenant_slug not found")
+				return
+			}
+		} else {
+			tenantID = claims.TenantID
+		}
+		token, uid, err := ad.InviteUser(r.Context(), tenantID, req.Email, req.Role, claims.UserID, clientIP(r), r.UserAgent())
+		if err != nil {
+			logger.Error("invite error", "err", err)
+			writeErr(w, http.StatusInternalServerError, "internal", "internal error")
+			return
+		}
+		writeJSON(w, http.StatusOK, inviteResp{
+			UserID:         uid,
+			MagicLinkToken: token,
+		})
+	}
+}
+
+// --- /v1/users/me ---------------------------------------------------------
+
+func meHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		claims, err := userClaimsFromAuthHeader(ad, r)
+		if err != nil {
+			writeErr(w, http.StatusUnauthorized, "unauthorized", "valid access token required")
+			return
+		}
+		u, err := ad.Store().GetUserByID(r.Context(), claims.UserID)
+		if err != nil {
+			writeErr(w, http.StatusInternalServerError, "internal", "user lookup failed")
+			return
+		}
+		writeJSON(w, http.StatusOK, map[string]any{
+			"user_id":     u.ID,
+			"email":       u.Email,
+			"role":        u.Role,
+			"status":      u.Status,
+			"tenant_id":   u.TenantID,
+			"display_name": u.DisplayName,
+		})
+	}
+}
+
+// --- helpers for bearer auth ----------------------------------------------
+
+// userIDFromAuthHeader extracts the user id from the access token in
+// the Authorization header. Returns ("", err) if absent or invalid.
+func userIDFromAuthHeader(ad *authd.Authd, r *http.Request) (string, error) {
+	tok, err := bearerFromAuthHeader(r)
+	if err != nil {
+		return "", err
+	}
+	claims, err := ad.VerifyAccessToken(tok)
+	if err != nil {
+		return "", err
+	}
+	return claims.UserID, nil
+}
+
+func userClaimsFromAuthHeader(ad *authd.Authd, r *http.Request) (*authd.AccessClaims, error) {
+	tok, err := bearerFromAuthHeader(r)
+	if err != nil {
+		return nil, err
+	}
+	return ad.VerifyAccessToken(tok)
+}
+
+func bearerFromAuthHeader(r *http.Request) (string, error) {
+	h := r.Header.Get("Authorization")
+	if h == "" {
+		return "", errors.New("missing Authorization header")
+	}
+	const prefix = "Bearer "
+	if len(h) <= len(prefix) || h[:len(prefix)] != prefix {
+		return "", errors.New("Authorization must be 'Bearer <token>'")
+	}
+	return h[len(prefix):], nil
+}

+ 6 - 5
go.mod

@@ -16,6 +16,7 @@ require (
 require (
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/cespare/xxhash/v2 v2.3.0 // indirect
+	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
@@ -29,10 +30,10 @@ require (
 	github.com/prometheus/procfs v0.16.1 // indirect
 	go.uber.org/atomic v1.11.0 // indirect
 	go.yaml.in/yaml/v2 v2.4.2 // indirect
-	golang.org/x/crypto v0.49.0 // indirect
-	golang.org/x/net v0.51.0 // indirect
-	golang.org/x/sync v0.20.0 // indirect
-	golang.org/x/sys v0.42.0 // indirect
-	golang.org/x/text v0.35.0 // indirect
+	golang.org/x/crypto v0.53.0 // indirect
+	golang.org/x/net v0.55.0 // indirect
+	golang.org/x/sync v0.21.0 // indirect
+	golang.org/x/sys v0.46.0 // indirect
+	golang.org/x/text v0.38.0 // indirect
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
 )

+ 12 - 0
go.sum

@@ -16,6 +16,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
 github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+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/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
 github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -91,14 +93,24 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
 go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
 golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
 golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
+golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
+golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
 golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
 golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
+golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
+golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
 golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
 golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
 golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
 golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
 golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
 golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
+golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
+golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
 gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
 gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=

+ 456 - 0
internal/authd/authd.go

@@ -0,0 +1,456 @@
+// Package authd implements the in-house multi-tenant auth IdP that
+// backs the M13 admin UI. It issues short-lived access JWTs (HS256,
+// 15m by default) and long-lived refresh tokens (opaque random
+// strings, 7d, stored server-side with rotation + family re-use
+// detection).
+//
+// The refresh-token side of things is mostly SQL functions
+// (migrations/009_auth.up.sql). This package owns:
+//
+//   - JWT signing and verification (HS256, shared secret v1).
+//   - Magic-link token generation, hashing, and consumption.
+//   - Password hashing (bcrypt).
+//   - Server-side session validation for incoming requests
+//     (used by other services via the VerifyAccessToken call).
+//   - Audit log writes for auth.* events.
+//
+// Out of scope for v1 (deferred to v2): JWKS, RS256, SSO mapping,
+// MFA, OAuth2 flows. The HS256 shared secret is the only auth
+// material — it MUST be rotated before any multi-instance deploy.
+//
+// Threading: Authd is safe for concurrent use. The store is
+// stateless; all state lives in Postgres.
+package authd
+
+import (
+	"context"
+	"crypto/rand"
+	"crypto/sha256"
+	"crypto/subtle"
+	"encoding/hex"
+	"errors"
+	"fmt"
+	"time"
+
+	"github.com/golang-jwt/jwt/v5"
+	"golang.org/x/crypto/bcrypt"
+
+	"git3.techno-world.net/lrosales/broad-announce/internal/postgres"
+)
+
+// Config is the authd runtime config. Loaded from env in main.
+type Config struct {
+	// JWTSecret is the HS256 signing key. Must be at least 32 bytes.
+	// Generated by scripts/gen-jwt-secret.sh on first install.
+	JWTSecret []byte
+
+	// Issuer is the `iss` claim. Should match across all services
+	// that need to verify tokens.
+	Issuer string
+
+	// AccessTokenTTL is how long access JWTs are valid. 15m default.
+	AccessTokenTTL time.Duration
+
+	// RefreshTokenTTL is how long refresh tokens are valid. 7d default.
+	RefreshTokenTTL time.Duration
+
+	// MagicLinkTTL is how long a magic link is valid. 24h default.
+	MagicLinkTTL time.Duration
+
+	// BcryptCost is the bcrypt work factor. 12 default.
+	BcryptCost int
+}
+
+// DefaultConfig returns Config with safe defaults. JWTSecret is
+// zero — main() must load it from env.
+func DefaultConfig() Config {
+	return Config{
+		Issuer:          "broad-announce",
+		AccessTokenTTL:  15 * time.Minute,
+		RefreshTokenTTL: 7 * 24 * time.Hour,
+		MagicLinkTTL:    24 * time.Hour,
+		BcryptCost:      12,
+	}
+}
+
+// Store returns the underlying Store. Used by HTTP handlers that
+// need direct access to user/tenant lookups not on the high-level
+// API.
+func (a *Authd) Store() *Store { return a.store }
+
+// Authd is the service object. Construct once at startup, pass to
+// the HTTP handlers.
+type Authd struct {
+	cfg   Config
+	store *Store
+}
+
+// New constructs an Authd. pool is the pgx pool; cfg must have a
+// non-zero JWTSecret (caller validates). pool may be nil for
+// tests that only exercise the pure-Go paths (bcrypt, JWT); any
+// call that hits the DB will return an error in that mode.
+func New(pool *postgres.Pool, cfg Config) (*Authd, error) {
+	if len(cfg.JWTSecret) < 32 {
+		return nil, fmt.Errorf("authd: JWTSecret must be at least 32 bytes (got %d)", len(cfg.JWTSecret))
+	}
+	if cfg.AccessTokenTTL == 0 {
+		cfg.AccessTokenTTL = 15 * time.Minute
+	}
+	if cfg.RefreshTokenTTL == 0 {
+		cfg.RefreshTokenTTL = 7 * 24 * time.Hour
+	}
+	if cfg.MagicLinkTTL == 0 {
+		cfg.MagicLinkTTL = 24 * time.Hour
+	}
+	if cfg.BcryptCost == 0 {
+		cfg.BcryptCost = 12
+	}
+	return &Authd{cfg: cfg, store: NewStore(pool)}, nil
+}
+
+// ---------------------------------------------------------------------------
+// Errors
+// ---------------------------------------------------------------------------
+
+// ErrInvalidCredentials is returned when login fails. The HTTP
+// handler maps this to 401 with a generic message — we never
+// disclose whether the email or the password was wrong.
+var ErrInvalidCredentials = errors.New("authd: invalid credentials")
+
+// ErrUserNotFound is the underlying cause. Handlers should NOT
+// surface this to clients.
+var ErrUserNotFound = errors.New("authd: user not found")
+
+// ErrUserDisabled is returned when the user exists but is in
+// 'pending' (no password set yet, must use magic link) or 'disabled'
+// (admin-blocked).
+var ErrUserDisabled = errors.New("authd: user not active")
+
+// ErrMagicLinkInvalid is returned for unknown / expired / consumed
+// magic links.
+var ErrMagicLinkInvalid = errors.New("authd: magic link invalid or expired")
+
+// ErrTokenReuse is returned when a refresh token is used after it's
+// been rotated. The whole family has been killed.
+var ErrTokenReuse = errors.New("authd: refresh token re-use detected, session killed")
+
+// ---------------------------------------------------------------------------
+// Passwords
+// ---------------------------------------------------------------------------
+
+// HashPassword returns a bcrypt hash of the plaintext password. Cost
+// is taken from cfg.BcryptCost.
+func (a *Authd) HashPassword(ctx context.Context, plaintext string) (string, error) {
+	hash, err := bcrypt.GenerateFromPassword([]byte(plaintext), a.cfg.BcryptCost)
+	if err != nil {
+		return "", fmt.Errorf("bcrypt: %w", err)
+	}
+	return string(hash), nil
+}
+
+// VerifyPassword reports whether plaintext matches the stored hash.
+// Returns nil on match, bcrypt.ErrMismatchedHashAndPassword on
+// mismatch, or another error if the hash is malformed.
+func (a *Authd) VerifyPassword(hash, plaintext string) error {
+	return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plaintext))
+}
+
+// ---------------------------------------------------------------------------
+// Magic links
+// ---------------------------------------------------------------------------
+
+// IssueMagicLink generates a magic link token for a user, hashes it,
+// stores the hash, and returns the PLAINTEXT token (the caller
+// emails this — it is never stored).
+//
+// Returns the token (hex, 64 chars), its expiry, and any error.
+func (a *Authd) IssueMagicLink(ctx context.Context, userID, purpose string) (token string, expiresAt time.Time, err error) {
+	if purpose != "invite" && purpose != "password_reset" && purpose != "mfa_reset" {
+		return "", time.Time{}, fmt.Errorf("authd: invalid magic link purpose %q", purpose)
+	}
+	// 32 random bytes → 64 hex chars
+	raw := make([]byte, 32)
+	if _, err := rand.Read(raw); err != nil {
+		return "", time.Time{}, fmt.Errorf("rand: %w", err)
+	}
+	token = hex.EncodeToString(raw)
+
+	hash := sha256.Sum256(raw) // hash the RAW bytes, not the hex string
+	expiresAt = time.Now().Add(a.cfg.MagicLinkTTL)
+
+	if err := a.store.InsertMagicLink(ctx, userID, hash[:], purpose, expiresAt); err != nil {
+		return "", time.Time{}, err
+	}
+	return token, expiresAt, nil
+}
+
+// ConsumeMagicLink validates a magic link token and returns the
+// associated user_id. Marks the link as consumed. Caller should
+// treat ErrMagicLinkInvalid as 401.
+func (a *Authd) ConsumeMagicLink(ctx context.Context, token, ip, ua string) (string, error) {
+	raw, err := hex.DecodeString(token)
+	if err != nil || len(raw) != 32 {
+		return "", ErrMagicLinkInvalid
+	}
+	hash := sha256.Sum256(raw)
+	userID, err := a.store.ConsumeMagicLink(ctx, hash[:], ip, ua)
+	if err != nil {
+		return "", err
+	}
+	return userID, nil
+}
+
+// ---------------------------------------------------------------------------
+// Sessions (refresh tokens, SQL-side)
+// ---------------------------------------------------------------------------
+
+// LoginResult is what Login + MagicLinkConsume return.
+type LoginResult struct {
+	AccessToken  string
+	RefreshToken string
+	UserID       string
+	TenantID     string
+	Role         string
+	ExpiresAt    time.Time
+}
+
+// Login authenticates with email+password, returns a fresh session.
+// On failure, returns ErrInvalidCredentials (or ErrUserDisabled)
+// and writes an audit_log row regardless of outcome.
+func (a *Authd) Login(ctx context.Context, email, password, ip, ua string) (*LoginResult, error) {
+	user, err := a.store.GetUserByEmail(ctx, email)
+	if err != nil {
+		if errors.Is(err, ErrUserNotFound) {
+			// Audit the failed attempt. Don't disclose existence.
+			_ = a.store.WriteAudit(ctx, "auth.login", "", ip, ua, "", "",
+				map[string]any{"email": email, "success": false, "reason": "not_found"})
+			return nil, ErrInvalidCredentials
+		}
+		return nil, err
+	}
+	if user.Status != "active" {
+		_ = a.store.WriteAudit(ctx, "auth.login", user.ID, ip, ua, "", user.TenantID,
+			map[string]any{"email": email, "success": false, "reason": "not_active"})
+		return nil, ErrUserDisabled
+	}
+	if err := a.VerifyPassword(user.PasswordHash, password); err != nil {
+		_ = a.store.WriteAudit(ctx, "auth.login", user.ID, ip, ua, "", user.TenantID,
+			map[string]any{"email": email, "success": false, "reason": "bad_password"})
+		return nil, ErrInvalidCredentials
+	}
+	res, err := a.issueSession(ctx, user, ip, ua)
+	if err != nil {
+		return nil, err
+	}
+	_ = a.store.WriteAudit(ctx, "auth.login", user.ID, ip, ua, "", user.TenantID,
+		map[string]any{"email": email, "success": true})
+	_ = a.store.TouchUserLogin(ctx, user.ID)
+	return res, nil
+}
+
+// issueSession is the shared path: mint JWT + ask Postgres for a
+// refresh token (via the SQL function).
+func (a *Authd) issueSession(ctx context.Context, user *User, ip, ua string) (*LoginResult, error) {
+	accessJWT, jti, expiresAt, err := a.mintAccessToken(user)
+	if err != nil {
+		return nil, err
+	}
+	refreshToken, err := a.store.IssueRefreshToken(ctx, user.ID, jti, int(a.cfg.RefreshTokenTTL.Seconds()), ip, ua)
+	if err != nil {
+		return nil, err
+	}
+	return &LoginResult{
+		AccessToken:  accessJWT,
+		RefreshToken: refreshToken,
+		UserID:       user.ID,
+		TenantID:     user.TenantID,
+		Role:         user.Role,
+		ExpiresAt:    expiresAt,
+	}, nil
+}
+
+// Refresh swaps a refresh token for a new access+refresh pair. The
+// old refresh is revoked; if the old refresh was already consumed,
+// the WHOLE family is killed (returns ErrTokenReuse).
+func (a *Authd) Refresh(ctx context.Context, presentedToken, ip, ua string) (*LoginResult, error) {
+	user, newRefresh, newJTI, err := a.store.RotateRefreshToken(ctx, presentedToken, int(a.cfg.RefreshTokenTTL.Seconds()), ip, ua)
+	if err != nil {
+		return nil, err
+	}
+	accessJWT, expiresAt, err := a.mintAccessTokenWithJTI(user, newJTI)
+	if err != nil {
+		return nil, err
+	}
+	_ = a.store.WriteAudit(ctx, "auth.refresh", user.ID, ip, ua, "", user.TenantID,
+		map[string]any{"success": true})
+	return &LoginResult{
+		AccessToken:  accessJWT,
+		RefreshToken: newRefresh,
+		UserID:       user.ID,
+		TenantID:     user.TenantID,
+		Role:         user.Role,
+		ExpiresAt:    expiresAt,
+	}, nil
+}
+
+// Logout revokes the refresh token. Idempotent — a missing token
+// returns nil (no error) so logout can't be used to probe token
+// validity.
+func (a *Authd) Logout(ctx context.Context, refreshToken, userID, ip, ua string) error {
+	if err := a.store.RevokeRefreshToken(ctx, refreshToken); err != nil {
+		return err
+	}
+	_ = a.store.WriteAudit(ctx, "auth.logout", userID, ip, ua, "", "", nil)
+	return nil
+}
+
+// ---------------------------------------------------------------------------
+// JWT
+// ---------------------------------------------------------------------------
+
+// AccessClaims is what we sign into the access JWT. It carries the
+// minimum to authorize a request, NOT a session token.
+type AccessClaims struct {
+	UserID    string `json:"sub"`
+	TenantID  string `json:"tid,omitempty"`
+	Role      string `json:"role"`
+	TokenType string `json:"typ"` // always "access"
+	jwt.RegisteredClaims
+}
+
+// mintAccessToken signs an access JWT for the given user. The JTI
+// is generated internally.
+func (a *Authd) mintAccessToken(user *User) (token string, jti string, expiresAt time.Time, err error) {
+	now := time.Now()
+	expiresAt = now.Add(a.cfg.AccessTokenTTL)
+	jti = newJTI()
+	claims := AccessClaims{
+		UserID:    user.ID,
+		TenantID:  user.TenantID,
+		Role:      user.Role,
+		TokenType: "access",
+		RegisteredClaims: jwt.RegisteredClaims{
+			Issuer:    a.cfg.Issuer,
+			Subject:   user.ID,
+			ExpiresAt: jwt.NewNumericDate(expiresAt),
+			IssuedAt:  jwt.NewNumericDate(now),
+			NotBefore: jwt.NewNumericDate(now),
+			ID:        jti,
+		},
+	}
+	t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+	signed, err := t.SignedString(a.cfg.JWTSecret)
+	if err != nil {
+		return "", "", time.Time{}, fmt.Errorf("sign jwt: %w", err)
+	}
+	return signed, jti, expiresAt, nil
+}
+
+// mintAccessTokenWithJTI signs an access JWT using the caller-supplied
+// JTI. Used by Refresh so the new refresh's access_jti matches the
+// row we just inserted.
+func (a *Authd) mintAccessTokenWithJTI(user *User, jti string) (string, time.Time, error) {
+	now := time.Now()
+	expiresAt := now.Add(a.cfg.AccessTokenTTL)
+	claims := AccessClaims{
+		UserID:    user.ID,
+		TenantID:  user.TenantID,
+		Role:      user.Role,
+		TokenType: "access",
+		RegisteredClaims: jwt.RegisteredClaims{
+			Issuer:    a.cfg.Issuer,
+			Subject:   user.ID,
+			ExpiresAt: jwt.NewNumericDate(expiresAt),
+			IssuedAt:  jwt.NewNumericDate(now),
+			NotBefore: jwt.NewNumericDate(now),
+			ID:        jti,
+		},
+	}
+	t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+	signed, err := t.SignedString(a.cfg.JWTSecret)
+	if err != nil {
+		return "", time.Time{}, fmt.Errorf("sign jwt: %w", err)
+	}
+	return signed, expiresAt, nil
+}
+
+// VerifyAccessToken parses and validates an access JWT. Returns the
+// claims on success. Used by other services that want to authorize
+// a request without going through authd.
+//
+// The signing method is enforced to be HMAC (not 'none', not RS256
+// with a confused-deputy attack). The expiry is checked.
+func (a *Authd) VerifyAccessToken(raw string) (*AccessClaims, error) {
+	claims := &AccessClaims{}
+	tok, err := jwt.ParseWithClaims(raw, claims, func(t *jwt.Token) (any, error) {
+		// Reject anything that isn't HMAC. See
+		// https://github.com/golang-jwt/jwt#security-considerations
+		if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
+			return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
+		}
+		return a.cfg.JWTSecret, nil
+	})
+	if err != nil {
+		return nil, fmt.Errorf("verify jwt: %w", err)
+	}
+	if !tok.Valid {
+		return nil, errors.New("verify jwt: token invalid")
+	}
+	if claims.TokenType != "access" {
+		return nil, fmt.Errorf("verify jwt: wrong token type %q", claims.TokenType)
+	}
+	return claims, nil
+}
+
+// ---------------------------------------------------------------------------
+// Invites
+// ---------------------------------------------------------------------------
+
+// InviteUser creates a pending user in a tenant and issues a magic
+// link token for the invitation. The token is returned to the
+// caller (the HTTP handler) which emails it.
+func (a *Authd) InviteUser(ctx context.Context, tenantID, email, role, inviterUserID, ip, ua string) (magicLinkToken string, userID string, err error) {
+	if role != "tenant_admin" && role != "viewer" {
+		return "", "", fmt.Errorf("authd: invalid invite role %q (super_admin is not invitable)", role)
+	}
+	userID, err = a.store.CreateUser(ctx, tenantID, email, role)
+	if err != nil {
+		return "", "", err
+	}
+	token, _, err := a.IssueMagicLink(ctx, userID, "invite")
+	if err != nil {
+		return "", "", err
+	}
+	_ = a.store.WriteAudit(ctx, "auth.invite", inviterUserID, ip, ua, userID, tenantID,
+		map[string]any{"email": email, "role": role})
+	return token, userID, nil
+}
+
+// SetPassword updates the user's password (bcrypt hash). Used both
+// for first-time setup via magic link and for password resets.
+func (a *Authd) SetPassword(ctx context.Context, userID, plaintext string) error {
+	hash, err := a.HashPassword(ctx, plaintext)
+	if err != nil {
+		return err
+	}
+	return a.store.SetUserPassword(ctx, userID, hash, "active")
+}
+
+// ---------------------------------------------------------------------------
+// helpers
+// ---------------------------------------------------------------------------
+
+// newJTI returns a 128-bit random ID encoded as hex. Used as the
+// `jti` claim on access tokens.
+func newJTI() string {
+	var b [16]byte
+	_, _ = rand.Read(b[:])
+	return hex.EncodeToString(b[:])
+}
+
+// SecureEqual is a constant-time compare. Use it for any byte slice
+// equality that could be timing-attacked (e.g. token prefix checks
+// in tests, never in prod hot paths where Postgres handles equality).
+func SecureEqual(a, b []byte) bool {
+	return subtle.ConstantTimeCompare(a, b) == 1
+}

+ 247 - 0
internal/authd/authd_test.go

@@ -0,0 +1,247 @@
+package authd
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	"github.com/golang-jwt/jwt/v5"
+	"golang.org/x/crypto/bcrypt"
+)
+
+// These tests cover the pure-Go logic in authd.go that does NOT
+// require Postgres: bcrypt password hashing/verification, JWT
+// signing/verification, magic-link token generation/hashing, and
+// the SecureEqual helper. The DB-backed paths are tested in
+// store_test.go (build tag 'postgres').
+
+func TestHashPassword_And_Verify(t *testing.T) {
+	cfg := DefaultConfig()
+	cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min")
+	a, err := New(nil, cfg) // nil pool ok for password tests
+	if err != nil {
+		t.Fatalf("new authd: %v", err)
+	}
+	hash, err := a.HashPassword(context.Background(), "hunter2-correct-horse")
+	if err != nil {
+		t.Fatalf("hash: %v", err)
+	}
+	if hash == "" {
+		t.Fatal("hash is empty")
+	}
+	if err := a.VerifyPassword(hash, "hunter2-correct-horse"); err != nil {
+		t.Errorf("verify correct: %v", err)
+	}
+	if err := a.VerifyPassword(hash, "wrong"); err == nil {
+		t.Error("verify wrong: expected error, got nil")
+	}
+}
+
+func TestHashPassword_BcryptCost(t *testing.T) {
+	cfg := DefaultConfig()
+	cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min")
+	cfg.BcryptCost = bcrypt.MinCost // speed up test
+	a, _ := New(nil, cfg)
+	start := time.Now()
+	_, err := a.HashPassword(context.Background(), "x")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if elapsed := time.Since(start); elapsed > 2*time.Second {
+		t.Errorf("bcrypt MinCost should be <2s, got %s", elapsed)
+	}
+}
+
+func TestNew_RejectsShortSecret(t *testing.T) {
+	cfg := DefaultConfig()
+	cfg.JWTSecret = []byte("too-short")
+	if _, err := New(nil, cfg); err == nil {
+		t.Error("expected error for short JWT secret, got nil")
+	}
+}
+
+func TestNew_AppliesDefaults(t *testing.T) {
+	cfg := Config{
+		JWTSecret: []byte("this-is-a-test-secret-with-32-bytes-min"),
+	}
+	a, err := New(nil, cfg)
+	if err != nil {
+		t.Fatalf("new: %v", err)
+	}
+	if a.cfg.AccessTokenTTL != 15*time.Minute {
+		t.Errorf("AccessTokenTTL default = %s, want 15m", a.cfg.AccessTokenTTL)
+	}
+	if a.cfg.RefreshTokenTTL != 7*24*time.Hour {
+		t.Errorf("RefreshTokenTTL default = %s, want 7d", a.cfg.RefreshTokenTTL)
+	}
+	if a.cfg.MagicLinkTTL != 24*time.Hour {
+		t.Errorf("MagicLinkTTL default = %s, want 24h", a.cfg.MagicLinkTTL)
+	}
+	if a.cfg.BcryptCost != 12 {
+		t.Errorf("BcryptCost default = %d, want 12", a.cfg.BcryptCost)
+	}
+}
+
+func TestIssueMagicLink_ReturnsHexToken(t *testing.T) {
+	cfg := DefaultConfig()
+	cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min")
+	a, _ := New(nil, cfg)
+
+	// IssueMagicLink requires a real DB (InsertMagicLink). We can
+	// only test the token shape via the random source by
+	// calling the parts we can. Skip if pool is nil.
+	// To avoid coupling, we just check that the method exists and
+	// the error path is what we expect (DB unavailable).
+	_, _, err := a.IssueMagicLink(context.Background(), "00000000-0000-0000-0000-000000000000", "invite")
+	if err == nil {
+		t.Error("expected DB error, got nil (pool was nil)")
+	}
+}
+
+func TestConsumeMagicLink_BadInput(t *testing.T) {
+	cfg := DefaultConfig()
+	cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min")
+	a, _ := New(nil, cfg)
+	// Wrong length
+	_, err := a.ConsumeMagicLink(context.Background(), "tooshort", "1.1.1.1", "ua")
+	if err != ErrMagicLinkInvalid {
+		t.Errorf("short token: err = %v, want ErrMagicLinkInvalid", err)
+	}
+	// Bad hex
+	_, err = a.ConsumeMagicLink(context.Background(), "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", "1.1.1.1", "ua")
+	if err != ErrMagicLinkInvalid {
+		t.Errorf("bad hex: err = %v, want ErrMagicLinkInvalid", err)
+	}
+}
+
+func TestVerifyAccessToken_RoundTrip(t *testing.T) {
+	cfg := DefaultConfig()
+	cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min")
+	a, _ := New(nil, cfg)
+
+	user := &User{ID: "u-1", TenantID: "t-1", Role: "tenant_admin", Status: "active"}
+	accessJWT, jti, exp, err := a.mintAccessToken(user)
+	if err != nil {
+		t.Fatalf("mint: %v", err)
+	}
+	if accessJWT == "" {
+		t.Fatal("empty token")
+	}
+	if jti == "" {
+		t.Fatal("empty jti")
+	}
+	if !exp.After(time.Now()) {
+		t.Fatal("token already expired")
+	}
+
+	claims, err := a.VerifyAccessToken(accessJWT)
+	if err != nil {
+		t.Fatalf("verify: %v", err)
+	}
+	if claims.UserID != "u-1" {
+		t.Errorf("UserID = %q, want u-1", claims.UserID)
+	}
+	if claims.TenantID != "t-1" {
+		t.Errorf("TenantID = %q, want t-1", claims.TenantID)
+	}
+	if claims.Role != "tenant_admin" {
+		t.Errorf("Role = %q, want tenant_admin", claims.Role)
+	}
+	if claims.TokenType != "access" {
+		t.Errorf("TokenType = %q, want access", claims.TokenType)
+	}
+}
+
+func TestVerifyAccessToken_RejectsBadSecret(t *testing.T) {
+	cfg := DefaultConfig()
+	cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min")
+	a, _ := New(nil, cfg)
+	user := &User{ID: "u-1", Status: "active"}
+	accessJWT, _, _, _ := a.mintAccessToken(user)
+
+	// Same secret, different bytes
+	other := DefaultConfig()
+	other.JWTSecret = []byte("different-secret-with-32-bytes-min!!")
+	b, _ := New(nil, other)
+	if _, err := b.VerifyAccessToken(accessJWT); err == nil {
+		t.Error("expected error from different-secret verifier, got nil")
+	}
+}
+
+func TestVerifyAccessToken_RejectsExpired(t *testing.T) {
+	cfg := DefaultConfig()
+	cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min")
+	cfg.AccessTokenTTL = -1 * time.Minute // already expired
+	a, _ := New(nil, cfg)
+
+	user := &User{ID: "u-1", Status: "active"}
+	accessJWT, _, _, _ := a.mintAccessToken(user)
+
+	_, err := a.VerifyAccessToken(accessJWT)
+	if err == nil {
+		t.Error("expected expired-token error, got nil")
+	}
+}
+
+func TestVerifyAccessToken_RejectsAlgConfusion(t *testing.T) {
+	// Classic alg=none / RS256-confused-as-HS256 attack. The verifier
+	// must reject any non-HMAC alg. We craft a token signed with the
+	// "none" alg and confirm it's rejected.
+	cfg := DefaultConfig()
+	cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min")
+	a, _ := New(nil, cfg)
+
+	claims := AccessClaims{
+		UserID:    "u-1",
+		Role:      "tenant_admin",
+		TokenType: "access",
+		RegisteredClaims: jwt.RegisteredClaims{
+			Issuer:    "broad-announce",
+			Subject:   "u-1",
+			ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
+		},
+	}
+	t1 := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
+	noneToken, err := t1.SignedString(jwt.UnsafeAllowNoneSignatureType)
+	if err != nil {
+		t.Fatalf("sign with none: %v", err)
+	}
+	if _, err := a.VerifyAccessToken(noneToken); err == nil {
+		t.Error("expected verifier to reject 'none' alg, got nil error")
+	}
+}
+
+func TestSecureEqual(t *testing.T) {
+	if !SecureEqual([]byte("abc"), []byte("abc")) {
+		t.Error("equal slices: want true")
+	}
+	if SecureEqual([]byte("abc"), []byte("abd")) {
+		t.Error("differing slices: want false")
+	}
+	if SecureEqual([]byte("abc"), []byte("abcd")) {
+		t.Error("different lengths: want false")
+	}
+}
+
+func TestNewJTI_Unique(t *testing.T) {
+	seen := make(map[string]struct{}, 1000)
+	for i := 0; i < 1000; i++ {
+		id := newJTI()
+		if _, dup := seen[id]; dup {
+			t.Fatalf("duplicate JTI on iter %d: %s", i, id)
+		}
+		seen[id] = struct{}{}
+	}
+}
+
+func TestNewJTI_HexShape(t *testing.T) {
+	id := newJTI()
+	if len(id) != 32 {
+		t.Errorf("JTI length = %d, want 32 hex chars (16 bytes)", len(id))
+	}
+	for _, c := range id {
+		if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
+			t.Fatalf("non-hex char in JTI: %q", c)
+		}
+	}
+}

+ 375 - 0
internal/authd/store.go

@@ -0,0 +1,375 @@
+// Package authd — store.go: Postgres-backed data access. The
+// schema is in migrations/009_auth.up.sql. This file is the only
+// place in the package that touches pgx directly; the rest of the
+// package uses the higher-level methods on *Authd.
+//
+// All methods take a context and respect cancellation.
+package authd
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"time"
+
+	"github.com/jackc/pgx/v5"
+	"github.com/jackc/pgx/v5/pgconn"
+
+	"git3.techno-world.net/lrosales/broad-announce/internal/postgres"
+)
+
+// Store wraps a pgx pool with auth-specific queries. Construct via
+// NewStore; do not instantiate directly.
+type Store struct {
+	pool *postgres.Pool
+}
+
+// NewStore constructs a Store.
+func NewStore(pool *postgres.Pool) *Store {
+	return &Store{pool: pool}
+}
+
+// ---------------------------------------------------------------------------
+// User types
+// ---------------------------------------------------------------------------
+
+// User is the row from auth.users, plus a couple of join fields
+// flattened for convenience.
+type User struct {
+	ID            string
+	GlobalID      string
+	TenantID      string
+	Email         string
+	Role          string
+	Status        string
+	DisplayName   string
+	PasswordHash  string
+	LastLoginAt   *time.Time
+	CreatedAt     time.Time
+	UpdatedAt     time.Time
+}
+
+// ---------------------------------------------------------------------------
+// User CRUD
+// ---------------------------------------------------------------------------
+
+// TenantIDBySlug resolves a tenant slug to its id. Returns
+// ErrUserNotFound if the slug doesn't exist (the error is
+// misnamed; v1.1 should split it).
+func (s *Store) TenantIDBySlug(ctx context.Context, slug string) (string, error) {
+	if s.pool == nil {
+		return "", errors.New("authd: no DB pool (test mode)")
+	}
+	const q = `SELECT id::text FROM auth.tenants WHERE slug = $1`
+	var id string
+	err := s.pool.QueryRow(ctx, q, slug).Scan(&id)
+	if err != nil {
+		if errors.Is(err, pgx.ErrNoRows) {
+			return "", ErrUserNotFound
+		}
+		return "", fmt.Errorf("tenant id by slug: %w", err)
+	}
+	return id, nil
+}
+
+// GetUserByEmail looks up a user by email. Email is unique per
+// tenant (or globally for super_admins), so we use the most recent
+// matching row if there are multiple.
+func (s *Store) GetUserByEmail(ctx context.Context, email string) (*User, error) {
+	const q = `
+		SELECT id::text, global_id::text, COALESCE(tenant_id::text, ''),
+		       email, role, status, COALESCE(display_name, ''),
+		       COALESCE(password_hash, ''), last_login_at, created_at, updated_at
+		FROM auth.users
+		WHERE email = $1
+		ORDER BY (tenant_id IS NULL) DESC, created_at DESC
+		LIMIT 1
+	`
+	u := &User{}
+	err := s.pool.QueryRow(ctx, q, email).Scan(
+		&u.ID, &u.GlobalID, &u.TenantID, &u.Email, &u.Role, &u.Status,
+		&u.DisplayName, &u.PasswordHash, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt,
+	)
+	if err != nil {
+		if errors.Is(err, pgx.ErrNoRows) {
+			return nil, ErrUserNotFound
+		}
+		return nil, fmt.Errorf("get user by email: %w", err)
+	}
+	return u, nil
+}
+
+// GetUserByID looks up a user by primary key.
+func (s *Store) GetUserByID(ctx context.Context, id string) (*User, error) {
+	const q = `
+		SELECT id::text, global_id::text, COALESCE(tenant_id::text, ''),
+		       email, role, status, COALESCE(display_name, ''),
+		       COALESCE(password_hash, ''), last_login_at, created_at, updated_at
+		FROM auth.users
+		WHERE id = $1
+	`
+	u := &User{}
+	err := s.pool.QueryRow(ctx, q, id).Scan(
+		&u.ID, &u.GlobalID, &u.TenantID, &u.Email, &u.Role, &u.Status,
+		&u.DisplayName, &u.PasswordHash, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt,
+	)
+	if err != nil {
+		if errors.Is(err, pgx.ErrNoRows) {
+			return nil, ErrUserNotFound
+		}
+		return nil, fmt.Errorf("get user by id: %w", err)
+	}
+	return u, nil
+}
+
+// CreateUser inserts a new pending user. Returns the new id.
+// Fails with a wrapped pg error if the email/tenant pair is not
+// unique.
+func (s *Store) CreateUser(ctx context.Context, tenantID, email, role string) (string, error) {
+	const q = `
+		INSERT INTO auth.users (tenant_id, email, role, status)
+		VALUES (
+			CASE WHEN $1 = '' THEN NULL ELSE $1::uuid END,
+			$2, $3, 'pending'
+		)
+		RETURNING id::text
+	`
+	var id string
+	err := s.pool.QueryRow(ctx, q, tenantID, email, role).Scan(&id)
+	if err != nil {
+		var pgErr *pgconn.PgError
+		if errors.As(err, &pgErr) && pgErr.Code == "23505" {
+			return "", fmt.Errorf("user %q already exists: %w", email, err)
+		}
+		return "", fmt.Errorf("create user: %w", err)
+	}
+	return id, nil
+}
+
+// SetUserPassword updates password_hash and status. Used by both
+// magic-link-first-login and password-reset paths.
+func (s *Store) SetUserPassword(ctx context.Context, userID, hash, newStatus string) error {
+	const q = `UPDATE auth.users SET password_hash = $2, status = $3 WHERE id = $1`
+	tag, err := s.pool.Exec(ctx, q, userID, hash, newStatus)
+	if err != nil {
+		return fmt.Errorf("set password: %w", err)
+	}
+	if tag.RowsAffected() == 0 {
+		return ErrUserNotFound
+	}
+	return nil
+}
+
+// TouchUserLogin updates last_login_at. Fire-and-forget from caller.
+func (s *Store) TouchUserLogin(ctx context.Context, userID string) error {
+	const q = `UPDATE auth.users SET last_login_at = NOW() WHERE id = $1`
+	_, err := s.pool.Exec(ctx, q, userID)
+	return err
+}
+
+// ---------------------------------------------------------------------------
+// Magic links
+// ---------------------------------------------------------------------------
+
+// InsertMagicLink stores the hash of a magic link token. The
+// plaintext is never persisted.
+func (s *Store) InsertMagicLink(ctx context.Context, userID string, hash []byte, purpose string, expiresAt time.Time) error {
+	if s.pool == nil {
+		return errors.New("authd: no DB pool (test mode)")
+	}
+	const q = `
+		INSERT INTO auth.magic_links (token_hash, user_id, purpose, expires_at)
+		VALUES ($1, $2::uuid, $3, $4)
+	`
+	_, err := s.pool.Exec(ctx, q, hash, userID, purpose, expiresAt)
+	if err != nil {
+		return fmt.Errorf("insert magic link: %w", err)
+	}
+	return nil
+}
+
+// ConsumeMagicLink atomically marks a link consumed and returns the
+// user_id. Returns ErrMagicLinkInvalid for unknown, expired, or
+// already-consumed links.
+func (s *Store) ConsumeMagicLink(ctx context.Context, hash []byte, ip, ua string) (string, error) {
+	const q = `
+		UPDATE auth.magic_links
+		   SET consumed_at = NOW(),
+		       consumed_ip = $2::inet,
+		       consumed_ua = $3
+		 WHERE token_hash = $1
+		   AND consumed_at IS NULL
+		   AND expires_at > NOW()
+		RETURNING user_id::text
+	`
+	var userID string
+	err := s.pool.QueryRow(ctx, q, hash, ip, ua).Scan(&userID)
+	if err != nil {
+		if errors.Is(err, pgx.ErrNoRows) {
+			return "", ErrMagicLinkInvalid
+		}
+		return "", fmt.Errorf("consume magic link: %w", err)
+	}
+	return userID, nil
+}
+
+// ---------------------------------------------------------------------------
+// Refresh tokens (delegate to SQL functions)
+// ---------------------------------------------------------------------------
+
+// IssueRefreshToken asks Postgres for a new token row. Returns the
+// plaintext token (the caller returns it to the client once and
+// discards it).
+func (s *Store) IssueRefreshToken(ctx context.Context, userID, accessJTI string, ttlSeconds int, ip, ua string) (string, error) {
+	const q = `SELECT token FROM auth.issue_refresh_token($1::uuid, $2, $3, $4::inet, $5)`
+	var token string
+	err := s.pool.QueryRow(ctx, q, userID, accessJTI, ttlSeconds, ip, ua).Scan(&token)
+	if err != nil {
+		return "", fmt.Errorf("issue refresh token: %w", err)
+	}
+	return token, nil
+}
+
+// RotateRefreshToken swaps a refresh token for a new one. On
+// re-use, returns ErrTokenReuse and the whole family is killed in
+// the SQL function. Returns the user, the new plaintext token, and
+// the JTI to use for the new access JWT.
+func (s *Store) RotateRefreshToken(ctx context.Context, presentedToken string, ttlSeconds int, ip, ua string) (*User, string, string, error) {
+	// We need the JTI for the new access token. The SQL function
+	// returns (id, token, family_id, expires_at, killed_family) but
+	// NOT a new JTI. We generate the JTI here and pass it in.
+	// Patch: the SQL function signature is fixed (p_new_access_jti
+	// is the last text arg), so we generate it client-side and pass
+	// it in, then read it back from the returned columns.
+	newJTI := newJTI()
+	const q = `
+		SELECT id::text, token, family_id, expires_at, killed_family
+		  FROM auth.rotate_refresh_token($1, $2, $3, $4::inet, $5)
+	`
+	var (
+		id       string
+		token    string
+		familyID string
+		exp      time.Time
+		killed   bool
+	)
+	err := s.pool.QueryRow(ctx, q, presentedToken, newJTI, ttlSeconds, ip, ua).Scan(&id, &token, &familyID, &exp, &killed)
+	if err != nil {
+		// Distinguish re-use (22023 with refresh_token_reuse) from
+		// other failures. The SQL raises different messages; we
+		// match on the message.
+		msg := err.Error()
+		if contains(msg, "refresh_token_reuse") {
+			return nil, "", "", ErrTokenReuse
+		}
+		if contains(msg, "expired_refresh_token") {
+			return nil, "", "", errors.New("authd: refresh token expired")
+		}
+		if contains(msg, "unknown_refresh_token") {
+			return nil, "", "", errors.New("authd: refresh token unknown")
+		}
+		return nil, "", "", fmt.Errorf("rotate refresh token: %w", err)
+	}
+	_ = killed  // logged separately
+	_ = familyID // future: pass back for client-side display
+	// Fetch the user (we need tenant + role for the new access JWT)
+	user, err := s.getUserByRefreshID(ctx, id)
+	if err != nil {
+		return nil, "", "", err
+	}
+	return user, token, newJTI, nil
+}
+
+// RevokeRefreshToken revokes a single token by plaintext. Returns
+// true if a row was actually revoked.
+func (s *Store) RevokeRefreshToken(ctx context.Context, token string) error {
+	const q = `SELECT auth.revoke_refresh_token($1)`
+	var revoked bool
+	err := s.pool.QueryRow(ctx, q, token).Scan(&revoked)
+	if err != nil {
+		return fmt.Errorf("revoke refresh token: %w", err)
+	}
+	_ = revoked
+	return nil
+}
+
+// getUserByRefreshID is a join used by RotateRefreshToken.
+func (s *Store) getUserByRefreshID(ctx context.Context, refreshID string) (*User, error) {
+	const q = `
+		SELECT u.id::text, u.global_id::text, COALESCE(u.tenant_id::text, ''),
+		       u.email, u.role, u.status, COALESCE(u.display_name, ''),
+		       COALESCE(u.password_hash, ''), u.last_login_at, u.created_at, u.updated_at
+		FROM auth.refresh_tokens rt
+		JOIN auth.users u ON u.id = rt.user_id
+		WHERE rt.id = $1::uuid
+	`
+	u := &User{}
+	err := s.pool.QueryRow(ctx, q, refreshID).Scan(
+		&u.ID, &u.GlobalID, &u.TenantID, &u.Email, &u.Role, &u.Status,
+		&u.DisplayName, &u.PasswordHash, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt,
+	)
+	if err != nil {
+		return nil, fmt.Errorf("get user by refresh id: %w", err)
+	}
+	return u, nil
+}
+
+// ---------------------------------------------------------------------------
+// Audit log
+// ---------------------------------------------------------------------------
+
+// WriteAudit records an event. targetID and tenantID are optional
+// (empty string → NULL in the column). payload may be nil.
+func (s *Store) WriteAudit(
+	ctx context.Context,
+	action string,
+	actorUserID string,
+	actorIP string,
+	actorUA string,
+	targetID string,
+	tenantID string,
+	payload map[string]any,
+) error {
+	var payloadJSON []byte
+	if payload != nil {
+		var err error
+		payloadJSON, err = json.Marshal(payload)
+		if err != nil {
+			return fmt.Errorf("marshal audit payload: %w", err)
+		}
+	} else {
+		payloadJSON = []byte("{}")
+	}
+	// Empty string → NULL for actor_user_id
+	var actorArg any
+	if actorUserID == "" {
+		actorArg = nil
+	} else {
+		actorArg = actorUserID
+	}
+	const q = `
+		INSERT INTO auth.audit_log
+		    (action, actor_user_id, actor_ip, actor_ua, target_id, tenant_id, payload)
+		VALUES
+		    ($1, $2::uuid, NULLIF($3, '')::inet, NULLIF($4, ''), NULLIF($5, '')::uuid, NULLIF($6, '')::uuid, $7::jsonb)
+	`
+	_, err := s.pool.Exec(ctx, q, action, actorArg, actorIP, actorUA, targetID, tenantID, payloadJSON)
+	if err != nil {
+		return fmt.Errorf("write audit: %w", err)
+	}
+	return nil
+}
+
+// ---------------------------------------------------------------------------
+// helpers
+// ---------------------------------------------------------------------------
+
+func contains(s, substr string) bool {
+	for i := 0; i+len(substr) <= len(s); i++ {
+		if s[i:i+len(substr)] == substr {
+			return true
+		}
+	}
+	return false
+}

+ 323 - 0
internal/authd/store_test.go

@@ -0,0 +1,323 @@
+//go:build postgres
+
+// Integration tests for the authd Store. Run with:
+//
+//   go test -tags=postgres ./internal/authd/
+//
+// Requires a running Postgres with the 009_auth migration applied
+// to the database. The TEST_AUTH_DSN env var must be set:
+//
+//   TEST_AUTH_DSN=postgres://postgres:testing@localhost:5432/test_auth?sslmode=disable
+//
+// These tests use the 'auth' schema directly (no separate test
+// schema). Each test TRUNCATEs all the auth tables in setup so
+// they don't see each other's data.
+
+package authd
+
+import (
+	"context"
+	"errors"
+	"os"
+	"testing"
+	"time"
+
+	"github.com/jackc/pgx/v5/pgxpool"
+	"golang.org/x/crypto/bcrypt"
+
+	"git3.techno-world.net/lrosales/broad-announce/internal/postgres"
+)
+
+func setupTestDB(t *testing.T) *postgres.Pool {
+	t.Helper()
+	dsn := os.Getenv("TEST_AUTH_DSN")
+	if dsn == "" {
+		t.Skip("TEST_AUTH_DSN not set; skipping integration tests")
+	}
+	cfg, err := pgxpool.ParseConfig(dsn)
+	if err != nil {
+		t.Fatalf("parse dsn: %v", err)
+	}
+	pingCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+	defer cancel()
+	pool, err := pgxpool.NewWithConfig(pingCtx, cfg)
+	if err != nil {
+		t.Fatalf("new: %v", err)
+	}
+	if err := pool.Ping(pingCtx); err != nil {
+		pool.Close()
+		t.Fatalf("ping: %v", err)
+	}
+	t.Cleanup(func() { pool.Close() })
+
+	// Apply the migration if it hasn't been yet (idempotent — uses
+	// CREATE TABLE IF NOT EXISTS).
+	migPath := findMigration(t)
+	migSQL, err := os.ReadFile(migPath)
+	if err != nil {
+		t.Fatalf("read migration: %v", err)
+	}
+	if _, err := pool.Exec(context.Background(), string(migSQL)); err != nil {
+		t.Fatalf("apply migration: %v", err)
+	}
+
+	// Truncate all auth tables so each test starts fresh. CASCADE
+	// handles FK dependencies.
+	if _, err := pool.Exec(context.Background(),
+		"TRUNCATE auth.audit_log, auth.sessions, auth.refresh_tokens, auth.magic_links, auth.users, auth.tenants RESTART IDENTITY CASCADE"); err != nil {
+		t.Fatalf("truncate: %v", err)
+	}
+	return pool
+}
+
+func findMigration(t *testing.T) string {
+	t.Helper()
+	candidates := []string{
+		"../../migrations/009_auth.up.sql",
+		"migrations/009_auth.up.sql",
+	}
+	for _, p := range candidates {
+		if _, err := os.Stat(p); err == nil {
+			return p
+		}
+	}
+	t.Fatal("cannot find migrations/009_auth.up.sql")
+	return ""
+}
+
+func newTestAuthd(t *testing.T, pool *postgres.Pool) *Authd {
+	t.Helper()
+	cfg := DefaultConfig()
+	cfg.JWTSecret = []byte("test-secret-with-32-bytes-min-len")
+	// Use bcrypt MinCost for tests — production uses 12.
+	cfg.BcryptCost = bcrypt.MinCost
+	a, err := New(pool, cfg)
+	if err != nil {
+		t.Fatalf("new: %v", err)
+	}
+	return a
+}
+
+func TestStore_CreateAndGetUser(t *testing.T) {
+	pool := setupTestDB(t)
+	s := NewStore(pool)
+	ctx := context.Background()
+
+	id, err := s.CreateUser(ctx, "", "super@x.test", "super_admin")
+	if err != nil {
+		t.Fatalf("create: %v", err)
+	}
+	u, err := s.GetUserByID(ctx, id)
+	if err != nil {
+		t.Fatalf("get: %v", err)
+	}
+	if u.Email != "super@x.test" {
+		t.Errorf("email = %q, want super@x.test", u.Email)
+	}
+	if u.Role != "super_admin" {
+		t.Errorf("role = %q, want super_admin", u.Role)
+	}
+	if u.Status != "pending" {
+		t.Errorf("status = %q, want pending", u.Status)
+	}
+}
+
+func TestStore_DuplicateEmailRejected(t *testing.T) {
+	pool := setupTestDB(t)
+	s := NewStore(pool)
+	ctx := context.Background()
+
+	_, err := s.CreateUser(ctx, "", "dup@x.test", "super_admin")
+	if err != nil {
+		t.Fatalf("first create: %v", err)
+	}
+	_, err = s.CreateUser(ctx, "", "dup@x.test", "super_admin")
+	if err == nil {
+		t.Error("expected duplicate-email error, got nil")
+	}
+}
+
+func TestAuthd_MagicLink_IssueConsume(t *testing.T) {
+	pool := setupTestDB(t)
+	a := newTestAuthd(t, pool)
+	s := a.store
+	ctx := context.Background()
+
+	uid, _ := s.CreateUser(ctx, "", "m@x.test", "super_admin")
+	token, exp, err := a.IssueMagicLink(ctx, uid, "invite")
+	if err != nil {
+		t.Fatalf("issue: %v", err)
+	}
+	if len(token) != 64 {
+		t.Errorf("token length = %d, want 64", len(token))
+	}
+	if !exp.After(time.Now()) {
+		t.Error("token expiry in the past")
+	}
+	gotUID, err := a.ConsumeMagicLink(ctx, token, "127.0.0.1", "test-ua")
+	if err != nil {
+		t.Fatalf("consume: %v", err)
+	}
+	if gotUID != uid {
+		t.Errorf("user_id = %q, want %q", gotUID, uid)
+	}
+	// Re-consume must fail
+	if _, err := a.ConsumeMagicLink(ctx, token, "127.0.0.1", "test-ua"); !errors.Is(err, ErrMagicLinkInvalid) {
+		t.Errorf("re-consume: err = %v, want ErrMagicLinkInvalid", err)
+	}
+}
+
+func TestAuthd_LoginAndRefreshAndLogout(t *testing.T) {
+	pool := setupTestDB(t)
+	a := newTestAuthd(t, pool)
+	s := a.store
+	ctx := context.Background()
+
+	// Provision: tenant + user with password
+	if _, err := pool.Exec(ctx, `
+		INSERT INTO auth.tenants (slug, display_name, contact_email)
+		VALUES ('acme', 'Acme', 'a@a.test')
+	`); err != nil {
+		t.Fatalf("insert tenant: %v", err)
+	}
+	var tenantID string
+	if err := pool.QueryRow(ctx, `SELECT id::text FROM auth.tenants WHERE slug='acme'`).Scan(&tenantID); err != nil {
+		t.Fatalf("get tenant: %v", err)
+	}
+	uid, err := s.CreateUser(ctx, tenantID, "admin@acme.test", "tenant_admin")
+	if err != nil {
+		t.Fatalf("create user: %v", err)
+	}
+	hash, _ := a.HashPassword(ctx, "correct-password")
+	if err := s.SetUserPassword(ctx, uid, hash, "active"); err != nil {
+		t.Fatalf("set password: %v", err)
+	}
+
+	// 1) Login wrong password
+	if _, err := a.Login(ctx, "admin@acme.test", "wrong", "127.0.0.1", "ua"); !errors.Is(err, ErrInvalidCredentials) {
+		t.Errorf("login wrong: err = %v, want ErrInvalidCredentials", err)
+	}
+
+	// 2) Login right password
+	res, err := a.Login(ctx, "admin@acme.test", "correct-password", "127.0.0.1", "ua")
+	if err != nil {
+		t.Fatalf("login ok: %v", err)
+	}
+	if res.AccessToken == "" || res.RefreshToken == "" {
+		t.Fatal("empty tokens in login result")
+	}
+	if res.TenantID != tenantID {
+		t.Errorf("tenantID = %q, want %q", res.TenantID, tenantID)
+	}
+	if res.Role != "tenant_admin" {
+		t.Errorf("role = %q, want tenant_admin", res.Role)
+	}
+
+	// Verify the access token
+	claims, err := a.VerifyAccessToken(res.AccessToken)
+	if err != nil {
+		t.Fatalf("verify jwt: %v", err)
+	}
+	if claims.UserID != uid || claims.TenantID != tenantID {
+		t.Errorf("claims mismatch: %+v", claims)
+	}
+
+	// 3) Refresh
+	res2, err := a.Refresh(ctx, res.RefreshToken, "127.0.0.1", "ua")
+	if err != nil {
+		t.Fatalf("refresh: %v", err)
+	}
+	if res2.RefreshToken == res.RefreshToken {
+		t.Error("refresh returned the same token (no rotation)")
+	}
+	if res2.AccessToken == res.AccessToken {
+		t.Error("refresh returned the same access token (no JTI rotation)")
+	}
+
+	// 4) Re-use the OLD refresh token — should kill the family
+	_, err = a.Refresh(ctx, res.RefreshToken, "127.0.0.1", "ua")
+	if !errors.Is(err, ErrTokenReuse) {
+		t.Errorf("reuse old refresh: err = %v, want ErrTokenReuse", err)
+	}
+
+	// The rotated (new) refresh should now ALSO be revoked
+	_, err = a.Refresh(ctx, res2.RefreshToken, "127.0.0.1", "ua")
+	if !errors.Is(err, ErrTokenReuse) {
+		t.Errorf("use new refresh after kill: err = %v, want ErrTokenReuse", err)
+	}
+
+	// 5) Logout flow on a fresh session
+	res3, err := a.Login(ctx, "admin@acme.test", "correct-password", "127.0.0.1", "ua")
+	if err != nil {
+		t.Fatalf("re-login: %v", err)
+	}
+	if err := a.Logout(ctx, res3.RefreshToken, uid, "127.0.0.1", "ua"); err != nil {
+		t.Errorf("logout: %v", err)
+	}
+	// Refresh after logout should fail with reuse (since it was rotated once
+	// internally? no — logout is a direct revoke, not a rotation. The
+	// presented token is now revoked. Subsequent use = reuse detection.)
+	_, err = a.Refresh(ctx, res3.RefreshToken, "127.0.0.1", "ua")
+	if err == nil {
+		t.Error("refresh after logout: expected error, got nil")
+	}
+}
+
+func TestAuthd_InviteAndSetPassword(t *testing.T) {
+	pool := setupTestDB(t)
+	a := newTestAuthd(t, pool)
+	ctx := context.Background()
+
+	if _, err := pool.Exec(ctx, `
+		INSERT INTO auth.tenants (slug, display_name, contact_email)
+		VALUES ('beta', 'Beta', 'b@b.test')
+	`); err != nil {
+		t.Fatalf("insert tenant: %v", err)
+	}
+	var tenantID string
+	if err := pool.QueryRow(ctx, `SELECT id::text FROM auth.tenants WHERE slug='beta'`).Scan(&tenantID); err != nil {
+		t.Fatalf("get tenant: %v", err)
+	}
+	inviter := "00000000-0000-0000-0000-000000000001"
+	magic, uid, err := a.InviteUser(ctx, tenantID, "new@beta.test", "viewer", inviter, "127.0.0.1", "ua")
+	if err != nil {
+		t.Fatalf("invite: %v", err)
+	}
+	if magic == "" || uid == "" {
+		t.Fatal("invite returned empty token or id")
+	}
+
+	// Consume the magic link, then set the password
+	gotUID, err := a.ConsumeMagicLink(ctx, magic, "127.0.0.1", "ua")
+	if err != nil {
+		t.Fatalf("consume: %v", err)
+	}
+	if gotUID != uid {
+		t.Errorf("consume uid = %q, want %q", gotUID, uid)
+	}
+	if err := a.SetPassword(ctx, uid, "new-password"); err != nil {
+		t.Fatalf("set password: %v", err)
+	}
+
+	// Login with the new password
+	if _, err := a.Login(ctx, "new@beta.test", "new-password", "127.0.0.1", "ua"); err != nil {
+		t.Errorf("login after invite+setpassword: %v", err)
+	}
+}
+
+func TestStore_AuditLog(t *testing.T) {
+	pool := setupTestDB(t)
+	s := NewStore(pool)
+	ctx := context.Background()
+
+	if err := s.WriteAudit(ctx, "test.event", "", "", "", "", "", map[string]any{"x": 1}); err != nil {
+		t.Fatalf("write audit: %v", err)
+	}
+	var count int
+	if err := pool.QueryRow(ctx, `SELECT count(*) FROM auth.audit_log WHERE action='test.event'`).Scan(&count); err != nil {
+		t.Fatalf("count: %v", err)
+	}
+	if count != 1 {
+		t.Errorf("audit count = %d, want 1", count)
+	}
+}

+ 4 - 0
migrations/009_auth.down.sql

@@ -0,0 +1,4 @@
+-- 009_auth.down.sql
+-- M13a: reverse the auth schema. Drops everything in the 'auth' schema.
+
+DROP SCHEMA IF EXISTS auth CASCADE;

+ 397 - 0
migrations/009_auth.up.sql

@@ -0,0 +1,397 @@
+-- 009_auth.up.sql
+-- M13a: Multi-tenant auth schema for authd (port 8804).
+--
+-- Tenancy model (per M13 survey, decision 1.1 = C, decision 2.1 = C hybrid):
+--   - One Postgres DB for everything (M11 already uses it for DLQ, deliveries).
+--   - Schema 'auth' is the namespace for authd tables.
+--   - A 'tenant' is a customer company. Each tenant has a 'tenant_slug'
+--     used in routes (e.g. /v1/tenants/acme-001/sources) and as a
+--     partition key for downstream queries.
+--   - A 'user' belongs to ONE tenant (super-admins have tenant_id=NULL
+--     and role='super_admin'; tenant-admins have role='tenant_admin').
+--   - 'global_id' on users is for SSO future (v2). For now it's
+--     just a stable opaque id we generate server-side.
+--
+-- Authentication (per survey 2.2 = A in-house JWT, 2.4 = B magic-link):
+--   - 'magic_links' stores the one-time tokens emailed to invited
+--     users. After use they're marked 'consumed_at'.
+--   - 'refresh_tokens' stores the long-lived tokens (7d TTL by default)
+--     that authd issues alongside the short-lived (15m) access JWT.
+--     The refresh token is a 256-bit random string, NOT a JWT — the
+--     server-side row is the source of truth (decision: refresh in
+--     server-side Postgres, rotated on use).
+--   - 'sessions' is the audit trail of every login/logout, used for
+--     security investigations ("who was logged in when X happened").
+--
+-- Audit (per survey: every service writes its own audit_log row):
+--   - 'audit_log' is shared across services. action is namespaced
+--     as 'auth.login', 'auth.logout', 'auth.invite', 'cert.issue',
+--     'cert.revoke', etc.
+--   - actor_user_id is nullable for system actions (cron, service tokens).
+--   - payload is jsonb for service-specific structured data.
+--
+-- Surfaces: authd (CRUD users/magic_links/sessions/refresh_tokens + reads
+-- audit_log), admind (reads audit_log for the admin UI), and a
+-- 'verify_session' SQL function used by ingestd/admind to validate
+-- a presented access JWT against the refresh-token table (so we can
+-- revoke a session by deleting its row).
+
+CREATE SCHEMA IF NOT EXISTS auth;
+SET search_path TO auth, public;
+
+-- pgcrypto provides gen_random_bytes() and digest() (used by the
+-- refresh-token functions below for secure random + sha256).
+-- gen_random_uuid() comes from pgcrypto on older Postgres and is
+-- built-in on 13+, but the extension is harmless to enable.
+CREATE EXTENSION IF NOT EXISTS pgcrypto;
+
+-- ---------------------------------------------------------------------------
+-- Tenants
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS tenants (
+    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    slug            TEXT NOT NULL UNIQUE
+                    CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$'),
+    display_name    TEXT NOT NULL,
+    status          TEXT NOT NULL DEFAULT 'active'
+                    CHECK (status IN ('active', 'suspended', 'archived')),
+    contact_email   TEXT NOT NULL,
+    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+    archived_at     TIMESTAMPTZ
+);
+
+CREATE INDEX IF NOT EXISTS tenants_status_idx ON tenants(status) WHERE status != 'archived';
+
+-- ---------------------------------------------------------------------------
+-- Users
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS users (
+    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    -- global_id is for SSO migration (v2). Until then, the per-tenant
+    -- (id, tenant_id) pair is the unique key.
+    global_id       UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
+    tenant_id       UUID REFERENCES tenants(id) ON DELETE CASCADE,
+    email           TEXT NOT NULL,
+    role            TEXT NOT NULL
+                    CHECK (role IN ('super_admin', 'tenant_admin', 'viewer')),
+    status          TEXT NOT NULL DEFAULT 'pending'
+                    CHECK (status IN ('pending', 'active', 'disabled')),
+    display_name    TEXT,
+    -- bcrypt of the password. NULL until the magic link is consumed.
+    password_hash   TEXT,
+    last_login_at   TIMESTAMPTZ,
+    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+    disabled_at     TIMESTAMPTZ
+);
+
+-- Email is unique per tenant (or unique globally for super_admins).
+-- Partial unique index for super_admins (tenant_id IS NULL).
+CREATE UNIQUE INDEX IF NOT EXISTS users_email_global_uniq
+    ON users(email) WHERE tenant_id IS NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS users_email_per_tenant_uniq
+    ON users(email, tenant_id) WHERE tenant_id IS NOT NULL;
+
+CREATE INDEX IF NOT EXISTS users_tenant_idx ON users(tenant_id) WHERE tenant_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS users_status_idx ON users(status) WHERE status != 'active';
+
+-- ---------------------------------------------------------------------------
+-- Magic links (invite + password-set)
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS magic_links (
+    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    -- token_hash is sha256(token). The plaintext token is emailed and
+    -- never stored. Lookup is by hash.
+    token_hash      BYTEA NOT NULL UNIQUE,
+    user_id         UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    purpose         TEXT NOT NULL
+                    CHECK (purpose IN ('invite', 'password_reset', 'mfa_reset')),
+    expires_at      TIMESTAMPTZ NOT NULL,
+    consumed_at     TIMESTAMPTZ,
+    -- ip that consumed it (audit trail)
+    consumed_ip     INET,
+    -- user-agent that consumed it (audit trail)
+    consumed_ua     TEXT,
+    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS magic_links_user_idx ON magic_links(user_id);
+CREATE INDEX IF NOT EXISTS magic_links_unconsumed_idx
+    ON magic_links(expires_at) WHERE consumed_at IS NULL;
+
+-- ---------------------------------------------------------------------------
+-- Refresh tokens (server-side session table)
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS refresh_tokens (
+    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    -- token_hash is sha256(token). The plaintext is in the httpOnly
+    -- cookie / mobile secure storage. Token is a 32-byte random hex string.
+    token_hash      BYTEA NOT NULL UNIQUE,
+    user_id         UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    -- jti of the access JWT that this refresh was issued with. Used
+    -- to correlate logs and to make rotation auditable.
+    access_jti      TEXT NOT NULL,
+    -- The previous token's hash (if rotated). Lets us detect a stolen
+    -- token being used after rotation.
+    parent_hash     BYTEA,
+    expires_at      TIMESTAMPTZ NOT NULL,
+    revoked_at      TIMESTAMPTZ,
+    -- The 'family' id groups all rotations of one login. If a non-head
+    -- token in a family is used, the family is killed (re-use detection).
+    family_id       UUID NOT NULL DEFAULT gen_random_uuid(),
+    created_ip      INET,
+    created_ua      TEXT,
+    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS refresh_tokens_user_idx ON refresh_tokens(user_id);
+CREATE INDEX IF NOT EXISTS refresh_tokens_family_idx ON refresh_tokens(family_id);
+CREATE INDEX IF NOT EXISTS refresh_tokens_active_idx
+    ON refresh_tokens(expires_at) WHERE revoked_at IS NULL;
+
+-- ---------------------------------------------------------------------------
+-- Sessions (read-only audit view of refresh_tokens, denormalized for
+-- fast "what devices is this user logged in from" queries).
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS sessions (
+    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    user_id         UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    refresh_id      UUID NOT NULL REFERENCES refresh_tokens(id) ON DELETE CASCADE,
+    ip              INET,
+    user_agent      TEXT,
+    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+    last_seen_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+    revoked_at      TIMESTAMPTZ
+);
+
+CREATE INDEX IF NOT EXISTS sessions_user_idx ON sessions(user_id);
+CREATE INDEX IF NOT EXISTS sessions_active_idx
+    ON sessions(user_id) WHERE revoked_at IS NULL;
+
+-- ---------------------------------------------------------------------------
+-- Audit log
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS audit_log (
+    id              BIGSERIAL PRIMARY KEY,
+    occurred_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+    -- 'auth.login', 'auth.logout', 'auth.invite', 'auth.magic_consume',
+    -- 'cert.issue', 'cert.revoke', 'tenant.create', 'user.disable', etc.
+    action          TEXT NOT NULL,
+    actor_user_id   UUID REFERENCES users(id) ON DELETE SET NULL,
+    actor_ip        INET,
+    actor_ua        TEXT,
+    -- The target of the action. For 'auth.invite' it's the invited
+    -- user. For 'cert.revoke' it's the source/cert id. Free-form uuid.
+    target_id       UUID,
+    -- Tenant scope (NULL for super-admin global actions).
+    tenant_id       UUID REFERENCES tenants(id) ON DELETE SET NULL,
+    -- Action-specific structured data. Example for 'auth.login':
+    --   {"email": "...", "success": true, "failure_reason": null}
+    payload         JSONB NOT NULL DEFAULT '{}'::jsonb
+);
+
+CREATE INDEX IF NOT EXISTS audit_log_action_time_idx
+    ON audit_log(action, occurred_at DESC);
+CREATE INDEX IF NOT EXISTS audit_log_actor_idx
+    ON audit_log(actor_user_id, occurred_at DESC)
+    WHERE actor_user_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS audit_log_tenant_idx
+    ON audit_log(tenant_id, occurred_at DESC)
+    WHERE tenant_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS audit_log_target_idx
+    ON audit_log(target_id) WHERE target_id IS NOT NULL;
+
+-- ---------------------------------------------------------------------------
+-- SQL functions
+-- ---------------------------------------------------------------------------
+
+-- generate_magic_link_token returns a 32-byte cryptographically random
+-- token, hex-encoded. Callers hash it before storing.
+CREATE OR REPLACE FUNCTION generate_magic_link_token()
+RETURNS TEXT
+LANGUAGE plpgsql
+SET search_path = auth, public
+AS $$
+DECLARE
+    raw BYTEA;
+BEGIN
+    raw := gen_random_bytes(32);
+    RETURN encode(raw, 'hex');
+END;
+$$;
+
+-- issue_refresh_token: create a new refresh token row + the matching
+-- session row in one transaction. Returns the plaintext token (caller
+-- hashes it for the response and stores only the hash).
+CREATE OR REPLACE FUNCTION issue_refresh_token(
+    p_user_id    UUID,
+    p_access_jti TEXT,
+    p_ttl_seconds INTEGER,
+    p_ip         INET,
+    p_ua         TEXT
+)
+RETURNS TABLE (id UUID, token TEXT, family_id UUID, expires_at TIMESTAMPTZ)
+LANGUAGE plpgsql
+SET search_path = auth, public
+AS $$
+DECLARE
+    v_raw        BYTEA := gen_random_bytes(32);
+    v_token      TEXT  := encode(v_raw, 'hex');
+    v_hash       BYTEA := digest(v_raw, 'sha256');
+    v_new_id     UUID;
+    v_family     UUID := gen_random_uuid();
+    v_expires    TIMESTAMPTZ := NOW() + (p_ttl_seconds || ' seconds')::INTERVAL;
+BEGIN
+    INSERT INTO auth.refresh_tokens
+        (token_hash, user_id, access_jti, family_id, expires_at, created_ip, created_ua)
+    VALUES
+        (v_hash, p_user_id, p_access_jti, v_family, v_expires, p_ip, p_ua)
+    RETURNING auth.refresh_tokens.id INTO v_new_id;
+
+    INSERT INTO auth.sessions
+        (user_id, refresh_id, ip, user_agent)
+    VALUES
+        (p_user_id, v_new_id, p_ip, p_ua);
+
+    RETURN QUERY SELECT v_new_id, v_token, v_family, v_expires;
+END;
+$$;
+
+-- rotate_refresh_token: consume a refresh token and issue a new one
+-- in the same family. If the presented token was already revoked OR
+-- the family has a re-use signal, the whole family is killed.
+CREATE OR REPLACE FUNCTION rotate_refresh_token(
+    p_presented_token TEXT,
+    p_new_access_jti  TEXT,
+    p_ttl_seconds     INTEGER,
+    p_ip              INET,
+    p_ua              TEXT
+)
+RETURNS TABLE (id UUID, token TEXT, family_id UUID, expires_at TIMESTAMPTZ, killed_family BOOLEAN)
+LANGUAGE plpgsql
+SET search_path = auth, public
+AS $$
+DECLARE
+    v_presented_hash BYTEA := digest(decode(p_presented_token, 'hex'), 'sha256');
+    v_old            RECORD;
+    v_raw            BYTEA;
+    v_token          TEXT;
+    v_hash           BYTEA;
+    v_new_id         UUID;
+    v_expires        TIMESTAMPTZ;
+    v_killed         BOOLEAN := FALSE;
+BEGIN
+    SELECT rt.id, rt.user_id, rt.family_id, rt.expires_at, rt.revoked_at, rt.parent_hash
+      INTO v_old
+      FROM auth.refresh_tokens rt
+     WHERE rt.token_hash = v_presented_hash;
+
+    IF NOT FOUND THEN
+        -- Unknown token — likely forgeries or already cleaned up.
+        RAISE EXCEPTION 'unknown_refresh_token' USING ERRCODE = '22023';
+    END IF;
+
+    IF v_old.expires_at < NOW() THEN
+        RAISE EXCEPTION 'expired_refresh_token' USING ERRCODE = '22023';
+    END IF;
+
+    -- Re-use detection: if this token is already revoked OR has a
+    -- child (parent_hash set), someone is replaying a stolen token.
+    IF v_old.revoked_at IS NOT NULL OR v_old.parent_hash IS NOT NULL THEN
+        -- Kill the whole family. Every token in it is suspect.
+        UPDATE auth.refresh_tokens
+           SET revoked_at = NOW()
+         WHERE auth.refresh_tokens.family_id = v_old.family_id
+           AND auth.refresh_tokens.revoked_at IS NULL;
+        UPDATE auth.sessions
+           SET revoked_at = NOW()
+         WHERE auth.sessions.user_id = v_old.user_id
+           AND auth.sessions.refresh_id IN (
+               SELECT auth.refresh_tokens.id
+                 FROM auth.refresh_tokens
+                WHERE auth.refresh_tokens.family_id = v_old.family_id
+           )
+           AND auth.sessions.revoked_at IS NULL;
+        v_killed := TRUE;
+        RAISE EXCEPTION 'refresh_token_reuse' USING ERRCODE = '22023';
+    END IF;
+
+    -- Revoke the old, issue the new
+    UPDATE auth.refresh_tokens
+       SET revoked_at = NOW()
+     WHERE auth.refresh_tokens.id = v_old.id;
+
+    v_raw     := gen_random_bytes(32);
+    v_token   := encode(v_raw, 'hex');
+    v_hash    := digest(v_raw, 'sha256');
+    v_expires := NOW() + (p_ttl_seconds || ' seconds')::INTERVAL;
+
+    INSERT INTO auth.refresh_tokens
+        (token_hash, user_id, access_jti, family_id, parent_hash, expires_at, created_ip, created_ua)
+    VALUES
+        (v_hash, v_old.user_id, p_new_access_jti, v_old.family_id, v_presented_hash, v_expires, p_ip, p_ua)
+    RETURNING auth.refresh_tokens.id INTO v_new_id;
+
+    UPDATE auth.sessions
+       SET last_seen_at = NOW(),
+           refresh_id   = v_new_id,
+           ip           = p_ip,
+           user_agent   = p_ua
+     WHERE refresh_id = v_old.id;
+
+    RETURN QUERY SELECT v_new_id, v_token, v_old.family_id, v_expires, v_killed;
+END;
+$$;
+
+-- revoke_refresh_token: revoke a single token by plaintext.
+CREATE OR REPLACE FUNCTION revoke_refresh_token(p_token TEXT)
+RETURNS BOOLEAN
+LANGUAGE plpgsql
+SET search_path = auth, public
+AS $$
+DECLARE
+    v_hash BYTEA := digest(decode(p_token, 'hex'), 'sha256');
+    v_id   UUID;
+BEGIN
+    UPDATE auth.refresh_tokens
+       SET revoked_at = NOW()
+     WHERE token_hash = v_hash
+       AND revoked_at IS NULL
+    RETURNING auth.refresh_tokens.id INTO v_id;
+
+    IF v_id IS NULL THEN
+        RETURN FALSE;
+    END IF;
+
+    UPDATE auth.sessions
+       SET revoked_at = NOW()
+     WHERE refresh_id = v_id
+       AND revoked_at IS NULL;
+
+    RETURN TRUE;
+END;
+$$;
+
+-- ---------------------------------------------------------------------------
+-- updated_at triggers
+-- ---------------------------------------------------------------------------
+CREATE OR REPLACE FUNCTION auth_set_updated_at()
+RETURNS TRIGGER
+LANGUAGE plpgsql
+AS $$
+BEGIN
+    NEW.updated_at := NOW();
+    RETURN NEW;
+END;
+$$;
+
+DROP TRIGGER IF EXISTS tenants_set_updated_at ON tenants;
+CREATE TRIGGER tenants_set_updated_at
+    BEFORE UPDATE ON tenants
+    FOR EACH ROW EXECUTE FUNCTION auth_set_updated_at();
+
+DROP TRIGGER IF EXISTS users_set_updated_at ON users;
+CREATE TRIGGER users_set_updated_at
+    BEFORE UPDATE ON users
+    FOR EACH ROW EXECUTE FUNCTION auth_set_updated_at();