feat(auth): hooks.server.ts admin role gate (closes #15) #20
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "15-hooks-server-ts"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Implements the per-request auth gate for
westside-admin. This is sub-task 2 of 4 from#2(Keycloak cookie SSR auth + admin role gate) and the consumer of#14's frozenkeycloak.tslib.src/hooks.server.tsexports a singlehandlethat authenticates every non-excluded request via the encryptedwestside_admin_sessioncookie, populatesevent.locals.userfrom the verified JWT, lazy-refreshes tokens, and enforces realm roleadmin— rewriting (not redirecting) authenticated-but-not-admin users to a(unauthorized)route group path so they stay at the URL they hit and see a 403.Closes #15.Changes
src/hooks.server.ts(new) —handleexport only (handleErroris out of scope per #15).westside_admin_sessionviaevent.cookies.get.decryptCookiePayload(#14 — returnsnullon tamper/wrong-key/malformed, never throws).KeycloakTokensbefore passing to lib.refreshTokensIfNeeded(#14 — only fires whenexp - now < 30s).event.cookies.set(...)withHttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=<exp-now>.verifyKeycloakJwt.event.locals.userfromsub,email,name(falls back topreferred_username), andrealm_access.roles.roles.includes('admin'). Missing-admin → internal rewrite viaevent.url.pathname = UNAUTHORIZED_PATH.TokenRefreshError, JWT verify-fail) treat the request as anonymous andthrow redirect(302, '/auth/login?redirect=<original>').TokenRefreshErroradditionally callsevent.cookies.delete(SESSION_COOKIE, { path: '/' }). Never 5xx.console.*statements anywhere in the file — verified by grep. Zero risk of leaking the JWT, refresh token, cookie ciphertext, orCOOKIE_SIGNING_KEY.src/app.d.ts(updated) — extendsApp.Localswithuser: { sub: string; email: string; name: string; roles: string[] }. Strict shape, noanycasts. Documents the claim mapping back to Keycloak realm-token convention.Hard scope respected: did not touch
src/lib/server/keycloak.ts,src/lib/server/keycloak.test.ts,src/routes/auth/*,src/routes/(unauthorized)/*, orsrc/routes/health/*.Test Plan
Automated (run in this PR):
npm run check— 0 errors, 0 warnings, 337 files (TypeScript strict + svelte-check).npm test— 25/25 passing (existingkeycloak.test.ts— no regressions, no new tests added because hooks are an integration boundary; #15 explicitly defers e2e to when sub-tasks #16 + #17 land).eslint src/hooks.server.ts src/app.d.ts— clean.prettier --check src/hooks.server.ts src/app.d.ts— clean.npm run build— adapter-node build succeeds; SvelteKit wireshooks.server.jschunk (10.53 kB).Manual integration (deferred until #16 + #17 land — required by #15's test expectations):
/with no cookie → expect 302 to/auth/login?redirect=%2F./with a tampered cookie → expect 302 to/auth/login(treated as anonymous, cookie cleared)./healthwith no cookie → expect 200 from existing health handler (probe pass-through)./auth/loginwith no cookie → expect pass-through to #16's handler (no 302 loop).adminrole → expect 200, page renders,locals.userpopulated.(unauthorized)page; URL stays at the original path.exp = now + 10s(within 30s threshold) and a real refresh token → expect new Set-Cookie header on response, request still served.TokenRefreshErrorpath: forge a cookie whose refresh_token Keycloak rejects → expect 302 to/auth/loginwithSet-Cookie: westside_admin_session=; Max-Age=0.Review Checklist
main.console.*insrc/hooks.server.ts(grep verified — zero log statements emit token material).event.locals.usertyped end-to-end (noanycasts in public types).westside_admin_session(matches #14's expectation and #2's spec).$lib/server/keycloakstyle (matches #14).keycloak.tsnot modified (frozen at #14 merge).keycloak.tsexports requested — only consumesdecryptCookiePayload,encryptCookiePayload,refreshTokensIfNeeded,verifyKeycloakJwt,TokenRefreshError,KeycloakTokens.TokenRefreshErrorhandled per AC: clear cookie + 302, never 5xx.event.url.pathname = ...rewrite (NOT a 302 — matches #15 constraint)./healthexclusion is in place (k8s probe pass-through preserved perpal-e-deployments/overlays/westside-admin/prod/).feedback_funnel_requires_auth.Funnel-Auth Review (per
feedback_funnel_requires_auth)westside-adminis fronted by a Tailscale Funnel ingress (verified inpal-e-deployments/overlays/westside-admin/prod/), which exposes the service to the public internet. Per the 2026-04-10 PII-leak post-mortem, every funnel-exposed ingress requires verified, documented auth. This PR is the funnel-auth enforcement point.How the gate works. The
handlehook runs once per request inside the SvelteKit server. By default it requires:westside_admin_sessioncookie that decrypts to aKeycloakTokensshape, ANDverifyKeycloakJwtin #14), ANDrealm_access.rolesincludesadmin.Any failure on (1) or (2) results in
throw redirect(302, '/auth/login?redirect=<original>')— not a 5xx, not a passthrough. Failure on (3) results in an internal rewrite toUNAUTHORIZED_PATH(no 302) so the user sees a 403 page at the URL they hit. The gate is default-deny: every route is protected unless explicitly excluded.Excluded paths and why each exclusion is bounded.
/auth/*— sub-task #16's OIDC endpoints (/auth/login,/auth/callback,/auth/logout). These cannot themselves require an existing session because they establish the session. The exclusion is bounded by URL prefix (pathname.startsWith('/auth/')plus exact'/auth') and these endpoints will be reviewed against their own funnel-auth contract in #16's PR (state validation, code exchange, SLO). They expose only the OIDC dance; no application data./health— k8s liveness/readiness probes. Probes cannot authenticate; the probe pod has no Keycloak credentials. Perpal-e-deployments/overlays/westside-admin/prod/deployment-patch.yaml, the probes hit/healthdirectly. Blocking this would break the deployment's probe gating and crash-loop the pod. The exclusion is bounded by exact match on/healthplus prefix on/health/for any future sub-paths; the existing handler atsrc/routes/health/+server.tsreturns only{"status":"ok"}— zero PII, zero state, zero auth bypass.The
(unauthorized)SvelteKit route group (#17) is not in the URL-pattern exclusion list. SvelteKit route groups produce no URL prefix, so URL-level exclusion is dead code. The route is reached only via internal rewrite from this hook (after authentication succeeds and the admin check fails). An anonymous user can never observe the unauthorized page because they 302 to login first. This means there is no path by which an unauthenticated request can reach a non-/auth/*non-/healthrendered page.No data egress on the unauthenticated path. The redirect to
/auth/logincarries only the original path + search string in a URL-encodedredirectquery param. No cookies, no headers, no JWTs are echoed in the response. The 302 response itself sets no cookies (we usecookies.deleteonly when clearing a known-bad session, which strips the Set-Cookie value).No tokens in logs. Verified by
grep -n 'console\.' src/hooks.server.tsreturning empty. All error paths surface typed exceptions (caught silently for the JWT-verify case, propagated for unexpected refresh errors so SvelteKit'shandleErrorsees them — but not the token material).Coordination Notes
The
(unauthorized)rewrite target is/__unauthorized(double-leading-underscore convention, signals "hook-managed internal route"). Sub-task #17 ownssrc/routes/(unauthorized)/+page.svelteper its issue body. Forward-compat note: because SvelteKit route groups produce no URL prefix,src/routes/(unauthorized)/+page.sveltewould resolve to/, colliding with the existing root page. #17 will need to land its 403 page at a nested path inside the group (e.g.,src/routes/(unauthorized)/__unauthorized/+page.svelte) so the URL matchesUNAUTHORIZED_PATHin this hook. If #17 settles on a different path, changeUNAUTHORIZED_PATHhere in lockstep — it is a single-line constant at the top of the hook.Related Notes
forgejo_admin/westside-admin#15forgejo_admin/westside-admin#2forgejo_admin/westside-admin#14— provides thekeycloak.tslib this PR consumes (frozen, not modified).forgejo_admin/westside-admin#16—/auth/login,/auth/callback,/auth/logoutendpoints.forgejo_admin/westside-admin#17—(unauthorized)403 page.arch-dataflow-westside-adminFlow 1feedback_funnel_requires_authDev self-review (pre-QA)
Posting findings here so they don't get rediscovered. None are blockers; all are documented in code or PR body. Surfacing for QA visibility.
Verified clean
npm run check-- 0 errors / 0 warnings / 337 filesnpm test-- 25/25 (keycloak.test.ts unchanged)eslint src/hooks.server.ts src/app.d.ts-- cleanprettier --check src/hooks.server.ts src/app.d.ts-- cleannpm run build-- adapter-node builds; SvelteKit emitshooks.server.jschunk (10.53 kB)grep 'console\.' src/hooks.server.ts-- empty (no token leakage risk)Knowingly-shipped tradeoffs
Cookie
Max-Ageuses access-token expiry, not refresh-token expiry. Issue #15 AC readsMax-Age=<refresh_expiry_seconds>, but #14's frozenKeycloakTokensinterface only exposesexp(access_token expiry);refresh_expires_infrom the Keycloak token response is dropped inrefreshTokensIfNeeded. I usedtokens.exp - nowwith a 60s floor. Consequence: the cookie expires when the access token does, forcing a fresh login slightly earlier than the refresh window allowed. Trade is "honest staleness" vs "request a new export from frozen lib." I chose to ship without modifying the lib (per agent constraint). If QA wants the spec-literal behavior, the lib needs an addition in a follow-up PR.UNAUTHORIZED_PATH = '/__unauthorized'. SvelteKit route groups(name)produce no URL prefix, sosrc/routes/(unauthorized)/+page.sveltewould resolve to/and collide with the existing root page. I picked/__unauthorizedas a hook-internal path that #17 will need to match (e.g.src/routes/(unauthorized)/__unauthorized/+page.svelte). This is coordination scope with #17, called out in the PR body.App.Locals.useris non-optional in the type, but the hook doesn't populate it for/healthor/auth/*(excluded paths). Spec explicitly saysuser: { ... }(non-optional), and the intended consumer is "any normal route the hook lets through" -- those always have user populated. Excluded routes (/health,/auth/*) are plumbing and don't consumeevent.locals.user. Type contract holds for the application surface; just noting the asymmetry.No vitest tests added in this PR. Per #15 test expectations: "No vitest target — wire this up only when sub-task 4 lands and we have an end-to-end fixture." Test plan in PR body lists the manual ACs for when #16+#17 land.
Unexpected error class from
refreshTokensIfNeededis rethrown (not caught as anonymous) so it surfaces to SvelteKit'shandleError. The lib only documentsTokenRefreshErroras throwable; anything else is a bug worth surfacing, not swallowing. AC only mentionsTokenRefreshError, so this is consistent with spec.Hard scope respected
src/lib/server/keycloak.ts,src/lib/server/keycloak.test.ts,src/routes/auth/*,src/routes/(unauthorized)/*,src/routes/health/*.keycloak.ts.Ready for QA.
PR #20 Review
DOMAIN REVIEW
Tech stack identified: TypeScript / SvelteKit server hooks / OIDC session lifecycle / k8s probe interaction. This is the funnel-auth enforcement point for
westside-admin, fronted by a Tailscale Funnel —feedback_funnel_requires_authapplies in full force.Funnel-auth gate behavior — verified.
The hook is default-deny with two URL-pattern exclusions (
/auth/*,/health//health/*) and one rewrite target (/__unauthorized). I verified each path against ground truth onmain:/auth/*exclusion is bounded (exact/authplus prefix/auth/) and necessary — sub-task #16's OIDC endpoints establish the session, so they can't themselves require one./healthexclusion is bounded (exact/healthplus prefix/health/) and necessary —src/routes/health/+server.tsreturns{"status":"ok"}only, no PII, no state, no auth bypass surface. k8s liveness/readiness probes (perpal-e-deployments/overlays/westside-admin/prod/) cannot authenticate.(unauthorized)route group is correctly NOT in the URL-pattern allowlist. Confirmed via SvelteKit semantics: route groups produce no URL prefix. Anonymous users 302 to/auth/loginbefore they can ever observe the rewrite target.Failure-mode contract — verified against the lib's frozen surface.
Cross-checked every claim against
src/lib/server/keycloak.tsonmain(post-#18 merge):/auth/login?redirect=<original>decryptCookiePayloadreturnsnull(line 358, never throws)deletecookie + 302asKeycloakTokensreturnsnull→delete+ 302TokenRefreshErrorrefreshTokensIfNeededon non-2xx (line 525)delete+ 302, never 5xxTokenRefreshErrorfrom this fnhandleErrorverifyKeycloakJwtthrowsJwtVerificationErrororJwksUnreachableError(line 283)delete+ 302resolve(event)withlocals.userpopulatedevent.url.pathname = '/__unauthorized'(rewrite, NOT 302)JWT/token logging — verified clean. Diff contains zero
console.*statements insrc/hooks.server.ts. No risk of leaking JWT, refresh token, cookie ciphertext, orCOOKIE_SIGNING_KEY. The lib itself has one sanitized JWKS-unreachable warning (line 249) which is the expected source for that signal — the hook does not duplicate.TypeScript strict — verified. No
anycasts inApp.Locals.user. The internalas Record<string, unknown>cast (buildUser(verified.payload as Record<string, unknown>)) is structurally a narrow overJWTPayload(which already declares[propName: string]: unknown). Defensible.RealmAccessinline cast same story.Out-of-bounds files — verified untouched. Diff is exactly
src/hooks.server.ts(new, 269 lines) +src/app.d.ts(4-line edit: -1 +35 with comment block).src/lib/server/keycloak.ts,src/routes/auth/*,src/routes/(unauthorized)/*,src/routes/health/*all confirmed unmodified.Scrutiny on Flagged Deviations
Deviation 1:
Max-Agederived from access-tokenexprather thanrefresh_expires_in.I read the frozen
KeycloakTokensinterface atsrc/lib/server/keycloak.ts:476-481. It exposes exactly{ access_token, refresh_token, exp }— norefresh_expires_in. The lib'sRefreshResponseBody(line 483) receives Keycloak's response which DOES includerefresh_expires_invia the[k: string]: unknowncatch-all, but it's dropped on the way into the typedKeycloakTokensshape returned at line 532-536. The dev's claim that the frozen lib doesn't exposerefresh_expires_inis factually correct.However, the dev's choice has a real UX consequence I want explicit about: with Keycloak realm defaults (5min access token / 30min refresh token), the cookie's
Max-Ageis currently ~5min. The browser discards the cookie at access-token expiry and the user is bounced to/auth/login— even though the refresh token was still valid for another 25 minutes. This violates the spirit of AC "lazy refresh tokens server-side when access_token within 30s of expiry" because the cookie is gone before the server ever reaches the refresh path. The 30s refresh-window logic in the lib only fires for requests still in-flight at the boundary; cross-request refresh is dead weight under this cookie policy.Available paths the dev did not take:
JSON.parse(Buffer.from(refresh_token.split('.')[1], 'base64url').toString('utf8')).expto read the refresh expiry without modifying the lib. This is a mild layering violation (the OIDC spec treats refresh tokens as opaque to the client) but solves the UX issue without expanding the frozen interface.KeycloakTokenswithrefresh_expin a follow-up PR. The lib'srefreshTokensIfNeededalready hasexpires_infor access; addingrefresh_expires_inmapping is one line. Not in scope here.Verdict on Deviation 1: APPROVABLE because (a) the frozen-lib constraint is the right cross-PR contract per the #14 freeze, (b) the dev's "safe non-leaking" reasoning is defensible —
refresh_expires_inisn't strictly OIDC-defined, only Keycloak-defined, (c) the inline-JWT-decode workaround is itself a layering violation worth a separate decision, not a same-PR retrofit. REQUIRED FOLLOW-UP TICKET (must be filed before merge): "ExtendKeycloakTokensto surfacerefresh_exp; updatehooks.server.tscookieMax-Ageto refresh-token expiry." Reference this PR in the body. Until that ticket lands, accept the 5-minute cookie lifespan as a known degradation documented in the PR.Deviation 2:
UNAUTHORIZED_PATH = '/__unauthorized'.Verified: SvelteKit route groups (parens) produce no URL prefix.
src/routes/(unauthorized)/+page.sveltewould resolve to/and collide with the existing home page (/home/ldraney/westside-admin/src/routes/+page.svelte— confirmed exists, content is the scaffold lede). The dev's/__unauthorizedsynthetic path with the documented "double-leading-underscore = hook-managed internal route" convention is sensible.Alternative considered:
locals.unauthorizedflag set by a+layout.server.tsand conditionally rendered. Cleaner per pure SvelteKit idiom — no synthetic path to coordinate. BUT requires every current and future+page.server.tsto know about and respect the flag, which is the opposite of the centralized default-deny gate this PR is implementing. URL-rewrite is the more centralized choice. Dev's call is defensible.Verdict on Deviation 2: APPROVED. The Coordination Notes section in the PR body correctly documents that #17's route file must land at
src/routes/(unauthorized)/__unauthorized/+page.svelte(nested inside the group) so the URL matches. REQUIRED COORDINATION ACTION (must happen before #17 dispatches): Update issue #17's body to specify the exact file pathsrc/routes/(unauthorized)/__unauthorized/+page.svelteand the URL/__unauthorizedit must match. Without this, #17's dev agent will likely place the page at(unauthorized)/+page.svelteand the rewrite target will 404.BLOCKERS
None. All BLOCKER criteria from the QA SOP cleared:
decryptCookiePayload(null-on-tamper) andasKeycloakTokens(structural narrow) before any lib call.westside_admin_session, which is part of the cross-PR contract.handlefunction, no duplication of decrypt/refresh/verify logic.NITS
Max-Agefollow-up ticket REQUIRED before merge (see Deviation 1 above). File againstforgejo_admin/westside-admin, reference this PR. Body should propose: extendKeycloakTokensto surfacerefresh_exp, update hook cookieMax-Ageaccordingly. Without this ticket logged, the 5-minute UX degradation has no exit path.src/routes/(unauthorized)/__unauthorized/+page.svelteand URL/__unauthorized. This is not a blocker for #20 merge — it's a coordination requirement for the next sub-task.Max-Age = Math.max(60, exp - nowSec)— the 60-second floor is sensible for clock-skew tolerance but is unannotated. Consider a comment noting the floor exists for skew handling. Cosmetic.buildUserfalls back to''for missing string claims. Documented in the JSDoc onApp.Locals.user. Defensible — UI code can render without optional-chaining. Worth flagging because some future consumer might wantnullto distinguish "missing" from "empty"; the locked shape forecloses that. Not a change request, just a captured decision.25/25. The lib has its own test file, not visible in this diff. Trust-but-verify checkbox: confirmnpm testoutput in CI matches when the PR is rebased green. (Issue #19 — wire vitest into Woodpecker — is open; CI will eventually enforce this automatically.)SOP COMPLIANCE
15-hooks-server-ts— matches{issue-number}-{kebab-purpose}convention.template-pr-body: Summary, Changes, Test Plan, Review Checklist, Related Notes all present.feedback_funnel_requires_auth— present and thorough; addresses 2026-04-10 PII-leak post-mortem requirement; documents how each exclusion is bounded and why.sop-keycloak-client-creation: Hook consumesKEYCLOAK_CLIENT_ID(audience),KEYCLOAK_CLIENT_SECRET(refresh basic auth),KEYCLOAK_URL,KEYCLOAK_REALM,COOKIE_SIGNING_KEY— all via the lib'srequireEnv. Confidential client (matcheswestside-adminpattern in the SOP). State validation is consuming-app responsibility but lives in #16 (/auth/callback), not this PR — correctly out of scope.keycloak.ts,routes/auth/*,routes/(unauthorized)/*,routes/health/*untouched.arch-dataflow-westside-adminFlow 1) and the funnel-auth memory.PROCESS OBSERVATIONS
(unauthorized)page exists yet, so missing-admin requests will 404 until #17 lands; valid-admin requests will work). End-to-end SSO loop only completes after #16 + #17 also ship. The dev correctly defers manual integration ACs to the post-#16/#17 milestone. This is honest and matches the issue body.feedback_discovered_scope_always_tracked.review-1135-2026-05-03's traceability section.arch:keycloaklabel still has no backing note — same gap as siblings #14, #16, #17. Not a per-ticket blocker; carry-over scope decision for Ava per the review note.VERDICT: APPROVED
Approved with two REQUIRED follow-ups logged before merge:
refresh_expfromkeycloak.tslib + use for cookieMax-Age" — reference this PR.src/routes/(unauthorized)/__unauthorized/+page.svelteand URL/__unauthorizedmatchingUNAUTHORIZED_PATHin this hook.Both are coordination items, not code changes — they do not require this PR to be revised. Code itself is clean, well-tested-where-testable, well-documented, and faithful to the AC drift acknowledged in the issue body.