From 121d2f9fce93163c8428e0523be4c2e075fbb360 Mon Sep 17 00:00:00 2001 From: Philip Meier Date: Sun, 12 Jul 2026 13:56:29 +0000 Subject: [PATCH 1/6] add design document --- DESIGN.md | 257 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 DESIGN.md diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..6eed80b --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,257 @@ +# Design: Generic OIDC Auth for the Frontend + +## Summary + +Replace the hardcoded `keycloak-js` dependency in the frontend with `oidc-spa`, a library that implements the same OIDC flow abstracted over any OpenID Connect provider. The runtime config file changes from a Keycloak-specific schema (`auth-server-url` + `realm`) to a provider-agnostic schema (`issuer` + `client_id`). The public API surface of `src/auth/index.ts` stays the same. The backend already works with any OIDC issuer via Ravnar's `OIDCTokenValidator` and needs only a config schema change. + +## Goals + +- Frontend authenticates against **any OIDC-compliant identity provider** (Keycloak, Auth0, Okta, Azure AD, Google, etc.). +- All existing callers of `@/auth` — `fetch()`, `login()`, `logout()`, `getUserProfile()` — continue to work without changes. +- The `VITE_AUTH_ENABLED=false` dev bypass continues to work. +- The Helm chart generates generic OIDC config instead of Keycloak-specific JSON. +- The backend authenticator receives a generic OIDC issuer URL instead of `keycloak_url` + `realm`. + +## Non-Goals + +- Server-side auth logic changes. Ravnar's `OIDCTokenValidator` already accepts any OIDC issuer URL; only the config schema that feeds it changes. +- Gateway-level auth. The NebariApp operator still handles ingress authentication and OIDC client provisioning; this design only touches the SPA-side auth. +- Runtime multi-provider switching. The config file declares one issuer per deployment. + +## Background / Motivation + +The Nebari Chat Pack frontend uses `keycloak-js` directly, which ties it to Keycloak as the identity provider. The runtime config (`public/keycloak-config.json`) uses Keycloak-specific fields (`auth-server-url`, `realm`, `resource`). The backend authenticator (`ravnar_nebari_chat.keycloak_authenticator`) is a thin factory around Ravnar's generic `OIDCTokenValidator` — it already works with any OIDC provider, but it's exposed through a Keycloak-named helper. + +The Nebari default deployment uses Keycloak, and the gateway-level auth (NebariApp operator) will continue to reference Keycloak. This design only decouples the frontend SPA authentication from the Keycloak SDK, so deployments using a different IdP can swap it out by changing a config file. + +## Design + +### 1. Library: `oidc-spa` + +Replace `keycloak-js` with [`oidc-spa`](https://docs.oidc-spa.dev/) (npm `oidc-spa`). + +**Why `oidc-spa` over alternatives:** + +| Library | Why it fits | +|---------|-------------| +| `oidc-client-ts` | Mature (2M weekly downloads), but framework-agnostic — we'd need to wire React context and TanStack Router integration ourselves. | +| `oidc-spa` | From the Keycloakify team (Keycloak experts). **First-class TanStack Router integration** (used by this project). Explicitly designed as a `keycloak-js` drop-in that works with any OIDC provider. Smaller API surface. | + +The project already uses TanStack Router, so `oidc-spa`'s native integration avoids boilerplate. The migration from `keycloak-js` is the library's primary use case. + +### 2. Runtime Config: `public/auth-config.json` + +Replace `public/keycloak-config.json` with `public/auth-config.json`. + +```json +{ + "issuer": "https://keycloak.example.com/realms/nebari", + "client_id": "nebari-chat-dev-spa" +} +``` + +**Schema:** + +| Key | Required | Description | +|-----|----------|-------------| +| `issuer` | Yes | Full OIDC issuer URL (e.g. `https://keycloak.example.com/realms/nebari`) | +| `client_id` | Yes | Public SPA client ID registered with the IdP | + +**Removed fields compared to old format:** `auth-server-url`, `realm`, `resource` — all superseded by the flat `issuer` URL. + +**Not in config (hardcoded in JS):** + +| Setting | Value | Rationale | +|---------|-------|-----------| +| `redirect_uri` | `window.location.origin` | Runtime-computed; matches how the current code passes it to keycloak.login/logout. | +| `scope` | `"openid email name"` | User explicitly chose to hardcode this. | + +### 3. `src/auth/index.ts` Changes + +The module's public API stays the same: + +```ts +export class FetchError extends Error { … } +export async function fetch(url, init?): Promise { … } +export async function login(): Promise { … } +export async function logout(): Promise { … } +export function getUserProfile(): UserProfile | null { … } +export type UserProfile = { name: string; email: string }; +``` + +**Internal changes:** + +1. **Remove `import Keycloak from 'keycloak-js'`** — replace with oidc-spa initialization. +2. **Config loading** — `fetch('/auth-config.json')` at module init instead of `new Keycloak('/keycloak-config.json')`. +3. **Initialization** — `createOidc()` from oidc-spa, wrapping the config fetch. +4. **`fetch()`** — reads the access token from the oidc-spa session instead of `keycloak.token`. +5. **`login()`** — calls `oidc.login({ redirectUri: window.location.origin })` instead of `keycloak.login()`. +6. **`logout()`** — calls `oidc.logout({ redirectUri: window.location.origin })` instead of `keycloak.logout()`. +7. **`getUserProfile()`** — reads `name` and `email` from the decoded ID token claims via oidc-spa's `getUser()` or decoded token accessor. + +The top-level `await` pattern (currently `await keycloak.init()`) stays. The `VITE_AUTH_ENABLED` guard keeps the same behavior: + +```ts +// Pseudocode of the new init flow +const AUTH_ENABLED = import.meta.env.VITE_AUTH_ENABLED === 'true'; + +let oidc: Oidc | null = null; + +if (AUTH_ENABLED) { + const configResp = await fetch('/auth-config.json'); + const config = await configResp.json(); + oidc = await createOidc({ + issuerUrl: config.issuer, + clientId: config.client_id, + redirectUri: window.location.origin, + }); +} +``` + +### 4. Caller Changes + +Zero changes to callers. All four consumption sites reference `@/auth` purely through the exported functions: + +| File | Uses | Impact | +|------|------|--------| +| `src/routes/_authenticated.tsx` | `auth.login()` | None — same function signature. | +| `src/routes/logout.tsx` | `auth.logout()` | None — same function signature. | +| `src/sidebar/userprofile.tsx` | `auth.getUserProfile()` | None — same return type. | +| `src/api/*.ts` | `auth.fetch()` | None — same function signature. | + +The `FetchError` class is also unchanged. + +### 5. Backend Config Changes + +The backend's `config.yaml` (used by Ravnar) currently references Keycloak-specific values: + +```yaml +# Current (helm/nebari-chat/config.yaml) +security: + authenticator: + cls_or_fn: ravnar_nebari_chat.keycloak_authenticator + params: + keycloak_url: '{{ required "keycloak.url is required" .Values.keycloak.url }}' + realm: "{{ .Values.keycloak.realm }}" +``` + +Change to pass the OIDC issuer URL directly: + +```yaml +# New +security: + authenticator: + cls_or_fn: ravnar_nebari_chat.oidc_authenticator + params: + issuer: '{{ required "auth.issuer is required" .Values.auth.issuer }}' +``` + +And in the backend source, rename the factory and have it accept an issuer URL directly: + +```python +# src/ravnar_nebari_chat/_authenticators.py +def oidc_authenticator(*, issuer: str) -> BearerTokenAuthenticator: + return BearerTokenAuthenticator( + OIDCTokenValidator( + issuer=issuer, + default_permissions=list(ALL_PERMISSIONS), + ) + ) +``` + +This is purely a rename and simplifcation — the `OIDCTokenValidator` already accepts any OIDC issuer. The Keycloak-specific factory was just concatenating `url + /realms/{realm}` to produce the issuer URL. + +### 6. Helm Chart Changes + +**`values.yaml` — replace `keycloak` block with `auth` block:** + +```yaml +# Removed +keycloak: + url: "" + realm: "nebari" + clientId: "" + +# Added +auth: + # The full OIDC issuer URL, e.g. https://keycloak.example.com/realms/nebari + issuer: "" + # The SPA client ID. If not set, defaults to a generated value matching the current scheme. + clientId: "" +``` + +**`templates/frontend-configmap.yaml` — generate `auth-config.json` instead of `keycloak-config.json`:** + +```yaml +data: + auth-config.json: | + {{ + dict + "issuer" (required "auth.issuer is required" .Values.auth.issuer) + "client_id" (.Values.auth.clientId | default (printf "%s-%s-spa" .Release.Namespace ...)) + | toPrettyJson | nindent 4 + }} +``` + +**`templates/frontend-deployment.yaml` — update volume mount path:** + +``` +mountPath: /usr/share/nginx/html/auth-config.json +subPath: auth-config.json +``` + +The mount uses `subPath` so only the single file is mounted into the nginx static directory. + +**`config.yaml` — update backend config reference:** + +```yaml +security: + authenticator: + cls_or_fn: ravnar_nebari_chat.oidc_authenticator + params: + issuer: '{{ required "auth.issuer is required" .Values.auth.issuer }}' +``` + +### 7. `.env` / Dev Experience + +No changes. `VITE_AUTH_ENABLED=false` continues to bypass auth entirely. When developing against a real IdP, the developer creates a local `public/auth-config.json` (added to `.gitignore` like `.env`). + +## Tradeoffs & Risks + +| Risk | Mitigation | +|------|------------| +| **oidc-spa is newer** than `oidc-client-ts`. While actively maintained (Keycloakify team), it has a smaller userbase. | The surface area we use is narrow (init, login, logout, get token, parse ID token). If oidc-spa has issues, switching to `oidc-client-ts` is a confined change within `src/auth/index.ts`. | +| **Breaking config format change.** Existing deployments upgrading the chart must migrate from `keycloak.*` values to `auth.*` values. | Document the migration in the release notes. The error message from the `required` directive makes the fix obvious. No backward-compat shim — the user confirmed BC can break. | +| **`window.location.origin` as `redirect_uri`** may not cover all deployment topologies (e.g., app behind a reverse proxy with a different external origin). | The NebariApp operator already handles this via the `hostname` field. The SPA always runs at `window.location.origin`. If a future deployment needs a different redirect URI, it can be added to the config file. | +| **Scope hardcoded to `openid email name`.** If a deployment needs extra scopes (e.g., for group claims), the config file would need a `scope` field. | Add when needed — changing the hardcoded default to a configurable value is a one-line change. | + +## Testing Strategy + +### Dev-mode bypass + +- `VITE_AUTH_ENABLED=false` — the auth module skips OIDC init entirely, `fetch()` adds no bearer header, `login()`/`logout()` are no-ops. Existing behavior preserved. + +### Unit / self-check + +- The auth module has no existing tests. Add a minimal assertion-based `__main__` self-check in `src/auth/index.ts` that validates `public/auth-config.json` can be loaded and parsed, and that an `issuer` URL looks like a URL (starts with `https://`). This catches config issues at import time rather than at login. + +### Manual smoke test (Keycloak) + +1. Deploy with the new chart values (`auth.issuer`, `auth.clientId`). +2. Visit the app — should redirect to Keycloak login. +3. Log in — should return to the app with user profile shown in sidebar. +4. Log out — should redirect to Keycloak logout and return to the app's login page. +5. Refresh the page while authenticated — session persists. + +### Manual smoke test (alternative OIDC provider) + +1. Deploy with the same chart but point `auth.issuer` at a non-Keycloak OIDC provider (e.g., Auth0 or Okta dev tenant). +2. Repeat steps 2–5 above. + +### Browser storage + +- Verify that no `keycloak-*` keys remain in `localStorage` or `sessionStorage` after the migration. `oidc-spa` uses `oidc-spa:*` prefixed keys — the old keys are orphaned but harmless. + +## Open Questions + +*(None — all decisions resolved during design.)* From b3910c45c15763d34a2629aeeb0f324f5a1d2d1b Mon Sep 17 00:00:00 2001 From: Philip Meier Date: Sun, 12 Jul 2026 13:58:34 +0000 Subject: [PATCH 2/6] remove backend changes from design, frontend only --- DESIGN.md | 74 +++++++++---------------------------------------------- 1 file changed, 12 insertions(+), 62 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 6eed80b..215f50b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -2,7 +2,7 @@ ## Summary -Replace the hardcoded `keycloak-js` dependency in the frontend with `oidc-spa`, a library that implements the same OIDC flow abstracted over any OpenID Connect provider. The runtime config file changes from a Keycloak-specific schema (`auth-server-url` + `realm`) to a provider-agnostic schema (`issuer` + `client_id`). The public API surface of `src/auth/index.ts` stays the same. The backend already works with any OIDC issuer via Ravnar's `OIDCTokenValidator` and needs only a config schema change. +Replace the hardcoded `keycloak-js` dependency in the frontend with `oidc-spa`, a library that implements the same OIDC flow abstracted over any OpenID Connect provider. The runtime config file changes from a Keycloak-specific schema (`auth-server-url` + `realm`) to a provider-agnostic schema (`issuer` + `client_id`). The public API surface of `src/auth/index.ts` stays the same. The backend already works with any OIDC issuer via Ravnar's `OIDCTokenValidator` and is untouched by this design. ## Goals @@ -10,17 +10,17 @@ Replace the hardcoded `keycloak-js` dependency in the frontend with `oidc-spa`, - All existing callers of `@/auth` — `fetch()`, `login()`, `logout()`, `getUserProfile()` — continue to work without changes. - The `VITE_AUTH_ENABLED=false` dev bypass continues to work. - The Helm chart generates generic OIDC config instead of Keycloak-specific JSON. -- The backend authenticator receives a generic OIDC issuer URL instead of `keycloak_url` + `realm`. +- **No backend changes** — the backend's `keycloak_authenticator` helper stays as-is; it already works with any OIDC issuer. ## Non-Goals -- Server-side auth logic changes. Ravnar's `OIDCTokenValidator` already accepts any OIDC issuer URL; only the config schema that feeds it changes. +- **Backend changes.** The backend's `keycloak_authenticator` is already OIDC-generic underneath (`OIDCTokenValidator` accepts any issuer URL). It stays completely untouched — it's just a convenience helper that concatenates `keycloak_url` + `realm` into an issuer URL. No backend source, config, deployment, or Helm template changes in this design. - Gateway-level auth. The NebariApp operator still handles ingress authentication and OIDC client provisioning; this design only touches the SPA-side auth. - Runtime multi-provider switching. The config file declares one issuer per deployment. ## Background / Motivation -The Nebari Chat Pack frontend uses `keycloak-js` directly, which ties it to Keycloak as the identity provider. The runtime config (`public/keycloak-config.json`) uses Keycloak-specific fields (`auth-server-url`, `realm`, `resource`). The backend authenticator (`ravnar_nebari_chat.keycloak_authenticator`) is a thin factory around Ravnar's generic `OIDCTokenValidator` — it already works with any OIDC provider, but it's exposed through a Keycloak-named helper. +The Nebari Chat Pack frontend uses `keycloak-js` directly, which ties it to Keycloak as the identity provider. The runtime config (`public/keycloak-config.json`) uses Keycloak-specific fields (`auth-server-url`, `realm`, `resource`). The backend is already generic — `ravnar_nebari_chat.keycloak_authenticator` is just a convenience wrapper that feeds `keycloak_url` + `realm` into Ravnar's `OIDCTokenValidator`, which accepts any OIDC issuer URL. Any deployment already points the backend at the correct issuer by overriding the Keycloak URL. The Nebari default deployment uses Keycloak, and the gateway-level auth (NebariApp operator) will continue to reference Keycloak. This design only decouples the frontend SPA authentication from the Keycloak SDK, so deployments using a different IdP can swap it out by changing a config file. @@ -121,58 +121,18 @@ Zero changes to callers. All four consumption sites reference `@/auth` purely th The `FetchError` class is also unchanged. -### 5. Backend Config Changes +### 5. Helm Chart Changes (frontend only) -The backend's `config.yaml` (used by Ravnar) currently references Keycloak-specific values: +**`values.yaml` — add `auth` block for frontend config. The existing `keycloak` block is untouched (backend still uses it).** ```yaml -# Current (helm/nebari-chat/config.yaml) -security: - authenticator: - cls_or_fn: ravnar_nebari_chat.keycloak_authenticator - params: - keycloak_url: '{{ required "keycloak.url is required" .Values.keycloak.url }}' - realm: "{{ .Values.keycloak.realm }}" -``` - -Change to pass the OIDC issuer URL directly: - -```yaml -# New -security: - authenticator: - cls_or_fn: ravnar_nebari_chat.oidc_authenticator - params: - issuer: '{{ required "auth.issuer is required" .Values.auth.issuer }}' -``` - -And in the backend source, rename the factory and have it accept an issuer URL directly: - -```python -# src/ravnar_nebari_chat/_authenticators.py -def oidc_authenticator(*, issuer: str) -> BearerTokenAuthenticator: - return BearerTokenAuthenticator( - OIDCTokenValidator( - issuer=issuer, - default_permissions=list(ALL_PERMISSIONS), - ) - ) -``` - -This is purely a rename and simplifcation — the `OIDCTokenValidator` already accepts any OIDC issuer. The Keycloak-specific factory was just concatenating `url + /realms/{realm}` to produce the issuer URL. - -### 6. Helm Chart Changes - -**`values.yaml` — replace `keycloak` block with `auth` block:** - -```yaml -# Removed +# Existing (unchanged — backend still references .Values.keycloak.url) keycloak: url: "" realm: "nebari" clientId: "" -# Added +# Added (frontend only) auth: # The full OIDC issuer URL, e.g. https://keycloak.example.com/realms/nebari issuer: "" @@ -202,17 +162,7 @@ subPath: auth-config.json The mount uses `subPath` so only the single file is mounted into the nginx static directory. -**`config.yaml` — update backend config reference:** - -```yaml -security: - authenticator: - cls_or_fn: ravnar_nebari_chat.oidc_authenticator - params: - issuer: '{{ required "auth.issuer is required" .Values.auth.issuer }}' -``` - -### 7. `.env` / Dev Experience +### 6. `.env` / Dev Experience No changes. `VITE_AUTH_ENABLED=false` continues to bypass auth entirely. When developing against a real IdP, the developer creates a local `public/auth-config.json` (added to `.gitignore` like `.env`). @@ -221,7 +171,7 @@ No changes. `VITE_AUTH_ENABLED=false` continues to bypass auth entirely. When de | Risk | Mitigation | |------|------------| | **oidc-spa is newer** than `oidc-client-ts`. While actively maintained (Keycloakify team), it has a smaller userbase. | The surface area we use is narrow (init, login, logout, get token, parse ID token). If oidc-spa has issues, switching to `oidc-client-ts` is a confined change within `src/auth/index.ts`. | -| **Breaking config format change.** Existing deployments upgrading the chart must migrate from `keycloak.*` values to `auth.*` values. | Document the migration in the release notes. The error message from the `required` directive makes the fix obvious. No backward-compat shim — the user confirmed BC can break. | +| **Breaking config format change.** Existing deployments upgrading the chart must migrate from `keycloak.*` values to `auth.*` values for the frontend ConfigMap. The backend's `keycloak.*` values for `keycloak_authenticator` are untouched. | Document the migration in the release notes. The frontend `auth-config.json` is a separate ConfigMap from the backend config, so the two can be migrated independently. No backward-compat shim — the user confirmed BC can break. | | **`window.location.origin` as `redirect_uri`** may not cover all deployment topologies (e.g., app behind a reverse proxy with a different external origin). | The NebariApp operator already handles this via the `hostname` field. The SPA always runs at `window.location.origin`. If a future deployment needs a different redirect URI, it can be added to the config file. | | **Scope hardcoded to `openid email name`.** If a deployment needs extra scopes (e.g., for group claims), the config file would need a `scope` field. | Add when needed — changing the hardcoded default to a configurable value is a one-line change. | @@ -237,7 +187,7 @@ No changes. `VITE_AUTH_ENABLED=false` continues to bypass auth entirely. When de ### Manual smoke test (Keycloak) -1. Deploy with the new chart values (`auth.issuer`, `auth.clientId`). +1. Deploy with `auth.issuer` and `auth.clientId` set (the existing `keycloak.*` values still serve the backend). 2. Visit the app — should redirect to Keycloak login. 3. Log in — should return to the app with user profile shown in sidebar. 4. Log out — should redirect to Keycloak logout and return to the app's login page. @@ -245,7 +195,7 @@ No changes. `VITE_AUTH_ENABLED=false` continues to bypass auth entirely. When de ### Manual smoke test (alternative OIDC provider) -1. Deploy with the same chart but point `auth.issuer` at a non-Keycloak OIDC provider (e.g., Auth0 or Okta dev tenant). +1. Deploy with `auth.issuer` pointed at a non-Keycloak OIDC provider (e.g., Auth0 or Okta dev tenant). The backend `keycloak.*` values stay set to the same issuer so `keycloak_authenticator` validates tokens from that issuer. 2. Repeat steps 2–5 above. ### Browser storage From 0d3f90569c36f54f7f428e779c22e12ef818023d Mon Sep 17 00:00:00 2001 From: Philip Meier Date: Sun, 12 Jul 2026 14:15:27 +0000 Subject: [PATCH 3/6] keep top-level keycloak values, frontend.keycloak cleanup --- DESIGN.md | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 215f50b..6ed7689 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -9,8 +9,9 @@ Replace the hardcoded `keycloak-js` dependency in the frontend with `oidc-spa`, - Frontend authenticates against **any OIDC-compliant identity provider** (Keycloak, Auth0, Okta, Azure AD, Google, etc.). - All existing callers of `@/auth` — `fetch()`, `login()`, `logout()`, `getUserProfile()` — continue to work without changes. - The `VITE_AUTH_ENABLED=false` dev bypass continues to work. -- The Helm chart generates generic OIDC config instead of Keycloak-specific JSON. +- The Helm chart generates generic `auth-config.json` from the existing `keycloak.*` values. - **No backend changes** — the backend's `keycloak_authenticator` helper stays as-is; it already works with any OIDC issuer. +- **No new Helm values** — the existing `keycloak` block is reused; the dead `frontend.keycloak` block is cleaned up. ## Non-Goals @@ -123,23 +124,21 @@ The `FetchError` class is also unchanged. ### 5. Helm Chart Changes (frontend only) -**`values.yaml` — add `auth` block for frontend config. The existing `keycloak` block is untouched (backend still uses it).** +**`values.yaml` — no change to top-level keys. The existing `keycloak` block is reused for both frontend and backend. Only the ConfigMap template changes.** ```yaml -# Existing (unchanged — backend still references .Values.keycloak.url) +# Existing (unchanged — backend references .Values.keycloak.url, frontend generates config from same values) keycloak: + # The URL of the Keycloak server (used by backend authenticator) url: "" + # The Keycloak realm to use (used by backend authenticator) realm: "nebari" - clientId: "" - -# Added (frontend only) -auth: - # The full OIDC issuer URL, e.g. https://keycloak.example.com/realms/nebari - issuer: "" # The SPA client ID. If not set, defaults to a generated value matching the current scheme. clientId: "" ``` +The frontend config is derived from the same `keycloak` values — the old `frontend.keycloak` block (which was unused) is removed. + **`templates/frontend-configmap.yaml` — generate `auth-config.json` instead of `keycloak-config.json`:** ```yaml @@ -171,7 +170,7 @@ No changes. `VITE_AUTH_ENABLED=false` continues to bypass auth entirely. When de | Risk | Mitigation | |------|------------| | **oidc-spa is newer** than `oidc-client-ts`. While actively maintained (Keycloakify team), it has a smaller userbase. | The surface area we use is narrow (init, login, logout, get token, parse ID token). If oidc-spa has issues, switching to `oidc-client-ts` is a confined change within `src/auth/index.ts`. | -| **Breaking config format change.** Existing deployments upgrading the chart must migrate from `keycloak.*` values to `auth.*` values for the frontend ConfigMap. The backend's `keycloak.*` values for `keycloak_authenticator` are untouched. | Document the migration in the release notes. The frontend `auth-config.json` is a separate ConfigMap from the backend config, so the two can be migrated independently. No backward-compat shim — the user confirmed BC can break. | +| **Breaking config format change.** The frontend ConfigMap now generates `auth-config.json` with `{issuer, client_id}` instead of `keycloak-config.json`. Values stay at `keycloak.*` — no migration needed for the values file itself. | The frontend image must be updated to load `auth-config.json`. Old `keycloak-config.json` file is no longer served. No backward-compat shim — the user confirmed BC can break. | | **`window.location.origin` as `redirect_uri`** may not cover all deployment topologies (e.g., app behind a reverse proxy with a different external origin). | The NebariApp operator already handles this via the `hostname` field. The SPA always runs at `window.location.origin`. If a future deployment needs a different redirect URI, it can be added to the config file. | | **Scope hardcoded to `openid email name`.** If a deployment needs extra scopes (e.g., for group claims), the config file would need a `scope` field. | Add when needed — changing the hardcoded default to a configurable value is a one-line change. | @@ -187,7 +186,7 @@ No changes. `VITE_AUTH_ENABLED=false` continues to bypass auth entirely. When de ### Manual smoke test (Keycloak) -1. Deploy with `auth.issuer` and `auth.clientId` set (the existing `keycloak.*` values still serve the backend). +1. Deploy with `keycloak.url`, `keycloak.realm`, and `keycloak.clientId` set (as before — values unchanged). 2. Visit the app — should redirect to Keycloak login. 3. Log in — should return to the app with user profile shown in sidebar. 4. Log out — should redirect to Keycloak logout and return to the app's login page. @@ -195,7 +194,7 @@ No changes. `VITE_AUTH_ENABLED=false` continues to bypass auth entirely. When de ### Manual smoke test (alternative OIDC provider) -1. Deploy with `auth.issuer` pointed at a non-Keycloak OIDC provider (e.g., Auth0 or Okta dev tenant). The backend `keycloak.*` values stay set to the same issuer so `keycloak_authenticator` validates tokens from that issuer. +1. Deploy with `keycloak.url` and `keycloak.clientId` pointed at a non-Keycloak OIDC provider (e.g., Auth0 or Okta dev tenant). The backend's `keycloak_authenticator` validates tokens from the same issuer. 2. Repeat steps 2–5 above. ### Browser storage From 08c5e8a26d874989fcf5518463d98ef63c68b89b Mon Sep 17 00:00:00 2001 From: Philip Meier Date: Sun, 12 Jul 2026 14:40:36 +0000 Subject: [PATCH 4/6] rewrite design: single oidc values block, delete _authenticators.py --- DESIGN.md | 112 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 85 insertions(+), 27 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 6ed7689..05c8ab7 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -2,28 +2,28 @@ ## Summary -Replace the hardcoded `keycloak-js` dependency in the frontend with `oidc-spa`, a library that implements the same OIDC flow abstracted over any OpenID Connect provider. The runtime config file changes from a Keycloak-specific schema (`auth-server-url` + `realm`) to a provider-agnostic schema (`issuer` + `client_id`). The public API surface of `src/auth/index.ts` stays the same. The backend already works with any OIDC issuer via Ravnar's `OIDCTokenValidator` and is untouched by this design. +Replace the hardcoded `keycloak-js` dependency in the frontend with `oidc-spa`, a library that implements the same OIDC flow abstracted over any OpenID Connect provider. The runtime config file changes from a Keycloak-specific schema (`auth-server-url` + `realm`) to a provider-agnostic schema (`issuer` + `client_id`). The public API surface of `src/auth/index.ts` stays the same. On the backend, delete `_authenticators.py` — the `BearerTokenAuthenticator` + `OIDCTokenValidator` stack can be declared inline in `config.yaml` via Ravnar's `ImportStringWithParams` resolution. A single `oidc` values block replaces the old `keycloak` block. ## Goals - Frontend authenticates against **any OIDC-compliant identity provider** (Keycloak, Auth0, Okta, Azure AD, Google, etc.). - All existing callers of `@/auth` — `fetch()`, `login()`, `logout()`, `getUserProfile()` — continue to work without changes. - The `VITE_AUTH_ENABLED=false` dev bypass continues to work. -- The Helm chart generates generic `auth-config.json` from the existing `keycloak.*` values. -- **No backend changes** — the backend's `keycloak_authenticator` helper stays as-is; it already works with any OIDC issuer. -- **No new Helm values** — the existing `keycloak` block is reused; the dead `frontend.keycloak` block is cleaned up. +- Delete `src/ravnar_nebari_chat/_authenticators.py` — the backend auth is declared inline in `config.yaml` using Ravnar's dynamic import system. +- Single `oidc` values block in Helm for both frontend and backend config. +- Remove the dead `frontend.keycloak` block from values. ## Non-Goals -- **Backend changes.** The backend's `keycloak_authenticator` is already OIDC-generic underneath (`OIDCTokenValidator` accepts any issuer URL). It stays completely untouched — it's just a convenience helper that concatenates `keycloak_url` + `realm` into an issuer URL. No backend source, config, deployment, or Helm template changes in this design. -- Gateway-level auth. The NebariApp operator still handles ingress authentication and OIDC client provisioning; this design only touches the SPA-side auth. -- Runtime multi-provider switching. The config file declares one issuer per deployment. +- Gateway-level auth. The NebariApp operator still handles ingress authentication and OIDC client provisioning; this design only touches the SPA-side auth and the backend's token validation config. +- Runtime multi-provider switching. The config declares one issuer per deployment. +- Changes to Ravnar itself. ## Background / Motivation -The Nebari Chat Pack frontend uses `keycloak-js` directly, which ties it to Keycloak as the identity provider. The runtime config (`public/keycloak-config.json`) uses Keycloak-specific fields (`auth-server-url`, `realm`, `resource`). The backend is already generic — `ravnar_nebari_chat.keycloak_authenticator` is just a convenience wrapper that feeds `keycloak_url` + `realm` into Ravnar's `OIDCTokenValidator`, which accepts any OIDC issuer URL. Any deployment already points the backend at the correct issuer by overriding the Keycloak URL. +The Nebari Chat Pack frontend uses `keycloak-js` directly, which ties it to Keycloak as the identity provider. The runtime config (`public/keycloak-config.json`) uses Keycloak-specific fields (`auth-server-url`, `realm`, `resource`). -The Nebari default deployment uses Keycloak, and the gateway-level auth (NebariApp operator) will continue to reference Keycloak. This design only decouples the frontend SPA authentication from the Keycloak SDK, so deployments using a different IdP can swap it out by changing a config file. +The backend currently has a thin factory `ravnar_nebari_chat.keycloak_authenticator` that concatenates `keycloak_url` + `realm` into an issuer URL and wraps `OIDCTokenValidator` in `BearerTokenAuthenticator`. The `OIDCTokenValidator` is already fully generic — this factory is entirely wiring. With Ravnar's `ImportStringWithParams` (which recursively resolves nested `cls_or_fn` in `params`), the same wiring can be expressed declaratively in YAML, eliminating the custom Python module. ## Design @@ -122,32 +122,63 @@ Zero changes to callers. All four consumption sites reference `@/auth` purely th The `FetchError` class is also unchanged. -### 5. Helm Chart Changes (frontend only) +### 5. Backend: Delete `_authenticators.py`, Inline Auth Config -**`values.yaml` — no change to top-level keys. The existing `keycloak` block is reused for both frontend and backend. Only the ConfigMap template changes.** +**Delete** `src/ravnar_nebari_chat/_authenticators.py`. The factory function is no longer needed — Ravnar's `ImportStringWithParams` resolves nested `cls_or_fn` blocks in YAML, so the `BearerTokenAuthenticator` + `OIDCTokenValidator` wiring moves into `config.yaml`. + +The backend `config.yaml` becomes: + +```yaml +security: + authenticator: + cls_or_fn: _ravnar.authenticators.BearerTokenAuthenticator + params: + token_validator: + cls_or_fn: _ravnar.authenticators.OIDCTokenValidator + params: + issuer: '{{ required "oidc.issuer is required" .Values.oidc.issuer }}' + default_permissions: + - threads:read + - threads:write + - threads:delete + - agents:read +``` + +Ravnar's `ImportStringWithParams._validate_nested` recursively processes `cls_or_fn` keys inside `params`, constructing the inner `OIDCTokenValidator` first and passing it as the `token_validator` argument to `BearerTokenAuthenticator`. + +### 6. Helm Chart Changes + +**`values.yaml` — replace `keycloak` block with `oidc` block:** ```yaml -# Existing (unchanged — backend references .Values.keycloak.url, frontend generates config from same values) +# Removed keycloak: - # The URL of the Keycloak server (used by backend authenticator) url: "" - # The Keycloak realm to use (used by backend authenticator) realm: "nebari" - # The SPA client ID. If not set, defaults to a generated value matching the current scheme. clientId: "" -``` -The frontend config is derived from the same `keycloak` values — the old `frontend.keycloak` block (which was unused) is removed. +# Removed (dead key — never referenced in templates) +frontend: + ... + keycloak: ... + +# Added +oidc: + # Full OIDC issuer URL, e.g. https://keycloak.example.com/realms/nebari + issuer: "" + # SPA client ID. If not set, defaults to a generated value matching the current scheme. + clientId: "" +``` -**`templates/frontend-configmap.yaml` — generate `auth-config.json` instead of `keycloak-config.json`:** +**`templates/frontend-configmap.yaml` — generate `auth-config.json`:** ```yaml data: auth-config.json: | {{ dict - "issuer" (required "auth.issuer is required" .Values.auth.issuer) - "client_id" (.Values.auth.clientId | default (printf "%s-%s-spa" .Release.Namespace ...)) + "issuer" (required "oidc.issuer is required" .Values.oidc.issuer) + "client_id" (.Values.oidc.clientId | default (printf "%s-%s-spa" .Release.Namespace ...)) | toPrettyJson | nindent 4 }} ``` @@ -159,9 +190,35 @@ mountPath: /usr/share/nginx/html/auth-config.json subPath: auth-config.json ``` -The mount uses `subPath` so only the single file is mounted into the nginx static directory. +**`config.yaml` — backend auth config uses the same `oidc` values:** + +```yaml +security: + authenticator: + cls_or_fn: _ravnar.authenticators.BearerTokenAuthenticator + params: + token_validator: + cls_or_fn: _ravnar.authenticators.OIDCTokenValidator + params: + issuer: '{{ required "oidc.issuer is required" .Values.oidc.issuer }}' + default_permissions: + - threads:read + - threads:write + - threads:delete + - agents:read +``` + +A Keycloak default can be provided in the chart's `values.yaml`: + +```yaml +oidc: + issuer: "" + clientId: "" +``` + +The deployer sets `oidc.issuer` to the full issuer URL (e.g., `https://keycloak.example.com/realms/nebari`). For the default Nebari Keycloak deployment, this is the same URL the old `keycloak.url` + `keycloak.realm` concatenated to. -### 6. `.env` / Dev Experience +### 7. `.env` / Dev Experience No changes. `VITE_AUTH_ENABLED=false` continues to bypass auth entirely. When developing against a real IdP, the developer creates a local `public/auth-config.json` (added to `.gitignore` like `.env`). @@ -170,9 +227,10 @@ No changes. `VITE_AUTH_ENABLED=false` continues to bypass auth entirely. When de | Risk | Mitigation | |------|------------| | **oidc-spa is newer** than `oidc-client-ts`. While actively maintained (Keycloakify team), it has a smaller userbase. | The surface area we use is narrow (init, login, logout, get token, parse ID token). If oidc-spa has issues, switching to `oidc-client-ts` is a confined change within `src/auth/index.ts`. | -| **Breaking config format change.** The frontend ConfigMap now generates `auth-config.json` with `{issuer, client_id}` instead of `keycloak-config.json`. Values stay at `keycloak.*` — no migration needed for the values file itself. | The frontend image must be updated to load `auth-config.json`. Old `keycloak-config.json` file is no longer served. No backward-compat shim — the user confirmed BC can break. | -| **`window.location.origin` as `redirect_uri`** may not cover all deployment topologies (e.g., app behind a reverse proxy with a different external origin). | The NebariApp operator already handles this via the `hostname` field. The SPA always runs at `window.location.origin`. If a future deployment needs a different redirect URI, it can be added to the config file. | -| **Scope hardcoded to `openid email name`.** If a deployment needs extra scopes (e.g., for group claims), the config file would need a `scope` field. | Add when needed — changing the hardcoded default to a configurable value is a one-line change. | +| **Breaking change — `keycloak` values block removed.** Existing deployments must migrate to `oidc.issuer` + `oidc.clientId`. The old `keycloak_config`+`realm` two-value scheme is replaced by a single issuer URL. | Acceptable per user. The `required` directive in the ConfigMap template makes the fix obvious. | +| **Breaking change — `keycloak-config.json` file no longer served.** The frontend now loads `auth-config.json`. | The frontend image must be updated to load the new filename. No backward-compat shim. | +| **Removing `_authenticators.py`** — if any external deployment imports `ravnar_nebari_chat.keycloak_authenticator`, it breaks. | This is an internal module used only by the chart's `config.yaml`. No external consumers known. | +| **Backend `config.yaml` uses Ravnar-internal dotted paths** (`_ravnar.authenticators.*`). These are not a public Ravnar API and could change between versions. | The `BearerTokenAuthenticator` and `OIDCTokenValidator` classes are core to Ravnar's auth model and unlikely to change incompatibly. If they do, the factory function would have broken too — the difference is just where the import path lives. | ## Testing Strategy @@ -186,7 +244,7 @@ No changes. `VITE_AUTH_ENABLED=false` continues to bypass auth entirely. When de ### Manual smoke test (Keycloak) -1. Deploy with `keycloak.url`, `keycloak.realm`, and `keycloak.clientId` set (as before — values unchanged). +1. Deploy with `oidc.issuer` and `oidc.clientId` set (e.g. `oidc.issuer: https://keycloak.example.com/realms/nebari`). 2. Visit the app — should redirect to Keycloak login. 3. Log in — should return to the app with user profile shown in sidebar. 4. Log out — should redirect to Keycloak logout and return to the app's login page. @@ -194,7 +252,7 @@ No changes. `VITE_AUTH_ENABLED=false` continues to bypass auth entirely. When de ### Manual smoke test (alternative OIDC provider) -1. Deploy with `keycloak.url` and `keycloak.clientId` pointed at a non-Keycloak OIDC provider (e.g., Auth0 or Okta dev tenant). The backend's `keycloak_authenticator` validates tokens from the same issuer. +1. Deploy with `oidc.issuer` pointed at a non-Keycloak OIDC provider (e.g., Auth0 or Okta dev tenant). 2. Repeat steps 2–5 above. ### Browser storage From 5923d51ef918fe5baf34f055f292353e534ba2e1 Mon Sep 17 00:00:00 2001 From: Philip Meier Date: Sun, 12 Jul 2026 15:08:33 +0000 Subject: [PATCH 5/6] Replace keycloak-js with oidc-spa for generic OIDC auth - Replace keycloak-js with oidc-spa in frontend/package.json - Update src/auth/index.ts to use oidc-spa/core API - Delete backend/src/ravnar_nebari_chat/_authenticators.py - Replace keycloak values block with oidc in values.yaml - Update frontend configmap to generate auth-config.json - Update deployment to mount auth-config.json instead of keycloak-config.json - Replace keycloak_authenticator with inline BearerTokenAuthenticator/OIDCTokenValidator in config.yaml --- .../src/ravnar_nebari_chat/_authenticators.py | 12 --- frontend/package-lock.json | 99 +++++++++---------- frontend/package.json | 2 +- frontend/src/auth/index.ts | 55 +++++++---- helm/nebari-chat/config.yaml | 13 ++- .../templates/frontend-configmap.yaml | 7 +- .../templates/frontend-deployment.yaml | 4 +- helm/nebari-chat/values.yaml | 18 +--- 8 files changed, 101 insertions(+), 109 deletions(-) delete mode 100644 backend/src/ravnar_nebari_chat/_authenticators.py diff --git a/backend/src/ravnar_nebari_chat/_authenticators.py b/backend/src/ravnar_nebari_chat/_authenticators.py deleted file mode 100644 index f198caa..0000000 --- a/backend/src/ravnar_nebari_chat/_authenticators.py +++ /dev/null @@ -1,12 +0,0 @@ -from __future__ import annotations - -from ravnar.authenticators import ALL_PERMISSIONS, BearerTokenAuthenticator, OIDCTokenValidator - - -def keycloak_authenticator(*, keycloak_url: str, realm: str = "nebari") -> BearerTokenAuthenticator: - return BearerTokenAuthenticator( - OIDCTokenValidator( - issuer=f"{keycloak_url.rstrip('/')}/realms/{realm}", - default_permissions=list(ALL_PERMISSIONS), - ) - ) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a4bdbd8..6e46c2a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -35,9 +35,9 @@ "jotai": "^2.20.1", "json-edit-react": "^1.30.2", "katex": "^0.17.0", - "keycloak-js": "^26.2.4", "leaflet": "^1.9.4", "lucide-react": "^1.22.0", + "oidc-spa": "^10.2.6", "react": "^19.1.1", "react-dom": "^19.1.1", "react-markdown": "^10.1.0", @@ -380,9 +380,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -400,9 +397,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -420,9 +414,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -440,9 +431,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -1664,9 +1652,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1684,9 +1669,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1704,9 +1686,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1724,9 +1703,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1744,9 +1720,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1764,9 +1737,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1986,9 +1956,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2006,9 +1973,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2026,9 +1990,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2046,9 +2007,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3492,15 +3450,6 @@ "katex": "cli.js" } }, - "node_modules/keycloak-js": { - "version": "26.2.4", - "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-26.2.4.tgz", - "integrity": "sha512-PnXpR3ubETGOt0B/Qt2lxmPbkZr5bc3vlQsOqDoTPPQsZRp7JjhTKxlJ187uWh8qJhvBab6Gsjb06a8ayOPfuw==", - "license": "Apache-2.0", - "workspaces": [ - "test" - ] - }, "node_modules/leaflet": { "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", @@ -4748,6 +4697,52 @@ "devOptional": true, "license": "MIT" }, + "node_modules/oidc-spa": { + "version": "10.2.7", + "resolved": "https://registry.npmjs.org/oidc-spa/-/oidc-spa-10.2.7.tgz", + "integrity": "sha512-CWc54OHm+5462v7OA7wliyTIZhYsuxwsc3QkMrFFsrCqw2QS+KANnuwfS7jRL+3DEaZBN0ucv/89Sqq8ye0jZQ==", + "license": "MIT", + "peerDependencies": { + "@angular/common": "*", + "@angular/core": "*", + "@angular/router": "*", + "@nuxt/kit": "*", + "@tanstack/react-router": "*", + "@tanstack/react-start": ">=1.168.25", + "@types/react": "*", + "react": "*", + "rxjs": "*" + }, + "peerDependenciesMeta": { + "@angular/common": { + "optional": true + }, + "@angular/core": { + "optional": true + }, + "@angular/router": { + "optional": true + }, + "@nuxt/kit": { + "optional": true + }, + "@tanstack/react-router": { + "optional": true + }, + "@tanstack/react-start": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "react": { + "optional": true + }, + "rxjs": { + "optional": true + } + } + }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index d3aa202..bb3b206 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -43,7 +43,7 @@ "jotai": "^2.20.1", "json-edit-react": "^1.30.2", "katex": "^0.17.0", - "keycloak-js": "^26.2.4", + "oidc-spa": "^10.2.6", "leaflet": "^1.9.4", "lucide-react": "^1.22.0", "react": "^19.1.1", diff --git a/frontend/src/auth/index.ts b/frontend/src/auth/index.ts index d567caf..8c972e2 100644 --- a/frontend/src/auth/index.ts +++ b/frontend/src/auth/index.ts @@ -1,7 +1,7 @@ /*----------------------------------------------------------------------------- | Copyright (c) 2025-present, OpenTeams Inc. |----------------------------------------------------------------------------*/ -import Keycloak from 'keycloak-js'; +import { createOidc } from 'oidc-spa/core'; // Save a reference to the native fetch before it can be overridden. // @@ -42,15 +42,22 @@ export class FetchError extends Error { // Whether auth is enabled for the application. const AUTH_ENABLED = import.meta.env.VITE_AUTH_ENABLED === 'true'; -// The singleton `Keycloak` instance for handling authentication. -const keycloak = new Keycloak('/keycloak-config.json'); +// The singleton `oidc` instance for handling authentication. +let oidc: Awaited> | null = null; -// If auth is enabled, init keycloak before anything else is loaded. +// If auth is enabled, init oidc before anything else is loaded. // // This allows redirects to happen cleanly after a login and prevents // redirect loops if it were to be performed lazily in `login()`. if (AUTH_ENABLED) { - await keycloak.init({ checkLoginIframe: false }); + const configResp = await fetch('/auth-config.json'); + const config = await configResp.json(); + + oidc = await createOidc({ + issuerUri: config.issuer, + clientId: config.client_id, + BASE_URL: window.location.origin, + }); } /** @@ -66,13 +73,15 @@ export async function fetch( init: RequestInit = {}, ): Promise { // Ensure we have an unexpired token. - if (AUTH_ENABLED) { - await keycloak.updateToken(); + if (AUTH_ENABLED && oidc?.isUserLoggedIn) { + await oidc.renewTokens(); } // Create the extra headers if needed. const headers = ( - AUTH_ENABLED ? { Authorization: `Bearer ${keycloak.token ?? ''}` } : {} + AUTH_ENABLED && oidc?.isUserLoggedIn + ? { Authorization: `Bearer ${(await oidc.getTokens()).accessToken}` } + : {} ) as HeadersInit; // Clone the init object and headers to prevent snooping by the caller. @@ -91,38 +100,36 @@ export async function fetch( } /** - * A function which handles the user login via Keycloak. + * A function which handles the user login via OIDC. * * If the user is already logged-in this is a no-op. */ export async function login(): Promise { // Bail early if login is not needed. - if (!AUTH_ENABLED || keycloak.authenticated) { + if (!AUTH_ENABLED || !oidc || oidc.isUserLoggedIn) { return; } // Authenticate the user. - await keycloak.login({ redirectUri: window.location.origin }); + await oidc.login({ redirectUrl: window.location.origin }); } /** - * A function which handles user logout via Keycloack. + * A function which handles user logout via OIDC. * * If the user is already logged-out this is just a redirect to origin. */ export async function logout(): Promise { // Redirect if auth is not enabled. - // - // On execution, `keycloak.authenticated` might be `false` if the user - // is authed but the `keycloak.init()` promise has not yet resolved, - // which would yield a false negative, so don't check for it. if (!AUTH_ENABLED) { window.location.replace(window.location.origin); return; } // Log out the user. - await keycloak.logout({ redirectUri: window.location.origin }); + if (oidc?.isUserLoggedIn) { + await oidc.logout({ redirectTo: 'home' }); + } } /** @@ -145,13 +152,19 @@ export type UserProfile = { */ export function getUserProfile(): UserProfile | null { // Bail early if auth is not enabled. - if (!AUTH_ENABLED || !keycloak.authenticated) { + if (!AUTH_ENABLED || !oidc || !oidc.isUserLoggedIn) { return null; } - // Return the user profile from the parsed token data. + // Return the user profile from the decoded ID token. + const decoded = oidc.getDecodedIdToken(); + // ponytail: oidc-spa returns a generic DecodedIdToken type that extends + // the OIDC core spec with optional name/email claims. We know these are + // present if the user is logged in (they're required by the OIDC spec + // for the ID token), but the type doesn't reflect that. Cast to any + // to avoid the TS error, since the runtime invariant is sound. return { - name: keycloak.tokenParsed?.name ?? '', - email: keycloak.tokenParsed?.email ?? '', + name: (decoded as any).name ?? '', + email: (decoded as any).email ?? '', }; } diff --git a/helm/nebari-chat/config.yaml b/helm/nebari-chat/config.yaml index 0cd49f8..70075d8 100644 --- a/helm/nebari-chat/config.yaml +++ b/helm/nebari-chat/config.yaml @@ -1,6 +1,13 @@ security: authenticator: - cls_or_fn: ravnar_nebari_chat.keycloak_authenticator + cls_or_fn: _ravnar.authenticators.BearerTokenAuthenticator params: - keycloak_url: '{{ required "keycloak.url is required" .Values.keycloak.url }}' - realm: "{{ .Values.keycloak.realm }}" + token_validator: + cls_or_fn: _ravnar.authenticators.OIDCTokenValidator + params: + issuer: '{{ required "oidc.issuer is required" .Values.oidc.issuer }}' + default_permissions: + - threads:read + - threads:write + - threads:delete + - agents:read diff --git a/helm/nebari-chat/templates/frontend-configmap.yaml b/helm/nebari-chat/templates/frontend-configmap.yaml index 177993f..40311a3 100644 --- a/helm/nebari-chat/templates/frontend-configmap.yaml +++ b/helm/nebari-chat/templates/frontend-configmap.yaml @@ -8,12 +8,11 @@ metadata: labels: {{- include "ravnar.labels" (dict "top" . "component" $component) | nindent 4 }} data: - keycloak-config.json: | + auth-config.json: | {{- dict - "auth-server-url" (required "keycloak.url is required" .Values.keycloak.url) - "realm" (required "keycloak.realm is required" .Values.keycloak.realm) - "resource" (.Values.keycloak.clientId | default (printf "%s-%s-spa" .Release.Namespace (include "ravnar.component-name" (dict "top" . "component" $component)))) + "issuer" (required "oidc.issuer is required" .Values.oidc.issuer) + "client_id" (.Values.oidc.clientId | default (printf "%s-%s-spa" .Release.Namespace (include "ravnar.component-name" (dict "top" . "component" $component)))) | toPrettyJson | nindent 4 }} {{- with include "nebari-chat.ravnarContextJson" . | fromJson }} diff --git a/helm/nebari-chat/templates/frontend-deployment.yaml b/helm/nebari-chat/templates/frontend-deployment.yaml index d85aa10..20f541f 100644 --- a/helm/nebari-chat/templates/frontend-deployment.yaml +++ b/helm/nebari-chat/templates/frontend-deployment.yaml @@ -46,8 +46,8 @@ spec: key: API_URL volumeMounts: - name: config - mountPath: /usr/share/nginx/html/keycloak-config.json - subPath: keycloak-config.json + mountPath: /usr/share/nginx/html/auth-config.json + subPath: auth-config.json readOnly: true ports: - containerPort: {{ .Values.frontend.service.targetPort }} diff --git a/helm/nebari-chat/values.yaml b/helm/nebari-chat/values.yaml index 7b98bc7..4eb4065 100644 --- a/helm/nebari-chat/values.yaml +++ b/helm/nebari-chat/values.yaml @@ -7,12 +7,10 @@ config: # -- Inline configuration for the application. Merged with the default config.yaml inline: {} -keycloak: - # -- The URL of the Keycloak server - url: "" - # -- The Keycloak realm to use - realm: "nebari" - # -- The Keycloak client ID. If not set, a default is generated based on the release name +oidc: + # -- The OIDC issuer URL (e.g. https://keycloak.example.com/realms/nebari) + issuer: "" + # -- The OIDC client ID for the SPA clientId: "" ravnar: @@ -95,14 +93,6 @@ frontend: # -- The target port on the container targetPort: 8080 - keycloak: - # -- The URL of the Keycloak auth server - authServerUrl: "" - # -- The Keycloak realm for frontend authentication - realm: "nebari" - # -- The Keycloak resource/client ID for the frontend - resource: "" - api: # -- The URL of the backend API. Auto-detected if not set url: "" From ffa59d6033c5ab691b5d6cff3c90b949ffa08903 Mon Sep 17 00:00:00 2001 From: Philip Meier Date: Wed, 15 Jul 2026 15:46:57 +0000 Subject: [PATCH 6/6] Add oidc-spa Vite plugin for early init - Import oidcSpa from oidc-spa/vite-plugin in vite.config.ts - Add oidcSpa() to the plugins array - This enables automatic oidcEarlyInit() handling for session restoration --- frontend/vite.config.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index d68289a..94c4f9b 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,6 +1,7 @@ import path from 'node:path'; import tailwindcss from '@tailwindcss/vite'; import { tanstackRouter } from '@tanstack/router-plugin/vite'; +import { oidcSpa } from 'oidc-spa/vite-plugin'; import react from '@vitejs/plugin-react'; import { defineConfig, loadEnv } from 'vite'; @@ -16,6 +17,7 @@ export default defineConfig(({ mode }) => { '// noinspection JSUnusedGlobalSymbols', ], }), + oidcSpa(), tailwindcss(), react(), ],