Skip to content

feat(egress): implement Keycloak RFC 8693 token-exchange for OBO - #1678

Open
ruspg wants to merge 1 commit into
agentic-community:mainfrom
ruspg:keycloak-obo-token-exchange
Open

feat(egress): implement Keycloak RFC 8693 token-exchange for OBO#1678
ruspg wants to merge 1 commit into
agentic-community:mainfrom
ruspg:keycloak-obo-token-exchange

Conversation

@ruspg

@ruspg ruspg commented Aug 23, 2026

Copy link
Copy Markdown

Refs #1461 (see Not covered below — this does not close it on its own)

What

Implements the Keycloak half of egress OBO token exchange (Phase 4 follow-up to #1269): replaces the not yet implemented stub in _keycloak_exchange_body with a real RFC 8693 standard-token-exchange request.

  • grant_type=urn:ietf:params:oauth:grant-type:token-exchange
  • subject_token + subject_token_type=urn:ietf:params:oauth:token-type:access_token
  • requested_token_type pinned to access_token rather than left to the server default — see Version semantics
  • audience = the bare target client id (registration-time validation already constrains audience shape upstream)
  • the gateway's own client_id / client_secret as form fields
  • scope sent only when explicit scopes are requested; omitting it makes Keycloak apply the requesting client's default client scopes — the gateway's own, not the audience client's

Error mapping

_map_token_error now takes the IdP family so a provider-specific remediation hint cannot reach an operator running the other IdP:

code mapping note
invalid_grant, invalid_token OboReauthRequired Keycloak never answers invalid_grant for token-exchange; legacy exchange reports an unusable subject_token as invalid_token, so the most likely runtime failure — the ingress JWT expiring mid-flight — was landing in the generic bucket
unsupported_grant_type OboConfigError the feature is not enabled on the server
access_denied OboConfigErrorKeycloak only names both authorization models rather than asserting one; see Version semantics. Deliberately not applied to Entra, which returns this for denied consent and for conditional-access blocks, and where reclassifying would move an already-released path out of the exchange_failed audit bucket
invalid_client, invalid_scope, unauthorized_client OboConfigError unchanged
invalid_request deliberately unclassified standard exchange answers it for an expired subject_token, for the client's exchange toggle being off, and for an unplaceable audience — two config, one re-auth. The code alone cannot separate them, and calling an expired token a config problem would stop the caller retrying with a fresh one. error_description is the only discriminator, so it is now logged

The IdP's error_description is now logged (truncated; it carries configuration text, never a token) — without it the overloaded codes above are undiagnosable.

Also in this PR: a blank target_audience is rejected before any credential leaves the process, and resp.json() on the success path is wrapped so a 200 with a broken body stays an OboExchangeError instead of escaping as a 500.

Version semantics

This matters more than it looks, because the repo ships three Keycloak majors across four surfaces:

surface Keycloak KC_FEATURES
docker-compose*.yml, terraform/aws-ecs 25.0 token-exchange,admin-api
infra/ (CDK) 26.2.4 token-exchange
charts/ (Helm) 26.3.3 (absent)

Legacy exchange (≤ 26.1) and standard exchange (26.2+) differ in ways this code path touches directly:

  • default requested_token_type — legacy refresh_token, standard access_token. Unpinned, the legacy path made Keycloak mint a refresh token on every per-request exchange that the code reads past and discards. Now pinned.
  • what access_denied means — legacy: the target client lacks the token-exchange permission. Standard: the gateway's own client is outside the subject token's audience. The message names both.
  • on 26.2+ both are enabled (token-exchange is the v1 key; v2 is on by default and takes precedence), so the same request is served by different implementations depending on the client's Standard-token-exchange toggle.

The live verification was done on Keycloak 25 — i.e. legacy exchange. The standard-exchange path is reasoned from the docs and source, not exercised.

Tests

26 unit tests, all passing. Beyond body shape and error mapping:

  • the POST target URL is asserted — previously it was captured and never checked, so a regression redirecting the gateway's client_secret and the user's JWT to another endpoint would have passed every assertion in the file;
  • scope inputs use shapes that survive ServerInfo._validate_egress_auth, which binds each scope's resource prefix to target_audience — bare OIDC names like profile are rejected at registration and asserting on them proves nothing;
  • an Entra access_denied test pins that the new branch does not reclassify that path.

Design question for review

obo_exchange routes the token POST through guarded_async_client with CREDENTIALED_OAUTH_PROFILE, which is HTTPS-only with a deliberately empty allowlist.

This blocks the project's own default deployment, not just unusual ones: .env.example and all three compose files ship KEYCLOAK_URL=http://keycloak:8080, and validate_url on that endpoint raises UrlValidationError: scheme 'http' is not allowed — before the body is ever built. Switching to HTTPS does not help either, because the empty allowlist rejects a private/container-network host at fetch time. Entra never hits this because its endpoint is public HTTPS.

Options:

  • (a) trust the configured IdP token_url specifically — it comes from operator config, not registrant input, which is how the module's docstring already reasons about it; or
  • (b) a dedicated opt-in allowlist knob for the IdP token endpoint.

This PR keeps the guard untouched and raises the question here / on #1461; happy to implement whichever fits the project's stance.

Not covered by this PR

Listed so the tracking issue is not closed by mistake:

  1. No integration test proving the exchanged token is delegated with the subject's sub preserved. Egress: token-exchange (OBO) for same-trust-domain backends #1269's checklist asks for that, plus a negative test for an audience the IdP does not own and a regression test that client_credentials never appears on the OBO path. None ship here.
  2. Docs still say this is unimplementeddocs/design/egress-auth-design.md lines 5, 239 and 257 ("currently raises a not-implemented error"), and docs/overview/feature-release-highlights.md:28. docs/obo-token-exchange.md was already ahead of the code (it advertises Keycloak RFC 8693 and the client-id target_audience), but has no Keycloak setup section, no KEYCLOAK_* row in the env table, and a failure-triage table of AADSTS* codes only. The ops notes below belong there.
  3. extra_audiences is a no-op on Keycloak. auth_server/server.py computes per-server OBO audiences and passes them to validate_token, and _server_advertises_per_server_prm() returns true for OBO on any provider — but KeycloakProvider.validate_token(**kwargs) swallows the argument and its allowlist is hardcoded. Harmless while Keycloak ignores RFC 8707 resource, but an operator who adds an audience mapper for the advertised per-server resource gets a fail-closed Invalid audience with no knob. Not touched here; happy to fix in a follow-up if you want it separate.
  4. egress_oauth.scopes is effectively unusable on Keycloak. _obo_scope_mismatches_target requires a scope's resource prefix to equal target_audience, which is an Entra convention; every scope a Keycloak operator would want (profile, email, …) is rejected at registration, and the shapes that survive are not Keycloak client-scope names. The happy path (empty scopes → target's defaults) works, and the UI exposes no scopes field, so this is latent rather than breaking.
  5. The Helm chart never enables token exchange (KC_FEATURES appears in no chart), so that surface needs a manual step.

Ops notes (for docs/obo-token-exchange.md)

  • legacy exchange (Keycloak ≤ 26.1) needs the target client's token-exchange permission via fine-grained admin permissions, otherwise the token endpoint answers access_denied — Client not allowed to exchange;
  • standard exchange (26.2+) instead needs the gateway's own client inside the subject token's aud;
  • the exchange uses the gateway IdP provider's client_id — the web client, not m2m_client_id — so whichever permission applies must be granted there;
  • the subject token's iss must match the token endpoint's host, which matters when the gateway and the caller reach the IdP through different front doors.

Replaces the Phase 4 stub in _keycloak_exchange_body with a real
standard-token-exchange request: the RFC 8693 grant URN, the caller's ingress
token as subject_token typed as an access_token, the bare target client id as
audience, and the gateway's own client credentials in the form body. scope is
sent only when explicitly requested; omitting it makes Keycloak apply the
requesting client's default client scopes -- the gateway's own, not the
audience client's.

requested_token_type is pinned rather than left to the server default, because
that default differs by Keycloak generation. Legacy exchange (<= 26.1) defaults
to refresh_token, so every per-request exchange would also mint a refresh token
this code reads past and discards; standard exchange (26.2+) defaults to
access_token. RFC 8693 makes the parameter OPTIONAL with a server-chosen
default, which is exactly why relying on it is an interop hazard: the same
request would behave differently on the two Keycloak majors this repo ships.

The error mapping is written against what Keycloak actually returns rather than
what a generic OAuth error table suggests.

  - invalid_token joins invalid_grant on the re-auth branch. Keycloak never
    answers invalid_grant for token-exchange, so the single most likely runtime
    failure -- the ingress JWT expiring mid-flight -- was landing in the generic
    bucket instead of telling the caller to re-authenticate.
  - unsupported_grant_type maps to config: the grant is not enabled on the
    server at all.
  - access_denied maps to config for Keycloak only, naming both authorization
    models rather than asserting one, since legacy exchange wants the
    token-exchange permission on the target client while standard exchange
    wants the gateway client inside the subject token's audience. Keeping it
    off the Entra path avoids reclassifying an already-released code path out
    of the exchange_failed audit bucket, where Entra returns the same code for
    denied consent and for conditional-access blocks.
  - invalid_request is deliberately left unclassified. Standard exchange
    answers it for an expired subject_token, for the client's exchange toggle
    being off, and for an audience that cannot be placed in the token -- two
    config, one re-auth, and the code alone cannot separate them. Calling an
    expired token a config problem would stop the caller retrying with a fresh
    one. error_description is the only discriminator, so it is now logged,
    truncated, and it carries IdP configuration text, never a token.

Two fail-closed gaps in the surrounding function are closed while here: a blank
target_audience is rejected before any credential leaves the process, and
resp.json() on the success path is wrapped so a 200 with a broken body stays an
OboExchangeError instead of escaping the caller's handler as a 500.

Tests flip test_keycloak_raises_not_implemented and add body-shape, scope,
error-mapping and fail-closed coverage. Three are worth calling out. The POST
target URL is asserted, without which a regression redirecting the gateway's
client_secret and the user's JWT to another endpoint would pass every other
assertion in the file. The scope inputs use shapes that survive
ServerInfo._validate_egress_auth, which binds each scope's resource prefix to
target_audience, so asserting on bare OIDC names would be green and
meaningless. And an Entra access_denied case pins that the new branch does not
reclassify that path.

Verified end-to-end against a live Keycloak 25 instance with
KC_FEATURES=token-exchange: the exchanged token carries the target audience and
preserves the subject's sub claim. That is legacy exchange; the standard path
is reasoned from the documentation and server source, not exercised.
@ruspg
ruspg force-pushed the keycloak-obo-token-exchange branch from 0dfd0e6 to f0c8c8c Compare August 26, 2026 06:38
@ruspg
ruspg marked this pull request as ready for review August 26, 2026 06:54

@omrishiv omrishiv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ruspg thank you for this PR. I believe #1707 is related as it is needed for internal keycloak installs to be able to use this feature. Can you update docs/design/egress-auth-design.md to mention that Keycloak is now supported for OBO.

Additionally, we might want to think about whether we want to support in-cluster, non tls OBO. The CREDENTIALED_OAUTH_PROFILE sets require_tls=True. we may want a way to override this, but for now, can we document this in the comment since keycloak is the default self-managed idp

@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

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.

3 participants