Scrubbed extract of
ADP-Orchestration-UI/Epic-1-Stories/US-OUI-1.5a-Keycloak-OIDC-Client.md— cited by the Chronicle (03-the-platform-rises). Redactions: phone / service detail / secrets / clearance / personal / internal hosts.
type: implementation-plan category: Work project: ADP-Orchestration-UI priority: High status: done created: 2026-06-15 completed: 2026-06-17 merge_sha: 7355d03 pr: “homelab/ADP-Orchestration-UI#11” epic: Epic-1-Foundation user-story: US-OUI-1.5a effort: 6h review-profile: standard parallel_group: W3-Keycloak-Identity wave: W3 lane: C repo: “homelab/ADP-Orchestration-UI” branch: feat/us-oui-1.5-keycloak-oidc-client tags:
- implementation-plan
- w3
- identity
- oidc
- pkce
- keycloak-js
US-OUI-1.5a: Real Keycloak OIDC public client (keycloak-js, PKCE)
Epic: [[README|Epic 1: Foundation]] Wave: [[../../AI-Dev-Platform/Trackers/Wave-3-Keycloak-Identity|W3]] · Lane C (consumer) Governing ADR: [[../../AI-Dev-Platform/ADR/ADR-018-Identity-Federation-Strategy|ADR-018]] · roles [[../../AI-Dev-Platform/ADR/ADR-013-Permission-And-Role-Model|ADR-013]]
Mission
Replace the src/services/auth.ts localStorage mock with a real keycloak-js Authorization-Code + PKCE (S256) public client against the live adp realm (oui-web, https://internal host/realms/adp). The decoded access token’s realm_access.roles flows into AuthSession.roles so the already-built PermissionContext / RoleGate / useHasRole / useHasSurface (US-OUI-1.6 — done on main) gate on real roles instead of stubRolesForIdentity. Real token storage + refresh; src/router/auth.tsx validates real tokens.
Phase-0 reconciliation (do not redo): the permission layer (src/auth/*) and router auth guard (src/router/auth.tsx) already exist + are tested on main. This lane does not rebuild RoleGate/PermissionContext — it swaps the data source feeding them from mock to real OIDC. Keep the AuthSession interface shape (subject, displayName, channel, grant, issuedAt, expiresAt, roles, projectRoles) so downstream consumers are untouched.
Context Bootstrap
- [[../../AI-Dev-Platform/ADR/ADR-018-Identity-Federation-Strategy|ADR-018]] §“Grant types per channel” (Auth-Code+PKCE for credentials/SSO) + the live realm facts: issuer
https://internal host/realms/adp, public clientoui-web, PKCE S256, redirect URIshttps://internal host/*+http://localhost:5173/*, web-origins set, tokenaudincludesais-api. - The current mock + its consumers — read in the worktree before changing:
src/services/auth.ts(the mock you replace:loginWithCredentials/Sso/ApiKey/DeviceCode,getToken/getSession/isAuthenticated/logout,stubRolesForIdentity,AuthSession).src/auth/PermissionContext.tsx+src/auth/roles.ts+src/auth/useHasRole.ts/useHasSurface.ts— trace HOW roles reach PermissionContext today (it currently derives from the auth session’sroles); your real token must feed the same path.src/router/auth.tsx(the route guard that callsisAuthenticated()).src/views/LoginView.tsx(the tab strip that calls the login functions).
Gate Policy
| Trigger | Gate | Condition |
|---|---|---|
Changing the AuthSession interface shape or the src/auth/* permission API |
Trust Gate | US-OUI-1.6 (PermissionContext/RoleGate) is done on main and consumed widely. Feed it real roles via the existing shape; do not refactor it. |
Implicit flow / non-PKCE, or pkce not S256 |
Trust Gate | oui-web is public + PKCE-only per ADR-018; implicit is forbidden. |
Issuer/realm/clientId diverging from https://internal host/realms/adp / oui-web |
Trust Gate | Cross-lane contract with the live Lane A realm. |
| Adding a client secret to the SPA | Trust Gate | oui-web is a PUBLIC client — there is no secret in the browser. |
npm run build / npm run test / npm run lint failing at Verify |
Trust Gate | Do not mark complete red. |
Deliverables
- Add
keycloak-jstopackage.json. -
src/services/keycloak.ts(new): a singleton Keycloak instance configured{ url: <VITE_KEYCLOAK_URL or https://internal host>, realm: 'adp', clientId: 'oui-web' },init({ onLoad: 'check-sso', pkceMethod: 'S256', silentCheckSsoRedirectUri }), plus token-refresh wiring (updateToken). Config viaimport.meta.env(VITE_KEYCLOAK_URL/VITE_KEYCLOAK_REALM/VITE_KEYCLOAK_CLIENT_ID) with the home-lab values as defaults;.env.development/.env.productiontemplates updated. - Rewrite
src/services/auth.tsto drive real flows offkeycloak.ts:loginWithCredentials/loginWithSso→keycloak.login({ … })(Auth Code + PKCE redirect; SSO viaidpHint— see deferral). The page redirects to Keycloak’s login form and back.- Build
AuthSessionfrom the decoded real token:subject = token.sub,displayName = token.preferred_username ?? token.name,roles = token.realm_access?.roles ?? [],expiresAtfromtoken.exp,issuedAtfromtoken.iat. KeepprojectRoles: [](server-side, forward-compat). getToken()→keycloak.token;getSession()→ built fromkeycloak.tokenParsed;isAuthenticated()→keycloak.authenticated && !keycloak.isTokenExpired();logout()→keycloak.logout().- Preserve the exported types (
AuthSession,AuthChannel,AuthGrant,AuthError, etc.) so imports don’t break. RemovestubRolesForIdentity/generateToken/ the localStorage token mock.
-
src/router/auth.tsx: the guard validates real Keycloak auth (and triggerskeycloak.login()/ redirect on unauthenticated protected routes). -
LoginView.tsx: the Credentials tab and SSO tab initiate the realkeycloak.login()redirect. Discord/GitHub SSO buttons are DEFERRED — render them disabled with a “configured when IdP brokers land” affordance (do NOT callidpHint: 'discord'|'github'— those brokers don’t exist yet and would error). Device-code + API-key tabs may keep their current stubbed behavior for v0.1.0 (note it); the credentials/SSO PKCE path is the gate. - Tests (Vitest): mock
keycloak-js; assertloginWithCredentials/getSessionbuild anAuthSessionwhoserolescome from a fake token’srealm_access.roles;isAuthenticatedreflects the mocked keycloak state;logoutdelegates tokeycloak.logout. A test provingPermissionContext/RoleGateshows/hides correctly when fed real-token roles (extend or mirror the existingsrc/auth/__tests__/permission-context.test.tsxpattern). Update any existingauth.tsmock-based tests to the new surface.
Execution Phases (TDD-ordered)
- Branch — work only in the assigned worktree on
feat/us-oui-1.5-keycloak-oidc-clientofforigin/main. - Load Context — read the bootstrap files; trace the roles→PermissionContext path. (Plan checkpoint.)
- RED — author/adjust the Vitest tests for the real-OIDC
auth.tssurface so they fail. Separatetest(auth): real keycloak-js oidc client (red)commit. (Test checkpoint.) - GREEN — add
keycloak-js+keycloak.ts, rewriteauth.ts, wirerouter/auth.tsx+LoginView.tsx. Minimum to green. - Verify & Refactor —
npm run build,npm run test,npm run lintall green (zero warnings). Update status toreview. - PR — push; one PR to
mainonhomelab/ADP-Orchestration-UI. Hand-author the PR body.
Definition of Done
-
keycloak-jsPKCE (S256) Auth-Code flow againstoui-webreplaces the mock; no client secret in the SPA. -
AuthSession.rolespopulated from the real token’srealm_access.roles; existingRoleGate/PermissionContextconsume them unchanged (shape preserved). - Discord/GitHub SSO buttons disabled/deferred; credentials + SSO-redirect path works.
-
npm run build/test/lintgreen. - PR merged to
main. - (W3 gate)
adp-ownercredentials round-trip through Keycloak PKCE and the dashboard renders role-gated surfaces — verified at W3 close against the deployed OUI + live Keycloak.
Related
- [[../../AI-Dev-Platform/Trackers/Wave-3-Keycloak-Identity|W3 tracker]] · [[../../HomeLab-Infrastructure/Epic-Identity-Provider/IP-IDP-1-Keycloak-Postgres-Stack|Lane A]] (issuer, live) · [[../../AI-Service-Implementation/Epic-Identity/IP-AIS-IDENTITY-1-JWT-Resource-Server|Lane B]] (resource server, merged)
- [[../../AI-Dev-Platform/ADR/ADR-018-Identity-Federation-Strategy|ADR-018]] · [[../../AI-Dev-Platform/ADR/ADR-013-Permission-And-Role-Model|ADR-013]]