Skip to content

Add token exchange functionality and related configuration - #3398

Open
Thushani-Jayasekera wants to merge 1 commit into
wso2:mainfrom
Thushani-Jayasekera:token-ex
Open

Add token exchange functionality and related configuration#3398
Thushani-Jayasekera wants to merge 1 commit into
wso2:mainfrom
Thushani-Jayasekera:token-ex

Conversation

@Thushani-Jayasekera

@Thushani-Jayasekera Thushani-Jayasekera commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Token exchange for the AI Workspace BFF

Trade the user's login token for one minted specifically for the Platform API, so the
credential the BFF sends upstream is audience- and scope-scoped to that one API.

Off by default. A deployment that omits the new
[ai_workspace.auth.oidc.token_exchange] table behaves exactly as it does today: the
login token is forwarded upstream unchanged. Nothing in this PR changes the behaviour
of an existing config.


Why

Two problems, one cause — the token the BFF forwards was minted for logging into the
AI Workspace
, not for calling the Platform API.

Some enterprise IDPs cannot mint the platform's ap:* scopes. Microsoft Entra ID
is the documented case: it has no way to register them, and requesting the full set
exceeds its authorize-URL length limit. The existing workaround is
[auth.authorization] mode = "role", where the BFF and the Platform API each load the
same role-to-scope-mapping.yaml and expand roles locally. That works, but it is one
grant table hand-mirrored across two services, and a drift shows up as a UI offering
actions that then 403.

No least privilege on the upstream hop. The forwarded token is the full-privilege
session token, carrying whatever audience the login client happens to have.

An exchange fixes both. The corporate IDP proves who the user is; an STS that can
mint ap:* scopes (WSO2 IS, Asgardeo) issues the token the Platform API actually
authorizes against. Both sides then read scopes off one token, and
[auth.authorization] mode can go back to "scope" on both — retiring the mirror.


What the flow looks like

Browser                BFF                      IDP / STS              Platform API
   │                    │                          │                        │
   │─ login ───────────▶│─ authorization_code ────▶│                        │
   │                    │◀──── login token ────────│                        │
   │                    │                          │                        │
   │                    │─ TOKEN EXCHANGE ────────▶│   ← the new step       │
   │                    │   subject_token=login    │                        │
   │                    │   audience=platform-api  │                        │
   │                    │◀── exchanged token ──────│                        │
   │◀── session cookie ─│   (aud=platform-api,     │                        │
   │                    │    scope=ap:*)           │                        │
   │─ GET /proxy/... ──▶│─ Bearer <exchanged> ─────────────────────────────▶│
  • The login token stays the session anchor — it is what the cookie carries and what
    the session store is keyed by. Only the upstream Authorization header changes.
  • The exchanged token is never sent to the browser.
  • The exchange runs eagerly at login, so a misconfiguration surfaces as a failed
    login an operator sees rather than a mystery 502 on the SPA's first call, and is
    cached on the session and renewed on the proxy path afterwards.

Two grant types, because the IDPs disagree

This shaped the design and is the least obvious part of the PR.

Entra ID does not implement RFC 8693. It rejects
grant_type=urn:ietf:params:oauth:grant-type:token-exchange outright with
AADSTS70003. Its on-behalf-of flow is a different specification (RFC 7523), not a
dialect of it:

RFC 8693 (token_exchange) Entra OBO (jwt_bearer)
grant_type …:token-exchange …:jwt-bearer
Subject travels as subject_token + subject_token_type assertion + requested_token_use=on_behalf_of
Target named by audience / resource scope (api://<app-id>/.default)
issued_token_type in response REQUIRED absent

Since Entra is the IDP the platform documents as unable to mint ap:* scopes,
supporting only RFC 8693 would have missed the motivating deployment. So grant_type
is a config key with both protocols behind one interface, validated against a closed
set at startup.

Confirmed RFC 8693 support (official docs): WSO2 IS / Asgardeo, Okta custom
authorization servers, Keycloak v2 (confidential clients only), PingFederate, PingOne,
Curity, PingAM. Not Entra ID.


Configuration

All keys live in [ai_workspace.auth.oidc.token_exchange], a child of the login table.
The minimal WSO2 / Asgardeo case is two keys:

[ai_workspace.auth.oidc]
# ... authority / client_id / client_secret / redirect_url as usual ...

[ai_workspace.auth.oidc.token_exchange]
enabled  = true
audience = "platform-api"

client_id, client_secret and scope fall back to the login client's, and
token_endpoint to the one discovered from authority — set them only when the
exchange differs from login.

Why a child table rather than more keys on [auth.oidc]

The two calls are genuinely two OAuth clients. Login posts grant_type=authorization_code
with the login client_id; the exchange posts grant_type=…:token-exchange with a
client_id an STS commonly registers separately. A table expresses that. It is a
child of [auth.oidc] rather than a sibling because both calls go to the same issuer
and four keys inherit from the parent, which is what keeps the common single-application
deployment down to two lines.

Full per-key operator documentation is in configs/config-template.toml; the design
notes are in bff/TOKEN_EXCHANGE.md.

Platform API side

No code change needed. Its IDP authenticator already validates issuer and audience
when configured, so two values must agree:

[platform_api.auth.idp]
issuer   = ["https://iam.example.com/oauth2/token"]   # who issued the EXCHANGED token
audience = ["platform-api"]                            # must match the exchange audience

A mismatch 401s every request and looks like a broken exchange when the fault is
upstream — the single most likely misconfiguration, called out in the docs.


Security properties

These are deliberate, not incidental.

  • Fail-closed, with no fallback. No path returns the unexchanged subject token. A
    failed exchange fails the request; forwarding the login token instead would reach the
    Platform API with the wrong audience and, on a role-mode IDP, no platform
    authorization at all. A misconfiguration takes the UI down rather than silently
    downgrading it, and is validated at startup wherever it can be.
  • Rejection vs. unavailability are distinguished. An IDP rejection destroys the
    session and returns 401 — it can never produce an upstream token. An unavailable
    IDP keeps the session and returns 502, since logging the user out over a transient
    blip would be self-inflicted. Neither response carries the IDP's reason: whether the
    subject or the target was refused maps out the deployment's trust configuration.
  • No credential reaches a log or an error string. The subject token, the exchanged
    token, and the client secret are never logged on any path, and
    TestExchangeErrorsDoNotContainTokens asserts no error value carries one — errors
    propagate outward, so they are the easiest way for a token to end up in someone's
    log aggregator.
  • The exchanged token never reaches the browser. /api/session reports the
    exchanged scopes (they are what the Platform API authorizes) but not the token.
  • A cached token cannot outlive its inputs. The cache entry carries a fingerprint
    of the settings that determine what the IDP mints, so a config change invalidates it;
    an unknown expiry is treated as uncacheable rather than as valid; and a rotated login
    token drops the cached exchange rather than carrying it forward.
  • No refresh token is requested for the exchanged token. RFC 8693 §2.2.1 advises
    against one when trading temporary credentials, and it would outlive the login session
    it derives from — revoking the upstream session would stop revoking API access. The
    BFF re-exchanges from the session's own subject token instead.
  • Single-flight per session. The burst of parallel calls the SPA makes on page load
    hits the IDP once, not once per request.

Testing

go test ./... in portals/ai-workspace/bff — all packages pass.

File Covers
internal/auth/tokenexchange_test.go Wire format pinned per grant, expiry/scope resolution, error classification, no-token-leak guard.
internal/config/token_exchange_test.go Defaults-off compatibility, credential inheritance, every validation rule, keys pinned to the right table.
internal/config/debug_overlay_test.go configs/config-debug.toml stays inert on an empty environment and wires up correctly once the variables are exported.
internal/server/token_exchange_test.go End-to-end: fail-closed, 401-vs-502 classification, caching, single-flight, exchanged scopes on /api/session.

The IDP is stubbed throughout, so the wire format is pinned against the specifications
and vendor documentation
, not against a live server.

Trying it locally

configs/config-debug.toml — the tracked overlay make bff-run and
.vscode/launch.json layer on top of configs/config.toml — carries a ready-made,
entirely {{ env }}-driven [auth.oidc] + [auth.oidc.token_exchange] pair, so no
credential is committed and the overlay is inert until the variables are exported:

export APIP_AIW_AUTH_MODE=oidc
export APIP_AIW_AUTH_OIDC_AUTHORITY=...          # discovery base
export APIP_AIW_AUTH_OIDC_CLIENT_ID=...          # the authorization_code client
export APIP_AIW_AUTH_OIDC_CLIENT_SECRET=...
export APIP_AIW_AUTH_OIDC_TOKEN_EXCHANGE_ENABLED=true
export APIP_AIW_AUTH_OIDC_TOKEN_EXCHANGE_AUDIENCE=platform-api
make bff-run

configs/config.toml is deliberately untouched: it ships in the distribution and is
mounted into the container as-is, and the debug overlay exists precisely to keep
debug-only values out of it.


Not done in this PR

Not yet verified against a live IDP. Both grants need a round trip before this is
trusted in production:

  1. WSO2 IS / Asgardeo — enable Token Exchange on the application's allowed grant
    types and register the audience. WSO2 answers invalid_target for an unregistered or
    multi-valued audience, and audience support begins after IS 7.3.0.
  2. Entra ID — confirm the OBO flow and the api://<app-id>/.default scope.

Then confirm the exchanged token's iss/aud satisfy [platform_api.auth.idp], and
that a role-mode deployment can be switched to mode = "scope" on both sides.

The Helm chart does not render the new table yet. ai-workspace-ui-helm-chart's
configmap template emits [ai_workspace.auth.oidc] but has no token-exchange values, so
a Kubernetes deployment cannot enable the feature from values.yaml. Config-file and
local deployments are unaffected. Worth a follow-up once the live-IDP verification above
settles the key set.


Reviewing

Roughly in dependency order:

File Change
internal/config/config.go TokenExchangeConfig as the token_exchange sub-table on OIDCConfig, credential/scope inheritance in normalize, validateTokenExchange.
internal/config/default_config.go Defaults — off, WSO2-shaped token types.
internal/auth/tokenexchange.go The exchanger: request building per grant, response parsing, error classification.
internal/auth/oidc.go Exposes TokenEndpoint() so the exchange reuses login's discovery.
internal/session/store.go ExchangedToken on the session and its Usable cache-validity rule.
internal/server/server.go Builds the exchanger; single-flight map; startup log line.
internal/server/handlers.go upstreamToken, cache + single-flight, eager exchange at login, exchanged scopes on /api/session, 401-vs-502 mapping.
internal/server/composite_handlers.go The two composite endpoints bypass handleProxy, so they resolve the upstream token the same way.
configs/config-template.toml Operator documentation of [auth.oidc.token_exchange].
configs/config-debug.toml Env-driven tables for local runs.
bff/TOKEN_EXCHANGE.md Design notes, per-key reference, local-testing recipe, open items.

@coderabbitai

coderabbitai Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Next included review available in 58 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

This review ran on the open-source allowance, not this organization's plan, because the pull request author doesn't have an assigned seat. Waiting won't change this — ask an organization admin to assign them a seat, or add seats in Billing if every seat is already assigned, then retry.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Team

Run ID: eea4f991-f09c-41df-921c-456516f3e8bc

📥 Commits

Reviewing files that changed from the base of the PR and between 70b3d52 and 4d91c82.

📒 Files selected for processing (12)
  • portals/ai-workspace/bff/internal/auth/oidc.go
  • portals/ai-workspace/bff/internal/auth/tokenexchange.go
  • portals/ai-workspace/bff/internal/auth/tokenexchange_test.go
  • portals/ai-workspace/bff/internal/config/config.go
  • portals/ai-workspace/bff/internal/config/default_config.go
  • portals/ai-workspace/bff/internal/config/token_exchange_test.go
  • portals/ai-workspace/bff/internal/server/composite_handlers.go
  • portals/ai-workspace/bff/internal/server/handlers.go
  • portals/ai-workspace/bff/internal/server/server.go
  • portals/ai-workspace/bff/internal/server/token_exchange_test.go
  • portals/ai-workspace/bff/internal/session/store.go
  • portals/ai-workspace/configs/config-template.toml

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant