/auth/login + /auth/callback + /auth/logout endpoints: OIDC code exchange + SLO (consumes #14) #16

Closed
opened 2026-05-03 14:57:07 +00:00 by forgejo_admin · 1 comment

Type

Feature

Lineage

Decomposed from forgejo_admin/westside-admin#2 (Keycloak cookie SSR auth + admin role gate). Sub-task 3 of 4. Depends on forgejo_admin/westside-admin#14 (keycloak.ts lib) — must land first. Independent of sub-task 2 (hooks.server.ts).

Repo

forgejo_admin/westside-admin

User Story

story-westside-admin-admin-row-crud. These endpoints are the OIDC protocol surface — they're the only routes that interact directly with Keycloak's /protocol/openid-connect/{auth,token,logout} endpoints. They sit OUTSIDE the admin-role gate (the user must be allowed to log in before they have a role).

Context

/auth/login (GET) builds the Keycloak /authorize URL with PKCE + a fresh OIDC state (#14's generateOidcState), stores the signed state in a transient HttpOnly cookie, and 302s the browser there. The transient state cookie has Max-Age=600 (10-minute auth window) and Path=/auth/callback (only sent to the callback).

/auth/callback (GET) reads the transient state cookie, calls #14's verifyOidcState against the state query param before any token exchange (CSRF prevention), then POSTs the code to Keycloak's /protocol/openid-connect/token endpoint with grant_type=authorization_code + the PKCE code_verifier + confidential-client basic auth (KEYCLOAK_CLIENT_ID:KEYCLOAK_CLIENT_SECRET). On success, encrypts the tokens via #14's encryptCookiePayload, sets the session cookie, deletes the transient state cookie, redirects to the original redirect query param (or /).

/auth/logout (POST) clears the session cookie, then 302s the browser to Keycloak's /protocol/openid-connect/logout endpoint (RP-initiated logout / SLO) so the user is logged out of all realm clients in the same session.

File Targets

Create:

  • src/routes/auth/login/+server.ts — GET only, builds Keycloak /authorize URL, sets transient state cookie, 302.
  • src/routes/auth/callback/+server.ts — GET only, validates state THEN exchanges code, sets session cookie, 302 to original target.
  • src/routes/auth/logout/+server.ts — POST only, clears session cookie, 302 to Keycloak logout endpoint.

Do NOT touch:

  • src/lib/server/keycloak.ts (sub-task 1, frozen).
  • src/hooks.server.ts (sub-task 2 — the hook redirects HERE but doesn't define these endpoints).
  • src/routes/(unauthorized)/* (sub-task 4 — orthogonal page).

Acceptance Criteria

  • /auth/login: on GET, generates fresh state via #14, sets transient cookie westside_admin_state with HttpOnly; Secure; SameSite=Lax; Path=/auth/callback; Max-Age=600, 302s to Keycloak /authorize URL with client_id, redirect_uri=https://westside-admin.tail5b443a.ts.net/auth/callback, response_type=code, scope=openid profile email, state={state}, code_challenge_method=S256, code_challenge={pkce}.
  • /auth/callback: on GET with mismatched state, returns HTTP 400 with body state_mismatch. Token exchange must NOT have run.
  • /auth/callback: on GET with missing state cookie, returns HTTP 400 with body state_missing. (Replay/late-callback hardening.)
  • /auth/callback: on GET with valid state, POSTs to Keycloak /token, on success encrypts tokens via #14's encryptCookiePayload, sets westside_admin_session cookie (HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age={refresh_expiry}), clears the transient state cookie, 302 to redirect query param (or / if absent).
  • /auth/callback: Keycloak /token failure (4xx/5xx response, network error) returns HTTP 502 with a generic body — never echoes the Keycloak error body to the client.
  • /auth/logout: POST clears westside_admin_session (set with Max-Age=0), 302s to ${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/logout?post_logout_redirect_uri=https://westside-admin.tail5b443a.ts.net/&id_token_hint={id_token}.
  • /auth/logout: also accepts GET? No — POST only, with CSRF protection via SvelteKit form actions or explicit origin check.
  • No tokens, refresh tokens, cookie ciphertext, or Keycloak /token response bodies in any log line.

Test Expectations

  • Manual integration tests against real Keycloak (staging / keycloak.tail5b443a.ts.net):
    • Open /auth/login in browser → expect Keycloak login form. Confirm state and code_challenge query params present.
    • Complete Keycloak login → expect redirect back to /auth/callback?code=...&state=... → expect 302 to /. Confirm westside_admin_session cookie set with HttpOnly and Secure.
    • Tamper with state query param manually before submission → expect 400.
    • Hit /auth/logout POST → expect cookie cleared + 302 to Keycloak logout endpoint.
  • Run command: npm run dev + manual browser flow.
  • No vitest target for these endpoints (integration boundary).

Constraints

  • Use fetch for the token POST. Do NOT pull axios/got.
  • Use SvelteKit's event.cookies API exclusively.
  • PKCE code_verifier generation: use crypto.randomBytes(32).toString('base64url') inline in /auth/login. The src/lib/server/keycloak.ts library is frozen at the #14 merge — do NOT add a new export for PKCE generation. The code_challenge derives from the verifier via SHA-256 + base64url, also inline.
  • The PKCE code_verifier must be stored alongside the state in the transient cookie (so callback can retrieve it) — encrypt the whole {state, code_verifier} blob via #14's encryptCookiePayload for the transient cookie, OR sign + concat. Choose one and document the call in code.
  • Redirect URI param MUST be https://westside-admin.tail5b443a.ts.net/auth/callback (matches Keycloak client config — exact match enforced by Keycloak).
  • Logout post_logout_redirect_uri MUST be https://westside-admin.tail5b443a.ts.net/ (matches "Valid post-logout redirect URIs" in Keycloak client config).

Checklist

  • PR opened
  • Manual ACs verified
  • No tokens in logs (grep verified)
  • State validation runs BEFORE token exchange (verified by reading the callback code top-to-bottom)
  • PR body includes funnel-auth review per feedback_funnel_requires_auth
  • project-westside-admin
  • forgejo_admin/westside-admin#2 — parent
  • forgejo_admin/westside-admin#14 — keycloak.ts lib (DEPENDS ON)
  • sop-keycloak-client-creation — defines the redirect URIs and state requirement
  • feedback_funnel_requires_auth
### Type Feature ### Lineage Decomposed from `forgejo_admin/westside-admin#2` (Keycloak cookie SSR auth + admin role gate). Sub-task 3 of 4. Depends on `forgejo_admin/westside-admin#14` (keycloak.ts lib) — must land first. Independent of sub-task 2 (hooks.server.ts). ### Repo `forgejo_admin/westside-admin` ### User Story `story-westside-admin-admin-row-crud`. These endpoints are the OIDC protocol surface — they're the only routes that interact directly with Keycloak's `/protocol/openid-connect/{auth,token,logout}` endpoints. They sit OUTSIDE the admin-role gate (the user must be allowed to log in *before* they have a role). ### Context `/auth/login` (GET) builds the Keycloak `/authorize` URL with PKCE + a fresh OIDC `state` (#14's `generateOidcState`), stores the signed state in a transient HttpOnly cookie, and 302s the browser there. The transient state cookie has `Max-Age=600` (10-minute auth window) and `Path=/auth/callback` (only sent to the callback). `/auth/callback` (GET) reads the transient state cookie, calls #14's `verifyOidcState` against the `state` query param **before** any token exchange (CSRF prevention), then POSTs the `code` to Keycloak's `/protocol/openid-connect/token` endpoint with `grant_type=authorization_code` + the PKCE `code_verifier` + confidential-client basic auth (`KEYCLOAK_CLIENT_ID:KEYCLOAK_CLIENT_SECRET`). On success, encrypts the tokens via #14's `encryptCookiePayload`, sets the session cookie, deletes the transient state cookie, redirects to the original `redirect` query param (or `/`). `/auth/logout` (POST) clears the session cookie, then 302s the browser to Keycloak's `/protocol/openid-connect/logout` endpoint (RP-initiated logout / SLO) so the user is logged out of all realm clients in the same session. ### File Targets Create: - `src/routes/auth/login/+server.ts` — GET only, builds Keycloak `/authorize` URL, sets transient state cookie, 302. - `src/routes/auth/callback/+server.ts` — GET only, validates state THEN exchanges code, sets session cookie, 302 to original target. - `src/routes/auth/logout/+server.ts` — POST only, clears session cookie, 302 to Keycloak logout endpoint. Do NOT touch: - `src/lib/server/keycloak.ts` (sub-task 1, frozen). - `src/hooks.server.ts` (sub-task 2 — the hook redirects HERE but doesn't define these endpoints). - `src/routes/(unauthorized)/*` (sub-task 4 — orthogonal page). ### Acceptance Criteria - [ ] `/auth/login`: on GET, generates fresh state via #14, sets transient cookie `westside_admin_state` with `HttpOnly; Secure; SameSite=Lax; Path=/auth/callback; Max-Age=600`, 302s to Keycloak `/authorize` URL with `client_id`, `redirect_uri=https://westside-admin.tail5b443a.ts.net/auth/callback`, `response_type=code`, `scope=openid profile email`, `state={state}`, `code_challenge_method=S256`, `code_challenge={pkce}`. - [ ] `/auth/callback`: on GET with mismatched state, returns HTTP 400 with body `state_mismatch`. **Token exchange must NOT have run.** - [ ] `/auth/callback`: on GET with missing state cookie, returns HTTP 400 with body `state_missing`. (Replay/late-callback hardening.) - [ ] `/auth/callback`: on GET with valid state, POSTs to Keycloak `/token`, on success encrypts tokens via #14's `encryptCookiePayload`, sets `westside_admin_session` cookie (`HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age={refresh_expiry}`), clears the transient state cookie, 302 to `redirect` query param (or `/` if absent). - [ ] `/auth/callback`: Keycloak `/token` failure (4xx/5xx response, network error) returns HTTP 502 with a generic body — never echoes the Keycloak error body to the client. - [ ] `/auth/logout`: POST clears `westside_admin_session` (set with `Max-Age=0`), 302s to `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/logout?post_logout_redirect_uri=https://westside-admin.tail5b443a.ts.net/&id_token_hint={id_token}`. - [ ] `/auth/logout`: also accepts GET? **No** — POST only, with CSRF protection via SvelteKit form actions or explicit origin check. - [ ] No tokens, refresh tokens, cookie ciphertext, or Keycloak `/token` response bodies in any log line. ### Test Expectations - Manual integration tests against real Keycloak (staging / `keycloak.tail5b443a.ts.net`): - Open `/auth/login` in browser → expect Keycloak login form. Confirm `state` and `code_challenge` query params present. - Complete Keycloak login → expect redirect back to `/auth/callback?code=...&state=...` → expect 302 to `/`. Confirm `westside_admin_session` cookie set with `HttpOnly` and `Secure`. - Tamper with `state` query param manually before submission → expect 400. - Hit `/auth/logout` POST → expect cookie cleared + 302 to Keycloak logout endpoint. - Run command: `npm run dev` + manual browser flow. - No vitest target for these endpoints (integration boundary). ### Constraints - Use `fetch` for the token POST. Do NOT pull `axios`/`got`. - Use SvelteKit's `event.cookies` API exclusively. - **PKCE `code_verifier` generation:** use `crypto.randomBytes(32).toString('base64url')` inline in `/auth/login`. The `src/lib/server/keycloak.ts` library is **frozen** at the #14 merge — do NOT add a new export for PKCE generation. The `code_challenge` derives from the verifier via SHA-256 + base64url, also inline. - The PKCE `code_verifier` must be stored alongside the state in the transient cookie (so callback can retrieve it) — encrypt the whole `{state, code_verifier}` blob via #14's `encryptCookiePayload` for the transient cookie, OR sign + concat. Choose one and document the call in code. - Redirect URI param MUST be `https://westside-admin.tail5b443a.ts.net/auth/callback` (matches Keycloak client config — exact match enforced by Keycloak). - Logout post_logout_redirect_uri MUST be `https://westside-admin.tail5b443a.ts.net/` (matches "Valid post-logout redirect URIs" in Keycloak client config). ### Checklist - [ ] PR opened - [ ] Manual ACs verified - [ ] No tokens in logs (grep verified) - [ ] State validation runs BEFORE token exchange (verified by reading the callback code top-to-bottom) - [ ] PR body includes funnel-auth review per `feedback_funnel_requires_auth` ### Related - `project-westside-admin` - `forgejo_admin/westside-admin#2` — parent - `forgejo_admin/westside-admin#14` — keycloak.ts lib (DEPENDS ON) - `sop-keycloak-client-creation` — defines the redirect URIs and state requirement - `feedback_funnel_requires_auth`
Author
Owner

Scope Review: APPROVED

Review note: review-1136-2026-05-03

Scope is solid. All three primitives the body references (generateOidcState, verifyOidcState, encryptCookiePayload) exist on main after the #14/#18 merge. File targets verified absent (no collisions). Env vars landed via pal-e-deployments#147. Keycloak client config matches the body's exact redirect URIs. Within the 5-minute rule (3 thin RequestHandler files in one repo, 7 ACs that collapse to 3 endpoints + 4 callback branches).

Two minor [BODY] nits — non-blocking, fix in flight or trust the dev:

  • Title says "/auth/callback + /auth/logout" (2 endpoints) but body now adds /auth/login (3 endpoints). Recommend retitling the issue: "/auth/login + /auth/callback + /auth/logout endpoints: OIDC code exchange + SLO (consumes #14)". Board item title is already correct.
  • Constraints section assumes PKCE code_verifier is generated inline in +server.ts but never says so. One sentence ("PKCE generation is not exported from keycloak.ts; derive code_verifier + code_challenge inline via Node's crypto.randomBytes + base64url + SHA-256") prevents the dev from inventing a new export on the frozen lib.

One carry-forward [SCOPE] flag from review-1132-2026-05-03: arch-keycloak umbrella note still missing across the admin-row-crud chain. Not a per-ticket blocker; Ava holds the call on whether to backfill.

Cleared to advance to todo.

## Scope Review: APPROVED Review note: `review-1136-2026-05-03` Scope is solid. All three primitives the body references (`generateOidcState`, `verifyOidcState`, `encryptCookiePayload`) exist on `main` after the #14/#18 merge. File targets verified absent (no collisions). Env vars landed via `pal-e-deployments#147`. Keycloak client config matches the body's exact redirect URIs. Within the 5-minute rule (3 thin RequestHandler files in one repo, 7 ACs that collapse to 3 endpoints + 4 callback branches). Two minor `[BODY]` nits — non-blocking, fix in flight or trust the dev: - Title says "/auth/callback + /auth/logout" (2 endpoints) but body now adds /auth/login (3 endpoints). Recommend retitling the issue: "/auth/login + /auth/callback + /auth/logout endpoints: OIDC code exchange + SLO (consumes #14)". Board item title is already correct. - Constraints section assumes PKCE `code_verifier` is generated inline in `+server.ts` but never says so. One sentence ("PKCE generation is not exported from keycloak.ts; derive `code_verifier` + `code_challenge` inline via Node's `crypto.randomBytes` + base64url + SHA-256") prevents the dev from inventing a new export on the frozen lib. One carry-forward `[SCOPE]` flag from `review-1132-2026-05-03`: `arch-keycloak` umbrella note still missing across the admin-row-crud chain. Not a per-ticket blocker; Ava holds the call on whether to backfill. Cleared to advance to `todo`.
forgejo_admin changed title from /auth/callback + /auth/logout endpoints: OIDC code exchange + SLO (consumes #14) to /auth/login + /auth/callback + /auth/logout endpoints: OIDC code exchange + SLO (consumes #14) 2026-05-03 16:06:47 +00:00
Sign in to join this conversation.
No labels
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-admin#16
No description provided.