feat: Keycloak OIDC auth -- protect write ops #24

Merged
forgejo_admin merged 2 commits from 22-feat-keycloak-oidc-auth-protect-write-op into main 2026-03-15 01:31:08 +00:00

Summary

  • Add Keycloak OIDC authentication via Auth.js (@auth/sveltekit)
  • All read operations remain public; write operations (Quick-Jot note creation) require authentication
  • Sign-out clears the Keycloak SSO session to prevent auto-re-authentication

Changes

  • src/auth.ts: Auth.js config with Keycloak provider, JWT role extraction from access token, and redirect callback that intercepts signout to route through Keycloak logout endpoint
  • src/hooks.server.ts: Wire Auth.js handle into SvelteKit server hooks
  • src/routes/+layout.server.ts: Pass session to layout data alongside existing projects
  • src/routes/+layout.svelte: Auth-aware navbar (user name + sign out when authenticated, sign in button when not); hide Quick-Jot FAB and n keyboard shortcut for unauthenticated users
  • src/routes/api/notes/+server.ts: Gate POST endpoint behind authenticated session check, return 401 if not authenticated
  • src/routes/signin/+page.svelte + +page.server.ts: Sign-in page with Keycloak redirect; already-authenticated users are redirected to home
  • src/routes/signout/+page.svelte + +page.server.ts: Sign-out page with hidden redirectTo input that triggers Keycloak logout endpoint to clear SSO session
  • k8s/deployment.yaml: Add envFrom for pal-e-auth-secrets secret and AUTH_TRUST_HOST: "true" env var
  • k8s/kustomization.yaml: Include pal-e-auth-secrets.enc.yaml in resources for ArgoCD SOPS CMP decryption
  • package.json: Add @auth/sveltekit dependency

Test Plan

  • npm run build passes
  • npm run lint passes
  • npm run check passes with 0 errors
  • Unauthenticated users can browse all read-only pages (notes, projects, boards, etc.)
  • Unauthenticated users see "Sign in" button in navbar, no Quick-Jot FAB
  • Clicking "Sign in" redirects to Keycloak login page
  • After Keycloak login, user is redirected back with name displayed in navbar
  • Authenticated users see Quick-Jot FAB and can create notes
  • POST to /api/notes without session returns 401
  • Clicking "Sign out" clears both Auth.js session AND Keycloak SSO session
  • After signout, re-visiting /signin shows Keycloak login (not auto-re-authenticated)

Review Checklist

  • Passed automated review-fix loop
  • No secrets committed
  • No unnecessary file changes
  • Commit messages are descriptive
  • Closes #22
  • Prerequisite: commit 8a9ed86 (SOPS-encrypted pal-e-auth-secrets.enc.yaml)
  • Reference: westside-app Auth.js + Keycloak implementation
## Summary - Add Keycloak OIDC authentication via Auth.js (@auth/sveltekit) - All read operations remain public; write operations (Quick-Jot note creation) require authentication - Sign-out clears the Keycloak SSO session to prevent auto-re-authentication ## Changes - `src/auth.ts`: Auth.js config with Keycloak provider, JWT role extraction from access token, and redirect callback that intercepts signout to route through Keycloak logout endpoint - `src/hooks.server.ts`: Wire Auth.js handle into SvelteKit server hooks - `src/routes/+layout.server.ts`: Pass `session` to layout data alongside existing `projects` - `src/routes/+layout.svelte`: Auth-aware navbar (user name + sign out when authenticated, sign in button when not); hide Quick-Jot FAB and `n` keyboard shortcut for unauthenticated users - `src/routes/api/notes/+server.ts`: Gate POST endpoint behind authenticated session check, return 401 if not authenticated - `src/routes/signin/+page.svelte` + `+page.server.ts`: Sign-in page with Keycloak redirect; already-authenticated users are redirected to home - `src/routes/signout/+page.svelte` + `+page.server.ts`: Sign-out page with hidden `redirectTo` input that triggers Keycloak logout endpoint to clear SSO session - `k8s/deployment.yaml`: Add `envFrom` for `pal-e-auth-secrets` secret and `AUTH_TRUST_HOST: "true"` env var - `k8s/kustomization.yaml`: Include `pal-e-auth-secrets.enc.yaml` in resources for ArgoCD SOPS CMP decryption - `package.json`: Add `@auth/sveltekit` dependency ## Test Plan - [x] `npm run build` passes - [x] `npm run lint` passes - [x] `npm run check` passes with 0 errors - [ ] Unauthenticated users can browse all read-only pages (notes, projects, boards, etc.) - [ ] Unauthenticated users see "Sign in" button in navbar, no Quick-Jot FAB - [ ] Clicking "Sign in" redirects to Keycloak login page - [ ] After Keycloak login, user is redirected back with name displayed in navbar - [ ] Authenticated users see Quick-Jot FAB and can create notes - [ ] POST to `/api/notes` without session returns 401 - [ ] Clicking "Sign out" clears both Auth.js session AND Keycloak SSO session - [ ] After signout, re-visiting `/signin` shows Keycloak login (not auto-re-authenticated) ## Review Checklist - [x] Passed automated review-fix loop - [x] No secrets committed - [x] No unnecessary file changes - [x] Commit messages are descriptive ## Related - Closes #22 - Prerequisite: commit `8a9ed86` (SOPS-encrypted `pal-e-auth-secrets.enc.yaml`) - Reference: westside-app Auth.js + Keycloak implementation
feat: add Keycloak OIDC authentication via Auth.js
Some checks failed
ci/woodpecker/pr/woodpecker Pipeline failed
c751c26b3e
Wire up @auth/sveltekit with Keycloak provider to protect write operations.
Reading remains public; note creation (Quick-Jot FAB + API) requires login.

- src/auth.ts: Auth.js config with Keycloak provider, JWT role extraction,
  and redirect callback that routes signout to Keycloak logout endpoint
  to clear SSO session
- src/hooks.server.ts: Wire Auth.js handle into SvelteKit hooks
- src/routes/+layout.server.ts: Pass session to layout alongside projects
- src/routes/+layout.svelte: Show user name + sign out when authenticated,
  sign in button when not; hide Quick-Jot FAB for unauthenticated users
- src/routes/api/notes/+server.ts: Gate POST behind session check (401)
- src/routes/signin/: Sign-in page with Keycloak redirect
- src/routes/signout/: Sign-out page that clears Keycloak SSO via
  redirect to Keycloak logout endpoint
- k8s/deployment.yaml: Add envFrom for pal-e-auth-secrets + AUTH_TRUST_HOST
- k8s/kustomization.yaml: Include pal-e-auth-secrets.enc.yaml resource

Closes #22

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fix: remove stale playwright entries from package.json
All checks were successful
ci/woodpecker/pr/woodpecker Pipeline was successful
3329d66e90
Playwright devDependency and test scripts leaked from a stashed branch.
This PR is auth-only -- playwright belongs in issue #23.

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

PR #24 Review

feat: Keycloak OIDC auth -- protect write ops
Branch: 22-feat-keycloak-oidc-auth-protect-write-op -> main


BLOCKERS

None.


NITS

  1. .env.example not updated with auth env vars. Local development requires AUTH_SECRET, AUTH_KEYCLOAK_ID, AUTH_KEYCLOAK_SECRET, and AUTH_KEYCLOAK_ISSUER but .env.example still only documents PAL_E_DOCS_API_URL. Developers cloning the repo will not know what auth environment variables to set. Add placeholder entries to .env.example.

  2. Hardcoded Tailscale URLs in src/auth.ts. KEYCLOAK_LOGOUT_URL, APP_URL, and CLIENT_ID are string constants rather than environment variables. This works for the current single-environment deployment, but makes local development impossible without code changes. Consider reading these from env vars with the current values as fallback defaults. Low priority given the tailnet-only deployment model.

  3. @ts-expect-error in session callback. The session.roles assignment uses @ts-expect-error instead of extending the Auth.js Session type via a src/app.d.ts declaration. This works but is fragile -- a future Auth.js upgrade that adds a roles field would silently change behavior. Consider adding a proper type augmentation in src/app.d.ts:

    declare module '@auth/sveltekit' {
      interface Session { roles: string[] }
    }
    
  4. token.accessToken stored but never used. In the JWT callback, token.accessToken = account.access_token is set but never referenced outside that callback. The roles are extracted immediately from the decoded payload. This adds the full access token to the JWT cookie unnecessarily, increasing cookie size. If roles are the only thing needed, remove the token.accessToken assignment.

  5. PR body says "Closes #22" but also mentions "Closes #20". The issue checklist in #22 says PR opened with "Closes #20" which appears to be a copy-paste artifact from the issue template. The PR body itself correctly references Closes #22. No action needed, just noting the issue-side discrepancy.


ACCEPTANCE CRITERIA VERIFICATION

# Criterion Status Evidence
1 Unauthenticated users can browse notes, search, view dashboard, view boards PASS No auth checks added to any read routes. Only POST /api/notes is gated. Layout passes session to UI but does not redirect unauthenticated users.
2 Unauthenticated users CANNOT see the Quick-Jot FAB PASS {#if isAuthenticated && !quickJotOpen} guards the FAB. Keyboard shortcut n also gated by isAuthenticated.
3 Unauthenticated users who POST to /api/notes get 401 PASS +server.ts checks locals.auth() and throws error(401, 'Authentication required') before any body parsing.
4 Clicking "Sign in" redirects to Keycloak login, then back to pal-e-app PASS signin/+page.server.ts exports actions: { default: signIn } which triggers Auth.js Keycloak OIDC flow. Already-authenticated users get 303 / redirect.
5 Authenticated users see their name in nav + "Sign out" link PASS Layout shows {session?.user?.name ?? 'User'} and a styled "Sign out" link when isAuthenticated.
6 Authenticated users see the FAB and can create notes PASS FAB visibility and n shortcut both controlled by isAuthenticated derived state.
7 Sign out clears session AND Keycloak SSO PASS Signout form sends hidden redirectTo=__keycloak_logout__. The redirect callback in auth.ts intercepts this sentinel value and constructs the Keycloak logout URL with post_logout_redirect_uri and client_id. This matches the proven westside-app pattern.
8 Session cookie is HttpOnly, Secure, SameSite=Lax PASS Auth.js defaults enforce HttpOnly, Secure (when trustHost: true over HTTPS), and SameSite=Lax. No overrides that would weaken these defaults.

ADDITIONAL CHECKS

Check Status Evidence
Auth.js config matches westside-app pattern PASS Same structure: Keycloak provider, JWT callback for role extraction, session callback for role attachment, redirect callback for Keycloak logout. pal-e-app uses TypeScript vs westside-app's JavaScript, but logic is identical.
SOPS secret properly referenced in deployment.yaml PASS envFrom: - secretRef: name: pal-e-auth-secrets correctly references the encrypted secret.
kustomization.yaml includes encrypted secret PASS pal-e-auth-secrets.enc.yaml added to resources list.
hooks.server.ts wires Auth.js handle correctly PASS Clean re-export: export const handle = authHandle.
401 gate checks session properly PASS await locals.auth() then if (!session?.user) is the correct Auth.js pattern.
No secrets leaked in code PASS All secrets in SOPS-encrypted YAML (ENC[AES256_GCM,...]). Age recipient key matches known public key age15ct78fr4scv.... No plaintext credentials anywhere.
Sign-out hits Keycloak logout URL with post_logout_redirect_uri PASS Redirect callback builds {KEYCLOAK_LOGOUT_URL}?post_logout_redirect_uri={APP_URL}&client_id={CLIENT_ID}.
FAB visibility controlled by session state PASS $derived(!!session?.user) -> isAuthenticated gates both FAB and keyboard shortcut.
Dark theme consistency PASS Sign-in and sign-out pages use the same bg-[#0e0e18], border-[#1a1a2e], text-gray-200/500 palette. Sign-in button uses #e94560 accent per constraint.
No unnecessary file changes (scope creep) PASS 13 files changed, all directly related to auth. No modifications to search, dashboard, boards, or other read routes.

SOP COMPLIANCE

  • Branch named after issue (22-feat-keycloak-oidc-auth-protect-write-op references issue #22)
  • PR body has: Summary, Changes, Test Plan, Related
  • Related section references Closes #22
  • Related section references the plan slug -- PR body does not include plan-pal-e-docs or phase-pal-e-docs-frontend-auth (the issue does reference the lineage, but the PR body omits it)
  • No secrets, .env files, or credentials committed (SOPS-encrypted only)
  • No unnecessary file changes
  • Commit messages are descriptive

VERDICT: APPROVED

Clean, well-scoped implementation that follows the proven westside-app Auth.js + Keycloak pattern. All 8 acceptance criteria are met. SOPS encryption is properly configured. No secrets exposed. The nits above (.env.example, hardcoded URLs, @ts-expect-error, unused token.accessToken, missing plan slug in PR body) are all non-blocking improvements that can be tracked separately.

## PR #24 Review **feat: Keycloak OIDC auth -- protect write ops** Branch: `22-feat-keycloak-oidc-auth-protect-write-op` -> `main` --- ### BLOCKERS None. --- ### NITS 1. **`.env.example` not updated with auth env vars.** Local development requires `AUTH_SECRET`, `AUTH_KEYCLOAK_ID`, `AUTH_KEYCLOAK_SECRET`, and `AUTH_KEYCLOAK_ISSUER` but `.env.example` still only documents `PAL_E_DOCS_API_URL`. Developers cloning the repo will not know what auth environment variables to set. Add placeholder entries to `.env.example`. 2. **Hardcoded Tailscale URLs in `src/auth.ts`.** `KEYCLOAK_LOGOUT_URL`, `APP_URL`, and `CLIENT_ID` are string constants rather than environment variables. This works for the current single-environment deployment, but makes local development impossible without code changes. Consider reading these from env vars with the current values as fallback defaults. Low priority given the tailnet-only deployment model. 3. **`@ts-expect-error` in session callback.** The `session.roles` assignment uses `@ts-expect-error` instead of extending the Auth.js `Session` type via a `src/app.d.ts` declaration. This works but is fragile -- a future Auth.js upgrade that adds a `roles` field would silently change behavior. Consider adding a proper type augmentation in `src/app.d.ts`: ```ts declare module '@auth/sveltekit' { interface Session { roles: string[] } } ``` 4. **`token.accessToken` stored but never used.** In the JWT callback, `token.accessToken = account.access_token` is set but never referenced outside that callback. The roles are extracted immediately from the decoded payload. This adds the full access token to the JWT cookie unnecessarily, increasing cookie size. If roles are the only thing needed, remove the `token.accessToken` assignment. 5. **PR body says "Closes #22" but also mentions "Closes #20".** The issue checklist in #22 says `PR opened with "Closes #20"` which appears to be a copy-paste artifact from the issue template. The PR body itself correctly references `Closes #22`. No action needed, just noting the issue-side discrepancy. --- ### ACCEPTANCE CRITERIA VERIFICATION | # | Criterion | Status | Evidence | |---|-----------|--------|----------| | 1 | Unauthenticated users can browse notes, search, view dashboard, view boards | PASS | No auth checks added to any read routes. Only `POST /api/notes` is gated. Layout passes session to UI but does not redirect unauthenticated users. | | 2 | Unauthenticated users CANNOT see the Quick-Jot FAB | PASS | `{#if isAuthenticated && !quickJotOpen}` guards the FAB. Keyboard shortcut `n` also gated by `isAuthenticated`. | | 3 | Unauthenticated users who POST to /api/notes get 401 | PASS | `+server.ts` checks `locals.auth()` and throws `error(401, 'Authentication required')` before any body parsing. | | 4 | Clicking "Sign in" redirects to Keycloak login, then back to pal-e-app | PASS | `signin/+page.server.ts` exports `actions: { default: signIn }` which triggers Auth.js Keycloak OIDC flow. Already-authenticated users get `303 /` redirect. | | 5 | Authenticated users see their name in nav + "Sign out" link | PASS | Layout shows `{session?.user?.name ?? 'User'}` and a styled "Sign out" link when `isAuthenticated`. | | 6 | Authenticated users see the FAB and can create notes | PASS | FAB visibility and `n` shortcut both controlled by `isAuthenticated` derived state. | | 7 | Sign out clears session AND Keycloak SSO | PASS | Signout form sends hidden `redirectTo=__keycloak_logout__`. The `redirect` callback in `auth.ts` intercepts this sentinel value and constructs the Keycloak logout URL with `post_logout_redirect_uri` and `client_id`. This matches the proven westside-app pattern. | | 8 | Session cookie is HttpOnly, Secure, SameSite=Lax | PASS | Auth.js defaults enforce HttpOnly, Secure (when `trustHost: true` over HTTPS), and SameSite=Lax. No overrides that would weaken these defaults. | ### ADDITIONAL CHECKS | Check | Status | Evidence | |-------|--------|----------| | Auth.js config matches westside-app pattern | PASS | Same structure: Keycloak provider, JWT callback for role extraction, session callback for role attachment, redirect callback for Keycloak logout. pal-e-app uses TypeScript vs westside-app's JavaScript, but logic is identical. | | SOPS secret properly referenced in deployment.yaml | PASS | `envFrom: - secretRef: name: pal-e-auth-secrets` correctly references the encrypted secret. | | kustomization.yaml includes encrypted secret | PASS | `pal-e-auth-secrets.enc.yaml` added to resources list. | | hooks.server.ts wires Auth.js handle correctly | PASS | Clean re-export: `export const handle = authHandle`. | | 401 gate checks session properly | PASS | `await locals.auth()` then `if (!session?.user)` is the correct Auth.js pattern. | | No secrets leaked in code | PASS | All secrets in SOPS-encrypted YAML (`ENC[AES256_GCM,...]`). Age recipient key matches known public key `age15ct78fr4scv...`. No plaintext credentials anywhere. | | Sign-out hits Keycloak logout URL with post_logout_redirect_uri | PASS | Redirect callback builds `{KEYCLOAK_LOGOUT_URL}?post_logout_redirect_uri={APP_URL}&client_id={CLIENT_ID}`. | | FAB visibility controlled by session state | PASS | `$derived(!!session?.user)` -> `isAuthenticated` gates both FAB and keyboard shortcut. | | Dark theme consistency | PASS | Sign-in and sign-out pages use the same `bg-[#0e0e18]`, `border-[#1a1a2e]`, `text-gray-200/500` palette. Sign-in button uses `#e94560` accent per constraint. | | No unnecessary file changes (scope creep) | PASS | 13 files changed, all directly related to auth. No modifications to search, dashboard, boards, or other read routes. | --- ### SOP COMPLIANCE - [x] Branch named after issue (`22-feat-keycloak-oidc-auth-protect-write-op` references issue #22) - [x] PR body has: Summary, Changes, Test Plan, Related - [x] Related section references `Closes #22` - [ ] Related section references the plan slug -- PR body does not include `plan-pal-e-docs` or `phase-pal-e-docs-frontend-auth` (the issue does reference the lineage, but the PR body omits it) - [x] No secrets, .env files, or credentials committed (SOPS-encrypted only) - [x] No unnecessary file changes - [x] Commit messages are descriptive --- ### VERDICT: APPROVED Clean, well-scoped implementation that follows the proven westside-app Auth.js + Keycloak pattern. All 8 acceptance criteria are met. SOPS encryption is properly configured. No secrets exposed. The nits above (`.env.example`, hardcoded URLs, `@ts-expect-error`, unused `token.accessToken`, missing plan slug in PR body) are all non-blocking improvements that can be tracked separately.
forgejo_admin deleted branch 22-feat-keycloak-oidc-auth-protect-write-op 2026-03-15 01:31:08 +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/pal-e-docs-app!24
No description provided.