feat(egress): implement Keycloak RFC 8693 token-exchange for OBO - #1678
Open
ruspg wants to merge 1 commit into
Open
feat(egress): implement Keycloak RFC 8693 token-exchange for OBO#1678ruspg wants to merge 1 commit into
ruspg wants to merge 1 commit into
Conversation
4 tasks
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
force-pushed
the
keycloak-obo-token-exchange
branch
from
August 26, 2026 06:38
0dfd0e6 to
f0c8c8c
Compare
ruspg
marked this pull request as ready for review
August 26, 2026 06:54
omrishiv
reviewed
Aug 31, 2026
omrishiv
left a comment
Contributor
There was a problem hiding this comment.
@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 Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
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.
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 implementedstub in_keycloak_exchange_bodywith a real RFC 8693 standard-token-exchange request.grant_type=urn:ietf:params:oauth:grant-type:token-exchangesubject_token+subject_token_type=urn:ietf:params:oauth:token-type:access_tokenrequested_token_typepinned toaccess_tokenrather than left to the server default — see Version semanticsaudience= the bare target client id (registration-time validation already constrains audience shape upstream)client_id/client_secretas form fieldsscopesent 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'sError mapping
_map_token_errornow takes the IdP family so a provider-specific remediation hint cannot reach an operator running the other IdP:invalid_grant,invalid_tokenOboReauthRequiredinvalid_grantfor token-exchange; legacy exchange reports an unusablesubject_tokenasinvalid_token, so the most likely runtime failure — the ingress JWT expiring mid-flight — was landing in the generic bucketunsupported_grant_typeOboConfigErroraccess_deniedOboConfigError— Keycloak onlyexchange_failedaudit bucketinvalid_client,invalid_scope,unauthorized_clientOboConfigErrorinvalid_requestsubject_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_descriptionis the only discriminator, so it is now loggedThe IdP's
error_descriptionis 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_audienceis rejected before any credential leaves the process, andresp.json()on the success path is wrapped so a 200 with a broken body stays anOboExchangeErrorinstead of escaping as a 500.Version semantics
This matters more than it looks, because the repo ships three Keycloak majors across four surfaces:
KC_FEATURESdocker-compose*.yml,terraform/aws-ecstoken-exchange,admin-apiinfra/(CDK)token-exchangecharts/(Helm)Legacy exchange (≤ 26.1) and standard exchange (26.2+) differ in ways this code path touches directly:
requested_token_type— legacyrefresh_token, standardaccess_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.access_deniedmeans — 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.token-exchangeis 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:
client_secretand the user's JWT to another endpoint would have passed every assertion in the file;ServerInfo._validate_egress_auth, which binds each scope's resource prefix totarget_audience— bare OIDC names likeprofileare rejected at registration and asserting on them proves nothing;access_deniedtest pins that the new branch does not reclassify that path.Design question for review
obo_exchangeroutes the token POST throughguarded_async_clientwithCREDENTIALED_OAUTH_PROFILE, which is HTTPS-only with a deliberately empty allowlist.This blocks the project's own default deployment, not just unusual ones:
.env.exampleand all three compose files shipKEYCLOAK_URL=http://keycloak:8080, andvalidate_urlon that endpoint raisesUrlValidationError: 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:
token_urlspecifically — it comes from operator config, not registrant input, which is how the module's docstring already reasons about it; orThis 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:
subpreserved. 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 thatclient_credentialsnever appears on the OBO path. None ship here.docs/design/egress-auth-design.mdlines 5, 239 and 257 ("currently raises a not-implemented error"), anddocs/overview/feature-release-highlights.md:28.docs/obo-token-exchange.mdwas already ahead of the code (it advertises Keycloak RFC 8693 and the client-idtarget_audience), but has no Keycloak setup section, noKEYCLOAK_*row in the env table, and a failure-triage table ofAADSTS*codes only. The ops notes below belong there.extra_audiencesis a no-op on Keycloak.auth_server/server.pycomputes per-server OBO audiences and passes them tovalidate_token, and_server_advertises_per_server_prm()returns true for OBO on any provider — butKeycloakProvider.validate_token(**kwargs)swallows the argument and its allowlist is hardcoded. Harmless while Keycloak ignores RFC 8707resource, but an operator who adds an audience mapper for the advertised per-server resource gets a fail-closedInvalid audiencewith no knob. Not touched here; happy to fix in a follow-up if you want it separate.egress_oauth.scopesis effectively unusable on Keycloak._obo_scope_mismatches_targetrequires a scope's resource prefix to equaltarget_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.KC_FEATURESappears in no chart), so that surface needs a manual step.Ops notes (for
docs/obo-token-exchange.md)access_denied — Client not allowed to exchange;aud;client_id— the web client, notm2m_client_id— so whichever permission applies must be granted there;issmust match the token endpoint's host, which matters when the gateway and the caller reach the IdP through different front doors.