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..73c0460b02 --- /dev/null +++ b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/design.md @@ -0,0 +1,140 @@ +# 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. 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 +- 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 + +**Non-Goals:** + +- 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: 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, 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. + +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 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. + +**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. + +### 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: 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: + +**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. + +**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. + +**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 + +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 + 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. + +**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: + +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 + +- **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 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/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..4c976a4210 --- /dev/null +++ b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/proposal.md @@ -0,0 +1,42 @@ +# 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`, `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`) +- 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`: 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 + +## 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 +- `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 new file mode 100644 index 0000000000..47612a6a59 --- /dev/null +++ b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/token-exchange/spec.md @@ -0,0 +1,144 @@ +# 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. 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. 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. + +#### Scenario: Token exchange disabled or not configured + +- **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 + +- **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 `auth.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 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 + +- **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. 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 + +- **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: Configurable fallback to service-account token + +The system SHALL support configurable fallback behavior via the `fallbackToServiceAccount` config option. + +**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 `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 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 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 + +- **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: 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** `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. If the retry also fails and `fallbackToServiceAccount` is `false`, the system SHALL return a 401 error. + +### 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..4360c48536 --- /dev/null +++ b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/specs/user-token-routing/spec.md @@ -0,0 +1,151 @@ +# Spec: user-token-routing + +Acquisition and forwarding of user OIDC tokens — via frontend API holder discovery (primary) or configurable request headers (fallback). + +## 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. 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. 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. + +#### 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 `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 (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) + +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 + +- **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 — 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 header-based OIDC token + +- **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 from either source + +- **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 + +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..412528a0cc --- /dev/null +++ b/workspaces/augment/openspec/changes/kagenti-user-level-token-exchange/tasks.md @@ -0,0 +1,51 @@ +# 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), `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 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 + +- [ ] 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. Frontend OIDC Discovery and Token Transport + +- [ ] 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 + +- [ ] 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