hooks.server.ts + app.d.ts: handle hook for cookie auth + admin role gate (consumes #14) #15

Closed
opened 2026-05-03 14:56:36 +00:00 by forgejo_admin · 1 comment

Type

Feature

Lineage

Decomposed from forgejo_admin/westside-admin#2 (Keycloak cookie SSR auth + admin role gate). Sub-task 2 of 4. Depends on forgejo_admin/westside-admin#14 (keycloak.ts lib) — must land first.

Repo

forgejo_admin/westside-admin

User Story

story-westside-admin-admin-row-crud. The handle hook is the per-request enforcement point: every authenticated request reaches the handle hook before any data-bearing route runs. This is where the funnel-auth gate is enforced.

Context

The handle hook reads the encrypted session cookie, decrypts it via #14's decryptCookiePayload, validates the embedded JWT via #14's verifyKeycloakJwt, checks realm_access.roles includes admin, and populates event.locals.user. Anonymous requests (no cookie) get redirected to /auth/login (which #16 builds). Authenticated-but-not-admin users get rendered the (unauthorized) 403 page (which #17 builds) without redirect.

The hook also performs lazy token refresh: if the embedded exp is within 30s of now, it calls #14's refreshTokensIfNeeded and re-encrypts the new tokens into the cookie before continuing the request.

File Targets

Create:

  • src/hooks.server.ts — the handle export (only — handleError is out of scope here).

Update:

  • src/app.d.ts — extend App.Locals with user: { sub: string; email: string; name: string; roles: string[] }.

Do NOT touch:

  • src/lib/server/keycloak.ts (sub-task 1, frozen at this point).
  • src/routes/auth/* (sub-task 3 — the hook redirects to these endpoints but doesn't define them).
  • src/routes/(unauthorized)/* (sub-task 4 — the hook renders this route group on missing-admin, doesn't define the page).

Acceptance Criteria

  • Anonymous request (no cookie) to any route except /auth/*, /(unauthorized), and /health 302s to /auth/login?redirect={original_url}.
  • /health requests pass through unauthenticated and reach the existing src/routes/health/ handler — k8s liveness/readiness probes cannot authenticate, so blocking them would break the deployment's probe gating.
  • If refreshTokensIfNeeded throws TokenRefreshError mid-request (refresh token expired, Keycloak refused), treat the request as anonymous: clear the session cookie via event.cookies.delete('westside_admin_session', { path: '/' }) and 302 to /auth/login?redirect={original_url}. Do NOT 5xx — the user just needs to log in again.
  • Request with valid session cookie + admin role passes through, populates event.locals.user.
  • Request with valid session cookie but missing admin role internally rewrites to the (unauthorized) route group (does NOT redirect — user stays at the URL they hit, sees 403).
  • Request with tampered/expired/wrong-key cookie is treated as anonymous (redirect to login) — never throws.
  • Token refresh: when JWT exp - now < 30s, hook calls refreshTokensIfNeeded, re-encrypts cookie, sets new Set-Cookie before continuing.
  • Cookie attributes set on every refresh: HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=<refresh_expiry_seconds>.
  • No tokens, refresh tokens, or cookie ciphertext appear in any log line emitted from this hook.
  • event.locals.user typed end-to-end (no any casts).

Test Expectations

  • Manual integration test (preferred over unit, since the hook is an integration boundary):
    • Hit / with no cookie → expect 302 to /auth/login?redirect=%2F.
    • Hit / with a tampered cookie → expect 302 to /auth/login (treated as anonymous).
    • With valid cookie + admin → expect 200, page renders.
    • With valid cookie + non-admin → expect 200 but body is the (unauthorized) page.
  • Run command: npm run dev + manual cookie injection via curl -b cookie.txt. (No vitest target — wire this up only when sub-task 4 lands and we have an end-to-end fixture.)

Constraints

  • Use SvelteKit's event.cookies.get('westside_admin_session') and event.cookies.set(...) — do NOT manually parse Cookie: headers.
  • Use event.locals for user object — do NOT stash on globalThis or module-level state.
  • Cookie name MUST be exactly westside_admin_session (matches #14's expectation and the spec in #2).
  • The (unauthorized) render must be done via event.url.pathname = ... rewrite or await resolve(event, { transformPageChunk: ... }) — NOT a 302 redirect (per AC).
  • Do NOT pre-validate routes (e.g., a route allowlist) — every route is admin-gated by default; the only exclusions are /auth/*, /(unauthorized), and /health. The /health exclusion is mandatory — k8s probes (defined in pal-e-deployments/overlays/westside-admin/prod/deployment-patch.yaml) hit /health without auth.
  • Match the import-path style from sub-task 1: import { ... } from '$lib/server/keycloak';.

Checklist

  • PR opened
  • Manual ACs verified
  • No tokens in logs (grep verified)
  • PR body includes funnel-auth review per feedback_funnel_requires_auth
  • project-westside-admin
  • forgejo_admin/westside-admin#2 — parent
  • forgejo_admin/westside-admin#14 — keycloak.ts lib (DEPENDS ON, must merge first)
  • arch-dataflow-westside-admin — Flow 1
  • feedback_funnel_requires_auth
### Type Feature ### Lineage Decomposed from `forgejo_admin/westside-admin#2` (Keycloak cookie SSR auth + admin role gate). Sub-task 2 of 4. Depends on `forgejo_admin/westside-admin#14` (keycloak.ts lib) — must land first. ### Repo `forgejo_admin/westside-admin` ### User Story `story-westside-admin-admin-row-crud`. The handle hook is the per-request enforcement point: every authenticated request reaches the handle hook before any data-bearing route runs. This is where the funnel-auth gate is enforced. ### Context The handle hook reads the encrypted session cookie, decrypts it via #14's `decryptCookiePayload`, validates the embedded JWT via #14's `verifyKeycloakJwt`, checks `realm_access.roles` includes `admin`, and populates `event.locals.user`. Anonymous requests (no cookie) get redirected to `/auth/login` (which #16 builds). Authenticated-but-not-admin users get rendered the `(unauthorized)` 403 page (which #17 builds) without redirect. The hook also performs lazy token refresh: if the embedded `exp` is within 30s of now, it calls #14's `refreshTokensIfNeeded` and re-encrypts the new tokens into the cookie before continuing the request. ### File Targets Create: - `src/hooks.server.ts` — the `handle` export (only — `handleError` is out of scope here). Update: - `src/app.d.ts` — extend `App.Locals` with `user: { sub: string; email: string; name: string; roles: string[] }`. Do NOT touch: - `src/lib/server/keycloak.ts` (sub-task 1, frozen at this point). - `src/routes/auth/*` (sub-task 3 — the hook redirects to these endpoints but doesn't define them). - `src/routes/(unauthorized)/*` (sub-task 4 — the hook renders this route group on missing-admin, doesn't define the page). ### Acceptance Criteria - [ ] Anonymous request (no cookie) to any route except `/auth/*`, `/(unauthorized)`, and `/health` 302s to `/auth/login?redirect={original_url}`. - [ ] `/health` requests pass through unauthenticated and reach the existing `src/routes/health/` handler — k8s liveness/readiness probes cannot authenticate, so blocking them would break the deployment's probe gating. - [ ] If `refreshTokensIfNeeded` throws `TokenRefreshError` mid-request (refresh token expired, Keycloak refused), treat the request as anonymous: clear the session cookie via `event.cookies.delete('westside_admin_session', { path: '/' })` and 302 to `/auth/login?redirect={original_url}`. Do NOT 5xx — the user just needs to log in again. - [ ] Request with valid session cookie + admin role passes through, populates `event.locals.user`. - [ ] Request with valid session cookie but missing admin role internally rewrites to the `(unauthorized)` route group (does NOT redirect — user stays at the URL they hit, sees 403). - [ ] Request with tampered/expired/wrong-key cookie is treated as anonymous (redirect to login) — never throws. - [ ] Token refresh: when JWT `exp - now < 30s`, hook calls `refreshTokensIfNeeded`, re-encrypts cookie, sets new Set-Cookie before continuing. - [ ] Cookie attributes set on every refresh: `HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=<refresh_expiry_seconds>`. - [ ] No tokens, refresh tokens, or cookie ciphertext appear in any log line emitted from this hook. - [ ] `event.locals.user` typed end-to-end (no `any` casts). ### Test Expectations - Manual integration test (preferred over unit, since the hook is an integration boundary): - Hit `/` with no cookie → expect 302 to `/auth/login?redirect=%2F`. - Hit `/` with a tampered cookie → expect 302 to `/auth/login` (treated as anonymous). - With valid cookie + admin → expect 200, page renders. - With valid cookie + non-admin → expect 200 but body is the `(unauthorized)` page. - Run command: `npm run dev` + manual cookie injection via `curl -b cookie.txt`. (No vitest target — wire this up only when sub-task 4 lands and we have an end-to-end fixture.) ### Constraints - Use SvelteKit's `event.cookies.get('westside_admin_session')` and `event.cookies.set(...)` — do NOT manually parse `Cookie:` headers. - Use `event.locals` for user object — do NOT stash on `globalThis` or module-level state. - Cookie name MUST be exactly `westside_admin_session` (matches #14's expectation and the spec in #2). - The (unauthorized) render must be done via `event.url.pathname = ...` rewrite or `await resolve(event, { transformPageChunk: ... })` — NOT a 302 redirect (per AC). - Do NOT pre-validate routes (e.g., a route allowlist) — every route is admin-gated by default; the only exclusions are `/auth/*`, `/(unauthorized)`, and `/health`. The `/health` exclusion is mandatory — k8s probes (defined in `pal-e-deployments/overlays/westside-admin/prod/deployment-patch.yaml`) hit `/health` without auth. - Match the import-path style from sub-task 1: `import { ... } from '$lib/server/keycloak';`. ### Checklist - [ ] PR opened - [ ] Manual ACs verified - [ ] No tokens in logs (grep verified) - [ ] PR body includes funnel-auth review per `feedback_funnel_requires_auth` ### Related - `project-westside-admin` - `forgejo_admin/westside-admin#2` — parent - `forgejo_admin/westside-admin#14` — keycloak.ts lib (DEPENDS ON, must merge first) - `arch-dataflow-westside-admin` — Flow 1 - `feedback_funnel_requires_auth`
Author
Owner

Scope Review: APPROVED

Review note: review-1135-2026-05-03

Scope is tight, file targets are accurate against post-#14 ground truth (PR #18 merged today), and all three claimed lib exports (verifyKeycloakJwt, decryptCookiePayload, refreshTokensIfNeeded) verified to exist in src/lib/server/keycloak.ts on main (HEAD 982df5b). Env vars merged via pal-e-deployments#147. No decomposition needed (1 create + 1 update, ~60-100 LoC, 5-8 min agent run).

Three small [BODY] items — none block dispatch:

  • [BODY] (should-fix) Resolve the /health route question — src/routes/health exists; the exclusion list (/auth/*, /(unauthorized)) does not name it. Recommend adding /health as a third exclusion, OR Ava routes the answer in the dev-agent prompt directly (30-sec clarification).
  • [BODY] (optional) Make TokenRefreshError mid-request behavior explicit (treat-as-anonymous; currently inferable from AC #4).
  • [BODY] (optional, cosmetic) Tighten the cookie-name attribution — the lib is cookie-name-agnostic by design, so "matches #14's expectation" is slightly off; the hook is where the constant lives.

[SCOPE] carry-over from review-1134-2026-05-03: arch-keycloak note still doesn't exist; arch-dataflow-westside-admin Flow 1 covers the surface this ticket touches. Ava's call whether to author now or defer.

Sequencing: #15 unblocked NOW. #16/#17 can run in parallel — do-not-touch fences between siblings are clean; full E2E manual ACs need all four landed.

## Scope Review: APPROVED Review note: `review-1135-2026-05-03` Scope is tight, file targets are accurate against post-#14 ground truth (PR #18 merged today), and all three claimed lib exports (`verifyKeycloakJwt`, `decryptCookiePayload`, `refreshTokensIfNeeded`) verified to exist in `src/lib/server/keycloak.ts` on `main` (HEAD `982df5b`). Env vars merged via `pal-e-deployments#147`. No decomposition needed (1 create + 1 update, ~60-100 LoC, 5-8 min agent run). Three small `[BODY]` items — none block dispatch: - `[BODY]` (should-fix) Resolve the `/health` route question — `src/routes/health` exists; the exclusion list (`/auth/*`, `/(unauthorized)`) does not name it. Recommend adding `/health` as a third exclusion, OR Ava routes the answer in the dev-agent prompt directly (30-sec clarification). - `[BODY]` (optional) Make `TokenRefreshError` mid-request behavior explicit (treat-as-anonymous; currently inferable from AC #4). - `[BODY]` (optional, cosmetic) Tighten the cookie-name attribution — the lib is cookie-name-agnostic by design, so "matches #14's expectation" is slightly off; the hook is where the constant lives. `[SCOPE]` carry-over from `review-1134-2026-05-03`: `arch-keycloak` note still doesn't exist; `arch-dataflow-westside-admin` Flow 1 covers the surface this ticket touches. Ava's call whether to author now or defer. Sequencing: #15 unblocked NOW. #16/#17 can run in parallel — do-not-touch fences between siblings are clean; full E2E manual ACs need all four landed.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
forgejo_admin/westside-admin#15
No description provided.