From b0008adaaa9cfbc9ca55633a51a856bfd73ca1cc Mon Sep 17 00:00:00 2001 From: gabemontero Date: Mon, 8 Jun 2026 13:55:15 -0400 Subject: [PATCH 1/6] docs(augment): add OpenSpec proposal for per-user Kagenti token exchange Add OpenSpec artifacts for the kagenti-user-level-token-exchange change, which implements RFC 8693 OAuth2 Token Exchange for the Kagenti provider enabling per-user authorization instead of shared service-account tokens. Artifacts created: - proposal.md: motivation, capabilities (token-exchange, user-token-routing), impact - design.md: 5 architectural decisions with alternatives, risks, and constraints - specs/token-exchange/spec.md: 7 requirements, 15 scenarios (config, RFC 8693 execution, caching, dedup, streaming, fallback, no-impact guarantee) - specs/user-token-routing/spec.md: 6 requirements, 8 scenarios (header routing, route extraction, interface widening, backward compatibility) - tasks.md: 6 task groups, 19 implementation tasks ordered by dependency Includes the preliminary implementation plan used as source material. Co-Authored-By: Claude Opus 4.6 Signed-off-by: gabemontero --- .../.openspec.yaml | 2 + .../design.md | 89 +++++++++++++ ...ken-exchange-prelim-implementation-plan.md | 125 ++++++++++++++++++ .../proposal.md | 39 ++++++ .../specs/token-exchange/spec.md | 122 +++++++++++++++++ .../specs/user-token-routing/spec.md | 69 ++++++++++ .../tasks.md | 42 ++++++ 7 files changed, 488 insertions(+) create mode 100644 workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/.openspec.yaml create mode 100644 workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/design.md create mode 100644 workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/kagenti-token-exchange-prelim-implementation-plan.md create mode 100644 workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/proposal.md create mode 100644 workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/token-exchange/spec.md create mode 100644 workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/user-token-routing/spec.md create mode 100644 workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/tasks.md diff --git a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/.openspec.yaml b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/.openspec.yaml new file mode 100644 index 0000000000..e8d4ccfe90 --- /dev/null +++ b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-08 diff --git a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/design.md b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/design.md new file mode 100644 index 0000000000..220df16806 --- /dev/null +++ b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/design.md @@ -0,0 +1,89 @@ +# Design: Per-User OAuth2 Token Exchange for Kagenti Provider + +## Context + +The augment plugin's Kagenti provider authenticates to Kagenti using a single shared service-account token via Client Credentials Grant (`KeycloakTokenManager`). All backend requests to Kagenti carry the same identity regardless of which Backstage user initiated the request. The user's identity is passed only as an informational `X-Backstage-User` header. + +Kagenti supports RFC 8693 OAuth2 Token Exchange, which can swap a user's OIDC access token for a Kagenti-scoped token preserving the user's `sub` claim while adding an `act` (actor) claim for the service. This enables per-user authorization decisions at the Kagenti layer. + +**Key constraint:** Backstage replaces the user's Keycloak OIDC token with its own JWT before it reaches backend plugins. `req.headers.authorization` contains a Backstage-minted token, not the user's Keycloak token. The user's original OIDC token must arrive via a separate mechanism. + +**Frontend constraint:** Backstage's `createApiFactory` requires all `deps` to be resolvable — there is no optional dependency concept. Adding `oidcAuthApiRef` as a hard dependency would crash deployments that don't use OIDC auth. `useApi()` throws synchronously during render if the API isn't registered. + +## Goals / Non-Goals + +**Goals:** + +- Enable per-user authorization at the Kagenti layer via RFC 8693 token exchange +- Keep the change backend-only — no frontend plugin modifications +- Graceful fallback to service-account token at every failure point +- Make per-user token exchange opt-in and disabled by default +- Support configurable header source for user OIDC tokens +- Zero impact on `ResponsesApiProvider` or Llama Stack code paths + +**Non-Goals:** + +- Frontend OIDC token injection (deferred to future work or auth proxy) +- Changes to the `KeycloakTokenManager` service-account flow +- Per-user authorization for non-Kagenti providers +- Custom Keycloak realm or client configuration tooling +- Token exchange for the `X-Backstage-User` header (informational only) + +## Decisions + +### Decision 1: Backend-only with configurable header source + +Accept the user's OIDC token from a configurable request header rather than coupling the frontend to OIDC auth. The token can be injected by: + +1. An auth proxy (oauth2-proxy, Keycloak Gatekeeper) — many RHDH deployments already have one +2. A future frontend change when Backstage adds optional API deps +3. Custom middleware extracting the OIDC token from the session + +**Alternative considered:** Frontend injection via `oidcAuthApiRef`. Rejected because `createApiFactory` hard dependency would break non-OIDC deployments. + +**Alternative considered:** Backstage middleware extracting from session. Rejected because it couples to Backstage auth internals and requires session storage access. + +### Decision 2: New `TokenExchangeManager` modeled on `KeycloakTokenManager` + +Create a dedicated manager class rather than extending `KeycloakTokenManager`. The exchange manager handles per-user token caching (keyed by user), concurrent request deduplication, streaming-aware token lifetime, and exchange-specific error handling. + +**Alternative considered:** Extending `KeycloakTokenManager` with user-keyed methods. Rejected because the two have different lifecycles (system-wide vs. per-user), different cache semantics, and mixing them would complicate the clean fallback logic. + +### Decision 3: Graceful fallback at every stage + +The implementation never blocks functionality. At every point where exchange is attempted, failure falls back to the existing service-account token: + +- Config disabled or header absent → service-account token directly +- Exchange call fails (network, Keycloak error) → try/catch, warn, service-account +- Exchanged token rejected (401) → clear both caches, retry with fresh token +- Keycloak doesn't support exchange (400 `unsupported_grant_type`) → catch, warn, service-account + +Worst case for a misconfigured deployment is a warning log and fallback to pre-existing behavior. + +### Decision 4: Widen `setUserContext` with optional second parameter + +The `AugmentProvider` interface method `setUserContext?(userRef: string)` is widened to `setUserContext?(userRef: string, bearerToken?: string)`. This is backward-compatible: `ResponsesApiProvider` does not implement `setUserContext` at all (it's optional on the interface), and the `if (provider.setUserContext)` guards in route code skip it entirely. + +### Decision 5: Config structure nested under `augment.kagenti.auth` + +Token exchange config is nested under the existing auth block as an optional `tokenExchange` sub-key: + +```yaml +augment: + kagenti: + auth: + tokenExchange: + enabled: true # default: false + audience: kagenti-api # default: auth.clientId + userTokenHeader: X-Forwarded-Access-Token # default: x-user-oidc-token +``` + +This reuses the existing `tokenEndpoint`, `clientId`, and `clientSecret` from the parent auth block. No new top-level config keys. + +## Risks / Trade-offs + +- **Auth proxy not configured** → No OIDC token in header → falls back to service-account silently. Deployments without an auth proxy get no per-user auth until they add one or a frontend solution is built. +- **Keycloak doesn't support token exchange** → Returns 400 `unsupported_grant_type`. Caught, warned, falls back. Requires Keycloak admin to enable token exchange on the realm. +- **OIDC token expired** → Exchange fails, caught, falls back. Short-lived tokens may cause frequent fallbacks; auth proxy should handle refresh. +- **Memory** → Per-user cache bounded by concurrent users (~2KB per entry × 1000 users = ~2MB). Acceptable for backend plugin. +- **Security surface** → The configurable header must be trusted. If an attacker can inject the header, they can impersonate users. Mitigated by typical auth proxy architectures stripping/overwriting upstream headers. diff --git a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/kagenti-token-exchange-prelim-implementation-plan.md b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/kagenti-token-exchange-prelim-implementation-plan.md new file mode 100644 index 0000000000..2c94c653f4 --- /dev/null +++ b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/kagenti-token-exchange-prelim-implementation-plan.md @@ -0,0 +1,125 @@ +# Plan: Per-User OAuth2 Token Exchange (RFC 8693) for Kagenti Provider + +_Created: May 30, 2026_ +_Status: Implementation complete (pending review)_ +_Branch: current working branch in rhdh-plugins_ + +## Context + +The augment plugin authenticates to Kagenti using a single shared service-account token (Client Credentials Grant). All users appear to Kagenti as the same service identity. The user's Backstage identity is passed only as an informational `X-Backstage-User` header — not an authorization credential. + +Kagenti supports RFC 8693 OAuth2 Token Exchange, which can exchange a user's OIDC access token for a Kagenti-scoped token that preserves the user's `sub` claim and adds an `act` (actor) claim identifying the service. This would enable per-user authorization at the Kagenti layer. + +**Key constraint:** Backstage replaces the user's Keycloak OIDC token with its own JWT before it reaches backend plugins. `req.headers.authorization` contains a Backstage-minted token, not the user's Keycloak token. The user's OIDC token is only available on the frontend via `oidcAuthApiRef.getAccessToken()`. + +**Frontend complication:** Backstage's `createApiFactory` requires all `deps` to be resolvable — there is no optional dependency concept. Adding `oidcAuthApiRef` as a hard dependency would crash deployments that don't use OIDC auth (GitHub auth, SAML, etc.). Similarly, `useApi()` throws synchronously during render if the API isn't registered — no clean try-catch pattern exists. + +## Approach: Backend-Only with Configurable Header Source and Graceful Fallback + +Rather than coupling the frontend to OIDC auth, this plan makes the backend accept the user's OIDC token from a **configurable request header**. The token can be injected by: + +1. **An auth proxy** (oauth2-proxy, Keycloak Gatekeeper) — many RHDH deployments already have one; just configure it to forward the OIDC access token as a header. +2. **A future frontend change** — if/when Backstage adds optional API deps or the augment frontend adopts a provider-aware token injection pattern, the backend is already ready. +3. **Custom middleware** — a lightweight Express middleware or Backstage module that extracts the OIDC token from the session and injects the header. + +This keeps the change **backend-only**, **provider-scoped** (only Kagenti uses it), and **backward-compatible** (disabled by default). + +### Graceful Fallback to Service-Account Token + +The implementation is designed so that per-user token exchange **never blocks functionality**. At every point where a per-user exchanged token is attempted, the code falls back to the existing shared service-account token (`KeycloakTokenManager`) if anything goes wrong: + +- **Token exchange disabled or header absent**: If `tokenExchange.enabled` is false (default), or the configured OIDC token header is not present on the request, the service-account token is used directly — no exchange is attempted. +- **Token exchange fails** (Keycloak error, network timeout, misconfigured client): Each exchange call in `requestCore.ts` (`doRequest`, `streamRequest`) is wrapped in a try/catch. On failure, a warning is logged and the service-account token is used instead. The request proceeds normally. +- **Exchanged token rejected (401)**: On a 401 response, `requestWithRetry` clears both the per-user exchanged token cache AND the service-account token cache, then retries with a fresh token. +- **Keycloak doesn't support token exchange**: Returns 400 `unsupported_grant_type`. Caught, warned, falls back to service-account. + +This means the worst case for a misconfigured deployment is a warning log and a fallback to the pre-existing behavior — never a broken request. + +### ResponsesApiProvider Impact: None + +`ResponsesApiProvider` does NOT implement `setUserContext` — the interface method is optional (`setUserContext?(...)`). The widened signature (`bearerToken?` added as second param) does not affect it: + +- The `if (provider.setUserContext)` guards in route code skip it entirely +- The route-level header extraction is harmless — the header value is never used +- No code in `providers/llamastack/` is modified +- All OpenAI Responses API calls continue to use the static API key unchanged + +## Config + +```yaml +augment: + kagenti: + auth: + tokenEndpoint: https://keycloak.example.com/realms/kagenti/protocol/openid-connect/token + clientId: augment-backend + clientSecret: ${KAGENTI_CLIENT_SECRET} + tokenExchange: # NEW — all optional + enabled: true # default: false + audience: kagenti-api # default: auth.clientId + userTokenHeader: X-Forwarded-Access-Token # default: x-user-oidc-token +``` + +## Files Changed (10 modified, 1 new) + +### New File + +| File | Description | +| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `plugins/augment-backend/src/providers/kagenti/client/TokenExchangeManager.ts` | RFC 8693 token exchange with per-user caching, concurrent dedup, streaming support, fallback-safe error handling. Modeled on `KeycloakTokenManager.ts`. | + +### Config (2 files) + +| File | Change | +| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `plugins/augment-backend/config.d.ts` | Added optional `tokenExchange` block inside `augment.kagenti.auth` with `enabled`, `audience`, `userTokenHeader` | +| `plugins/augment-backend/src/providers/kagenti/config/KagentiConfigLoader.ts` | Parses new config; extends `KagentiConfig.auth` type; defaults: enabled=false, audience=clientId, userTokenHeader='x-user-oidc-token' | + +### Token Flow (2 files) + +| File | Change | +| -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `plugins/augment-backend/src/providers/kagenti/client/requestCore.ts` | Extended `RequestCoreContext` with `tokenExchangeManager?` and `getUserBearerToken`. `doRequest()`, `streamRequest()`, `requestWithRetry()` try exchange first, fall back to service-account. | +| `plugins/augment-backend/src/providers/kagenti/client/KagentiApiClient.ts` | Widened `KagentiRequestContext` with `bearerToken?`. Added `tokenExchangeManager?` to options. Wired into `RequestCoreContext`. | + +### Provider + Interface (2 files) + +| File | Change | +| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `plugins/augment-backend/src/providers/kagenti/KagentiProvider.ts` | Creates `TokenExchangeManager` in `initialize()` when config enables it. Passes to API client (both in `initialize()` and `ensureClientUrl()`). Widened `setUserContext(userRef, bearerToken?)`. Added `getUserTokenHeader()` getter. | +| `plugins/augment-backend/src/providers/providerInterface.ts` | Widened `setUserContext?(userRef: string, bearerToken?: string): void` — optional second param, no impact on ResponsesApiProvider | + +### Routes (4 files) + +| File | Change | +| ----------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `plugins/augment-backend/src/routes/types.ts` | Added `userTokenHeader?: string` to `RouteContext` | +| `plugins/augment-backend/src/router.ts` | Dynamic getter on `RouteContext` that reads header name from KagentiProvider config | +| `plugins/augment-backend/src/routes/chatRoutes.ts` | Both sync and streaming `setUserContext` call sites extract OIDC token from configured header | +| `plugins/augment-backend/src/routes/kagentiRoutes.ts` | `/kagenti` middleware extracts OIDC token from configured header | + +### NOT Changed + +- **Frontend plugin** — No changes. OIDC token arrives via infrastructure (auth proxy) or future frontend work. +- **`ResponsesApiProvider`** / `providers/llamastack/` — Zero code changes. +- **`KeycloakTokenManager`** — Untouched. Remains fallback for system operations. +- **`X-Backstage-User` header** — Still sent alongside exchanged token for audit logging. + +## Verification + +1. **TypeScript compilation**: `npx tsc --noEmit` passes clean (verified). +2. **Unit tests needed**: `TokenExchangeManager.test.ts` — exchange, caching, dedup, fallback on error, streaming lifetime, clearUserCache/clearAllCache. +3. **Backward compat**: With `tokenExchange` absent, behavior is identical to before. +4. **Integration test**: Requires Keycloak with token exchange enabled + auth proxy forwarding OIDC token in the configured header. + +## Risks + +- **Auth proxy not configured**: No OIDC token in header → falls back to service-account silently. +- **Keycloak doesn't support token exchange**: Returns 400, caught, warned, falls back. +- **OIDC token expired**: Exchange fails, caught, falls back. +- **Memory**: Per-user cache bounded by concurrent users (~2KB × 1000 users = ~2MB). + +## Related Documents + +- Kagenti API auth docs: https://github.com/kagenti/kagenti/blob/v0.6.0-rc.3/docs/api-authentication.md +- Kagenti identity guide: https://github.com/kagenti/kagenti/blob/main/docs/identity-guide.md +- OpenAI RBAC (orthogonal): https://developers.openai.com/api/docs/guides/rbac diff --git a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/proposal.md b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/proposal.md new file mode 100644 index 0000000000..607c4d63b6 --- /dev/null +++ b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/proposal.md @@ -0,0 +1,39 @@ +# Proposal: Per-User OAuth2 Token Exchange for Kagenti Provider + +## Why + +The augment plugin authenticates to Kagenti using a single shared service-account token (Client Credentials Grant), so all users appear to Kagenti as the same service identity. The user's Backstage identity is passed only as an informational `X-Backstage-User` header — not an authorization credential. Kagenti supports RFC 8693 OAuth2 Token Exchange, which can exchange a user's OIDC access token for a Kagenti-scoped token that preserves the user's `sub` claim and adds an `act` (actor) claim identifying the service, enabling per-user authorization at the Kagenti layer. + +## What Changes + +- New `TokenExchangeManager` class implementing RFC 8693 token exchange with per-user caching, concurrent request deduplication, streaming-aware lifetime management, and fallback-safe error handling +- Extended `KagentiConfigLoader` to parse optional `tokenExchange` config block (`enabled`, `audience`, `userTokenHeader`) +- Extended `requestCore.ts` (`doRequest`, `streamRequest`, `requestWithRetry`) to attempt per-user token exchange first, falling back to the shared service-account token on any failure +- Widened `KagentiApiClient` context and `AugmentProvider` interface to carry the user's bearer token alongside user ref +- Route-level extraction of the user's OIDC token from a configurable request header (default: `x-user-oidc-token`) +- Graceful fallback at every stage: disabled config, absent header, exchange failure, and 401 retry all fall back to the existing `KeycloakTokenManager` service-account token + +## Capabilities + +### New Capabilities + +- `token-exchange`: RFC 8693 OAuth2 token exchange for per-user Kagenti authorization — config schema, token lifecycle management, per-user caching, concurrent deduplication, and fallback behavior +- `user-token-routing`: Route-level extraction and forwarding of user OIDC tokens from configurable request headers to the Kagenti provider + +### Modified Capabilities + +## Impact + +- `plugins/augment-backend/config.d.ts` — new `tokenExchange` config block +- `plugins/augment-backend/src/providers/kagenti/config/KagentiConfigLoader.ts` — parse new config +- `plugins/augment-backend/src/providers/kagenti/client/TokenExchangeManager.ts` — **new file** +- `plugins/augment-backend/src/providers/kagenti/client/requestCore.ts` — exchange-first token flow +- `plugins/augment-backend/src/providers/kagenti/client/KagentiApiClient.ts` — widened context +- `plugins/augment-backend/src/providers/kagenti/KagentiProvider.ts` — instantiates exchange manager +- `plugins/augment-backend/src/providers/providerInterface.ts` — widened `setUserContext` signature +- `plugins/augment-backend/src/routes/types.ts` — `userTokenHeader` on `RouteContext` +- `plugins/augment-backend/src/router.ts` — dynamic header getter from provider config +- `plugins/augment-backend/src/routes/chatRoutes.ts` — OIDC token extraction in chat handlers +- `plugins/augment-backend/src/routes/kagentiRoutes.ts` — OIDC token extraction in Kagenti middleware +- No frontend changes — OIDC token arrives via infrastructure (auth proxy) or future frontend work +- No changes to `ResponsesApiProvider`, `providers/llamastack/`, or `KeycloakTokenManager` diff --git a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/token-exchange/spec.md b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/token-exchange/spec.md new file mode 100644 index 0000000000..228cfc974c --- /dev/null +++ b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/token-exchange/spec.md @@ -0,0 +1,122 @@ +# Spec: token-exchange + +RFC 8693 OAuth2 token exchange for per-user Kagenti authorization. + +## ADDED Requirements + +### Requirement: Token exchange configuration schema + +The system SHALL support an optional `tokenExchange` configuration block nested under `augment.kagenti.auth` with the following fields: + +- `enabled` (boolean, default: `false`) — whether to attempt per-user token exchange +- `audience` (string, default: value of `auth.clientId`) — the `audience` parameter in the RFC 8693 exchange request +- `userTokenHeader` (string, default: `x-user-oidc-token`) — the HTTP request header from which to read the user's OIDC access token + +The system SHALL reuse the parent `auth.tokenEndpoint`, `auth.clientId`, and `auth.clientSecret` for the exchange request. No new top-level config keys SHALL be introduced. + +#### Scenario: Token exchange defaults when not configured + +- **WHEN** no `tokenExchange` block is present in config +- **THEN** the system SHALL treat token exchange as disabled (`enabled: false`) and use only the service-account token + +#### Scenario: Token exchange with explicit config + +- **WHEN** `tokenExchange.enabled` is `true` and `audience` is set to `kagenti-api` +- **THEN** the system SHALL use `kagenti-api` as the audience parameter in exchange requests + +#### Scenario: Token exchange audience defaults to clientId + +- **WHEN** `tokenExchange.enabled` is `true` and `audience` is not specified +- **THEN** the system SHALL use the value of `auth.clientId` as the audience parameter + +### Requirement: RFC 8693 token exchange execution + +The `TokenExchangeManager` SHALL perform OAuth2 Token Exchange per RFC 8693 by sending a POST to the configured `tokenEndpoint` with: + +- `grant_type`: `urn:ietf:params:oauth:grant-type:token-exchange` +- `subject_token`: the user's OIDC access token +- `subject_token_type`: `urn:ietf:params:oauth:token-type:access_token` +- `audience`: the configured audience value +- `client_id` and `client_secret`: from the parent auth config + +The exchanged token SHALL preserve the user's `sub` claim and add an `act` (actor) claim identifying the service. + +#### Scenario: Successful token exchange + +- **WHEN** a valid user OIDC token is provided and Keycloak supports token exchange +- **THEN** the system SHALL return a Kagenti-scoped access token with the user's `sub` claim preserved + +#### Scenario: Exchange request format + +- **WHEN** a token exchange is initiated +- **THEN** the POST body SHALL include `grant_type`, `subject_token`, `subject_token_type`, `audience`, `client_id`, and `client_secret` as form-urlencoded parameters + +### Requirement: Per-user token caching + +The `TokenExchangeManager` SHALL cache exchanged tokens keyed by user identity. Cached tokens SHALL be reused for subsequent requests from the same user until they expire or are invalidated. + +#### Scenario: Cache hit for same user + +- **WHEN** a user's token has been successfully exchanged and cached and a new request arrives from the same user +- **THEN** the system SHALL return the cached exchanged token without making a new exchange request + +#### Scenario: Cache miss for different user + +- **WHEN** a request arrives from a user who has no cached exchanged token +- **THEN** the system SHALL perform a new token exchange for that user + +### Requirement: Concurrent request deduplication + +The `TokenExchangeManager` SHALL deduplicate concurrent exchange requests for the same user. If multiple requests for the same user arrive while an exchange is in-flight, all SHALL receive the result of the single in-flight exchange. + +#### Scenario: Concurrent requests for same user + +- **WHEN** three requests from the same user arrive while a token exchange is in progress +- **THEN** only one exchange request SHALL be made to Keycloak and all three requests SHALL receive the same exchanged token + +### Requirement: Streaming-aware token lifetime + +The `TokenExchangeManager` SHALL support streaming-aware token lifetime management. Streaming requests SHALL hold a reference that prevents the token from being evicted while the stream is active. + +#### Scenario: Token not evicted during active stream + +- **WHEN** a streaming request is using an exchanged token and the token's TTL expires +- **THEN** the system SHALL NOT evict the token until the stream completes + +### Requirement: Graceful fallback to service-account token + +The system SHALL fall back to the existing `KeycloakTokenManager` service-account token whenever per-user token exchange cannot complete. Fallback SHALL occur silently with a warning log — requests SHALL NOT fail due to exchange issues. + +#### Scenario: Token exchange disabled + +- **WHEN** `tokenExchange.enabled` is `false` or not configured +- **THEN** the system SHALL use the service-account token for all Kagenti requests + +#### Scenario: User OIDC token header absent + +- **WHEN** `tokenExchange.enabled` is `true` but the configured header is not present on the request +- **THEN** the system SHALL use the service-account token and log a debug message + +#### Scenario: Exchange call fails with network error + +- **WHEN** the exchange POST to Keycloak fails due to network error or timeout +- **THEN** the system SHALL log a warning and use the service-account token + +#### Scenario: Keycloak returns unsupported_grant_type + +- **WHEN** Keycloak returns 400 with `error: unsupported_grant_type` +- **THEN** the system SHALL log a warning and use the service-account token + +#### Scenario: Exchanged token rejected with 401 + +- **WHEN** Kagenti returns 401 for a request using an exchanged token +- **THEN** the system SHALL clear both the per-user exchanged token cache AND the service-account token cache, then retry with a fresh token + +### Requirement: No impact on ResponsesApiProvider + +The token exchange feature SHALL NOT affect `ResponsesApiProvider` or Llama Stack provider code paths. The widened `setUserContext` signature (`bearerToken?` optional second param) SHALL be backward-compatible. Providers that do not implement `setUserContext` SHALL be skipped by the `if (provider.setUserContext)` guard. + +#### Scenario: ResponsesApiProvider unaffected + +- **WHEN** a request is routed to `ResponsesApiProvider` +- **THEN** no token exchange logic SHALL execute and the provider SHALL use its static API key unchanged diff --git a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/user-token-routing/spec.md b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/user-token-routing/spec.md new file mode 100644 index 0000000000..ba0beec069 --- /dev/null +++ b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/user-token-routing/spec.md @@ -0,0 +1,69 @@ +# Spec: user-token-routing + +Route-level extraction and forwarding of user OIDC tokens from configurable request headers. + +## ADDED Requirements + +### Requirement: Configurable user token header on RouteContext + +The `RouteContext` type SHALL include an optional `userTokenHeader` field containing the header name from which to extract the user's OIDC token. The value SHALL be sourced dynamically from the `KagentiProvider` configuration. + +#### Scenario: RouteContext includes header name + +- **WHEN** a route handler accesses `RouteContext.userTokenHeader` +- **THEN** it SHALL return the header name configured in `augment.kagenti.auth.tokenExchange.userTokenHeader` + +#### Scenario: RouteContext when token exchange not configured + +- **WHEN** token exchange is not configured +- **THEN** `RouteContext.userTokenHeader` SHALL be `undefined` + +### Requirement: Router dynamic header getter + +The router SHALL create a dynamic getter on `RouteContext` that reads the header name from the `KagentiProvider` configuration. The getter SHALL resolve at request time so that provider initialization order does not matter. + +#### Scenario: Dynamic getter resolves from provider + +- **WHEN** a request arrives and `KagentiProvider` has been initialized with `tokenExchange.userTokenHeader: X-Forwarded-Access-Token` +- **THEN** the getter SHALL return `X-Forwarded-Access-Token` + +### Requirement: OIDC token extraction in chat routes + +The chat route handlers (both synchronous and streaming) SHALL extract the user's OIDC token from the configured header and pass it as the second argument to `provider.setUserContext(userRef, bearerToken)`. + +#### Scenario: Chat route extracts OIDC token + +- **WHEN** a chat request arrives with the configured OIDC header present +- **THEN** the route handler SHALL call `provider.setUserContext(userRef, tokenValue)` where `tokenValue` is the header value + +#### Scenario: Chat route with no OIDC header + +- **WHEN** a chat request arrives without the configured OIDC header +- **THEN** the route handler SHALL call `provider.setUserContext(userRef, undefined)` and the provider SHALL fall back to the service-account token + +### Requirement: OIDC token extraction in Kagenti routes + +The `/kagenti` middleware SHALL extract the user's OIDC token from the configured header and pass it to the Kagenti provider. + +#### Scenario: Kagenti middleware extracts OIDC token + +- **WHEN** a request to a `/kagenti` endpoint arrives with the configured OIDC header present +- **THEN** the middleware SHALL extract the token value and forward it to the provider + +### Requirement: Widened provider interface + +The `AugmentProvider` interface method `setUserContext` SHALL accept an optional second parameter `bearerToken?: string`. This widening SHALL be backward-compatible — existing providers that do not accept the second parameter SHALL continue to function. + +#### Scenario: Provider interface backward compatibility + +- **WHEN** a provider implements `setUserContext(userRef: string)` without the second parameter +- **THEN** calling `setUserContext(userRef, bearerToken)` SHALL NOT cause a runtime error + +### Requirement: X-Backstage-User header preserved + +The existing `X-Backstage-User` header SHALL continue to be sent alongside any exchanged token. Token exchange does not replace the informational user identity header. + +#### Scenario: Both headers present + +- **WHEN** a request to Kagenti uses an exchanged per-user token +- **THEN** the `X-Backstage-User` header SHALL still be included in the outgoing request for audit logging diff --git a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/tasks.md b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/tasks.md new file mode 100644 index 0000000000..2650bda810 --- /dev/null +++ b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/tasks.md @@ -0,0 +1,42 @@ +# Tasks: Per-User OAuth2 Token Exchange for Kagenti Provider + +## 1. Configuration Schema + +- [ ] 1.1 Add optional `tokenExchange` block to `plugins/augment-backend/config.d.ts` under `augment.kagenti.auth` with `enabled` (boolean), `audience` (string), and `userTokenHeader` (string) +- [ ] 1.2 Extend `KagentiConfig.auth` type in `plugins/augment-backend/src/providers/kagenti/config/KagentiConfigLoader.ts` with parsed `tokenExchange` fields (defaults: enabled=false, audience=clientId, userTokenHeader='x-user-oidc-token') + +## 2. Token Exchange Manager + +- [ ] 2.1 Create `plugins/augment-backend/src/providers/kagenti/client/TokenExchangeManager.ts` implementing RFC 8693 exchange with per-user caching keyed by user identity +- [ ] 2.2 Implement concurrent request deduplication — in-flight exchange for same user shared across waiting callers +- [ ] 2.3 Implement streaming-aware token lifetime — hold reference preventing eviction during active streams +- [ ] 2.4 Implement graceful error handling — catch exchange failures (network, 400 unsupported_grant_type, Keycloak errors), log warning, return null to signal fallback + +## 3. Request Core Integration + +- [ ] 3.1 Extend `RequestCoreContext` in `plugins/augment-backend/src/providers/kagenti/client/requestCore.ts` with optional `tokenExchangeManager` and `getUserBearerToken` fields +- [ ] 3.2 Update `doRequest()` to attempt per-user token exchange first, falling back to service-account token on null/error +- [ ] 3.3 Update `streamRequest()` with same exchange-first-then-fallback flow +- [ ] 3.4 Update `requestWithRetry()` to clear both per-user and service-account caches on 401, then retry with fresh token + +## 4. API Client and Provider Wiring + +- [ ] 4.1 Widen `KagentiRequestContext` in `plugins/augment-backend/src/providers/kagenti/client/KagentiApiClient.ts` with optional `bearerToken` field and wire `tokenExchangeManager` into `RequestCoreContext` +- [ ] 4.2 Widen `setUserContext` in `plugins/augment-backend/src/providers/providerInterface.ts` to `setUserContext?(userRef: string, bearerToken?: string): void` +- [ ] 4.3 Update `KagentiProvider.initialize()` to create `TokenExchangeManager` when config enables token exchange, and pass it to the API client +- [ ] 4.4 Update `KagentiProvider.ensureClientUrl()` to also wire the `TokenExchangeManager` into the API client +- [ ] 4.5 Implement `KagentiProvider.getUserTokenHeader()` getter returning the configured header name + +## 5. Route-Level Token Extraction + +- [ ] 5.1 Add optional `userTokenHeader` to `RouteContext` in `plugins/augment-backend/src/routes/types.ts` +- [ ] 5.2 Create dynamic getter on `RouteContext` in `plugins/augment-backend/src/router.ts` that reads header name from `KagentiProvider` config +- [ ] 5.3 Update `plugins/augment-backend/src/routes/chatRoutes.ts` — both sync and streaming handlers extract OIDC token from configured header and pass to `setUserContext(userRef, bearerToken)` +- [ ] 5.4 Update `plugins/augment-backend/src/routes/kagentiRoutes.ts` — `/kagenti` middleware extracts OIDC token from configured header + +## 6. Verification + +- [ ] 6.1 Verify `npx tsc --noEmit` passes clean with all changes +- [ ] 6.2 Write unit tests for `TokenExchangeManager` — exchange execution, caching, concurrent dedup, fallback on error, streaming lifetime, clearUserCache/clearAllCache +- [ ] 6.3 Verify backward compatibility — behavior identical with `tokenExchange` absent from config +- [ ] 6.4 Verify `ResponsesApiProvider` and Llama Stack paths are completely unaffected From dd2b59e90ade3963d4c118f8f90c5db38c5d10a4 Mon Sep 17 00:00:00 2001 From: gabemontero Date: Tue, 9 Jun 2026 17:30:37 -0400 Subject: [PATCH 2/6] docs(augment): address PR review feedback on Kagenti token exchange proposal - List supported audience values (Kagenti API client ID, RHDH client ID, or any Keycloak client with exchange permissions) - Use fully qualified `auth.tokenEndpoint` consistently - Add note explaining unsupported_grant_type is a defensive fallback, broaden language from Keycloak to IdP - Defer refresh token support with rationale for re-exchange-on-expiry - Remove duplicate "token exchange disabled" scenario, merge into single "disabled or not configured" scenario - Add inline examples to userTokenHeader in user-token-routing spec - Clarify custom auth mechanism support in Decision 1 - Add log severity levels to Decision 3 fallback cases, document fail-hard as considered-and-rejected alternative - Add Prerequisites section documenting deployment requirements - Explain OIDC token refresh responsibility lies with injection layer Co-Authored-By: Claude Opus 4.6 Signed-off-by: gabemontero --- .../design.md | 27 +++++++++++++----- .../specs/token-exchange/spec.md | 28 ++++++++++--------- .../specs/user-token-routing/spec.md | 2 +- 3 files changed, 36 insertions(+), 21 deletions(-) diff --git a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/design.md b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/design.md index 220df16806..44fb548abf 100644 --- a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/design.md +++ b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/design.md @@ -38,6 +38,9 @@ Accept the user's OIDC token from a configurable request header rather than coup 1. An auth proxy (oauth2-proxy, Keycloak Gatekeeper) — many RHDH deployments already have one 2. A future frontend change when Backstage adds optional API deps 3. Custom middleware extracting the OIDC token from the session +4. A customer's own auth infrastructure — any mechanism that can place a valid OIDC-compatible access token into the configured HTTP header + +**Custom auth mechanisms:** The configurable header approach is deliberately auth-provider-agnostic. The system does not assume Keycloak or any specific IdP — it only requires that (a) a valid OIDC access token arrives in the configured header, and (b) the configured `auth.tokenEndpoint` supports RFC 8693 token exchange for that token. Customers using custom auth can integrate by configuring their auth layer to inject the user's token into the header and pointing `auth.tokenEndpoint` at their IdP's token endpoint. **Alternative considered:** Frontend injection via `oidcAuthApiRef`. Rejected because `createApiFactory` hard dependency would break non-OIDC deployments. @@ -51,14 +54,16 @@ Create a dedicated manager class rather than extending `KeycloakTokenManager`. T ### Decision 3: Graceful fallback at every stage -The implementation never blocks functionality. At every point where exchange is attempted, failure falls back to the existing service-account token: +The implementation never blocks functionality. At every point where exchange is attempted, failure falls back to the existing service-account token. However, to avoid masking misconfigurations, fallback cases are logged at different severity levels depending on whether they indicate a problem: + +- Config disabled or header absent → service-account token directly (debug-level log — this is expected in deployments that don't use token exchange or for unauthenticated requests) +- Exchange call fails (network, IdP error) → try/catch, **warn**-level log with error details, service-account fallback +- Exchanged token rejected (401) → **warn**-level log, clear both caches, retry with fresh token +- IdP doesn't support exchange (400 `unsupported_grant_type`) → **warn**-level log, service-account fallback -- Config disabled or header absent → service-account token directly -- Exchange call fails (network, Keycloak error) → try/catch, warn, service-account -- Exchanged token rejected (401) → clear both caches, retry with fresh token -- Keycloak doesn't support exchange (400 `unsupported_grant_type`) → catch, warn, service-account +Warning-level logs for unexpected failures ensure that admins who enable token exchange can diagnose issues via standard log monitoring. The system does not fail hard because blocking user requests due to an exchange misconfiguration is worse than falling back to the pre-existing service-account behavior — the user still gets a response, and the admin gets a warning to investigate. -Worst case for a misconfigured deployment is a warning log and fallback to pre-existing behavior. +**Alternative considered:** Failing hard when exchange is enabled but fails. Rejected because this would turn a misconfiguration into an outage — the service-account token path is known to work and is the pre-existing behavior. The warning logs provide the diagnostic signal without the blast radius. ### Decision 4: Widen `setUserContext` with optional second parameter @@ -80,10 +85,18 @@ augment: This reuses the existing `tokenEndpoint`, `clientId`, and `clientSecret` from the parent auth block. No new top-level config keys. +## Prerequisites + +For per-user token exchange to function, the deployment must have: + +1. **A mechanism to inject the user's OIDC access token** into the configured HTTP header (default: `x-user-oidc-token`). Typical options include an auth proxy (oauth2-proxy, Keycloak Gatekeeper), custom middleware, or a customer's own auth infrastructure. Without this, the system falls back to the service-account token. +2. **An IdP token endpoint that supports RFC 8693 token exchange** (configured via `auth.tokenEndpoint`). For Keycloak, `token-exchange-standard:v2` is enabled by default in modern versions. The requesting client (`auth.clientId`) must have permission to exchange tokens for the target audience. +3. **The existing `auth.clientId`, `auth.clientSecret`, and `auth.tokenEndpoint`** must already be configured for the Kagenti provider's service-account flow. + ## Risks / Trade-offs - **Auth proxy not configured** → No OIDC token in header → falls back to service-account silently. Deployments without an auth proxy get no per-user auth until they add one or a frontend solution is built. - **Keycloak doesn't support token exchange** → Returns 400 `unsupported_grant_type`. Caught, warned, falls back. Requires Keycloak admin to enable token exchange on the realm. -- **OIDC token expired** → Exchange fails, caught, falls back. Short-lived tokens may cause frequent fallbacks; auth proxy should handle refresh. +- **OIDC token expired** → Exchange fails, caught, falls back. Short-lived tokens may cause frequent fallbacks. The backend plugin cannot refresh the user's OIDC token because it only receives the access token via the HTTP header — it does not have the user's refresh token. Keeping the user's OIDC token alive is the responsibility of the layer that injects it (auth proxy, frontend, or customer auth infrastructure). For example, oauth2-proxy handles token refresh transparently and forwards a valid access token on each request. - **Memory** → Per-user cache bounded by concurrent users (~2KB per entry × 1000 users = ~2MB). Acceptable for backend plugin. - **Security surface** → The configurable header must be trusted. If an attacker can inject the header, they can impersonate users. Mitigated by typical auth proxy architectures stripping/overwriting upstream headers. diff --git a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/token-exchange/spec.md b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/token-exchange/spec.md index 228cfc974c..6ed5873aec 100644 --- a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/token-exchange/spec.md +++ b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/token-exchange/spec.md @@ -9,15 +9,18 @@ RFC 8693 OAuth2 token exchange for per-user Kagenti authorization. The system SHALL support an optional `tokenExchange` configuration block nested under `augment.kagenti.auth` with the following fields: - `enabled` (boolean, default: `false`) — whether to attempt per-user token exchange -- `audience` (string, default: value of `auth.clientId`) — the `audience` parameter in the RFC 8693 exchange request +- `audience` (string, default: value of `auth.clientId`) — the `audience` parameter in the RFC 8693 exchange request. Must be a valid Keycloak client ID that is configured to permit token exchange from the requesting client (`auth.clientId`). Supported values: + - The Kagenti API client ID (e.g., `kagenti-api`) — the typical production value, targeting the Keycloak client that represents the Kagenti service + - The RHDH/Backstage client ID (the value of `auth.clientId`) — the default when no explicit audience is set + - Any other Keycloak client ID that has token exchange permissions granted for the requesting client - `userTokenHeader` (string, default: `x-user-oidc-token`) — the HTTP request header from which to read the user's OIDC access token The system SHALL reuse the parent `auth.tokenEndpoint`, `auth.clientId`, and `auth.clientSecret` for the exchange request. No new top-level config keys SHALL be introduced. -#### Scenario: Token exchange defaults when not configured +#### Scenario: Token exchange disabled or not configured -- **WHEN** no `tokenExchange` block is present in config -- **THEN** the system SHALL treat token exchange as disabled (`enabled: false`) and use only the service-account token +- **WHEN** `tokenExchange.enabled` is `false` or no `tokenExchange` block is present in config +- **THEN** the system SHALL treat token exchange as disabled and use only the service-account token for all Kagenti requests #### Scenario: Token exchange with explicit config @@ -31,7 +34,7 @@ The system SHALL reuse the parent `auth.tokenEndpoint`, `auth.clientId`, and `au ### Requirement: RFC 8693 token exchange execution -The `TokenExchangeManager` SHALL perform OAuth2 Token Exchange per RFC 8693 by sending a POST to the configured `tokenEndpoint` with: +The `TokenExchangeManager` SHALL perform OAuth2 Token Exchange per RFC 8693 by sending a POST to the configured `auth.tokenEndpoint` with: - `grant_type`: `urn:ietf:params:oauth:grant-type:token-exchange` - `subject_token`: the user's OIDC access token @@ -53,7 +56,9 @@ The exchanged token SHALL preserve the user's `sub` claim and add an `act` (acto ### Requirement: Per-user token caching -The `TokenExchangeManager` SHALL cache exchanged tokens keyed by user identity. Cached tokens SHALL be reused for subsequent requests from the same user until they expire or are invalidated. +The `TokenExchangeManager` SHALL cache exchanged tokens keyed by user identity. Cached tokens SHALL be reused for subsequent requests from the same user until they expire or are invalidated. When a cached token expires, the system SHALL perform a new token exchange rather than using a refresh token. + +> **Deferred:** RFC 8693 responses may include a `refresh_token` that could be used to silently renew exchanged tokens without a full re-exchange. This is deferred as a future enhancement due to the added complexity of per-user refresh token tracking, refresh failure handling, and the fact that Keycloak's token exchange does not always return a refresh token depending on client configuration. #### Scenario: Cache hit for same user @@ -87,11 +92,6 @@ The `TokenExchangeManager` SHALL support streaming-aware token lifetime manageme The system SHALL fall back to the existing `KeycloakTokenManager` service-account token whenever per-user token exchange cannot complete. Fallback SHALL occur silently with a warning log — requests SHALL NOT fail due to exchange issues. -#### Scenario: Token exchange disabled - -- **WHEN** `tokenExchange.enabled` is `false` or not configured -- **THEN** the system SHALL use the service-account token for all Kagenti requests - #### Scenario: User OIDC token header absent - **WHEN** `tokenExchange.enabled` is `true` but the configured header is not present on the request @@ -102,9 +102,11 @@ The system SHALL fall back to the existing `KeycloakTokenManager` service-accoun - **WHEN** the exchange POST to Keycloak fails due to network error or timeout - **THEN** the system SHALL log a warning and use the service-account token -#### Scenario: Keycloak returns unsupported_grant_type +#### Scenario: IdP returns unsupported_grant_type + +> **Note:** Modern Keycloak versions enable `token-exchange-standard:v2` by default, making this scenario unlikely in typical deployments. It remains as a defensive fallback for older Keycloak versions, realm/client-level policies that restrict token exchange permissions, or non-Keycloak OIDC providers that do not support RFC 8693. -- **WHEN** Keycloak returns 400 with `error: unsupported_grant_type` +- **WHEN** the IdP returns 400 with `error: unsupported_grant_type` - **THEN** the system SHALL log a warning and use the service-account token #### Scenario: Exchanged token rejected with 401 diff --git a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/user-token-routing/spec.md b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/user-token-routing/spec.md index ba0beec069..6b70cf707a 100644 --- a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/user-token-routing/spec.md +++ b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/user-token-routing/spec.md @@ -6,7 +6,7 @@ Route-level extraction and forwarding of user OIDC tokens from configurable requ ### Requirement: Configurable user token header on RouteContext -The `RouteContext` type SHALL include an optional `userTokenHeader` field containing the header name from which to extract the user's OIDC token. The value SHALL be sourced dynamically from the `KagentiProvider` configuration. +The `RouteContext` type SHALL include an optional `userTokenHeader` field containing the header name from which to extract the user's OIDC token (e.g., `x-user-oidc-token` or `X-Forwarded-Access-Token`). The value SHALL be sourced dynamically from the `KagentiProvider` configuration. #### Scenario: RouteContext includes header name From 33bdf8bc278771d172548db0faba1184392500fc Mon Sep 17 00:00:00 2001 From: gabemontero Date: Wed, 10 Jun 2026 15:34:56 -0400 Subject: [PATCH 3/6] docs(augment): add frontend OIDC discovery as primary token acquisition path Adopt the orchestrator's useApiHolder() pattern for runtime OIDC provider discovery, making it the primary path for acquiring the user's OIDC token for RFC 8693 exchange. The header-based approach becomes the fallback for non-OIDC deployments where frontend discovery is not available. - Rewrite Decision 1 from "backend-only with configurable header" to "frontend OIDC discovery with header-based fallback" - Add new user-token-routing requirement for frontend OIDC token acquisition via useApiHolder() and the orchestrator's findCustomProvider pattern, with scenarios for first-time login, existing session, and provider-not-discoverable - Update chat route token extraction to account for both frontend-acquired and header-based token sources - Add risk for useApiHolder() private API fragility (apiHolder.apis accessed via @ts-ignore), mitigated by orchestrator already shipping this pattern in production RHDH - Update prerequisites, goals, and config documentation to reflect dual-path acquisition Co-Authored-By: Claude Opus 4.6 Signed-off-by: gabemontero --- .../design.md | 36 ++++++++------- .../specs/token-exchange/spec.md | 6 +-- .../specs/user-token-routing/spec.md | 46 +++++++++++++++---- 3 files changed, 61 insertions(+), 27 deletions(-) diff --git a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/design.md b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/design.md index 44fb548abf..c0810ec687 100644 --- a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/design.md +++ b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/design.md @@ -8,22 +8,20 @@ Kagenti supports RFC 8693 OAuth2 Token Exchange, which can swap a user's OIDC ac **Key constraint:** Backstage replaces the user's Keycloak OIDC token with its own JWT before it reaches backend plugins. `req.headers.authorization` contains a Backstage-minted token, not the user's Keycloak token. The user's original OIDC token must arrive via a separate mechanism. -**Frontend constraint:** Backstage's `createApiFactory` requires all `deps` to be resolvable — there is no optional dependency concept. Adding `oidcAuthApiRef` as a hard dependency would crash deployments that don't use OIDC auth. `useApi()` throws synchronously during render if the API isn't registered. +**Frontend constraint:** Backstage's `createApiFactory` requires all `deps` to be resolvable — there is no optional dependency concept. Adding `oidcAuthApiRef` as a hard dependency would crash deployments that don't use OIDC auth. `useApi()` throws synchronously during render if the API isn't registered. However, `useApiHolder()` provides dynamic discovery at runtime — the orchestrator plugin already uses this pattern to find custom auth providers without static dependencies (see `useOrchestratorAuth.ts`). ## Goals / Non-Goals **Goals:** - Enable per-user authorization at the Kagenti layer via RFC 8693 token exchange -- Keep the change backend-only — no frontend plugin modifications +- Acquire the user's OIDC token via frontend discovery (primary) or configurable header (fallback) - Graceful fallback to service-account token at every failure point - Make per-user token exchange opt-in and disabled by default -- Support configurable header source for user OIDC tokens - Zero impact on `ResponsesApiProvider` or Llama Stack code paths **Non-Goals:** -- Frontend OIDC token injection (deferred to future work or auth proxy) - Changes to the `KeycloakTokenManager` service-account flow - Per-user authorization for non-Kagenti providers - Custom Keycloak realm or client configuration tooling @@ -31,18 +29,23 @@ Kagenti supports RFC 8693 OAuth2 Token Exchange, which can swap a user's OIDC ac ## Decisions -### Decision 1: Backend-only with configurable header source +### Decision 1: Frontend OIDC discovery with header-based fallback -Accept the user's OIDC token from a configurable request header rather than coupling the frontend to OIDC auth. The token can be injected by: +The primary token acquisition path uses `useApiHolder()` to dynamically discover the OIDC auth provider at runtime — the same pattern the orchestrator plugin uses in `useOrchestratorAuth.ts`. When the user first interacts with Kagenti, the frontend discovers the OIDC provider via the API holder, triggers a login prompt if needed, and sends the OIDC token to the backend for RFC 8693 exchange. This avoids the `createApiFactory` hard dependency problem while giving users an explicit "connect to Kagenti" experience. + +When the OIDC provider is not discoverable (non-Keycloak deployments, headless environments), the system falls back to reading the user's OIDC token from a configurable request header. The header can be populated by: 1. An auth proxy (oauth2-proxy, Keycloak Gatekeeper) — many RHDH deployments already have one -2. A future frontend change when Backstage adds optional API deps -3. Custom middleware extracting the OIDC token from the session -4. A customer's own auth infrastructure — any mechanism that can place a valid OIDC-compatible access token into the configured HTTP header +2. Custom middleware extracting the OIDC token from the session +3. A customer's own auth infrastructure — any mechanism that can place a valid OIDC-compatible access token into the configured HTTP header + +**Custom auth mechanisms:** The header-based fallback is deliberately auth-provider-agnostic. The system does not assume Keycloak or any specific IdP — it only requires that (a) a valid OIDC access token arrives in the configured header, and (b) the configured `auth.tokenEndpoint` supports RFC 8693 token exchange for that token. + +**Why `useApiHolder()` and not `useApi()`:** `useApi()` throws synchronously if the API isn't registered, and `createApiFactory` has no optional dependency concept. `useApiHolder()` returns `undefined` for unregistered APIs, allowing graceful detection. The orchestrator uses this for custom providers (`findCustomProvider` in `useOrchestratorAuth.ts`), including accessing `internal.auth.oidc` via the API holder's internal map. -**Custom auth mechanisms:** The configurable header approach is deliberately auth-provider-agnostic. The system does not assume Keycloak or any specific IdP — it only requires that (a) a valid OIDC access token arrives in the configured header, and (b) the configured `auth.tokenEndpoint` supports RFC 8693 token exchange for that token. Customers using custom auth can integrate by configuring their auth layer to inject the user's token into the header and pointing `auth.tokenEndpoint` at their IdP's token endpoint. +**Alternative considered:** `createApiFactory` with `oidcAuthApiRef` as a hard dependency. Rejected because it would crash non-OIDC deployments. -**Alternative considered:** Frontend injection via `oidcAuthApiRef`. Rejected because `createApiFactory` hard dependency would break non-OIDC deployments. +**Alternative considered:** Header-only (no frontend). Rejected because it requires auth proxy configuration for the common Keycloak case, adds deployment complexity, and misses the opportunity to give users an explicit login-to-Kagenti action. **Alternative considered:** Backstage middleware extracting from session. Rejected because it couples to Backstage auth internals and requires session storage access. @@ -80,23 +83,24 @@ augment: tokenExchange: enabled: true # default: false audience: kagenti-api # default: auth.clientId - userTokenHeader: X-Forwarded-Access-Token # default: x-user-oidc-token + userTokenHeader: X-Forwarded-Access-Token # default: x-user-oidc-token (fallback only) ``` -This reuses the existing `tokenEndpoint`, `clientId`, and `clientSecret` from the parent auth block. No new top-level config keys. +This reuses the existing `tokenEndpoint`, `clientId`, and `clientSecret` from the parent auth block. No new top-level config keys. The `userTokenHeader` is only used when frontend OIDC discovery is not available — in Keycloak deployments where discovery succeeds, the frontend acquires the token directly and the header config is unused. ## Prerequisites For per-user token exchange to function, the deployment must have: -1. **A mechanism to inject the user's OIDC access token** into the configured HTTP header (default: `x-user-oidc-token`). Typical options include an auth proxy (oauth2-proxy, Keycloak Gatekeeper), custom middleware, or a customer's own auth infrastructure. Without this, the system falls back to the service-account token. +1. **An OIDC auth provider accessible via the Backstage API holder** (primary path) — in RHDH Keycloak deployments, this is typically `internal.auth.oidc`. The frontend discovers this at runtime via `useApiHolder()`. **OR** a mechanism to inject the user's OIDC access token into the configured HTTP header (fallback path, default: `x-user-oidc-token`) — typical options include an auth proxy (oauth2-proxy, Keycloak Gatekeeper), custom middleware, or a customer's own auth infrastructure. Without either, the system falls back to the service-account token. 2. **An IdP token endpoint that supports RFC 8693 token exchange** (configured via `auth.tokenEndpoint`). For Keycloak, `token-exchange-standard:v2` is enabled by default in modern versions. The requesting client (`auth.clientId`) must have permission to exchange tokens for the target audience. 3. **The existing `auth.clientId`, `auth.clientSecret`, and `auth.tokenEndpoint`** must already be configured for the Kagenti provider's service-account flow. ## Risks / Trade-offs -- **Auth proxy not configured** → No OIDC token in header → falls back to service-account silently. Deployments without an auth proxy get no per-user auth until they add one or a frontend solution is built. +- **OIDC provider not discoverable** → Frontend discovery returns `undefined` and no header token present → falls back to service-account silently. Non-OIDC deployments without a header injection mechanism get no per-user auth. - **Keycloak doesn't support token exchange** → Returns 400 `unsupported_grant_type`. Caught, warned, falls back. Requires Keycloak admin to enable token exchange on the realm. -- **OIDC token expired** → Exchange fails, caught, falls back. Short-lived tokens may cause frequent fallbacks. The backend plugin cannot refresh the user's OIDC token because it only receives the access token via the HTTP header — it does not have the user's refresh token. Keeping the user's OIDC token alive is the responsibility of the layer that injects it (auth proxy, frontend, or customer auth infrastructure). For example, oauth2-proxy handles token refresh transparently and forwards a valid access token on each request. +- **OIDC token expired** → Exchange fails, caught, falls back. Short-lived tokens may cause frequent fallbacks. When the token is acquired via frontend discovery, the OIDC provider may handle refresh transparently via its `getIdToken()` implementation. When acquired via header, the backend cannot refresh the user's token — keeping it alive is the responsibility of the injecting layer (auth proxy or customer infrastructure). - **Memory** → Per-user cache bounded by concurrent users (~2KB per entry × 1000 users = ~2MB). Acceptable for backend plugin. +- **`useApiHolder()` internal API access** → The orchestrator's `findCustomProvider` accesses `apiHolder.apis` (a private `Map`) via `@ts-ignore` to discover statically-registered auth providers like `internal.auth.oidc`. This is not a public Backstage API and could break if Backstage changes the internal representation. Mitigated by the fact that the orchestrator already ships this pattern in production RHDH, and the header-based fallback provides a working alternative if the internal access breaks. - **Security surface** → The configurable header must be trusted. If an attacker can inject the header, they can impersonate users. Mitigated by typical auth proxy architectures stripping/overwriting upstream headers. diff --git a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/token-exchange/spec.md b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/token-exchange/spec.md index 6ed5873aec..294671923e 100644 --- a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/token-exchange/spec.md +++ b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/token-exchange/spec.md @@ -13,7 +13,7 @@ The system SHALL support an optional `tokenExchange` configuration block nested - The Kagenti API client ID (e.g., `kagenti-api`) — the typical production value, targeting the Keycloak client that represents the Kagenti service - The RHDH/Backstage client ID (the value of `auth.clientId`) — the default when no explicit audience is set - Any other Keycloak client ID that has token exchange permissions granted for the requesting client -- `userTokenHeader` (string, default: `x-user-oidc-token`) — the HTTP request header from which to read the user's OIDC access token +- `userTokenHeader` (string, default: `x-user-oidc-token`) — the HTTP request header from which to read the user's OIDC access token when frontend API holder discovery is not available (fallback path) The system SHALL reuse the parent `auth.tokenEndpoint`, `auth.clientId`, and `auth.clientSecret` for the exchange request. No new top-level config keys SHALL be introduced. @@ -92,9 +92,9 @@ The `TokenExchangeManager` SHALL support streaming-aware token lifetime manageme The system SHALL fall back to the existing `KeycloakTokenManager` service-account token whenever per-user token exchange cannot complete. Fallback SHALL occur silently with a warning log — requests SHALL NOT fail due to exchange issues. -#### Scenario: User OIDC token header absent +#### Scenario: No user OIDC token from any source -- **WHEN** `tokenExchange.enabled` is `true` but the configured header is not present on the request +- **WHEN** `tokenExchange.enabled` is `true` but no OIDC token was provided by the frontend and the configured header is not present on the request - **THEN** the system SHALL use the service-account token and log a debug message #### Scenario: Exchange call fails with network error diff --git a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/user-token-routing/spec.md b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/user-token-routing/spec.md index 6b70cf707a..9b99ae4f99 100644 --- a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/user-token-routing/spec.md +++ b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/user-token-routing/spec.md @@ -1,12 +1,37 @@ # Spec: user-token-routing -Route-level extraction and forwarding of user OIDC tokens from configurable request headers. +Acquisition and forwarding of user OIDC tokens — via frontend API holder discovery (primary) or configurable request headers (fallback). ## ADDED Requirements -### Requirement: Configurable user token header on RouteContext +### Requirement: Frontend OIDC token acquisition via API holder discovery -The `RouteContext` type SHALL include an optional `userTokenHeader` field containing the header name from which to extract the user's OIDC token (e.g., `x-user-oidc-token` or `X-Forwarded-Access-Token`). The value SHALL be sourced dynamically from the `KagentiProvider` configuration. +The frontend SHALL discover the OIDC auth provider at runtime using `useApiHolder()` and the orchestrator's `findCustomProvider` pattern. When Kagenti token exchange is enabled and the user initiates a Kagenti interaction: + +1. Attempt to discover the OIDC auth provider via `useApp().getPlugins()` API enumeration +2. If not found, attempt to discover via the API holder's internal map (matching the orchestrator's `apiHolder.apis` pattern for `internal.auth.oidc`) +3. If found, call `getIdToken()` to obtain the user's OIDC token (which triggers a login prompt if the user hasn't authenticated with the OIDC provider yet) +4. Send the OIDC token to the backend via the request for RFC 8693 exchange +5. If the OIDC provider is not discoverable, fall through to the header-based path + +#### Scenario: OIDC provider discovered and user authenticates + +- **WHEN** the OIDC auth provider is discoverable via the API holder and the user has not yet authenticated +- **THEN** the frontend SHALL trigger the OIDC login flow and, on success, send the obtained token to the backend + +#### Scenario: OIDC provider discovered and user already authenticated + +- **WHEN** the OIDC auth provider is discoverable and the user has an active OIDC session +- **THEN** the frontend SHALL obtain the token via `getIdToken()` without prompting and send it to the backend + +#### Scenario: OIDC provider not discoverable + +- **WHEN** the OIDC auth provider is not found via plugin API enumeration or the API holder's internal map +- **THEN** the frontend SHALL not attempt OIDC login and the system SHALL fall back to reading the token from the configured request header + +### Requirement: Configurable user token header on RouteContext (fallback path) + +The `RouteContext` type SHALL include an optional `userTokenHeader` field containing the header name from which to extract the user's OIDC token (e.g., `x-user-oidc-token` or `X-Forwarded-Access-Token`) when the frontend OIDC discovery path is not available. The value SHALL be sourced dynamically from the `KagentiProvider` configuration. #### Scenario: RouteContext includes header name @@ -29,16 +54,21 @@ The router SHALL create a dynamic getter on `RouteContext` that reads the header ### Requirement: OIDC token extraction in chat routes -The chat route handlers (both synchronous and streaming) SHALL extract the user's OIDC token from the configured header and pass it as the second argument to `provider.setUserContext(userRef, bearerToken)`. +The chat route handlers (both synchronous and streaming) SHALL extract the user's OIDC token — either from the frontend-provided token (sent as part of the request when acquired via API holder discovery) or from the configured header (fallback path) — and pass it as the second argument to `provider.setUserContext(userRef, bearerToken)`. + +#### Scenario: Chat route with frontend-acquired OIDC token + +- **WHEN** a chat request arrives with an OIDC token acquired via frontend API holder discovery +- **THEN** the route handler SHALL call `provider.setUserContext(userRef, tokenValue)` -#### Scenario: Chat route extracts OIDC token +#### Scenario: Chat route with header-based OIDC token -- **WHEN** a chat request arrives with the configured OIDC header present +- **WHEN** a chat request arrives without a frontend-acquired token but with the configured OIDC header present - **THEN** the route handler SHALL call `provider.setUserContext(userRef, tokenValue)` where `tokenValue` is the header value -#### Scenario: Chat route with no OIDC header +#### Scenario: Chat route with no OIDC token from either source -- **WHEN** a chat request arrives without the configured OIDC header +- **WHEN** a chat request arrives without a frontend-acquired token and without the configured OIDC header - **THEN** the route handler SHALL call `provider.setUserContext(userRef, undefined)` and the provider SHALL fall back to the service-account token ### Requirement: OIDC token extraction in Kagenti routes From 589827c50cea7257f0fb6d25e04083107b5022f9 Mon Sep 17 00:00:00 2001 From: gabemontero Date: Sun, 21 Jun 2026 23:45:22 -0400 Subject: [PATCH 4/6] docs(augment): address PR review - scope to backend-only, add configurable fallback - Clarify phasing: this change is backend-only (header-based OIDC token acquisition); frontend OIDC discovery via useApiHolder() is a planned follow-up - Add fallbackToServiceAccount config option (default: true) so strict security environments can opt out of silent fallback to service-account token - Update design.md, proposal.md, specs, and tasks.md for consistency Co-Authored-By: Claude Opus 4.6 Signed-off-by: gabemontero --- .../design.md | 50 +++++++++++-------- .../proposal.md | 4 +- .../specs/token-exchange/spec.md | 40 +++++++++++---- .../specs/user-token-routing/spec.md | 50 ++++--------------- .../tasks.md | 10 ++-- 5 files changed, 77 insertions(+), 77 deletions(-) diff --git a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/design.md b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/design.md index c0810ec687..a01bd3e31d 100644 --- a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/design.md +++ b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/design.md @@ -15,8 +15,8 @@ Kagenti supports RFC 8693 OAuth2 Token Exchange, which can swap a user's OIDC ac **Goals:** - Enable per-user authorization at the Kagenti layer via RFC 8693 token exchange -- Acquire the user's OIDC token via frontend discovery (primary) or configurable header (fallback) -- Graceful fallback to service-account token at every failure point +- Acquire the user's OIDC token via configurable request header (frontend OIDC discovery via `useApiHolder()` is a planned follow-up) +- Configurable fallback behavior: graceful fallback to service-account token by default, with an option to fail hard for strict security environments - Make per-user token exchange opt-in and disabled by default - Zero impact on `ResponsesApiProvider` or Llama Stack code paths @@ -29,23 +29,17 @@ Kagenti supports RFC 8693 OAuth2 Token Exchange, which can swap a user's OIDC ac ## Decisions -### Decision 1: Frontend OIDC discovery with header-based fallback +### Decision 1: Header-based OIDC token acquisition (frontend discovery as follow-up) -The primary token acquisition path uses `useApiHolder()` to dynamically discover the OIDC auth provider at runtime — the same pattern the orchestrator plugin uses in `useOrchestratorAuth.ts`. When the user first interacts with Kagenti, the frontend discovers the OIDC provider via the API holder, triggers a login prompt if needed, and sends the OIDC token to the backend for RFC 8693 exchange. This avoids the `createApiFactory` hard dependency problem while giving users an explicit "connect to Kagenti" experience. - -When the OIDC provider is not discoverable (non-Keycloak deployments, headless environments), the system falls back to reading the user's OIDC token from a configurable request header. The header can be populated by: +**This change implements the header-based path only.** The user's OIDC token is read from a configurable request header (default: `x-user-oidc-token`). The header can be populated by: 1. An auth proxy (oauth2-proxy, Keycloak Gatekeeper) — many RHDH deployments already have one 2. Custom middleware extracting the OIDC token from the session 3. A customer's own auth infrastructure — any mechanism that can place a valid OIDC-compatible access token into the configured HTTP header -**Custom auth mechanisms:** The header-based fallback is deliberately auth-provider-agnostic. The system does not assume Keycloak or any specific IdP — it only requires that (a) a valid OIDC access token arrives in the configured header, and (b) the configured `auth.tokenEndpoint` supports RFC 8693 token exchange for that token. - -**Why `useApiHolder()` and not `useApi()`:** `useApi()` throws synchronously if the API isn't registered, and `createApiFactory` has no optional dependency concept. `useApiHolder()` returns `undefined` for unregistered APIs, allowing graceful detection. The orchestrator uses this for custom providers (`findCustomProvider` in `useOrchestratorAuth.ts`), including accessing `internal.auth.oidc` via the API holder's internal map. +**Custom auth mechanisms:** The header-based path is deliberately auth-provider-agnostic. The system does not assume Keycloak or any specific IdP — it only requires that (a) a valid OIDC access token arrives in the configured header, and (b) the configured `auth.tokenEndpoint` supports RFC 8693 token exchange for that token. -**Alternative considered:** `createApiFactory` with `oidcAuthApiRef` as a hard dependency. Rejected because it would crash non-OIDC deployments. - -**Alternative considered:** Header-only (no frontend). Rejected because it requires auth proxy configuration for the common Keycloak case, adds deployment complexity, and misses the opportunity to give users an explicit login-to-Kagenti action. +**Planned follow-up — frontend OIDC discovery:** A subsequent change will add frontend OIDC discovery using `useApiHolder()` to dynamically discover the OIDC auth provider at runtime — the same pattern the orchestrator plugin uses in `useOrchestratorAuth.ts`. When the user first interacts with Kagenti, the frontend would discover the OIDC provider via the API holder, trigger a login prompt if needed, and send the OIDC token to the backend for RFC 8693 exchange. This avoids the `createApiFactory` hard dependency problem (which throws synchronously if the API isn't registered) while giving users an explicit "connect to Kagenti" experience. The header-based path implemented in this change will serve as the fallback when the OIDC provider is not discoverable (non-Keycloak deployments, headless environments). **Alternative considered:** Backstage middleware extracting from session. Rejected because it couples to Backstage auth internals and requires session storage access. @@ -55,18 +49,29 @@ Create a dedicated manager class rather than extending `KeycloakTokenManager`. T **Alternative considered:** Extending `KeycloakTokenManager` with user-keyed methods. Rejected because the two have different lifecycles (system-wide vs. per-user), different cache semantics, and mixing them would complicate the clean fallback logic. -### Decision 3: Graceful fallback at every stage +### Decision 3: Configurable fallback behavior + +The default behavior falls back to the existing service-account token whenever exchange cannot complete. A new `fallbackToServiceAccount` config option (default: `true`) controls this behavior: -The implementation never blocks functionality. At every point where exchange is attempted, failure falls back to the existing service-account token. However, to avoid masking misconfigurations, fallback cases are logged at different severity levels depending on whether they indicate a problem: +**When `fallbackToServiceAccount: true` (default):** - Config disabled or header absent → service-account token directly (debug-level log — this is expected in deployments that don't use token exchange or for unauthenticated requests) - Exchange call fails (network, IdP error) → try/catch, **warn**-level log with error details, service-account fallback - Exchanged token rejected (401) → **warn**-level log, clear both caches, retry with fresh token - IdP doesn't support exchange (400 `unsupported_grant_type`) → **warn**-level log, service-account fallback -Warning-level logs for unexpected failures ensure that admins who enable token exchange can diagnose issues via standard log monitoring. The system does not fail hard because blocking user requests due to an exchange misconfiguration is worse than falling back to the pre-existing service-account behavior — the user still gets a response, and the admin gets a warning to investigate. +Warning-level logs for unexpected failures ensure that admins who enable token exchange can diagnose issues via standard log monitoring. + +**When `fallbackToServiceAccount: false` (strict mode):** + +- No user OIDC token available → request fails with 401 and an error message indicating that per-user auth is required +- Exchange call fails (network, IdP error) → request fails with 502 and error details +- IdP doesn't support exchange → request fails with 502 and error details +- Exchanged token rejected (401) → clear caches, retry once with fresh exchange, fail with 401 if retry also fails + +Strict mode is intended for environments with strong security postures where silently downgrading to a shared service-account identity is not acceptable. Admins who enable strict mode accept that exchange failures will surface as user-facing errors rather than being silently absorbed. -**Alternative considered:** Failing hard when exchange is enabled but fails. Rejected because this would turn a misconfiguration into an outage — the service-account token path is known to work and is the pre-existing behavior. The warning logs provide the diagnostic signal without the blast radius. +**Why configurable and not always-fail:** Failing hard when exchange is enabled but fails would turn a misconfiguration into an outage for deployments that are transitioning to per-user auth or testing the feature. The default permissive behavior is safer for initial rollout, while strict mode gives security-conscious deployments the hard guarantee they need. ### Decision 4: Widen `setUserContext` with optional second parameter @@ -83,24 +88,25 @@ augment: tokenExchange: enabled: true # default: false audience: kagenti-api # default: auth.clientId - userTokenHeader: X-Forwarded-Access-Token # default: x-user-oidc-token (fallback only) + userTokenHeader: X-Forwarded-Access-Token # default: x-user-oidc-token + fallbackToServiceAccount: true # default: true — set to false for strict security environments ``` -This reuses the existing `tokenEndpoint`, `clientId`, and `clientSecret` from the parent auth block. No new top-level config keys. The `userTokenHeader` is only used when frontend OIDC discovery is not available — in Keycloak deployments where discovery succeeds, the frontend acquires the token directly and the header config is unused. +This reuses the existing `tokenEndpoint`, `clientId`, and `clientSecret` from the parent auth block. No new top-level config keys. ## Prerequisites For per-user token exchange to function, the deployment must have: -1. **An OIDC auth provider accessible via the Backstage API holder** (primary path) — in RHDH Keycloak deployments, this is typically `internal.auth.oidc`. The frontend discovers this at runtime via `useApiHolder()`. **OR** a mechanism to inject the user's OIDC access token into the configured HTTP header (fallback path, default: `x-user-oidc-token`) — typical options include an auth proxy (oauth2-proxy, Keycloak Gatekeeper), custom middleware, or a customer's own auth infrastructure. Without either, the system falls back to the service-account token. +1. **A mechanism to inject the user's OIDC access token into the configured HTTP header** (default: `x-user-oidc-token`) — typical options include an auth proxy (oauth2-proxy, Keycloak Gatekeeper), custom middleware, or a customer's own auth infrastructure. Without a header injection mechanism, the system falls back to the service-account token (or fails if `fallbackToServiceAccount: false`). A planned follow-up will add frontend OIDC discovery via `useApiHolder()` as an additional acquisition path. 2. **An IdP token endpoint that supports RFC 8693 token exchange** (configured via `auth.tokenEndpoint`). For Keycloak, `token-exchange-standard:v2` is enabled by default in modern versions. The requesting client (`auth.clientId`) must have permission to exchange tokens for the target audience. 3. **The existing `auth.clientId`, `auth.clientSecret`, and `auth.tokenEndpoint`** must already be configured for the Kagenti provider's service-account flow. ## Risks / Trade-offs -- **OIDC provider not discoverable** → Frontend discovery returns `undefined` and no header token present → falls back to service-account silently. Non-OIDC deployments without a header injection mechanism get no per-user auth. +- **No OIDC token in header** → No header token present → falls back to service-account (or fails in strict mode). Non-OIDC deployments without a header injection mechanism get no per-user auth until frontend OIDC discovery is added. - **Keycloak doesn't support token exchange** → Returns 400 `unsupported_grant_type`. Caught, warned, falls back. Requires Keycloak admin to enable token exchange on the realm. -- **OIDC token expired** → Exchange fails, caught, falls back. Short-lived tokens may cause frequent fallbacks. When the token is acquired via frontend discovery, the OIDC provider may handle refresh transparently via its `getIdToken()` implementation. When acquired via header, the backend cannot refresh the user's token — keeping it alive is the responsibility of the injecting layer (auth proxy or customer infrastructure). +- **OIDC token expired** → Exchange fails, caught, falls back (or fails in strict mode). Short-lived tokens may cause frequent fallbacks. The backend cannot refresh the user's token — keeping it alive is the responsibility of the injecting layer (auth proxy or customer infrastructure). - **Memory** → Per-user cache bounded by concurrent users (~2KB per entry × 1000 users = ~2MB). Acceptable for backend plugin. -- **`useApiHolder()` internal API access** → The orchestrator's `findCustomProvider` accesses `apiHolder.apis` (a private `Map`) via `@ts-ignore` to discover statically-registered auth providers like `internal.auth.oidc`. This is not a public Backstage API and could break if Backstage changes the internal representation. Mitigated by the fact that the orchestrator already ships this pattern in production RHDH, and the header-based fallback provides a working alternative if the internal access breaks. +- **`useApiHolder()` internal API access (follow-up risk)** → The planned frontend OIDC discovery follow-up would use the orchestrator's `findCustomProvider` pattern, which accesses `apiHolder.apis` (a private `Map`) via `@ts-ignore`. This is not a public Backstage API and could break if Backstage changes the internal representation. Mitigated by the fact that the orchestrator already ships this pattern in production RHDH, and the header-based path implemented in this change provides a working alternative. - **Security surface** → The configurable header must be trusted. If an attacker can inject the header, they can impersonate users. Mitigated by typical auth proxy architectures stripping/overwriting upstream headers. diff --git a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/proposal.md b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/proposal.md index 607c4d63b6..248653fdae 100644 --- a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/proposal.md +++ b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/proposal.md @@ -7,7 +7,7 @@ The augment plugin authenticates to Kagenti using a single shared service-accoun ## What Changes - New `TokenExchangeManager` class implementing RFC 8693 token exchange with per-user caching, concurrent request deduplication, streaming-aware lifetime management, and fallback-safe error handling -- Extended `KagentiConfigLoader` to parse optional `tokenExchange` config block (`enabled`, `audience`, `userTokenHeader`) +- Extended `KagentiConfigLoader` to parse optional `tokenExchange` config block (`enabled`, `audience`, `userTokenHeader`, `fallbackToServiceAccount`) - Extended `requestCore.ts` (`doRequest`, `streamRequest`, `requestWithRetry`) to attempt per-user token exchange first, falling back to the shared service-account token on any failure - Widened `KagentiApiClient` context and `AugmentProvider` interface to carry the user's bearer token alongside user ref - Route-level extraction of the user's OIDC token from a configurable request header (default: `x-user-oidc-token`) @@ -35,5 +35,5 @@ The augment plugin authenticates to Kagenti using a single shared service-accoun - `plugins/augment-backend/src/router.ts` — dynamic header getter from provider config - `plugins/augment-backend/src/routes/chatRoutes.ts` — OIDC token extraction in chat handlers - `plugins/augment-backend/src/routes/kagentiRoutes.ts` — OIDC token extraction in Kagenti middleware -- No frontend changes — OIDC token arrives via infrastructure (auth proxy) or future frontend work +- **Scope: backend-only** — this change implements header-based OIDC token acquisition. Frontend OIDC discovery via `useApiHolder()` (modeled on the orchestrator's `useOrchestratorAuth.ts` pattern) is designed as a follow-up change. - No changes to `ResponsesApiProvider`, `providers/llamastack/`, or `KeycloakTokenManager` diff --git a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/token-exchange/spec.md b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/token-exchange/spec.md index 294671923e..919cfbdfaa 100644 --- a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/token-exchange/spec.md +++ b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/token-exchange/spec.md @@ -13,7 +13,8 @@ The system SHALL support an optional `tokenExchange` configuration block nested - The Kagenti API client ID (e.g., `kagenti-api`) — the typical production value, targeting the Keycloak client that represents the Kagenti service - The RHDH/Backstage client ID (the value of `auth.clientId`) — the default when no explicit audience is set - Any other Keycloak client ID that has token exchange permissions granted for the requesting client -- `userTokenHeader` (string, default: `x-user-oidc-token`) — the HTTP request header from which to read the user's OIDC access token when frontend API holder discovery is not available (fallback path) +- `userTokenHeader` (string, default: `x-user-oidc-token`) — the HTTP request header from which to read the user's OIDC access token +- `fallbackToServiceAccount` (boolean, default: `true`) — when `true`, exchange failures fall back to the service-account token silently with a warning log. When `false` (strict mode), exchange failures result in an error response (401 or 502) instead of falling back. The system SHALL reuse the parent `auth.tokenEndpoint`, `auth.clientId`, and `auth.clientSecret` for the exchange request. No new top-level config keys SHALL be introduced. @@ -88,31 +89,50 @@ The `TokenExchangeManager` SHALL support streaming-aware token lifetime manageme - **WHEN** a streaming request is using an exchanged token and the token's TTL expires - **THEN** the system SHALL NOT evict the token until the stream completes -### Requirement: Graceful fallback to service-account token +### Requirement: Configurable fallback to service-account token -The system SHALL fall back to the existing `KeycloakTokenManager` service-account token whenever per-user token exchange cannot complete. Fallback SHALL occur silently with a warning log — requests SHALL NOT fail due to exchange issues. +The system SHALL support configurable fallback behavior via the `fallbackToServiceAccount` config option. -#### Scenario: No user OIDC token from any source +**When `fallbackToServiceAccount` is `true` (default):** The system SHALL fall back to the existing `KeycloakTokenManager` service-account token whenever per-user token exchange cannot complete. Fallback SHALL occur with a warning log — requests SHALL NOT fail due to exchange issues. -- **WHEN** `tokenExchange.enabled` is `true` but no OIDC token was provided by the frontend and the configured header is not present on the request +**When `fallbackToServiceAccount` is `false` (strict mode):** The system SHALL return an error response when per-user token exchange cannot complete. This mode is intended for environments with strict security postures where silently downgrading to a shared service-account identity is not acceptable. + +#### Scenario: No user OIDC token — permissive mode + +- **WHEN** `tokenExchange.enabled` is `true` and `fallbackToServiceAccount` is `true` and no OIDC token is present in the configured header - **THEN** the system SHALL use the service-account token and log a debug message -#### Scenario: Exchange call fails with network error +#### Scenario: No user OIDC token — strict mode + +- **WHEN** `tokenExchange.enabled` is `true` and `fallbackToServiceAccount` is `false` and no OIDC token is present in the configured header +- **THEN** the system SHALL return a 401 error indicating that per-user authentication is required + +#### Scenario: Exchange call fails with network error — permissive mode -- **WHEN** the exchange POST to Keycloak fails due to network error or timeout +- **WHEN** `fallbackToServiceAccount` is `true` and the exchange POST to Keycloak fails due to network error or timeout - **THEN** the system SHALL log a warning and use the service-account token -#### Scenario: IdP returns unsupported_grant_type +#### Scenario: Exchange call fails with network error — strict mode + +- **WHEN** `fallbackToServiceAccount` is `false` and the exchange POST to Keycloak fails due to network error or timeout +- **THEN** the system SHALL return a 502 error with details about the exchange failure + +#### Scenario: IdP returns unsupported_grant_type — permissive mode > **Note:** Modern Keycloak versions enable `token-exchange-standard:v2` by default, making this scenario unlikely in typical deployments. It remains as a defensive fallback for older Keycloak versions, realm/client-level policies that restrict token exchange permissions, or non-Keycloak OIDC providers that do not support RFC 8693. -- **WHEN** the IdP returns 400 with `error: unsupported_grant_type` +- **WHEN** `fallbackToServiceAccount` is `true` and the IdP returns 400 with `error: unsupported_grant_type` - **THEN** the system SHALL log a warning and use the service-account token +#### Scenario: IdP returns unsupported_grant_type — strict mode + +- **WHEN** `fallbackToServiceAccount` is `false` and the IdP returns 400 with `error: unsupported_grant_type` +- **THEN** the system SHALL return a 502 error indicating the IdP does not support token exchange + #### Scenario: Exchanged token rejected with 401 - **WHEN** Kagenti returns 401 for a request using an exchanged token -- **THEN** the system SHALL clear both the per-user exchanged token cache AND the service-account token cache, then retry with a fresh token +- **THEN** the system SHALL clear both the per-user exchanged token cache AND the service-account token cache, then retry with a fresh token. If the retry also fails and `fallbackToServiceAccount` is `false`, the system SHALL return a 401 error. ### Requirement: No impact on ResponsesApiProvider diff --git a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/user-token-routing/spec.md b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/user-token-routing/spec.md index 9b99ae4f99..6ea47c7b02 100644 --- a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/user-token-routing/spec.md +++ b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/user-token-routing/spec.md @@ -1,37 +1,14 @@ # Spec: user-token-routing -Acquisition and forwarding of user OIDC tokens — via frontend API holder discovery (primary) or configurable request headers (fallback). +Acquisition and forwarding of user OIDC tokens via configurable request headers. -## ADDED Requirements - -### Requirement: Frontend OIDC token acquisition via API holder discovery - -The frontend SHALL discover the OIDC auth provider at runtime using `useApiHolder()` and the orchestrator's `findCustomProvider` pattern. When Kagenti token exchange is enabled and the user initiates a Kagenti interaction: - -1. Attempt to discover the OIDC auth provider via `useApp().getPlugins()` API enumeration -2. If not found, attempt to discover via the API holder's internal map (matching the orchestrator's `apiHolder.apis` pattern for `internal.auth.oidc`) -3. If found, call `getIdToken()` to obtain the user's OIDC token (which triggers a login prompt if the user hasn't authenticated with the OIDC provider yet) -4. Send the OIDC token to the backend via the request for RFC 8693 exchange -5. If the OIDC provider is not discoverable, fall through to the header-based path - -#### Scenario: OIDC provider discovered and user authenticates - -- **WHEN** the OIDC auth provider is discoverable via the API holder and the user has not yet authenticated -- **THEN** the frontend SHALL trigger the OIDC login flow and, on success, send the obtained token to the backend +> **Follow-up:** Frontend OIDC token acquisition via `useApiHolder()` (modeled on the orchestrator's `findCustomProvider` pattern) is planned as a separate change. When implemented, the header-based path below will serve as the fallback for non-OIDC deployments. -#### Scenario: OIDC provider discovered and user already authenticated - -- **WHEN** the OIDC auth provider is discoverable and the user has an active OIDC session -- **THEN** the frontend SHALL obtain the token via `getIdToken()` without prompting and send it to the backend - -#### Scenario: OIDC provider not discoverable - -- **WHEN** the OIDC auth provider is not found via plugin API enumeration or the API holder's internal map -- **THEN** the frontend SHALL not attempt OIDC login and the system SHALL fall back to reading the token from the configured request header +## ADDED Requirements -### Requirement: Configurable user token header on RouteContext (fallback path) +### Requirement: Configurable user token header on RouteContext -The `RouteContext` type SHALL include an optional `userTokenHeader` field containing the header name from which to extract the user's OIDC token (e.g., `x-user-oidc-token` or `X-Forwarded-Access-Token`) when the frontend OIDC discovery path is not available. The value SHALL be sourced dynamically from the `KagentiProvider` configuration. +The `RouteContext` type SHALL include an optional `userTokenHeader` field containing the header name from which to extract the user's OIDC token (e.g., `x-user-oidc-token` or `X-Forwarded-Access-Token`). The value SHALL be sourced dynamically from the `KagentiProvider` configuration. #### Scenario: RouteContext includes header name @@ -54,22 +31,17 @@ The router SHALL create a dynamic getter on `RouteContext` that reads the header ### Requirement: OIDC token extraction in chat routes -The chat route handlers (both synchronous and streaming) SHALL extract the user's OIDC token — either from the frontend-provided token (sent as part of the request when acquired via API holder discovery) or from the configured header (fallback path) — and pass it as the second argument to `provider.setUserContext(userRef, bearerToken)`. - -#### Scenario: Chat route with frontend-acquired OIDC token - -- **WHEN** a chat request arrives with an OIDC token acquired via frontend API holder discovery -- **THEN** the route handler SHALL call `provider.setUserContext(userRef, tokenValue)` +The chat route handlers (both synchronous and streaming) SHALL extract the user's OIDC token from the configured header and pass it as the second argument to `provider.setUserContext(userRef, bearerToken)`. -#### Scenario: Chat route with header-based OIDC token +#### Scenario: Chat route with OIDC token in header -- **WHEN** a chat request arrives without a frontend-acquired token but with the configured OIDC header present +- **WHEN** a chat request arrives with the configured OIDC header present - **THEN** the route handler SHALL call `provider.setUserContext(userRef, tokenValue)` where `tokenValue` is the header value -#### Scenario: Chat route with no OIDC token from either source +#### Scenario: Chat route with no OIDC token -- **WHEN** a chat request arrives without a frontend-acquired token and without the configured OIDC header -- **THEN** the route handler SHALL call `provider.setUserContext(userRef, undefined)` and the provider SHALL fall back to the service-account token +- **WHEN** a chat request arrives without the configured OIDC header +- **THEN** the route handler SHALL call `provider.setUserContext(userRef, undefined)` and the provider SHALL fall back to the service-account token (or fail if `fallbackToServiceAccount` is `false`) ### Requirement: OIDC token extraction in Kagenti routes diff --git a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/tasks.md b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/tasks.md index 2650bda810..734ca17823 100644 --- a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/tasks.md +++ b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/tasks.md @@ -1,16 +1,18 @@ # Tasks: Per-User OAuth2 Token Exchange for Kagenti Provider +> **Scope:** This change covers backend-only implementation (header-based OIDC token acquisition). Frontend OIDC discovery via `useApiHolder()` is a planned follow-up change. + ## 1. Configuration Schema -- [ ] 1.1 Add optional `tokenExchange` block to `plugins/augment-backend/config.d.ts` under `augment.kagenti.auth` with `enabled` (boolean), `audience` (string), and `userTokenHeader` (string) -- [ ] 1.2 Extend `KagentiConfig.auth` type in `plugins/augment-backend/src/providers/kagenti/config/KagentiConfigLoader.ts` with parsed `tokenExchange` fields (defaults: enabled=false, audience=clientId, userTokenHeader='x-user-oidc-token') +- [ ] 1.1 Add optional `tokenExchange` block to `plugins/augment-backend/config.d.ts` under `augment.kagenti.auth` with `enabled` (boolean), `audience` (string), `userTokenHeader` (string), and `fallbackToServiceAccount` (boolean) +- [ ] 1.2 Extend `KagentiConfig.auth` type in `plugins/augment-backend/src/providers/kagenti/config/KagentiConfigLoader.ts` with parsed `tokenExchange` fields (defaults: enabled=false, audience=clientId, userTokenHeader='x-user-oidc-token', fallbackToServiceAccount=true) ## 2. Token Exchange Manager - [ ] 2.1 Create `plugins/augment-backend/src/providers/kagenti/client/TokenExchangeManager.ts` implementing RFC 8693 exchange with per-user caching keyed by user identity - [ ] 2.2 Implement concurrent request deduplication — in-flight exchange for same user shared across waiting callers - [ ] 2.3 Implement streaming-aware token lifetime — hold reference preventing eviction during active streams -- [ ] 2.4 Implement graceful error handling — catch exchange failures (network, 400 unsupported_grant_type, Keycloak errors), log warning, return null to signal fallback +- [ ] 2.4 Implement configurable error handling — when `fallbackToServiceAccount` is `true`, catch exchange failures and return null to signal fallback with warning log. When `false` (strict mode), throw with appropriate error (401 for missing token, 502 for exchange failure) so the request fails instead of falling back. ## 3. Request Core Integration @@ -37,6 +39,6 @@ ## 6. Verification - [ ] 6.1 Verify `npx tsc --noEmit` passes clean with all changes -- [ ] 6.2 Write unit tests for `TokenExchangeManager` — exchange execution, caching, concurrent dedup, fallback on error, streaming lifetime, clearUserCache/clearAllCache +- [ ] 6.2 Write unit tests for `TokenExchangeManager` — exchange execution, caching, concurrent dedup, fallback on error, strict mode error propagation, streaming lifetime, clearUserCache/clearAllCache - [ ] 6.3 Verify backward compatibility — behavior identical with `tokenExchange` absent from config - [ ] 6.4 Verify `ResponsesApiProvider` and Llama Stack paths are completely unaffected From 940ad1beaa366627f760a3c0ad846569a4c09748 Mon Sep 17 00:00:00 2001 From: gabemontero Date: Mon, 22 Jun 2026 09:36:52 -0400 Subject: [PATCH 5/6] docs(augment): bring frontend OIDC discovery into scope as primary path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Header-only token acquisition is insufficient for the common RHDH deployment pattern where users log in via Backstage's built-in Keycloak auth — Backstage replaces the user's OIDC token with its own JWT, and these deployments typically don't have an auth proxy injecting the original token into a header. Frontend OIDC discovery via useApiHolder() (modeled on the orchestrator's findCustomProvider pattern) is now the primary token acquisition path, with header-based extraction as the fallback for non-OIDC deployments and headless environments. Co-Authored-By: Claude Opus 4.6 Signed-off-by: gabemontero --- .../design.md | 28 +++++++---- .../proposal.md | 2 +- .../specs/token-exchange/spec.md | 6 +-- .../specs/user-token-routing/spec.md | 50 +++++++++++++++---- .../tasks.md | 20 +++++--- 5 files changed, 75 insertions(+), 31 deletions(-) diff --git a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/design.md b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/design.md index a01bd3e31d..f27fd9a459 100644 --- a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/design.md +++ b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/design.md @@ -15,7 +15,7 @@ Kagenti supports RFC 8693 OAuth2 Token Exchange, which can swap a user's OIDC ac **Goals:** - Enable per-user authorization at the Kagenti layer via RFC 8693 token exchange -- Acquire the user's OIDC token via configurable request header (frontend OIDC discovery via `useApiHolder()` is a planned follow-up) +- Acquire the user's OIDC token via frontend OIDC discovery (primary) or configurable request header (fallback) - Configurable fallback behavior: graceful fallback to service-account token by default, with an option to fail hard for strict security environments - Make per-user token exchange opt-in and disabled by default - Zero impact on `ResponsesApiProvider` or Llama Stack code paths @@ -29,17 +29,25 @@ Kagenti supports RFC 8693 OAuth2 Token Exchange, which can swap a user's OIDC ac ## Decisions -### Decision 1: Header-based OIDC token acquisition (frontend discovery as follow-up) +### Decision 1: Frontend OIDC discovery (primary) with header-based fallback -**This change implements the header-based path only.** The user's OIDC token is read from a configurable request header (default: `x-user-oidc-token`). The header can be populated by: +The primary token acquisition path uses `useApiHolder()` to dynamically discover the OIDC auth provider at runtime — the same pattern the orchestrator plugin uses in `useOrchestratorAuth.ts`. When the user first interacts with Kagenti, the frontend discovers the OIDC provider via the API holder, triggers a login prompt if needed, and sends the OIDC token to the backend for RFC 8693 exchange. This avoids the `createApiFactory` hard dependency problem (which throws synchronously if the API isn't registered) while giving users an explicit "connect to Kagenti" experience. -1. An auth proxy (oauth2-proxy, Keycloak Gatekeeper) — many RHDH deployments already have one +**Why frontend discovery is required out of the gate:** The most common RHDH deployment pattern uses Backstage's built-in Keycloak auth — users log in via the RHDH UI, and Backstage replaces the user's Keycloak OIDC token with its own JWT before requests reach backend plugins. These deployments typically do not have an auth proxy injecting the original OIDC token into a header. Without frontend OIDC discovery, per-user token exchange would not work for this common case, making the feature effectively unusable for most deployments. + +When the OIDC provider is not discoverable (non-Keycloak deployments, headless environments), the system falls back to reading the user's OIDC token from a configurable request header (default: `x-user-oidc-token`). The header can be populated by: + +1. An auth proxy (oauth2-proxy, Keycloak Gatekeeper) — some RHDH deployments already have one 2. Custom middleware extracting the OIDC token from the session 3. A customer's own auth infrastructure — any mechanism that can place a valid OIDC-compatible access token into the configured HTTP header -**Custom auth mechanisms:** The header-based path is deliberately auth-provider-agnostic. The system does not assume Keycloak or any specific IdP — it only requires that (a) a valid OIDC access token arrives in the configured header, and (b) the configured `auth.tokenEndpoint` supports RFC 8693 token exchange for that token. +**Custom auth mechanisms:** The header-based fallback is deliberately auth-provider-agnostic. The system does not assume Keycloak or any specific IdP — it only requires that (a) a valid OIDC access token arrives in the configured header, and (b) the configured `auth.tokenEndpoint` supports RFC 8693 token exchange for that token. + +**Why `useApiHolder()` and not `useApi()`:** `useApi()` throws synchronously if the API isn't registered, and `createApiFactory` has no optional dependency concept. `useApiHolder()` returns `undefined` for unregistered APIs, allowing graceful detection. The orchestrator uses this for custom providers (`findCustomProvider` in `useOrchestratorAuth.ts`), including accessing `internal.auth.oidc` via the API holder's internal map. + +**Alternative considered:** Header-only (no frontend). Rejected because most RHDH deployments use Backstage's built-in auth without an auth proxy, so the header would never be populated — making the feature unusable in the common case. -**Planned follow-up — frontend OIDC discovery:** A subsequent change will add frontend OIDC discovery using `useApiHolder()` to dynamically discover the OIDC auth provider at runtime — the same pattern the orchestrator plugin uses in `useOrchestratorAuth.ts`. When the user first interacts with Kagenti, the frontend would discover the OIDC provider via the API holder, trigger a login prompt if needed, and send the OIDC token to the backend for RFC 8693 exchange. This avoids the `createApiFactory` hard dependency problem (which throws synchronously if the API isn't registered) while giving users an explicit "connect to Kagenti" experience. The header-based path implemented in this change will serve as the fallback when the OIDC provider is not discoverable (non-Keycloak deployments, headless environments). +**Alternative considered:** `createApiFactory` with `oidcAuthApiRef` as a hard dependency. Rejected because it would crash non-OIDC deployments. **Alternative considered:** Backstage middleware extracting from session. Rejected because it couples to Backstage auth internals and requires session storage access. @@ -98,15 +106,15 @@ This reuses the existing `tokenEndpoint`, `clientId`, and `clientSecret` from th For per-user token exchange to function, the deployment must have: -1. **A mechanism to inject the user's OIDC access token into the configured HTTP header** (default: `x-user-oidc-token`) — typical options include an auth proxy (oauth2-proxy, Keycloak Gatekeeper), custom middleware, or a customer's own auth infrastructure. Without a header injection mechanism, the system falls back to the service-account token (or fails if `fallbackToServiceAccount: false`). A planned follow-up will add frontend OIDC discovery via `useApiHolder()` as an additional acquisition path. +1. **An OIDC auth provider accessible via the Backstage API holder** (primary path) — in RHDH Keycloak deployments, this is typically `internal.auth.oidc`. The frontend discovers this at runtime via `useApiHolder()`. **OR** a mechanism to inject the user's OIDC access token into the configured HTTP header (fallback path, default: `x-user-oidc-token`) — typical options include an auth proxy (oauth2-proxy, Keycloak Gatekeeper), custom middleware, or a customer's own auth infrastructure. Without either, the system falls back to the service-account token (or fails if `fallbackToServiceAccount: false`). 2. **An IdP token endpoint that supports RFC 8693 token exchange** (configured via `auth.tokenEndpoint`). For Keycloak, `token-exchange-standard:v2` is enabled by default in modern versions. The requesting client (`auth.clientId`) must have permission to exchange tokens for the target audience. 3. **The existing `auth.clientId`, `auth.clientSecret`, and `auth.tokenEndpoint`** must already be configured for the Kagenti provider's service-account flow. ## Risks / Trade-offs -- **No OIDC token in header** → No header token present → falls back to service-account (or fails in strict mode). Non-OIDC deployments without a header injection mechanism get no per-user auth until frontend OIDC discovery is added. +- **OIDC provider not discoverable and no header token** → Frontend discovery returns `undefined` and no header token present → falls back to service-account (or fails in strict mode). Non-OIDC deployments without a header injection mechanism get no per-user auth. - **Keycloak doesn't support token exchange** → Returns 400 `unsupported_grant_type`. Caught, warned, falls back. Requires Keycloak admin to enable token exchange on the realm. -- **OIDC token expired** → Exchange fails, caught, falls back (or fails in strict mode). Short-lived tokens may cause frequent fallbacks. The backend cannot refresh the user's token — keeping it alive is the responsibility of the injecting layer (auth proxy or customer infrastructure). +- **OIDC token expired** → Exchange fails, caught, falls back (or fails in strict mode). Short-lived tokens may cause frequent fallbacks. When the token is acquired via frontend discovery, the OIDC provider may handle refresh transparently via its `getIdToken()` implementation. When acquired via header, the backend cannot refresh the user's token — keeping it alive is the responsibility of the injecting layer (auth proxy or customer infrastructure). - **Memory** → Per-user cache bounded by concurrent users (~2KB per entry × 1000 users = ~2MB). Acceptable for backend plugin. -- **`useApiHolder()` internal API access (follow-up risk)** → The planned frontend OIDC discovery follow-up would use the orchestrator's `findCustomProvider` pattern, which accesses `apiHolder.apis` (a private `Map`) via `@ts-ignore`. This is not a public Backstage API and could break if Backstage changes the internal representation. Mitigated by the fact that the orchestrator already ships this pattern in production RHDH, and the header-based path implemented in this change provides a working alternative. +- **`useApiHolder()` internal API access** → The frontend OIDC discovery uses the orchestrator's `findCustomProvider` pattern, which accesses `apiHolder.apis` (a private `Map`) via `@ts-ignore`. This is not a public Backstage API and could break if Backstage changes the internal representation. Mitigated by the fact that the orchestrator already ships this pattern in production RHDH, and the header-based fallback provides a working alternative if the internal access breaks. - **Security surface** → The configurable header must be trusted. If an attacker can inject the header, they can impersonate users. Mitigated by typical auth proxy architectures stripping/overwriting upstream headers. diff --git a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/proposal.md b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/proposal.md index 248653fdae..e2e4200bfb 100644 --- a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/proposal.md +++ b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/proposal.md @@ -35,5 +35,5 @@ The augment plugin authenticates to Kagenti using a single shared service-accoun - `plugins/augment-backend/src/router.ts` — dynamic header getter from provider config - `plugins/augment-backend/src/routes/chatRoutes.ts` — OIDC token extraction in chat handlers - `plugins/augment-backend/src/routes/kagentiRoutes.ts` — OIDC token extraction in Kagenti middleware -- **Scope: backend-only** — this change implements header-based OIDC token acquisition. Frontend OIDC discovery via `useApiHolder()` (modeled on the orchestrator's `useOrchestratorAuth.ts` pattern) is designed as a follow-up change. +- Frontend OIDC discovery utility using `useApiHolder()` (modeled on the orchestrator's `useOrchestratorAuth.ts` pattern) — discovers OIDC auth provider at runtime, acquires user's token, sends to backend for exchange. This is the primary token acquisition path; header-based extraction is the fallback for non-OIDC deployments. - No changes to `ResponsesApiProvider`, `providers/llamastack/`, or `KeycloakTokenManager` diff --git a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/token-exchange/spec.md b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/token-exchange/spec.md index 919cfbdfaa..8218aa38ef 100644 --- a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/token-exchange/spec.md +++ b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/token-exchange/spec.md @@ -13,7 +13,7 @@ The system SHALL support an optional `tokenExchange` configuration block nested - The Kagenti API client ID (e.g., `kagenti-api`) — the typical production value, targeting the Keycloak client that represents the Kagenti service - The RHDH/Backstage client ID (the value of `auth.clientId`) — the default when no explicit audience is set - Any other Keycloak client ID that has token exchange permissions granted for the requesting client -- `userTokenHeader` (string, default: `x-user-oidc-token`) — the HTTP request header from which to read the user's OIDC access token +- `userTokenHeader` (string, default: `x-user-oidc-token`) — the HTTP request header from which to read the user's OIDC access token when frontend API holder discovery is not available (fallback path) - `fallbackToServiceAccount` (boolean, default: `true`) — when `true`, exchange failures fall back to the service-account token silently with a warning log. When `false` (strict mode), exchange failures result in an error response (401 or 502) instead of falling back. The system SHALL reuse the parent `auth.tokenEndpoint`, `auth.clientId`, and `auth.clientSecret` for the exchange request. No new top-level config keys SHALL be introduced. @@ -99,12 +99,12 @@ The system SHALL support configurable fallback behavior via the `fallbackToServi #### Scenario: No user OIDC token — permissive mode -- **WHEN** `tokenExchange.enabled` is `true` and `fallbackToServiceAccount` is `true` and no OIDC token is present in the configured header +- **WHEN** `tokenExchange.enabled` is `true` and `fallbackToServiceAccount` is `true` and no OIDC token was provided by the frontend and the configured header is not present on the request - **THEN** the system SHALL use the service-account token and log a debug message #### Scenario: No user OIDC token — strict mode -- **WHEN** `tokenExchange.enabled` is `true` and `fallbackToServiceAccount` is `false` and no OIDC token is present in the configured header +- **WHEN** `tokenExchange.enabled` is `true` and `fallbackToServiceAccount` is `false` and no OIDC token was provided by the frontend and the configured header is not present on the request - **THEN** the system SHALL return a 401 error indicating that per-user authentication is required #### Scenario: Exchange call fails with network error — permissive mode diff --git a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/user-token-routing/spec.md b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/user-token-routing/spec.md index 6ea47c7b02..336360a480 100644 --- a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/user-token-routing/spec.md +++ b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/user-token-routing/spec.md @@ -1,14 +1,39 @@ # Spec: user-token-routing -Acquisition and forwarding of user OIDC tokens via configurable request headers. - -> **Follow-up:** Frontend OIDC token acquisition via `useApiHolder()` (modeled on the orchestrator's `findCustomProvider` pattern) is planned as a separate change. When implemented, the header-based path below will serve as the fallback for non-OIDC deployments. +Acquisition and forwarding of user OIDC tokens — via frontend API holder discovery (primary) or configurable request headers (fallback). ## ADDED Requirements -### Requirement: Configurable user token header on RouteContext +### Requirement: Frontend OIDC token acquisition via API holder discovery + +The frontend SHALL discover the OIDC auth provider at runtime using `useApiHolder()` and the orchestrator's `findCustomProvider` pattern. When Kagenti token exchange is enabled and the user initiates a Kagenti interaction: + +1. Attempt to discover the OIDC auth provider via `useApp().getPlugins()` API enumeration +2. If not found, attempt to discover via the API holder's internal map (matching the orchestrator's `apiHolder.apis` pattern for `internal.auth.oidc`) +3. If found, call `getIdToken()` to obtain the user's OIDC token (which triggers a login prompt if the user hasn't authenticated with the OIDC provider yet) +4. Send the OIDC token to the backend via the request for RFC 8693 exchange +5. If the OIDC provider is not discoverable, fall through to the header-based path + +> **Why this is required out of the gate:** Most RHDH deployments use Backstage's built-in Keycloak auth, where the user logs in via the RHDH UI. Backstage replaces the user's Keycloak OIDC token with its own JWT before it reaches backend plugins, and these deployments typically do not have an auth proxy injecting the original OIDC token into a header. Without frontend discovery, per-user token exchange would not work for this common deployment pattern. + +#### Scenario: OIDC provider discovered and user authenticates + +- **WHEN** the OIDC auth provider is discoverable via the API holder and the user has not yet authenticated +- **THEN** the frontend SHALL trigger the OIDC login flow and, on success, send the obtained token to the backend + +#### Scenario: OIDC provider discovered and user already authenticated -The `RouteContext` type SHALL include an optional `userTokenHeader` field containing the header name from which to extract the user's OIDC token (e.g., `x-user-oidc-token` or `X-Forwarded-Access-Token`). The value SHALL be sourced dynamically from the `KagentiProvider` configuration. +- **WHEN** the OIDC auth provider is discoverable and the user has an active OIDC session +- **THEN** the frontend SHALL obtain the token via `getIdToken()` without prompting and send it to the backend + +#### Scenario: OIDC provider not discoverable + +- **WHEN** the OIDC auth provider is not found via plugin API enumeration or the API holder's internal map +- **THEN** the frontend SHALL not attempt OIDC login and the system SHALL fall back to reading the token from the configured request header + +### Requirement: Configurable user token header on RouteContext (fallback path) + +The `RouteContext` type SHALL include an optional `userTokenHeader` field containing the header name from which to extract the user's OIDC token (e.g., `x-user-oidc-token` or `X-Forwarded-Access-Token`) when the frontend OIDC discovery path is not available. The value SHALL be sourced dynamically from the `KagentiProvider` configuration. #### Scenario: RouteContext includes header name @@ -31,16 +56,21 @@ The router SHALL create a dynamic getter on `RouteContext` that reads the header ### Requirement: OIDC token extraction in chat routes -The chat route handlers (both synchronous and streaming) SHALL extract the user's OIDC token from the configured header and pass it as the second argument to `provider.setUserContext(userRef, bearerToken)`. +The chat route handlers (both synchronous and streaming) SHALL extract the user's OIDC token — either from the frontend-provided token (sent as part of the request when acquired via API holder discovery) or from the configured header (fallback path) — and pass it as the second argument to `provider.setUserContext(userRef, bearerToken)`. + +#### Scenario: Chat route with frontend-acquired OIDC token + +- **WHEN** a chat request arrives with an OIDC token acquired via frontend API holder discovery +- **THEN** the route handler SHALL call `provider.setUserContext(userRef, tokenValue)` -#### Scenario: Chat route with OIDC token in header +#### Scenario: Chat route with header-based OIDC token -- **WHEN** a chat request arrives with the configured OIDC header present +- **WHEN** a chat request arrives without a frontend-acquired token but with the configured OIDC header present - **THEN** the route handler SHALL call `provider.setUserContext(userRef, tokenValue)` where `tokenValue` is the header value -#### Scenario: Chat route with no OIDC token +#### Scenario: Chat route with no OIDC token from either source -- **WHEN** a chat request arrives without the configured OIDC header +- **WHEN** a chat request arrives without a frontend-acquired token and without the configured OIDC header - **THEN** the route handler SHALL call `provider.setUserContext(userRef, undefined)` and the provider SHALL fall back to the service-account token (or fail if `fallbackToServiceAccount` is `false`) ### Requirement: OIDC token extraction in Kagenti routes diff --git a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/tasks.md b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/tasks.md index 734ca17823..4776b4030b 100644 --- a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/tasks.md +++ b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/tasks.md @@ -1,7 +1,5 @@ # Tasks: Per-User OAuth2 Token Exchange for Kagenti Provider -> **Scope:** This change covers backend-only implementation (header-based OIDC token acquisition). Frontend OIDC discovery via `useApiHolder()` is a planned follow-up change. - ## 1. Configuration Schema - [ ] 1.1 Add optional `tokenExchange` block to `plugins/augment-backend/config.d.ts` under `augment.kagenti.auth` with `enabled` (boolean), `audience` (string), `userTokenHeader` (string), and `fallbackToServiceAccount` (boolean) @@ -36,9 +34,17 @@ - [ ] 5.3 Update `plugins/augment-backend/src/routes/chatRoutes.ts` — both sync and streaming handlers extract OIDC token from configured header and pass to `setUserContext(userRef, bearerToken)` - [ ] 5.4 Update `plugins/augment-backend/src/routes/kagentiRoutes.ts` — `/kagenti` middleware extracts OIDC token from configured header -## 6. Verification +## 6. Frontend OIDC Discovery + +- [ ] 6.1 Create frontend utility for OIDC provider discovery using `useApiHolder()` — attempt `useApp().getPlugins()` API enumeration first, then fall back to API holder internal map (`apiHolder.apis` for `internal.auth.oidc`, matching the orchestrator's `findCustomProvider` pattern) +- [ ] 6.2 Implement OIDC token acquisition — call `getIdToken()` on the discovered provider (triggers login prompt if user hasn't authenticated with the OIDC provider yet), cache the result for the session +- [ ] 6.3 Wire OIDC token into Kagenti API requests — when the frontend has acquired an OIDC token, include it in requests to the backend so it can be used for RFC 8693 exchange +- [ ] 6.4 Handle graceful degradation — when OIDC provider is not discoverable, do not prompt or error; fall through silently so the backend uses the header-based fallback path + +## 7. Verification -- [ ] 6.1 Verify `npx tsc --noEmit` passes clean with all changes -- [ ] 6.2 Write unit tests for `TokenExchangeManager` — exchange execution, caching, concurrent dedup, fallback on error, strict mode error propagation, streaming lifetime, clearUserCache/clearAllCache -- [ ] 6.3 Verify backward compatibility — behavior identical with `tokenExchange` absent from config -- [ ] 6.4 Verify `ResponsesApiProvider` and Llama Stack paths are completely unaffected +- [ ] 7.1 Verify `npx tsc --noEmit` passes clean with all changes (backend and frontend) +- [ ] 7.2 Write unit tests for `TokenExchangeManager` — exchange execution, caching, concurrent dedup, fallback on error, strict mode error propagation, streaming lifetime, clearUserCache/clearAllCache +- [ ] 7.3 Write unit tests for frontend OIDC discovery — provider found, provider not found (graceful degradation), token acquisition, login prompt trigger +- [ ] 7.4 Verify backward compatibility — behavior identical with `tokenExchange` absent from config +- [ ] 7.5 Verify `ResponsesApiProvider` and Llama Stack paths are completely unaffected From 0358fc799dc3159ea72bf76923b299872e0494c4 Mon Sep 17 00:00:00 2001 From: gabemontero Date: Mon, 22 Jun 2026 10:51:41 -0400 Subject: [PATCH 6/6] docs(augment): address 8 frontend OIDC discovery gaps with concrete solutions - Transport: frontend sets same userTokenHeader that backend already reads - File paths: added useKagentiOidcToken.ts hook, AugmentApi.ts modifications - UX trigger: discover on mount, acquire token lazily on first interaction - Fixed getIdToken() -> getAccessToken() (OAuthApi for RFC 8693 access_token) - Token lifecycle: provider handles refresh, hook caches provider reference - Config visibility: tokenExchange.enabled marked @visibility frontend - Dynamic plugin: no export/wiring changes needed, hook is internal - Task specificity: section 6 now has file paths and detailed descriptions Co-Authored-By: Claude Opus 4.6 --- .../design.md | 24 ++++++- .../proposal.md | 7 +- .../specs/token-exchange/spec.md | 4 +- .../specs/user-token-routing/spec.md | 68 ++++++++++++++++--- .../tasks.md | 11 +-- 5 files changed, 94 insertions(+), 20 deletions(-) diff --git a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/design.md b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/design.md index f27fd9a459..73c0460b02 100644 --- a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/design.md +++ b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/design.md @@ -31,7 +31,13 @@ Kagenti supports RFC 8693 OAuth2 Token Exchange, which can swap a user's OIDC ac ### Decision 1: Frontend OIDC discovery (primary) with header-based fallback -The primary token acquisition path uses `useApiHolder()` to dynamically discover the OIDC auth provider at runtime — the same pattern the orchestrator plugin uses in `useOrchestratorAuth.ts`. When the user first interacts with Kagenti, the frontend discovers the OIDC provider via the API holder, triggers a login prompt if needed, and sends the OIDC token to the backend for RFC 8693 exchange. This avoids the `createApiFactory` hard dependency problem (which throws synchronously if the API isn't registered) while giving users an explicit "connect to Kagenti" experience. +The primary token acquisition path uses `useApiHolder()` to dynamically discover the OIDC auth provider at runtime — the same pattern the orchestrator plugin uses in `useOrchestratorAuth.ts`. When the user first interacts with Kagenti, the frontend discovers the OIDC provider via the API holder, calls `getAccessToken()` (the `OAuthApi` interface — correct for RFC 8693 where `subject_token_type` is `access_token`), triggers a login prompt if needed, and sends the OIDC token to the backend for RFC 8693 exchange. This avoids the `createApiFactory` hard dependency problem (which throws synchronously if the API isn't registered) while giving users an explicit "connect to Kagenti" experience. + +**UX trigger:** OIDC provider discovery is attempted on component mount (e.g., when `ChatContainer` loads). Token acquisition is deferred to the first Kagenti API request — the hook memoizes the provider reference but does not call `getAccessToken()` until a chat interaction is initiated. If the user has an active OIDC session, token acquisition is silent; if not, the OIDC provider's login prompt is triggered. If the user dismisses the login prompt, the hook returns `undefined` and the system falls through to the header-based path (or service-account fallback). + +**`getAccessToken()` vs `getIdToken()`:** The orchestrator plugin supports both `OAuthApi.getAccessToken()` and `OpenIdConnectApi.getIdToken()` depending on the auth type. For RFC 8693 token exchange, the correct method is `getAccessToken()` because the exchange request specifies `subject_token_type: urn:ietf:params:oauth:token-type:access_token`. The `getIdToken()` method returns an ID token (JWT with `sub`, `aud`, `iss` claims), which is a different token type (`id_token`) and is not what the exchange endpoint expects as the subject token. The access token is the credential the user would present to an API — which is exactly what we're exchanging for a Kagenti-scoped version. + +**Graceful degradation:** The orchestrator's `findCustomProvider` throws when the provider is not found. Our implementation wraps the discovery in a try/catch — failure to find the OIDC provider is not an error condition, it just means the frontend path is unavailable and the system should fall through silently to the header-based path. **Why frontend discovery is required out of the gate:** The most common RHDH deployment pattern uses Backstage's built-in Keycloak auth — users log in via the RHDH UI, and Backstage replaces the user's Keycloak OIDC token with its own JWT before requests reach backend plugins. These deployments typically do not have an auth proxy injecting the original OIDC token into a header. Without frontend OIDC discovery, per-user token exchange would not work for this common case, making the feature effectively unusable for most deployments. @@ -102,6 +108,18 @@ augment: This reuses the existing `tokenEndpoint`, `clientId`, and `clientSecret` from the parent auth block. No new top-level config keys. +**Frontend config visibility:** The `tokenExchange.enabled` field must be visible to the frontend so the OIDC discovery hook knows whether to attempt provider discovery. Backstage config visibility is controlled via `config.d.ts` — the `enabled` field will be marked with `@visibility frontend` so it can be read by the frontend via `configApi.getOptionalBoolean('augment.kagenti.auth.tokenExchange.enabled')`. Other fields (`audience`, `userTokenHeader`, `clientSecret`) remain backend-only. The `userTokenHeader` value is not needed by the frontend because the frontend always uses the same header name — it is the backend that decides which header to read from, and the frontend simply sets the header that the backend expects. + +### Decision 6: Frontend-to-backend token transport + +The frontend sends the acquired OIDC token to the backend using the same `userTokenHeader` header (default: `x-user-oidc-token`) that the backend already reads from. This is set in `AugmentApi.ts`'s request preparation (the `_buildInit` method that already adds custom headers like `X-Backstage-Request: augment`). The backend does not need to distinguish whether the header was populated by the frontend or an auth proxy — it reads the same header in both cases. + +This design means the transport mechanism requires no backend changes beyond what is already specified for the header-based path. The frontend simply populates the header that the backend already knows how to read. + +**Frontend token lifecycle:** The hook caches the OIDC token reference (the provider's `getAccessToken()` return) for the duration of the React component tree's mount. Token refresh is delegated to the OIDC provider's implementation — Backstage auth providers handle refresh transparently via their internal session management. When the component unmounts and remounts (e.g., navigating away and back), discovery runs again. There is no explicit expiry tracking in the hook — the provider is the authority on token validity. + +**Dynamic plugin packaging:** The augment plugin runs as a dynamic plugin in RHDH. The new hook (`useKagentiOidcToken.ts`) and modifications to `AugmentApi.ts` are internal to the existing frontend plugin package. No new exports, entry points, or dynamic plugin wiring changes are needed — the hook is consumed internally by the chat components, and the `AugmentApi` class is already the plugin's API client. The dynamic plugin's `package.json` export map is unaffected. + ## Prerequisites For per-user token exchange to function, the deployment must have: @@ -114,7 +132,9 @@ For per-user token exchange to function, the deployment must have: - **OIDC provider not discoverable and no header token** → Frontend discovery returns `undefined` and no header token present → falls back to service-account (or fails in strict mode). Non-OIDC deployments without a header injection mechanism get no per-user auth. - **Keycloak doesn't support token exchange** → Returns 400 `unsupported_grant_type`. Caught, warned, falls back. Requires Keycloak admin to enable token exchange on the realm. -- **OIDC token expired** → Exchange fails, caught, falls back (or fails in strict mode). Short-lived tokens may cause frequent fallbacks. When the token is acquired via frontend discovery, the OIDC provider may handle refresh transparently via its `getIdToken()` implementation. When acquired via header, the backend cannot refresh the user's token — keeping it alive is the responsibility of the injecting layer (auth proxy or customer infrastructure). +- **OIDC token expired** → Exchange fails, caught, falls back (or fails in strict mode). Short-lived tokens may cause frequent fallbacks. When the token is acquired via frontend discovery, the OIDC provider handles refresh transparently via its internal session management (subsequent `getAccessToken()` calls return a refreshed token). When acquired via header, the backend cannot refresh the user's token — keeping it alive is the responsibility of the injecting layer (auth proxy or customer infrastructure). - **Memory** → Per-user cache bounded by concurrent users (~2KB per entry × 1000 users = ~2MB). Acceptable for backend plugin. - **`useApiHolder()` internal API access** → The frontend OIDC discovery uses the orchestrator's `findCustomProvider` pattern, which accesses `apiHolder.apis` (a private `Map`) via `@ts-ignore`. This is not a public Backstage API and could break if Backstage changes the internal representation. Mitigated by the fact that the orchestrator already ships this pattern in production RHDH, and the header-based fallback provides a working alternative if the internal access breaks. - **Security surface** → The configurable header must be trusted. If an attacker can inject the header, they can impersonate users. Mitigated by typical auth proxy architectures stripping/overwriting upstream headers. +- **Frontend login prompt UX** → When the OIDC provider is discovered but the user hasn't authenticated, `getAccessToken()` triggers the provider's login flow (typically a redirect or popup). If the user dismisses it, the hook returns `undefined` and the system falls through to header/service-account. There is no retry or re-prompt in the current design — the user would need to reload the page or navigate back to trigger discovery again. This is intentional to avoid nagging. +- **`getAccessToken()` vs `getIdToken()` correctness** → The orchestrator supports both `OAuthApi` and `OpenIdConnectApi` interfaces. We use `getAccessToken()` (access token) because RFC 8693 specifies `subject_token_type: access_token`. Using `getIdToken()` (ID token) would send the wrong token type and could cause the exchange to fail or produce unexpected results depending on IdP configuration. diff --git a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/proposal.md b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/proposal.md index e2e4200bfb..4c976a4210 100644 --- a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/proposal.md +++ b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/proposal.md @@ -18,7 +18,7 @@ The augment plugin authenticates to Kagenti using a single shared service-accoun ### New Capabilities - `token-exchange`: RFC 8693 OAuth2 token exchange for per-user Kagenti authorization — config schema, token lifecycle management, per-user caching, concurrent deduplication, and fallback behavior -- `user-token-routing`: Route-level extraction and forwarding of user OIDC tokens from configurable request headers to the Kagenti provider +- `user-token-routing`: Acquisition and forwarding of user OIDC tokens — via frontend API holder discovery (primary, using `getAccessToken()`) or configurable request headers (fallback) — to the Kagenti provider for RFC 8693 exchange ### Modified Capabilities @@ -35,5 +35,8 @@ The augment plugin authenticates to Kagenti using a single shared service-accoun - `plugins/augment-backend/src/router.ts` — dynamic header getter from provider config - `plugins/augment-backend/src/routes/chatRoutes.ts` — OIDC token extraction in chat handlers - `plugins/augment-backend/src/routes/kagentiRoutes.ts` — OIDC token extraction in Kagenti middleware -- Frontend OIDC discovery utility using `useApiHolder()` (modeled on the orchestrator's `useOrchestratorAuth.ts` pattern) — discovers OIDC auth provider at runtime, acquires user's token, sends to backend for exchange. This is the primary token acquisition path; header-based extraction is the fallback for non-OIDC deployments. +- `plugins/augment/src/hooks/useKagentiOidcToken.ts` — **new file**: React hook using `useApiHolder()` and the `findCustomProvider` pattern (from orchestrator's `useOrchestratorAuth.ts`) to discover OIDC auth provider at runtime, call `getAccessToken()` (OAuthApi interface — correct for RFC 8693 `subject_token_type: access_token`), and return the token for use in API requests. Handles graceful degradation (try/catch around discovery, returns `undefined` if not found). Discovers on mount, acquires token lazily on first Kagenti interaction. +- `plugins/augment/src/api/AugmentApi.ts` — modified `_buildInit` method to include OIDC token as the configured `userTokenHeader` header on outgoing requests when available (same header the backend reads from, regardless of source) +- `plugins/augment-backend/config.d.ts` — `tokenExchange.enabled` marked `@visibility frontend` so frontend can check via `configApi` - No changes to `ResponsesApiProvider`, `providers/llamastack/`, or `KeycloakTokenManager` +- No changes to dynamic plugin exports or entry points — the hook and API client changes are internal to the existing frontend plugin package diff --git a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/token-exchange/spec.md b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/token-exchange/spec.md index 8218aa38ef..47612a6a59 100644 --- a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/token-exchange/spec.md +++ b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/token-exchange/spec.md @@ -13,7 +13,7 @@ The system SHALL support an optional `tokenExchange` configuration block nested - The Kagenti API client ID (e.g., `kagenti-api`) — the typical production value, targeting the Keycloak client that represents the Kagenti service - The RHDH/Backstage client ID (the value of `auth.clientId`) — the default when no explicit audience is set - Any other Keycloak client ID that has token exchange permissions granted for the requesting client -- `userTokenHeader` (string, default: `x-user-oidc-token`) — the HTTP request header from which to read the user's OIDC access token when frontend API holder discovery is not available (fallback path) +- `userTokenHeader` (string, default: `x-user-oidc-token`) — the HTTP request header from which to read the user's OIDC access token. This header is populated by the frontend (via `AugmentApi.ts`) when OIDC discovery succeeds, or by an auth proxy in deployments where frontend discovery is not available. The backend reads from this header regardless of source. - `fallbackToServiceAccount` (boolean, default: `true`) — when `true`, exchange failures fall back to the service-account token silently with a warning log. When `false` (strict mode), exchange failures result in an error response (401 or 502) instead of falling back. The system SHALL reuse the parent `auth.tokenEndpoint`, `auth.clientId`, and `auth.clientSecret` for the exchange request. No new top-level config keys SHALL be introduced. @@ -47,7 +47,7 @@ The exchanged token SHALL preserve the user's `sub` claim and add an `act` (acto #### Scenario: Successful token exchange -- **WHEN** a valid user OIDC token is provided and Keycloak supports token exchange +- **WHEN** a valid user OIDC access token is provided (via frontend-set header or auth proxy header) and Keycloak supports token exchange - **THEN** the system SHALL return a Kagenti-scoped access token with the user's `sub` claim preserved #### Scenario: Exchange request format diff --git a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/user-token-routing/spec.md b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/user-token-routing/spec.md index 336360a480..4360c48536 100644 --- a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/user-token-routing/spec.md +++ b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/user-token-routing/spec.md @@ -6,13 +6,16 @@ Acquisition and forwarding of user OIDC tokens — via frontend API holder disco ### Requirement: Frontend OIDC token acquisition via API holder discovery -The frontend SHALL discover the OIDC auth provider at runtime using `useApiHolder()` and the orchestrator's `findCustomProvider` pattern. When Kagenti token exchange is enabled and the user initiates a Kagenti interaction: +The frontend SHALL discover the OIDC auth provider at runtime using `useApiHolder()` and the orchestrator's `findCustomProvider` pattern. Discovery is attempted on component mount; token acquisition is deferred to the first Kagenti API request. When Kagenti token exchange is enabled and the user initiates a Kagenti interaction: -1. Attempt to discover the OIDC auth provider via `useApp().getPlugins()` API enumeration -2. If not found, attempt to discover via the API holder's internal map (matching the orchestrator's `apiHolder.apis` pattern for `internal.auth.oidc`) -3. If found, call `getIdToken()` to obtain the user's OIDC token (which triggers a login prompt if the user hasn't authenticated with the OIDC provider yet) -4. Send the OIDC token to the backend via the request for RFC 8693 exchange -5. If the OIDC provider is not discoverable, fall through to the header-based path +1. Check `configApi.getOptionalBoolean('augment.kagenti.auth.tokenExchange.enabled')` — if `false` or absent, skip discovery entirely +2. Attempt to discover the OIDC auth provider via `useApp().getPlugins()` API enumeration +3. If not found, attempt to discover via the API holder's internal map (matching the orchestrator's `apiHolder.apis` pattern for `internal.auth.oidc`) +4. Wrap discovery in try/catch — the orchestrator's `findCustomProvider` throws when the provider is not found; our implementation treats this as a non-error (returns `undefined`) +5. If found, call `getAccessToken()` (`OAuthApi` interface) to obtain the user's OIDC access token. This is the correct token type for RFC 8693 exchange where `subject_token_type` is `urn:ietf:params:oauth:token-type:access_token`. (Note: `getIdToken()` from `OpenIdConnectApi` returns an ID token, which is a different token type and not what the exchange endpoint expects as the subject token.) +6. If `getAccessToken()` triggers a login prompt and the user dismisses it, the hook returns `undefined` and the system falls through to the header-based path +7. Send the OIDC token to the backend by setting the configured `userTokenHeader` header (default: `x-user-oidc-token`) on the API request — the same header the backend already reads from regardless of source +8. If the OIDC provider is not discoverable, fall through to the header-based path silently (no error, no prompt) > **Why this is required out of the gate:** Most RHDH deployments use Backstage's built-in Keycloak auth, where the user logs in via the RHDH UI. Backstage replaces the user's Keycloak OIDC token with its own JWT before it reaches backend plugins, and these deployments typically do not have an auth proxy injecting the original OIDC token into a header. Without frontend discovery, per-user token exchange would not work for this common deployment pattern. @@ -24,12 +27,59 @@ The frontend SHALL discover the OIDC auth provider at runtime using `useApiHolde #### Scenario: OIDC provider discovered and user already authenticated - **WHEN** the OIDC auth provider is discoverable and the user has an active OIDC session -- **THEN** the frontend SHALL obtain the token via `getIdToken()` without prompting and send it to the backend +- **THEN** the frontend SHALL obtain the token via `getAccessToken()` without prompting and send it to the backend via the `userTokenHeader` header + +#### Scenario: User dismisses OIDC login prompt + +- **WHEN** the OIDC auth provider is discoverable but the user has not yet authenticated and dismisses the login prompt +- **THEN** the hook SHALL return `undefined`, the frontend SHALL NOT retry or re-prompt, and the system SHALL fall through to the header-based path (or service-account fallback). The user would need to reload the page or navigate back to trigger discovery again. #### Scenario: OIDC provider not discoverable -- **WHEN** the OIDC auth provider is not found via plugin API enumeration or the API holder's internal map -- **THEN** the frontend SHALL not attempt OIDC login and the system SHALL fall back to reading the token from the configured request header +- **WHEN** the OIDC auth provider is not found via plugin API enumeration or the API holder's internal map (discovery wrapped in try/catch) +- **THEN** the frontend SHALL not attempt OIDC login, SHALL NOT log an error (this is expected in non-OIDC deployments), and the system SHALL fall back to reading the token from the configured request header + +#### Scenario: Token exchange not enabled in config + +- **WHEN** `configApi.getOptionalBoolean('augment.kagenti.auth.tokenExchange.enabled')` returns `false` or `undefined` +- **THEN** the frontend SHALL skip OIDC discovery entirely and not attempt any token acquisition + +### Requirement: Frontend-to-backend token transport + +The frontend SHALL send the acquired OIDC token to the backend by setting the `userTokenHeader` header (default: `x-user-oidc-token`) on every Kagenti API request via `AugmentApi.ts`'s request preparation (`_buildInit` method). The backend SHALL read from this same header regardless of whether the frontend or an auth proxy populated it — no backend transport changes are needed. + +#### Scenario: Frontend sets OIDC token header + +- **WHEN** the frontend has acquired an OIDC access token via API holder discovery +- **THEN** the `AugmentApi` class SHALL include the token as the value of the configured `userTokenHeader` header in the `_buildInit` method's `Headers` object, alongside existing headers like `X-Backstage-Request: augment` + +#### Scenario: Frontend has no OIDC token + +- **WHEN** the frontend did not acquire an OIDC token (discovery failed, user dismissed prompt, or token exchange not enabled) +- **THEN** the `AugmentApi` class SHALL NOT set the `userTokenHeader` header and the request proceeds without it + +### Requirement: Frontend config visibility + +The `tokenExchange.enabled` config field SHALL be marked with `@visibility frontend` in `config.d.ts` so that the frontend hook can check whether to attempt OIDC discovery. Other `tokenExchange` fields (`audience`, `userTokenHeader`, `fallbackToServiceAccount`, `clientSecret`) SHALL remain backend-only. + +#### Scenario: Frontend reads enabled flag + +- **WHEN** the frontend hook initializes +- **THEN** it SHALL check `configApi.getOptionalBoolean('augment.kagenti.auth.tokenExchange.enabled')` and skip all OIDC discovery if the value is `false` or absent + +### Requirement: Frontend token lifecycle + +The frontend hook SHALL cache the OIDC provider reference for the duration of the component tree's mount. Token refresh SHALL be delegated to the OIDC provider's implementation — Backstage auth providers handle refresh transparently via internal session management. When the component unmounts and remounts (e.g., navigating away and back), discovery runs again. There SHALL be no explicit expiry tracking in the hook. + +#### Scenario: Token refresh handled by provider + +- **WHEN** the cached OIDC access token expires while the component is mounted +- **THEN** the next call to `getAccessToken()` on the cached provider reference SHALL return a refreshed token (handled by the provider's internal session management, not by the hook) + +#### Scenario: Component remount triggers rediscovery + +- **WHEN** the user navigates away from the chat view and returns +- **THEN** the hook SHALL re-run OIDC provider discovery on mount (provider reference is not persisted across unmounts) ### Requirement: Configurable user token header on RouteContext (fallback path) diff --git a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/tasks.md b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/tasks.md index 4776b4030b..412528a0cc 100644 --- a/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/tasks.md +++ b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/tasks.md @@ -34,12 +34,13 @@ - [ ] 5.3 Update `plugins/augment-backend/src/routes/chatRoutes.ts` — both sync and streaming handlers extract OIDC token from configured header and pass to `setUserContext(userRef, bearerToken)` - [ ] 5.4 Update `plugins/augment-backend/src/routes/kagentiRoutes.ts` — `/kagenti` middleware extracts OIDC token from configured header -## 6. Frontend OIDC Discovery +## 6. Frontend OIDC Discovery and Token Transport -- [ ] 6.1 Create frontend utility for OIDC provider discovery using `useApiHolder()` — attempt `useApp().getPlugins()` API enumeration first, then fall back to API holder internal map (`apiHolder.apis` for `internal.auth.oidc`, matching the orchestrator's `findCustomProvider` pattern) -- [ ] 6.2 Implement OIDC token acquisition — call `getIdToken()` on the discovered provider (triggers login prompt if user hasn't authenticated with the OIDC provider yet), cache the result for the session -- [ ] 6.3 Wire OIDC token into Kagenti API requests — when the frontend has acquired an OIDC token, include it in requests to the backend so it can be used for RFC 8693 exchange -- [ ] 6.4 Handle graceful degradation — when OIDC provider is not discoverable, do not prompt or error; fall through silently so the backend uses the header-based fallback path +- [ ] 6.1 Mark `tokenExchange.enabled` with `@visibility frontend` in `plugins/augment-backend/config.d.ts` so the frontend can read it via `configApi` +- [ ] 6.2 Create `plugins/augment/src/hooks/useKagentiOidcToken.ts` — React hook using `useApiHolder()` for OIDC provider discovery. Attempt `useApp().getPlugins()` API enumeration first, then fall back to API holder internal map (`apiHolder.apis` for `internal.auth.oidc`, matching the orchestrator's `findCustomProvider` pattern in `useOrchestratorAuth.ts`). Wrap discovery in try/catch (orchestrator's `findCustomProvider` throws on not-found; we return `undefined`). Check `configApi.getOptionalBoolean('augment.kagenti.auth.tokenExchange.enabled')` — skip discovery if not enabled. Discover provider on component mount, defer `getAccessToken()` call to first Kagenti interaction. +- [ ] 6.3 Implement OIDC token acquisition in `useKagentiOidcToken.ts` — call `getAccessToken()` (`OAuthApi` interface, NOT `getIdToken()`) on the discovered provider. `getAccessToken()` is correct because RFC 8693 specifies `subject_token_type: access_token`. If the user hasn't authenticated with the OIDC provider, `getAccessToken()` triggers the provider's login flow. If the user dismisses the prompt, return `undefined` (no retry, no re-prompt). Cache the provider reference for the component mount duration; delegate token refresh to the provider's internal session management. +- [ ] 6.4 Wire OIDC token into API requests in `plugins/augment/src/api/AugmentApi.ts` — modify the `_buildInit` method to include the OIDC token as the `userTokenHeader` header (default: `x-user-oidc-token`) alongside existing headers like `X-Backstage-Request: augment`. The backend reads from this same header regardless of whether the frontend or an auth proxy populated it. When no token is available, do not set the header. +- [ ] 6.5 Handle graceful degradation — when OIDC provider is not discoverable, do not prompt or error; fall through silently so the backend uses the header-based fallback path or service-account token. No new exports, entry points, or dynamic plugin wiring changes needed — the hook is internal to the existing frontend plugin package. ## 7. Verification