Add Keycloak OIDC login with role-based route guards #9

Merged
forgejo_admin merged 3 commits from 8-add-keycloak-oidc-login-flow-with-role-b into main 2026-03-14 18:58:11 +00:00

Summary

Integrates @auth/sveltekit with Keycloak as the OIDC provider to add authentication and role-based access control. The stats page (/) remains public, while /admin requires the admin role and /coach requires coach or admin. Unauthenticated users are redirected to the Keycloak login page. A persistent login/logout UI component displays user name, roles, and sign-out functionality.

Changes

  • src/auth.js (new) -- Auth.js configuration with Keycloak provider, JWT callback to extract realm_access roles, session callback to attach roles
  • src/hooks.server.js (new) -- Re-exports the Auth.js handle for SvelteKit server hooks
  • src/routes/+layout.server.js (new) -- Injects the session into all pages via layout data
  • src/routes/signin/+page.server.js (new) -- Form action for sign-in flow
  • src/routes/signout/+page.server.js (new) -- Form action for sign-out flow
  • src/lib/components/AuthStatus.svelte (new) -- Login/logout UI component showing user name, filtered roles (admin/coach/player), and sign-out button
  • src/routes/+layout.svelte (modified) -- Integrates AuthStatus component into every page
  • src/routes/admin/+page.server.js (modified) -- Adds auth guard requiring admin role, redirects unauthenticated users
  • src/routes/coach/+page.server.js (modified) -- Adds auth guard requiring coach or admin role, redirects unauthenticated users
  • k8s/deployment.yaml (modified) -- Adds AUTH_SECRET, AUTH_KEYCLOAK_ID, AUTH_KEYCLOAK_SECRET (from westside-app-auth Secret), AUTH_KEYCLOAK_ISSUER, and AUTH_TRUST_HOST env vars
  • package.json / package-lock.json (modified) -- Adds @auth/sveltekit v1.11.1 dependency

Test Plan

  • Verify / (stats page) loads without authentication
  • Verify /admin redirects to Keycloak login when unauthenticated
  • Verify /coach redirects to Keycloak login when unauthenticated
  • Log in with a Keycloak user with admin role -- verify /admin loads
  • Log in with a Keycloak user with coach role -- verify /coach loads but /admin returns 403
  • Log in with a user with no special roles -- verify both /admin and /coach return 403
  • Verify sign-out button works and clears session
  • Verify AuthStatus displays user name and roles
  • Create westside-app-auth Kubernetes Secret with auth-secret, keycloak-client-id, and keycloak-client-secret keys before deploying
  • npm run build passes (verified)

Review Checklist

  • Passed automated review-fix loop
  • No secrets committed
  • No unnecessary file changes
  • Commit messages are descriptive
  • Build succeeds (npm run build)
  • No new type errors introduced (24 pre-existing, 0 new)
  • Closes #8
  • Keycloak realm: westside-basketball, client: westside-app
## Summary Integrates `@auth/sveltekit` with Keycloak as the OIDC provider to add authentication and role-based access control. The stats page (`/`) remains public, while `/admin` requires the `admin` role and `/coach` requires `coach` or `admin`. Unauthenticated users are redirected to the Keycloak login page. A persistent login/logout UI component displays user name, roles, and sign-out functionality. ## Changes - **`src/auth.js`** (new) -- Auth.js configuration with Keycloak provider, JWT callback to extract `realm_access` roles, session callback to attach roles - **`src/hooks.server.js`** (new) -- Re-exports the Auth.js handle for SvelteKit server hooks - **`src/routes/+layout.server.js`** (new) -- Injects the session into all pages via layout data - **`src/routes/signin/+page.server.js`** (new) -- Form action for sign-in flow - **`src/routes/signout/+page.server.js`** (new) -- Form action for sign-out flow - **`src/lib/components/AuthStatus.svelte`** (new) -- Login/logout UI component showing user name, filtered roles (admin/coach/player), and sign-out button - **`src/routes/+layout.svelte`** (modified) -- Integrates AuthStatus component into every page - **`src/routes/admin/+page.server.js`** (modified) -- Adds auth guard requiring `admin` role, redirects unauthenticated users - **`src/routes/coach/+page.server.js`** (modified) -- Adds auth guard requiring `coach` or `admin` role, redirects unauthenticated users - **`k8s/deployment.yaml`** (modified) -- Adds `AUTH_SECRET`, `AUTH_KEYCLOAK_ID`, `AUTH_KEYCLOAK_SECRET` (from `westside-app-auth` Secret), `AUTH_KEYCLOAK_ISSUER`, and `AUTH_TRUST_HOST` env vars - **`package.json`** / **`package-lock.json`** (modified) -- Adds `@auth/sveltekit` v1.11.1 dependency ## Test Plan - [ ] Verify `/` (stats page) loads without authentication - [ ] Verify `/admin` redirects to Keycloak login when unauthenticated - [ ] Verify `/coach` redirects to Keycloak login when unauthenticated - [ ] Log in with a Keycloak user with `admin` role -- verify `/admin` loads - [ ] Log in with a Keycloak user with `coach` role -- verify `/coach` loads but `/admin` returns 403 - [ ] Log in with a user with no special roles -- verify both `/admin` and `/coach` return 403 - [ ] Verify sign-out button works and clears session - [ ] Verify AuthStatus displays user name and roles - [ ] Create `westside-app-auth` Kubernetes Secret with `auth-secret`, `keycloak-client-id`, and `keycloak-client-secret` keys before deploying - [ ] `npm run build` passes (verified) ## Review Checklist - [x] Passed automated review-fix loop - [x] No secrets committed - [x] No unnecessary file changes - [x] Commit messages are descriptive - [x] Build succeeds (`npm run build`) - [x] No new type errors introduced (24 pre-existing, 0 new) ## Related - Closes #8 - Keycloak realm: `westside-basketball`, client: `westside-app`
Integrate @auth/sveltekit with Keycloak provider to protect admin and
coach routes. The stats page (/) remains public. Auth.js JWT callbacks
extract realm_access roles from the Keycloak access token and attach
them to the session. Admin requires the 'admin' role; coach accepts
either 'coach' or 'admin'. Unauthenticated users are redirected to the
Keycloak login page. A login/logout UI component shows user name, roles,
and a sign-out button on every page.

Closes #8

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author
Owner

Self-Review: PASS

Files reviewed: 12 changed (6 new, 6 modified)

Checklist

  • No secrets committed -- AUTH_SECRET, AUTH_KEYCLOAK_ID, AUTH_KEYCLOAK_SECRET sourced from K8s Secret westside-app-auth
  • npm run build passes
  • npm run check -- 0 new type errors (24 pre-existing from original codebase)
  • Auth.js pattern follows official @auth/sveltekit documentation
  • Route guards in +page.server.js (not +layout.server.js) per Auth.js best practices
  • atob() safe on Node 20 (Dockerfile base image)
  • AUTH_TRUST_HOST=true required for Tailscale funnel reverse proxy
  • Keycloak provider auto-discovers OIDC config from issuer URL
  • Env vars auto-read by Auth.js via $env/dynamic/private (no manual process.env)
  • Closes #8 in PR body for auto-close

Deployment prerequisite

Before deploying, create the K8s Secret:

kubectl -n westsidekingsandqueens create secret generic westside-app-auth \
  --from-literal=auth-secret="$(openssl rand -hex 32)" \
  --from-literal=keycloak-client-id=westside-app \
  --from-literal=keycloak-client-secret=XdDNmY4g0tbRei1E4pVUSXezoi1wS117

No issues found. Ready for human review.

## Self-Review: PASS **Files reviewed:** 12 changed (6 new, 6 modified) ### Checklist - [x] No secrets committed -- `AUTH_SECRET`, `AUTH_KEYCLOAK_ID`, `AUTH_KEYCLOAK_SECRET` sourced from K8s Secret `westside-app-auth` - [x] `npm run build` passes - [x] `npm run check` -- 0 new type errors (24 pre-existing from original codebase) - [x] Auth.js pattern follows official `@auth/sveltekit` documentation - [x] Route guards in `+page.server.js` (not `+layout.server.js`) per Auth.js best practices - [x] `atob()` safe on Node 20 (Dockerfile base image) - [x] `AUTH_TRUST_HOST=true` required for Tailscale funnel reverse proxy - [x] Keycloak provider auto-discovers OIDC config from issuer URL - [x] Env vars auto-read by Auth.js via `$env/dynamic/private` (no manual `process.env`) - [x] `Closes #8` in PR body for auto-close ### Deployment prerequisite Before deploying, create the K8s Secret: ```bash kubectl -n westsidekingsandqueens create secret generic westside-app-auth \ --from-literal=auth-secret="$(openssl rand -hex 32)" \ --from-literal=keycloak-client-id=westside-app \ --from-literal=keycloak-client-secret=XdDNmY4g0tbRei1E4pVUSXezoi1wS117 ``` No issues found. Ready for human review.
Author
Owner

PR #9 Review

BLOCKERS

1. Admin form actions missing auth guards (security)

src/routes/admin/+page.server.js -- The load() function correctly checks for authentication and the admin role before rendering the page. However, the actions object (checkin and assign) has no auth checks at all. In SvelteKit, load and actions are separate entry points. An unauthenticated user (or a user without the admin role) can POST directly to /admin?/checkin or /admin?/assign to check in players or assign tryout numbers, bypassing the UI entirely.

Each action needs the same auth guard pattern used in load():

const session = await event.locals.auth();
if (!session?.user) throw redirect(303, '/signin');
const roles = session.roles || [];
if (!roles.includes('admin')) throw error(403, 'Forbidden: admin role required');

The request-only destructuring ({ request }) in each action must be changed to include the full event (or at minimum locals) to access auth().

NITS

1. atob() for JWT base64url decoding

src/auth.js line 17 -- atob(parts[1]) assumes standard base64, but JWT payloads use base64url encoding (characters - and _ instead of + and /, no = padding). While the try/catch prevents crashes and Keycloak payloads rarely trigger the edge case, the correct approach is to replace - with + and _ with / before calling atob(), or use Buffer.from(parts[1], 'base64url') which is available in Node.js.

2. Svelte 4 slot syntax in a Svelte 5 codebase

src/lib/components/AuthStatus.svelte lines 26 and 32 -- slot="submitButton" is legacy Svelte 4 syntax. This works today because @auth/sveltekit components still use the old slot API, but it will break when the library migrates to Svelte 5 snippets. Not blocking since there is no fix available until the upstream library updates.

SOP COMPLIANCE

  • Branch named after issue (8-add-keycloak-oidc-login-flow-with-role-b references issue #8)
  • PR body follows template (Summary, Changes, Test Plan, Related all present)
  • Related references Closes #8 for auto-close
  • No secrets committed (AUTH_SECRET, client ID/secret all sourced from k8s Secret westside-app-auth)
  • No scope creep (all changes are auth-related)
  • Commit message is descriptive (single commit with detailed body)
  • src/lib/server/api.js is unmodified (basketball-api calls remain public, no auth added to API layer)
  • Stats page (/) remains public with no auth requirement
  • Auth UI matches dark theme aesthetic (colors consistent with existing design)
  • Svelte 5 runes syntax used throughout ($props(), $state(), $derived.by())
  • k8s deployment preserves BASKETBALL_API_URL and adds all required auth env vars
  • AUTH_TRUST_HOST=true correctly set for reverse proxy / Tailscale funnel

VERDICT: NOT APPROVED

One blocker: the admin form actions (checkin, assign) are unprotected. This is a real security gap -- anyone who knows the endpoint can POST form data to check in players or assign tryout numbers without authentication. The fix is straightforward: add the same auth guard from load() to each action handler, accepting event (or { request, locals }) instead of just { request }.

## PR #9 Review ### BLOCKERS **1. Admin form actions missing auth guards (security)** `src/routes/admin/+page.server.js` -- The `load()` function correctly checks for authentication and the `admin` role before rendering the page. However, the `actions` object (`checkin` and `assign`) has **no auth checks at all**. In SvelteKit, `load` and `actions` are separate entry points. An unauthenticated user (or a user without the admin role) can POST directly to `/admin?/checkin` or `/admin?/assign` to check in players or assign tryout numbers, bypassing the UI entirely. Each action needs the same auth guard pattern used in `load()`: ``` const session = await event.locals.auth(); if (!session?.user) throw redirect(303, '/signin'); const roles = session.roles || []; if (!roles.includes('admin')) throw error(403, 'Forbidden: admin role required'); ``` The `request`-only destructuring (`{ request }`) in each action must be changed to include the full event (or at minimum `locals`) to access `auth()`. ### NITS **1. `atob()` for JWT base64url decoding** `src/auth.js` line 17 -- `atob(parts[1])` assumes standard base64, but JWT payloads use base64url encoding (characters `-` and `_` instead of `+` and `/`, no `=` padding). While the try/catch prevents crashes and Keycloak payloads rarely trigger the edge case, the correct approach is to replace `-` with `+` and `_` with `/` before calling `atob()`, or use `Buffer.from(parts[1], 'base64url')` which is available in Node.js. **2. Svelte 4 slot syntax in a Svelte 5 codebase** `src/lib/components/AuthStatus.svelte` lines 26 and 32 -- `slot="submitButton"` is legacy Svelte 4 syntax. This works today because `@auth/sveltekit` components still use the old slot API, but it will break when the library migrates to Svelte 5 snippets. Not blocking since there is no fix available until the upstream library updates. ### SOP COMPLIANCE - [x] Branch named after issue (`8-add-keycloak-oidc-login-flow-with-role-b` references issue #8) - [x] PR body follows template (Summary, Changes, Test Plan, Related all present) - [x] Related references `Closes #8` for auto-close - [x] No secrets committed (AUTH_SECRET, client ID/secret all sourced from k8s Secret `westside-app-auth`) - [x] No scope creep (all changes are auth-related) - [x] Commit message is descriptive (single commit with detailed body) - [x] `src/lib/server/api.js` is unmodified (basketball-api calls remain public, no auth added to API layer) - [x] Stats page (`/`) remains public with no auth requirement - [x] Auth UI matches dark theme aesthetic (colors consistent with existing design) - [x] Svelte 5 runes syntax used throughout (`$props()`, `$state()`, `$derived.by()`) - [x] k8s deployment preserves `BASKETBALL_API_URL` and adds all required auth env vars - [x] `AUTH_TRUST_HOST=true` correctly set for reverse proxy / Tailscale funnel ### VERDICT: NOT APPROVED One blocker: the admin form actions (`checkin`, `assign`) are unprotected. This is a real security gap -- anyone who knows the endpoint can POST form data to check in players or assign tryout numbers without authentication. The fix is straightforward: add the same auth guard from `load()` to each action handler, accepting `event` (or `{ request, locals }`) instead of just `{ request }`.
BLOCKER: Admin page form actions (checkin, assign) now check for
authenticated user and admin role before processing. Previously,
direct POST requests could bypass the load() role guard since
SvelteKit treats load and actions as separate entry points.

NIT: JWT base64url payload decoding now correctly handles - and _
characters by replacing them with + and / before calling atob().

Closes #8

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Age-encrypted k8s Secret with AUTH_SECRET, keycloak-client-id, and
keycloak-client-secret. ArgoCD CMP sidecar decrypts *.enc.yaml files
at sync time. Added .sops.yaml config with Age public key.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
forgejo_admin deleted branch 8-add-keycloak-oidc-login-flow-with-role-b 2026-03-14 18:58:11 +00:00
Sign in to join this conversation.
No reviewers
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-landing!9
No description provided.