M08-ui-scaffold-and-auth.md 14 KB

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:

    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 <UI_SERVICE_TOKEN>.
    • X-Acting-User-Id: <session.user_id> if a user is in the session.
    • Accept: application/json.
    • User agent: irdb-ui/<version>.
  • 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:
    • AuthClientupsertOidc(...), upsertLocal(...).
    • AdminClientgetMe(), 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 <html>. 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 <API_BASE_URL>/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 <head> 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

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=<that 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:

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