feat(auth): (unauthorized) 403 page (closes #17) #23
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "17-unauthorized-page"
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
(unauthorized)route group that renders the 403 page when the auth hook (#15, merged via PR #20) rewrites authenticated-but-not-admin requests to/__unauthorized. Final sub-task of#2(Keycloak cookie SSR auth + admin role gate) — the parent's checklist is now complete.The page preserves the user-visible URL (the rewrite is internal), drops the root admin chrome via the
@-sigil layout reset, displays the user's name with a clear message about the missingadminrealm role, and offers a same-origin<form>-POST sign-out button that hits#16's CSRF-protected/auth/logout(no JS required).Closes #17
Changes
Five new files, no modifications to existing files:
src/routes/(unauthorized)/+layout@.svelte— layout reset (note the@sigil). A bare+layout.sveltewould still be wrapped by the root admin chrome; the@makes this a top-level layout component that does not inherit fromsrc/routes/+layout.svelte. Importsapp.cssso the--color-*and--space-*tokens still resolve.src/routes/(unauthorized)/+layout.server.ts—load({ locals }) => ({ user: locals.user }). Required because the page-level+page.server.tsloadthrowserror(403, …), and SvelteKit's contract is that anerror()thrown from a page load discards the page load's return value before rendering+error.svelte. Layout loads survive the throw, so this file is what populates$page.data.userin the error boundary. The@sigil resets layout component inheritance only — server loads run through the full chain regardless.src/routes/(unauthorized)/__unauthorized/+page.server.ts— throwserror(403, 'Admin role required')from@sveltejs/kit. The throw both sets HTTP status 403 (setHeaderscannot do this — it's a header API, not a status API) and routes rendering to the co-located+error.svelte.src/routes/(unauthorized)/__unauthorized/+page.svelte— placeholder. SvelteKit needs a+page.sveltefor the route to resolve, but it never renders because the load throws first. One-line HTML comment.src/routes/(unauthorized)/__unauthorized/+error.svelte— the actual 403 UI. Heading "Admin access required", user name from$page.data.user.name(withemailand a generic fallback), the error message from$page.error.message, and the sign-out form. Pure CSS in a<style>block using the existing--color-*/--space-*custom-property tokens — no framework imports, no new deps, no images.Funnel-Auth Review
Per
feedback_funnel_requires_auth, every funnel-exposed surface needs documented auth.This PR is the consumer of the hook's role-gate decision; it doesn't introduce a new ingress and doesn't add any path that bypasses authentication. The funnel-auth posture is unchanged from PR #20:
src/hooks.server.ts) 302s anonymous requests to/auth/loginbefore any route handler, including this one, runs. An anonymous user can never observe/__unauthorized— they will be sent to login first regardless of which path they hit.(unauthorized)group is the hook's internal rewrite (event.url.pathname = '/__unauthorized') when an authenticated user lacks theadminrole. The browser-visible URL stays at the original path (e.g./players); the rewrite is server-internal.locals.user, which is populated by the hook from a verified Keycloak JWT — same auth source as the rest of the app. No new lookup, no new identity surface.<form>to/auth/logout(#16), which already enforces anOrigin-header CSRF check against the canonical app URL.#17answer was "the SvelteKit fallback error page with no sign-out path"; the post-#17answer is "a deliberate 403 page with their name and a one-click sign-out". This narrows the social-engineering surface around stale or wrong-account sessions and gives the user a clean exit instead of a dead-end error screen.No new headers, no new endpoints, no new auth flows.
Test Plan
Automated (run in this PR's worktree,
/tmp/westside-admin-17):npm run checkexits 0 —0 ERRORS 0 WARNINGS 0 FILES_WITH_PROBLEMS.App.Locals.usertyping reaches the loads as expected.npm testexits 0 — 25/25 lib tests still pass (no regressions; this PR adds no new tests because the route layer has no unit-testable surface — see "Discovered scope" below).npm run buildsucceeds foradapter-node. Build output includesentries/pages/(unauthorized)/__unauthorized/_error.svelte.jsand_page.svelte.js, confirming the route group compiled.npx eslint src/routes/(unauthorized)/clean.npx prettier --check src/routes/(unauthorized)/clean (the pre-existing.woodpecker.yamlprettier warning is onmainand unrelated to this PR).Manual (per AC, post-merge in the deployed env):
adminrealm role from a test user); hit/players→ expect URL stays/players, page body is the 403 UI, network tab shows403 Forbidden. Verifiable viacurl -i -b cookie.txt https://westside-admin.tail5b443a.ts.net/players— first response lineHTTP/1.1 403 Forbidden.<header class="site-header">markup is NOT present (the@sigil is doing its job)./.<form action="/auth/logout" method="POST">works without JS).Review Checklist
feedback_no_tailwind). Pure CSS in<style>blocks using existingapp.csstokens.package.jsonunchanged).static/.anycasts. Loads use the generated$types(LayoutServerLoad,PageServerLoad).src/lib/server/keycloak.ts,src/hooks.server.ts,src/routes/auth/*,src/routes/+layout.svelte,src/app.d.ts.17-unauthorized-page.@sigil (+layout@.svelte), not a bare+layout.svelte.error(403, …)from@sveltejs/kit(notsetHeaders).+layout.server.ts(not+page.server.ts, whose return value the throw discards).<form action="/auth/logout" method="POST">(no JS-only fetch).Related Notes
forgejo_admin/westside-admin#17forgejo_admin/westside-admin#2(Keycloak cookie SSR auth + admin role gate) — this is the LAST of 4 sub-tasks; parent's checklist is now complete.#14— keycloak.ts SSR auth lib + vitest setup (PR #18)#15— hooks.server.ts admin role gate (PR #20)#16— /auth/login + /callback + /logout endpoints (PR #21)feedback_no_tailwind— pure CSS only.feedback_funnel_requires_auth— funnel-auth posture documented above.arch-dataflow-westside-admin(the funnel-auth → cookie-auth → role-gate →(unauthorized)403 path is the closing edge of the auth subgraph for this app).PR #23 Review
DOMAIN REVIEW
Tech stack: SvelteKit (Svelte 5 runes) + TypeScript + adapter-node + pure CSS. Auth surface only — no DB, no FastAPI, no IaC.
1. Layout reset via
@sigil — VERIFIEDsrc/routes/(unauthorized)/+layout@.svelteis named with the@sigil before the extension (not a bare+layout.svelte). This is the documented SvelteKit mechanism for opting out of root-layout component inheritance. The build artifact path mentioned in the PR body (entries/pages/(unauthorized)/__unauthorized/_error.svelte.js) is consistent with the route-group resolving correctly. The file imports../../app.cssso design tokens still resolve, and uses{@render children()}(Svelte 5 snippet API). Wrapped in a<main>with sane defaults (max-width, centered, token-based padding). Correct.2.
+layout.server.tsdata persistence — VERIFIEDload({ locals }) => ({ user: locals.user })exactly as required. The doc comment correctly states the SvelteKit contract: anerror()thrown from a page load discards that page-load's return value, but layout loads survive and persist into the error boundary. This is the only mechanism that makes$page.data.useravailable in+error.svelte. Typed via the generatedLayoutServerLoad. Correct.3. HTTP 403 status — VERIFIED
+page.server.tsimportserrorfrom@sveltejs/kitandthrow error(403, 'Admin role required'). SvelteKit guarantees the throw sets the response status to 403 and routes to the nearest+error.svelte. The PR body correctly notes thatsetHeaderscannot set status (header API, not status API). Typed viaPageServerLoad. Correct.4. Sign-out form — VERIFIED
<form action="/auth/logout" method="POST">with a<button type="submit">Sign out</button>. Noon:submit, nofetch, no JS handler. Same-origin POST satisfies the Origin-header CSRF check installed by PR #21. Works without JS. Correct.5. No Tailwind, no new dependencies — VERIFIED
No framework imports anywhere. Pure CSS in
<style>blocks using existing--color-*and--space-*tokens fromapp.css.package.jsonis not in the diff (additions=196 deletions=0 across 5 new files only).6.
+page.svelteplaceholder is intentionally inert — VERIFIEDSingle HTML comment line. Never renders because the load throws first. SvelteKit requires a
+page.sveltefor the route to resolve. Correct.Out-of-bounds files — VERIFIED
The diff is 5 new files only, all under
src/routes/(unauthorized)/. Untouched:src/lib/server/keycloak.ts,src/hooks.server.ts,src/routes/auth/*,src/routes/+layout.svelte,src/app.d.ts. Strict scope discipline.TypeScript / leakage checks — VERIFIED
anycasts. Both loads use generated$types.console.loganywhere.name,email, and the static error message.name || email || 'an authenticated user'is graceful and avoidsundefinedrendering.$page.error?.message ?? 'Admin role required'defends against missing error message.Funnel-auth posture — VERIFIED
Per
feedback_funnel_requires_auth, the PR body contains a dedicated Funnel-Auth Review section. The audit holds: this PR is a consumer of the existing hook's role-gate decision, not a new ingress. Anonymous users cannot reach/__unauthorized— the hook 302s them to/auth/loginbefore any route handler runs. The only way in is the hook's internal rewrite for authenticated-but-not-admin requests. No new headers, no new endpoints, no new auth flows.BLOCKERS
None.
NITS
Hardcoded color hex in
+error.svelte—border: 1px solid #e5e7eband.body code { background: #f3f4f6 }are raw hex while the rest of the file uses--color-*tokens. If--color-borderand--color-code-bg(or similar) don't exist inapp.cssyet, this is a token-system gap rather than a dev-side miss. Worth filing a follow-up to extend the token palette so future components don't repeat the inline hex. Non-blocking.Magic numbers for type-size and border-radius —
0.75rem,0.875rem,1.75rem,0.5rem,0.375rem,0.25rem,0.125remappear inline. Same root cause:app.cssapparently has--space-*but not--font-size-*or--radius-*tokens. Not this PR's job to invent the token system. Non-blocking.No automated test on the route-group surface — The PR body explicitly acknowledges this ("the route layer has no unit-testable surface") and points to the existing 25/25 lib tests. Reasonable: throwing-error route loads are awkward to unit-test; the meaningful coverage is the post-merge manual AC walk-through (curl the path, inspect rendered HTML, click sign out, disable JS retest). Not a blocker per the QA agent's
feedback_qa_ci_blockersrule because this isn't a "test that won't run in CI" — it's a "no-test-warranted by design" call, and the lib-layer tests covering keycloak.ts (PR #18) already exercise the auth primitives.SOP COMPLIANCE
17-unauthorized-page— matches{issue-number}-{kebab-case-purpose}.## Summary,## Changes,## Test Plan,## Related Notes. Adds## Funnel-Auth Reviewand## Review Checklist(above-spec).npm run check(0/0),npm test(25/25),npm run buildsuccess, eslint and prettier clean. Pre-existing unrelated.woodpecker.yamlprettier warning noted as onmain, not this PR's responsibility..envfiles, or credentials committed.(unauthorized)/. Out-of-bounds files explicitly listed and untouched.feedback_funnel_requires_auth.feedback_no_tailwind.PROCESS OBSERVATIONS
@sigil semantics, the page-load-throw discards return value contract, whysetHeaderscan't set status) is captured in inline file-header comments. Future readers won't have to re-derive these from SvelteKit docs.westside-adminaccumulates more(unauthorized)-style groups, a follow-up issue to add Playwright coverage on the auth-state matrix (anon → 302, auth+role → 200, auth-no-role → 403) would be appropriate. Not for this PR.VERDICT: APPROVED