# 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.

## Use as a JWT verifier from other services

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

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

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

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

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

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

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