Add token exchange functionality and related configuration - #3398
Open
Thushani-Jayasekera wants to merge 1 commit into
Open
Add token exchange functionality and related configuration#3398Thushani-Jayasekera wants to merge 1 commit into
Thushani-Jayasekera wants to merge 1 commit into
Conversation
Thushani-Jayasekera
requested review from
AnuGayan,
Arshardh,
CrowleyRajapakse,
HeshanSudarshana,
HiranyaKavishani,
Induwara04,
Krishanx92,
PasanT9,
Piumal1999,
RakhithaRR,
Tharsanan1,
ashera96,
chamilaadhi,
dushaniw,
hisanhunais,
lasanthaS,
malinthaprasan,
pubudu538,
renuka-fernando,
senthuran16,
tgtshanika,
tharikaGitHub,
tharindu1st and
thivindu
as code owners
September 7, 2026 16:59
Contributor
|
Warning Review limit reachedNext included review available in 58 minutes. View limit detailsLimit 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. Review configuration: ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Team Run ID: 📒 Files selected for processing (12)
Comment |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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: thelogin 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 IDis 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 thesame
role-to-scope-mapping.yamland expand roles locally. That works, but it is onegrant 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 actuallyauthorizes against. Both sides then read scopes off one token, and
[auth.authorization] modecan go back to"scope"on both — retiring the mirror.What the flow looks like
the session store is keyed by. Only the upstream
Authorizationheader changes.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-exchangeoutright withAADSTS70003. Its on-behalf-of flow is a different specification (RFC 7523), not adialect of it:
token_exchange)jwt_bearer)grant_type…:token-exchange…:jwt-bearersubject_token+subject_token_typeassertion+requested_token_use=on_behalf_ofaudience/resourcescope(api://<app-id>/.default)issued_token_typein responseSince 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_typeis 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:
client_id,client_secretandscopefall back to the login client's, andtoken_endpointto the one discovered fromauthority— set them only when theexchange 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_codewith the login
client_id; the exchange postsgrant_type=…:token-exchangewith aclient_idan STS commonly registers separately. A table expresses that. It is achild of
[auth.oidc]rather than a sibling because both calls go to the same issuerand 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 designnotes 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:
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.
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.
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.
token, and the client secret are never logged on any path, and
TestExchangeErrorsDoNotContainTokensasserts no error value carries one — errorspropagate outward, so they are the easiest way for a token to end up in someone's
log aggregator.
/api/sessionreports theexchanged scopes (they are what the Platform API authorizes) but not the token.
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.
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.
hits the IDP once, not once per request.
Testing
go test ./...inportals/ai-workspace/bff— all packages pass.internal/auth/tokenexchange_test.gointernal/config/token_exchange_test.gointernal/config/debug_overlay_test.goconfigs/config-debug.tomlstays inert on an empty environment and wires up correctly once the variables are exported.internal/server/token_exchange_test.go/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 overlaymake bff-runand.vscode/launch.jsonlayer on top ofconfigs/config.toml— carries a ready-made,entirely
{{ env }}-driven[auth.oidc]+[auth.oidc.token_exchange]pair, so nocredential is committed and the overlay is inert until the variables are exported:
configs/config.tomlis deliberately untouched: it ships in the distribution and ismounted 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:
Token Exchangeon the application's allowed granttypes and register the audience. WSO2 answers
invalid_targetfor an unregistered ormulti-valued audience, and
audiencesupport begins after IS 7.3.0.api://<app-id>/.defaultscope.Then confirm the exchanged token's
iss/audsatisfy[platform_api.auth.idp], andthat 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'sconfigmap template emits
[ai_workspace.auth.oidc]but has no token-exchange values, soa Kubernetes deployment cannot enable the feature from
values.yaml. Config-file andlocal deployments are unaffected. Worth a follow-up once the live-IDP verification above
settles the key set.
Reviewing
Roughly in dependency order:
internal/config/config.goTokenExchangeConfigas thetoken_exchangesub-table onOIDCConfig, credential/scope inheritance innormalize,validateTokenExchange.internal/config/default_config.gointernal/auth/tokenexchange.gointernal/auth/oidc.goTokenEndpoint()so the exchange reuses login's discovery.internal/session/store.goExchangedTokenon the session and itsUsablecache-validity rule.internal/server/server.gointernal/server/handlers.goupstreamToken, cache + single-flight, eager exchange at login, exchanged scopes on/api/session, 401-vs-502 mapping.internal/server/composite_handlers.gohandleProxy, so they resolve the upstream token the same way.configs/config-template.toml[auth.oidc.token_exchange].configs/config-debug.tomlbff/TOKEN_EXCHANGE.md