# 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_<kind3>_<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_<kind3>_<base32>` 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_<kind>_<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.
