Add Keycloak OIDC login flow with role-based route protection #8

Closed
opened 2026-03-14 16:30:42 +00:00 by forgejo_admin · 0 comments

Lineage

plan-2026-03-08-tryout-prep → Phase 5 → Phase 5d (westside-app login)

Repo

forgejo_admin/westside-app

User Story

As a coach or admin user
I want to log in to the westside-app via Keycloak
So that I can access role-protected dashboards (admin/coach) while the stats page remains public

Context

Keycloak IdP is deployed at keycloak.tail5b443a.ts.net with realm westside-basketball. An OIDC client westside-app is configured as a confidential client with:

  • Client ID: westside-app
  • Valid redirect URIs: https://westsidekingsandqueens.tail5b443a.ts.net/*
  • Standard flow: ON, Direct access grants: ON
  • Roles configured: player, coach, admin

The app currently has NO auth — admin and coach pages are completely unprotected. This issue adds Keycloak OIDC authentication using Auth.js (@auth/sveltekit).

Client secret (for env var, not to commit): XdDNmY4g0tbRei1E4pVUSXezoi1wS117

File Targets

Files to create:

  • src/hooks.server.js — SvelteKit server hook with Auth.js handle
  • src/routes/auth/[...auth]/+server.js — Auth.js catch-all API route (NOTE: Auth.js for SvelteKit may use hooks only — check @auth/sveltekit docs and use whichever pattern is current)
  • src/lib/components/AuthStatus.svelte — login/logout UI component

Files to modify:

  • package.json — add @auth/sveltekit dependency (use npm install)
  • src/routes/+layout.server.jsCREATE: root layout server load to inject session
  • src/routes/+layout.svelte — add AuthStatus component to nav area
  • src/routes/admin/+page.server.js — add role guard (require admin role, redirect to login)
  • src/routes/coach/+page.server.js — add role guard (require admin or coach role, redirect to login)
  • src/routes/+page.server.js — stays public, no auth required (do NOT break this)
  • k8s/deployment.yaml — add AUTH_SECRET, AUTH_KEYCLOAK_ID, AUTH_KEYCLOAK_SECRET, AUTH_KEYCLOAK_ISSUER, AUTH_TRUST_HOST env vars

Files NOT to touch:

  • src/lib/server/api.js — API helper doesn't need auth headers (basketball-api public endpoints are used)
  • svelte.config.js — adapter config is fine as-is

Auth.js Integration Pattern

// src/hooks.server.js
import { SvelteKitAuth } from '@auth/sveltekit';
import Keycloak from '@auth/sveltekit/providers/keycloak';

export const { handle, signIn, signOut } = SvelteKitAuth({
  providers: [
    Keycloak({
      clientId: process.env.AUTH_KEYCLOAK_ID,
      clientSecret: process.env.AUTH_KEYCLOAK_SECRET,
      issuer: process.env.AUTH_KEYCLOAK_ISSUER,
    }),
  ],
  callbacks: {
    async jwt({ token, account, profile }) {
      // On initial sign-in, extract realm roles from the access token
      if (account) {
        token.roles = profile?.realm_access?.roles || [];
      }
      return token;
    },
    async session({ session, token }) {
      session.user.roles = token.roles || [];
      return session;
    },
  },
  trustHost: true,
});

Route Protection Pattern

// src/routes/admin/+page.server.js (add to existing load function)
import { redirect } from '@sveltejs/kit';

export async function load({ locals, ...rest }) {
  const session = await locals.auth();
  if (!session?.user) {
    throw redirect(302, '/auth/signin');
  }
  if (!session.user.roles?.includes('admin')) {
    throw redirect(302, '/?error=unauthorized');
  }
  // ... existing load logic (fetch roster data)
}

Login/Logout UI

Add a simple auth status to the layout:

  • When unauthenticated: "Sign In" button
  • When authenticated: show user name + role badge + "Sign Out" button
  • Minimal styling — match existing app aesthetic (dark theme, red accents)

Acceptance Criteria

  • Unauthenticated users can view / (stats page) without login
  • Unauthenticated users visiting /admin are redirected to Keycloak login
  • Unauthenticated users visiting /coach are redirected to Keycloak login
  • After Keycloak login, user is redirected back to the requested page
  • Admin user sees /admin page with full functionality
  • Coach user sees /coach page but is redirected away from /admin
  • Login/logout UI appears in the layout
  • k8s deployment has correct env vars for Keycloak OIDC

Test Expectations

  • Manual test: navigate to /admin → redirected to Keycloak → login → see admin page
  • Manual test: navigate to /coach → redirected to Keycloak → login → see coach page
  • Manual test: navigate to / → see stats without login
  • Note: SvelteKit + Auth.js integration testing is primarily manual. No unit test framework is set up for this repo.

Constraints

  • Use @auth/sveltekit (the official Auth.js SvelteKit adapter), NOT a custom OAuth implementation
  • The Keycloak provider import path may vary by Auth.js version — check the @auth/sveltekit package docs
  • AUTH_SECRET must be a random 32+ char string for session encryption (generate one, add to k8s secret)
  • AUTH_TRUST_HOST=true is required because we're behind a Tailscale funnel reverse proxy
  • The existing BASKETBALL_API_URL env var must be preserved — the app still fetches roster data from basketball-api
  • Do NOT add auth to the basketball-api fetch calls in src/lib/server/api.js — those endpoints are public
  • The app uses Svelte 5 (runes syntax) — use {#if} blocks, not $: reactive statements

Checklist

  • PR opened
  • Tests pass (at minimum: npm run build succeeds)
  • No unrelated changes
  • plan-2026-03-08-tryout-prep — parent plan
  • Phase 5b (Keycloak realm config) — completed
  • Phase 5c (basketball-api OIDC) — parallel work, separate repo
### Lineage `plan-2026-03-08-tryout-prep` → Phase 5 → Phase 5d (westside-app login) ### Repo `forgejo_admin/westside-app` ### User Story As a coach or admin user I want to log in to the westside-app via Keycloak So that I can access role-protected dashboards (admin/coach) while the stats page remains public ### Context Keycloak IdP is deployed at `keycloak.tail5b443a.ts.net` with realm `westside-basketball`. An OIDC client `westside-app` is configured as a confidential client with: - Client ID: `westside-app` - Valid redirect URIs: `https://westsidekingsandqueens.tail5b443a.ts.net/*` - Standard flow: ON, Direct access grants: ON - Roles configured: `player`, `coach`, `admin` The app currently has NO auth — admin and coach pages are completely unprotected. This issue adds Keycloak OIDC authentication using Auth.js (`@auth/sveltekit`). **Client secret** (for env var, not to commit): `XdDNmY4g0tbRei1E4pVUSXezoi1wS117` ### File Targets Files to create: - `src/hooks.server.js` — SvelteKit server hook with Auth.js handle - `src/routes/auth/[...auth]/+server.js` — Auth.js catch-all API route (NOTE: Auth.js for SvelteKit may use hooks only — check `@auth/sveltekit` docs and use whichever pattern is current) - `src/lib/components/AuthStatus.svelte` — login/logout UI component Files to modify: - `package.json` — add `@auth/sveltekit` dependency (use `npm install`) - `src/routes/+layout.server.js` — **CREATE**: root layout server load to inject session - `src/routes/+layout.svelte` — add AuthStatus component to nav area - `src/routes/admin/+page.server.js` — add role guard (require `admin` role, redirect to login) - `src/routes/coach/+page.server.js` — add role guard (require `admin` or `coach` role, redirect to login) - `src/routes/+page.server.js` — stays public, no auth required (do NOT break this) - `k8s/deployment.yaml` — add `AUTH_SECRET`, `AUTH_KEYCLOAK_ID`, `AUTH_KEYCLOAK_SECRET`, `AUTH_KEYCLOAK_ISSUER`, `AUTH_TRUST_HOST` env vars Files NOT to touch: - `src/lib/server/api.js` — API helper doesn't need auth headers (basketball-api public endpoints are used) - `svelte.config.js` — adapter config is fine as-is ### Auth.js Integration Pattern ```javascript // src/hooks.server.js import { SvelteKitAuth } from '@auth/sveltekit'; import Keycloak from '@auth/sveltekit/providers/keycloak'; export const { handle, signIn, signOut } = SvelteKitAuth({ providers: [ Keycloak({ clientId: process.env.AUTH_KEYCLOAK_ID, clientSecret: process.env.AUTH_KEYCLOAK_SECRET, issuer: process.env.AUTH_KEYCLOAK_ISSUER, }), ], callbacks: { async jwt({ token, account, profile }) { // On initial sign-in, extract realm roles from the access token if (account) { token.roles = profile?.realm_access?.roles || []; } return token; }, async session({ session, token }) { session.user.roles = token.roles || []; return session; }, }, trustHost: true, }); ``` ### Route Protection Pattern ```javascript // src/routes/admin/+page.server.js (add to existing load function) import { redirect } from '@sveltejs/kit'; export async function load({ locals, ...rest }) { const session = await locals.auth(); if (!session?.user) { throw redirect(302, '/auth/signin'); } if (!session.user.roles?.includes('admin')) { throw redirect(302, '/?error=unauthorized'); } // ... existing load logic (fetch roster data) } ``` ### Login/Logout UI Add a simple auth status to the layout: - When unauthenticated: "Sign In" button - When authenticated: show user name + role badge + "Sign Out" button - Minimal styling — match existing app aesthetic (dark theme, red accents) ### Acceptance Criteria - [ ] Unauthenticated users can view `/` (stats page) without login - [ ] Unauthenticated users visiting `/admin` are redirected to Keycloak login - [ ] Unauthenticated users visiting `/coach` are redirected to Keycloak login - [ ] After Keycloak login, user is redirected back to the requested page - [ ] Admin user sees `/admin` page with full functionality - [ ] Coach user sees `/coach` page but is redirected away from `/admin` - [ ] Login/logout UI appears in the layout - [ ] k8s deployment has correct env vars for Keycloak OIDC ### Test Expectations - [ ] Manual test: navigate to `/admin` → redirected to Keycloak → login → see admin page - [ ] Manual test: navigate to `/coach` → redirected to Keycloak → login → see coach page - [ ] Manual test: navigate to `/` → see stats without login - Note: SvelteKit + Auth.js integration testing is primarily manual. No unit test framework is set up for this repo. ### Constraints - Use `@auth/sveltekit` (the official Auth.js SvelteKit adapter), NOT a custom OAuth implementation - The Keycloak provider import path may vary by Auth.js version — check the `@auth/sveltekit` package docs - `AUTH_SECRET` must be a random 32+ char string for session encryption (generate one, add to k8s secret) - `AUTH_TRUST_HOST=true` is required because we're behind a Tailscale funnel reverse proxy - The existing `BASKETBALL_API_URL` env var must be preserved — the app still fetches roster data from basketball-api - Do NOT add auth to the basketball-api fetch calls in `src/lib/server/api.js` — those endpoints are public - The app uses Svelte 5 (runes syntax) — use `{#if}` blocks, not `$:` reactive statements ### Checklist - [ ] PR opened - [ ] Tests pass (at minimum: `npm run build` succeeds) - [ ] No unrelated changes ### Related - `plan-2026-03-08-tryout-prep` — parent plan - Phase 5b (Keycloak realm config) — completed - Phase 5c (basketball-api OIDC) — parallel work, separate repo
Sign in to join this conversation.
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#8
No description provided.