# M03 — API Auth Foundations > Fresh Claude Code agent prompt. M02 must be complete and committed. > Estimated effort: large. This milestone is auth-critical; spend time on tests. ## Mission Implement the api's authentication and authorization foundations: token kinds (`reporter`, `consumer`, `admin`, `service`), token resolution, RBAC middleware, the impersonation header pattern for the UI BFF, and the auth endpoints (`/api/v1/auth/users/upsert-oidc`, `/api/v1/auth/users/upsert-local`, `/api/v1/admin/me`). Bootstrap the service token on startup. **No business endpoints yet** — only the auth machinery. ## Before you start 1. Verify M02 acceptance: ```bash git log --oneline -2 # M01, M02 commits cd api && composer test && composer stan && cd .. ``` 2. Read `SPEC.md` §6 (API Contracts — Authentication tokens, Auth API), §7 only the "Identity resolution flow" and RBAC matrix, §8 (Authentication & Authorization, the api-side parts), §14 (Coding Conventions). 3. Pay particular attention in SPEC §8 to **where each auth concern lives**: the api owns token validation, the `users` table, RBAC. The ui owns OIDC redirects, sessions, password validation. This milestone is api-only. ## Tasks ### 1. Token domain In `api/src/Domain/Auth/`: - `TokenKind.php` — enum: `Reporter`, `Consumer`, `Admin`, `Service`. - `Role.php` — enum: `Viewer`, `Operator`, `Admin`. Method `satisfies(Role $required): bool`. - `Token.php` — value object representing a parsed token (`kind`, `prefix`, `id` after lookup, `subject` (reporter/consumer/admin user id), `role` for admin tokens). - `TokenIssuer.php` — generates tokens. Format: `irdb__<32 base32 chars>` where `kind3` is `rep|con|adm|svc`. Uses `random_bytes(20)` (160 bits) → base32. Returns the raw token string. - `TokenHasher.php` — `hash(string $raw): string` using SHA-256 hex. Pure function, easy to test. In `api/src/Infrastructure/Auth/`: - `TokenRepository.php` — `findByHash(string $hash): ?TokenRecord`, `create(TokenRecord)`, `markUsed(int $id, DateTimeImmutable)`. `TokenRecord` carries: id, kind, hash, prefix, reporter_id, consumer_id, role (for admin kind), expires_at, revoked_at, last_used_at. Apply `WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now)` in lookups. ### 2. User identity domain In `api/src/Domain/User/`: - `User.php` — value object: `id`, `subject` (OIDC sub, nullable), `email`, `displayName`, `role: Role`, `isLocal: bool`. In `api/src/Infrastructure/Auth/`: - `UserRepository.php` — methods: - `findById(int): ?User` - `findBySubject(string): ?User` (OIDC sub) - `findLocalByUsername(string): ?User` - `upsertOidc(string $sub, string $email, string $displayName, array $groupIds, Role $defaultRole): User` — looks up by sub; if not found, derives role from `oidc_role_mappings` (highest role granted by any matching group; default if none); inserts. If found, updates email/display_name and recomputes role from current group memberships. - `upsertLocal(string $username): User` — finds-or-inserts the local admin record with `is_local=true`, `role=Admin`. Always returns role Admin. - `RoleMappingRepository.php` — `resolveRole(array $groupIds, Role $default): Role`. ### 3. Middlewares In `api/src/Infrastructure/Http/Middleware/`: - `TokenAuthenticationMiddleware.php` — extracts `Authorization: Bearer ...`, parses the kind prefix, looks up by hash, attaches a resolved `AuthenticatedPrincipal` to the request attributes. On failure: `401`. Updates `last_used_at` async (write-behind is fine; for now, synchronous update is acceptable). - `ImpersonationMiddleware.php` — runs after `TokenAuthenticationMiddleware`. If the principal's token kind is `Service`: - require `X-Acting-User-Id` header; missing → `400 Bad Request` with `{"error":"missing X-Acting-User-Id"}`. - look up the user; not found → `403`. - replace the principal with one carrying that user's id and role. - For non-service tokens, `X-Acting-User-Id` is ignored (do not 400). - `RbacMiddleware.php` — route-attached middleware factory: `RbacMiddleware::require(Role::Operator)` returns a middleware that 403s if the principal doesn't have the role. - Extra: `JsonErrorHandler.php` — converts thrown exceptions to JSON error responses (`{"error":"...","details":...}`). Wire it as Slim's error handler. `AuthenticatedPrincipal` (in `api/src/Domain/Auth/`): ```php final class AuthenticatedPrincipal { public function __construct( public readonly TokenKind $tokenKind, public readonly ?int $userId, // present when service-impersonating or admin-token bound to user public readonly ?Role $role, // null for reporter/consumer public readonly ?int $reporterId, public readonly ?int $consumerId, public readonly int $tokenId, ) {} } ``` ### 4. Service token bootstrap In `api/src/Infrastructure/Auth/ServiceTokenBootstrap.php`: - Run on api container startup (call from `entrypoint.sh` via a `bin/console auth:bootstrap-service-token` command, or run inline at app boot before HTTP serving). - Reads `UI_SERVICE_TOKEN` env var. If empty: log a warning, skip. - If set: hash it, look up in `api_tokens`. If absent, insert a row with `kind=service`, hash, prefix (first 8 chars of raw), no FKs, no expiry. If present and hash matches an existing service-kind row, do nothing. If a different service-kind row exists, log a warning (rotation case) but do not auto-revoke — operator must clean up. Update `api/docker/entrypoint.sh` so the `api` mode runs `bin/console auth:bootstrap-service-token` before launching FrankenPHP. ### 5. Routes In `api/src/Application/`: - `Auth/AuthController.php`: - `POST /api/v1/auth/users/upsert-oidc` — service token only. Body `{subject, email, display_name, groups: []}`. Returns `{user_id, role, email, display_name, is_local: false}`. - `POST /api/v1/auth/users/upsert-local` — service token only. Body `{username}`. Returns local admin user record. - `GET /api/v1/auth/users/{id}` — service token only. Returns user, 404 otherwise. - `Admin/MeController.php`: - `GET /api/v1/admin/me` — admin token OR service+impersonation. Returns `{user_id, email, display_name, role, source: "oidc"|"local"|"admin-token"}`. Wire routes with the appropriate middleware stack: `TokenAuthenticationMiddleware` → `ImpersonationMiddleware` → `RbacMiddleware::require(...)`. ### 6. CLI Extend `api/bin/console`: - `auth:bootstrap-service-token` (described above). - `auth:create-token --kind=admin --role=admin` — creates an admin token (used in dev/manual ops). Outputs the raw token to stdout exactly once. Refuse to create `service` kind via this command. ### 7. Configuration Add to `api/config/settings.php`: - `UI_SERVICE_TOKEN` (required when `OIDC_DEFAULT_ROLE` ≠ `none` and the UI is in use; default behavior: warn if empty). - `UI_ORIGIN` for CORS (read but no enforcement yet — CORS middleware is a stretch task here; if you add it, be conservative). ## Implementation notes - **Token format and lookup**: `irdb__` is parsed before DB lookup so a malformed token returns 401 without a DB hit. Hash the entire raw string (including prefix) for storage; the prefix is also stored separately for log readability. - **Constant-time comparison**: `hash_equals()` when comparing token hashes if you ever do an inline compare. For lookup-by-hash, the DB index is fine. - **Don't leak which token kind was wrong**. On any mismatch (bad kind for the route, expired, revoked), return a uniform 401 with body `{"error":"unauthorized"}`. Reserve 403 for "authenticated, wrong role." - **Service token rotation**: out of scope this milestone. The bootstrap just handles "set or not set." Document that rotating means: deploy with new value, restart api, manually revoke old hash via a future tool. - **`X-Acting-User-Id` header**: validate format (positive integer). Reject malformed with 400. - **Audit log**: not yet wired. Audit emitter lands in M12. Don't half-build it now. - **Tests**: this milestone lives or dies by test coverage. Aim for an integration test per cell of this matrix: | Token kind | Has X-Acting-User-Id? | User exists? | Endpoint requires role | Expected outcome | |--------------|-----------------------|--------------|------------------------|---------------------------------| | no token | - | - | viewer | 401 | | bad token | - | - | viewer | 401 | | reporter | - | - | viewer | 401 (wrong kind for admin route)| | admin/viewer | - | - | viewer | 200 | | admin/viewer | - | - | operator | 403 | | admin/admin | - | - | admin | 200 | | service | no | - | viewer | 400 | | service | yes | no | viewer | 403 | | service | yes (viewer user) | yes | viewer | 200 | | service | yes (viewer user) | yes | operator | 403 | | service | yes (admin user) | yes | admin | 200 | ## Out of scope (DO NOT) - No reporter/consumer endpoints (`/api/v1/report`, `/api/v1/blocklist`). M04. - No internal job endpoints. M05. - No rate limiting. M04. - No OIDC validation (the api doesn't validate OIDC tokens; the ui does, then calls `upsert-oidc`). - No password validation. The api never sees passwords. - No CORS enforcement (OK to add headers but don't gate on them yet). - No ui changes. - No audit log emission. - No new dependencies. ## Acceptance ```bash cd api && composer cs && composer stan && composer test && cd .. # Service token bootstrap docker compose down -v cp .env.example .env # Fill in: UI_SERVICE_TOKEN=<32 hex>, INTERNAL_JOB_TOKEN=<32 hex>, etc. docker compose up -d sleep 15 # Verify the token row exists docker compose exec -T api sqlite3 /data/irdb.sqlite \ "SELECT kind FROM api_tokens WHERE kind='service';" | grep -q "service" # /api/v1/admin/me with bad auth test "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8081/api/v1/admin/me)" = "401" # /api/v1/admin/me with admin token (created via CLI) ADMIN_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=admin --quiet) RESP=$(curl -s -H "Authorization: Bearer $ADMIN_TOKEN" http://localhost:8081/api/v1/admin/me) echo "$RESP" | grep -q '"role":"admin"' echo "$RESP" | grep -q '"source":"admin-token"' # Upsert local user via service token (simulating the ui) SVC_TOKEN=$(grep ^UI_SERVICE_TOKEN= .env | cut -d= -f2) RESP=$(curl -s -X POST \ -H "Authorization: Bearer $SVC_TOKEN" \ -H "Content-Type: application/json" \ -d '{"username":"admin"}' \ http://localhost:8081/api/v1/auth/users/upsert-local) USER_ID=$(echo "$RESP" | php -r 'echo json_decode(stream_get_contents(STDIN), true)["user_id"];') [ -n "$USER_ID" ] # Service + impersonation: fetches /admin/me as that user RESP=$(curl -s -H "Authorization: Bearer $SVC_TOKEN" -H "X-Acting-User-Id: $USER_ID" \ http://localhost:8081/api/v1/admin/me) echo "$RESP" | grep -q '"is_local":true' echo "$RESP" | grep -q '"role":"admin"' # Service without impersonation header → 400 test "$(curl -s -o /dev/null -w '%{http_code}' \ -H "Authorization: Bearer $SVC_TOKEN" \ http://localhost:8081/api/v1/admin/me)" = "400" docker compose down -v ``` ## Handoff 1. Commit: ``` feat(M03): api auth foundations — tokens, RBAC, BFF impersonation - token kinds: reporter | consumer | admin | service (irdb__<32b32> format) - TokenAuthenticationMiddleware + ImpersonationMiddleware + RbacMiddleware - /api/v1/auth/users/upsert-{oidc,local}, /api/v1/admin/me - service token bootstrap on container startup - integration tests cover the full auth matrix ``` 2. Append to `PROGRESS.md`: ```markdown ## M03 — API auth foundations (done) **Built:** token kinds, hashing, RBAC, impersonation pattern, auth endpoints, service token bootstrap. **API contract decisions:** - 401 = bad/expired/revoked/wrong-kind token (uniform body) - 403 = authenticated but wrong role - 400 = service token without X-Acting-User-Id header - last_used_at updated synchronously (move to async in M14 if perf demands) **Notes for next milestone:** - Reporter and consumer tokens have no role column; their auth carries reporter_id / consumer_id only. - M04's report endpoint reads `principal->reporterId` from request attrs. - Admin endpoints in later milestones can use `RbacMiddleware::require(Role::Operator)` etc. **Deviations from SPEC:** none. **Added dependencies:** none. ``` 3. **Stop.** Do not start M04.