# M08 — UI Scaffold & Auth Flows > Fresh Claude Code agent prompt. M07 must be complete and committed. > Estimated effort: large. This milestone establishes the entire UI baseline. ## Mission Build the `ui` container's foundation: Slim app, base layout (Tailwind + dark mode + sidebar/topnav), session manager, CSRF middleware, ApiClient with retry, OIDC redirect/callback flow, local admin login form, logout. After this milestone, a user can sign in via OIDC against a test tenant or as local admin, and a `/app/me` page renders showing the user's identity. **Page content beyond `/app/me` lands in M09.** ## Before you start 1. Verify M07: ```bash git log --oneline -7 cd api && composer test && composer stan && cd .. cd ui && composer test && composer stan && cd .. ``` 2. Read `SPEC.md` §2 (UI tech stack), §6 (Auth API endpoints — `/api/v1/auth/users/upsert-{oidc,local}`, `/api/v1/admin/me`), §7 (UI Container — full section, especially "Identity resolution flow"), §8 (Authentication — UI-side parts). 3. Set up an Entra test tenant or use a workforce/personal account if you can. Document the setup steps you used in `doc/oidc.md` (this is allowed even though M13 owns most docs — auth setup is a prerequisite for testing this milestone). ## Tasks ### 1. Base UI infrastructure In `ui/src/App/`: - `Bootstrap.php` — boots Slim, registers middlewares, registers routes. - `Container.php` — DI bindings. Bind a `GuzzleHttp\Client` configured with `API_BASE_URL` and 5s default timeout. - `Routes.php` — declared routes; auth-required routes mounted under `/app/*`. In `ui/src/Http/`: - `JsonExceptionHandler.php` — catches uncaught exceptions, renders a friendly Twig error page (not raw JSON; this is a UI), logs the full exception. Distinguish 4xx vs 5xx in the rendered template. - `CsrfMiddleware.php` — generates a per-session CSRF token, validates on POST/PUT/PATCH/DELETE, exposes the token to Twig via a global. Use a constant-time compare. - `FlashMessageMiddleware.php` — pulls flash messages from session and exposes to Twig. ### 2. Session management In `ui/src/Auth/`: - `SessionManager.php` — wraps PHP native sessions. Methods: `startSession()`, `setUser(int $userId, string $displayName, string $role, ?string $email)`, `getUser(): ?UserContext`, `clear()`, `regenerateId()` (call after auth success). - `UserContext.php` — value object with the cached fields. - Sessions: file-based, inside the container. Cookie: name `irdb_session`, `HttpOnly`, `SameSite=Lax`, `Secure` when `APP_ENV=production`. - Session lifetime: 8 hours of inactivity; absolute max 24 hours. ### 3. ApiClient In `ui/src/ApiClient/`: - `ApiClient.php` — wraps Guzzle. Auto-attaches: - `Authorization: Bearer `. - `X-Acting-User-Id: ` if a user is in the session. - `Accept: application/json`. - User agent: `irdb-ui/`. - Automatic retry (1 retry on connection errors and 5xx; no retry on 4xx). - Maps non-2xx responses to typed exceptions: `ApiAuthException` (401/403), `ApiValidationException` (400/422 — carries field errors), `ApiNotFoundException` (404), `ApiServerException` (5xx), `ApiUnreachableException` (network/timeout). - Subclients per endpoint group: - `AuthClient` — `upsertOidc(...)`, `upsertLocal(...)`. - `AdminClient` — `getMe()`, plus stubs for endpoints used in M09–M12. **Don't implement subclient methods you don't yet need; M09+ adds them as needed.** DTOs in `ui/src/ApiClient/DTOs/` — small classes mirroring API response shapes (`UserDto`, `PolicyDto`, etc.). Strict types; no array soup leaking into controllers. ### 4. OIDC flow In `ui/src/Auth/OidcController.php`: - `GET /login/oidc` — initiates flow. Generates state + code-verifier + nonce; stores in session; redirects to Entra authorize endpoint with PKCE. - `GET /oidc/callback`: 1. Validates state. 2. Exchanges code for tokens via the OIDC client. 3. Validates the ID token (signature, issuer, audience, expiry, nonce). 4. Extracts `sub`, `email` (or `preferred_username` if email absent), `name`, `groups` (array of group object IDs). 5. Calls `AuthClient::upsertOidc($sub, $email, $displayName, $groups)`. 6. If the API returns a user with role `none` (or however your `OIDC_DEFAULT_ROLE=none` case surfaces), redirect to a "no access" page rather than logging in. 7. Calls `SessionManager::regenerateId()`, then `setUser(...)`. 8. Redirects to `/app/me` (the session manager remembers a `next` URL if one was set pre-auth). Use `jumbojett/openid-connect-php`. Configure scopes: `openid profile email`. The `groups` claim must already be present in the ID token from Entra; document that in `doc/oidc.md`. ### 5. Local admin login In `ui/src/Auth/LocalLoginController.php`: - `GET /login` — renders the login form. - `POST /login/local`: 1. CSRF check (handled by middleware, but verify it's wired). 2. Validate username matches `LOCAL_ADMIN_USERNAME`. 3. `password_verify` against `LOCAL_ADMIN_PASSWORD_HASH`. 4. Throttle: track failed attempts in session + a small in-memory backoff. After 5 failures: 30-second lockout. (Full brute-force protection is M14; this is the basic version.) 5. On success: `AuthClient::upsertLocal($username)`, regenerate session, `setUser(...)`, redirect to `/app/me`. 6. On failure: flash error, redirect back to `/login`. - Hide local sign-in entirely if `LOCAL_ADMIN_ENABLED=false`. ### 6. Logout - `POST /logout` — clears session, redirects to `/login`. CSRF-protected. ### 7. Pages In `ui/resources/views/`: - `layout.twig` — full layout: top nav (logo, search-box stub, dark-mode toggle button, user menu with logout), sidebar (placeholder links to Dashboard, IPs, Subnets, Allowlist, Policies, Reporters, Consumers, Tokens, Categories, Audit, Settings — these aren't built yet, but the nav structure is). - `pages/login.twig` — clean centered card. "Sign in with Microsoft" primary button (only if `OIDC_ENABLED=true`), "Local sign-in" collapsed/hidden behind a link (only if `LOCAL_ADMIN_ENABLED=true`). - `pages/me.twig` — `/app/me`: shows user_id, email, display_name, role, source ("oidc" / "local"). Includes a "Logout" button (POSTs). - `pages/no-access.twig` — for OIDC users who land here without a role grant. - `pages/error.twig` — generic error template. - `partials/topnav.twig`, `partials/sidebar.twig`, `partials/flash.twig`, `partials/csrf.twig`. Tailwind: dark mode via `class="dark"` toggled by JS on ``. Persist to `localStorage` (key `irdb-theme`). Default to system preference on first visit. Use `prefers-color-scheme` media query as fallback. Alpine for the dark-mode toggle and the user menu dropdown. htmx not strictly needed yet but the pattern should be established for M09's tables. ### 8. Healthz update `GET /healthz` in the ui: - Returns `200` always (unless the ui itself is broken). - Body includes `api_reachable` (boolean, last status) and `last_api_check_at` (ISO 8601 of most recent successful HEAD on `/healthz`). - A background ticker is overkill — just remember the most recent ApiClient call's success/failure in a singleton service. If no API call has happened yet, both fields are null. ### 9. Configuration Read these env vars (all UI-side, per SPEC §9): - `OIDC_ENABLED`, `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URI`. - `LOCAL_ADMIN_ENABLED`, `LOCAL_ADMIN_USERNAME`, `LOCAL_ADMIN_PASSWORD_HASH`. - `UI_SERVICE_TOKEN`, `API_BASE_URL`. - `UI_SECRET`, `PUBLIC_URL`, `APP_ENV`, `LOG_LEVEL`. Validate at startup: log a clear error and exit non-zero if `UI_SERVICE_TOKEN` or `API_BASE_URL` are missing. If both `OIDC_ENABLED=false` and `LOCAL_ADMIN_ENABLED=false`, exit non-zero (no way to log in). ### 10. OIDC documentation stub Create `doc/oidc.md` with the steps you actually used to set up Entra ID for testing: - App registration creation - Redirect URI - Client secret - API permissions (and consent) - Group claim configuration in token configuration / app manifest - Test user assignment Keep it factual; M13 will polish. ## Implementation notes - **`upsertOidc` failure modes**: if the API returns 5xx, render an error page with retry. If it returns the user with role `viewer` and you're redirecting to `/app/me` (where they can see their identity but nothing else yet), that's correct behavior. The "no role" case (when `OIDC_DEFAULT_ROLE=none` and no group matches) needs a clear "no access — contact admin" page. - **Session fixation**: regenerate session ID on every auth-state change (login success, logout). Sessions before login should not carry over. - **CSRF on logout**: yes, even logout. Otherwise CSRF can log users out unexpectedly. - **htmx + CSRF**: htmx requests must include the CSRF token. Use the standard pattern of a meta tag in `layout.twig` and an htmx config that pulls it. - **Dark mode FOUC**: prevent flash by inlining a tiny script in `` that reads `localStorage` and applies the class before the body renders. - **API unreachable**: when ApiClient throws `ApiUnreachableException`, the relevant page should render a friendly degraded state (sidebar still visible, content area shows "API unreachable; retrying in 30s"). Don't crash the whole UI. - **Tests**: - Unit: SessionManager, ApiClient exception mapping, CSRF middleware. - Integration: spin up the ui Slim app with a mocked ApiClient. Verify login flows, redirects, session set, CSRF rejection. - You can mock OIDC; full OIDC integration tests against a real tenant are out of scope (manual verification suffices). ## Out of scope (DO NOT) - Any UI page beyond `/login`, `/oidc/callback`, `/app/me`, `/no-access`, errors. M09 onward. - Calling admin endpoints other than `/api/v1/admin/me`. M09+. - Token entry list, IP search, dashboard charts, etc. M09+. - Audit display. M12. - Settings page with job triggers. M12. - Brute-force lockout beyond the basic 5-fail/30s. Full version M14. - Rate-limiting the UI. M14. - New api endpoints. (If you find you need one, stop and reconsider — likely M09+ work creeping in.) - Touching `api/` code at all. UI-only milestone. ## Acceptance ```bash cd ui && composer cs && composer stan && composer test && cd .. cd ui && npm ci && npm run build && cd .. docker compose down -v cp .env.example .env # Set: UI_SERVICE_TOKEN matches between containers # Set: LOCAL_ADMIN_ENABLED=true, LOCAL_ADMIN_USERNAME=admin # Generate hash: php -r 'echo password_hash("test1234", PASSWORD_ARGON2ID);' # Set: LOCAL_ADMIN_PASSWORD_HASH= # OIDC: leave with placeholder issuer/client_id/secret unless you have a tenant ready docker compose up -d sleep 20 # UI returns login page when not authenticated curl -s http://localhost:8080/ -L | grep -q "Sign in" # Healthz works on UI curl -sf http://localhost:8080/healthz | grep -q '"status":"ok"' # /app/me unauthenticated redirects to /login test "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/app/me)" = "302" # Local admin login flow (CSRF-aware) COOKIE_JAR=$(mktemp) # 1. GET /login to obtain a session + CSRF token LOGIN_PAGE=$(curl -s -c $COOKIE_JAR http://localhost:8080/login) CSRF=$(echo "$LOGIN_PAGE" | grep -oE 'name="csrf_token" value="[^"]+"' | cut -d'"' -f4) [ -n "$CSRF" ] # 2. POST credentials curl -s -b $COOKIE_JAR -c $COOKIE_JAR -X POST \ -d "csrf_token=$CSRF&username=admin&password=test1234" \ http://localhost:8080/login/local -L -o /tmp/me_response.html grep -q "admin@local" /tmp/me_response.html || grep -q '"role":"admin"' /tmp/me_response.html || grep -qi "local admin" /tmp/me_response.html # Wrong password is rejected COOKIE_JAR2=$(mktemp) LOGIN_PAGE=$(curl -s -c $COOKIE_JAR2 http://localhost:8080/login) CSRF=$(echo "$LOGIN_PAGE" | grep -oE 'name="csrf_token" value="[^"]+"' | cut -d'"' -f4) RESP=$(curl -s -b $COOKIE_JAR2 -c $COOKIE_JAR2 -X POST \ -d "csrf_token=$CSRF&username=admin&password=WRONG" \ http://localhost:8080/login/local -L) echo "$RESP" | grep -qi "invalid\|incorrect\|failed" # CSRF without token is rejected test "$(curl -s -o /dev/null -w '%{http_code}' -X POST \ -d "username=admin&password=test1234" \ http://localhost:8080/login/local)" = "403" # Logout curl -s -b $COOKIE_JAR -c $COOKIE_JAR -X POST \ -d "csrf_token=$CSRF" \ http://localhost:8080/logout -L > /dev/null # After logout, /app/me redirects to /login test "$(curl -s -b $COOKIE_JAR -o /dev/null -w '%{http_code}' http://localhost:8080/app/me)" = "302" docker compose down -v ``` OIDC flow against a real tenant: manual verification. Document the test in `PROGRESS.md`. ## Handoff 1. Commit: ``` feat(M08): ui scaffold, OIDC + local admin auth, session, ApiClient - Slim+Twig+Tailwind base with dark-mode toggle and sidebar/topnav layout - ApiClient with auto Bearer + X-Acting-User-Id, retry, typed exceptions - OIDC code-flow with PKCE; ID token validation; upsert-oidc → session - Local admin login with Argon2id verify, basic 5-fail/30s throttle - logout, CSRF-protected; CSRF middleware globally - /app/me renders user identity, /no-access for unmapped OIDC users - doc/oidc.md drafted with Entra setup steps ``` 2. Append to `PROGRESS.md`: ```markdown ## M08 — UI scaffold & auth (done) **Built:** UI base, both auth paths, sessions, ApiClient. **Notes for next milestone:** - AdminClient has only `getMe()`; M09 adds methods for IP search, IP detail, etc. - The ApiClient's exception types are stable; M09+ catches them in controllers. - Sidebar links exist but most lead to "not implemented" placeholders. M09–M12 fills them. - dark mode persistence: `localStorage.irdb-theme = 'dark' | 'light'`. - M14 will replace the basic 5/30 throttle with a full brute-force lockout. **Manual verification:** - OIDC flow against [tenant name / "test tenant configured at ..."]: succeeded for users in groups [...]. - Tested role mapping by [...]. **Deviations from SPEC:** none. **Added dependencies:** [list, e.g. jumbojett/openid-connect-php was already in SPEC §2]. ``` 3. **Stop.** Do not start M09.