Repository navigation
Limit delegate token exchange to source clients - #6988
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #6988 +/- ##
==========================================
+ Coverage 80.26% 80.29% +0.02%
==========================================
Files 848 848
Lines 86308 86420 +112
==========================================
+ Hits 69274 69387 +113
+ Misses 17029 17028 -1
Partials 5 5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
ChrisJBurns
left a comment
There was a problem hiding this comment.
-
medium · security — api_audience guard may miss external identities laundered via jwt-bearer (ID-JAG) (
pkg/authserver/server/tokenexchange/handler.go,pkg/authserver/server/tokenexchange/handler_source_policy_test.go)The exemption skips tokens whose external_issuer claim is set. A restricted delegate that redeems an ID-JAG mints its own token. If that token lacks external_issuer, it can be exchanged into an uncovered api_audience. No test covers this path.
-
medium · correctness — Regenerate the Go SDK client for the new policy schema (
sdk/go/client/client_test.go,sdk/go/openapi.json,sdk/go/openapi.yaml)The SDK OpenAPI snapshots add token_exchange, but no generated client types or serializers change. SDK callers cannot express restricted policies through the typed client, and typed read-modify-write flows cannot preserve the new block.
A configured delegate client may exchange any self-issued token the server has issued, including tokens issued to unrelated clients. Those tokens are bearer credentials, so a token that leaks from one client, or that a malicious dynamically registered client collects through normal consent, can be laundered through any delegate into that delegate's own downstream access. RFC 8693 leaves exchange policy to the authorization server, and RFC 8707 audience limits do not stop this when both clients share an audience, so the server has to bind the source itself. Add an optional token_exchange block per delegate with self_issued_subject_tokens (any or restricted). Restricted limits the delegate to subject tokens issued to itself or to its allowed_source_clients, and requires any may_act to match. A may_act claim cannot replace this check: the server never writes may_act into its own tokens, and when one is present it skips the client_id binding, so it cannot narrow access on the self-issued path. Examples: - Blocked laundering: a dashboard client B logs a user in, and a broker delegate A holds the token-exchange grant. With restricted and no allowed_source_clients, A cannot exchange B's token for its own downstream access. - Intended sharing: A brokers requests for a CLI client B. Listing B in allowed_source_clients lets A exchange B's tokens and nothing else. - Audience grant: A is granted api_audience for a reporting API with no subject-token coverage. This is an explicit, operator-chosen exception for self-issued tokens only. The default stays any, so existing delegates keep today's behaviour and get the startup WARN. A client that sets grant_types must declare the block and a mode, so the trust level is an explicit choice. Not closed here: a stolen bearer token can still be exchanged by a delegate on the source list, and api_audience can be granted without subject-token coverage for self-issued tokens. Sender-constraining subject tokens is the follow-up for the first. Co-authored-by: Mecatl <noreply@mecatl.dev>
The new SelfIssuedSubjectTokens field tag is 137 characters and cannot be wrapped, so it takes the same lll exemption the file already uses for its other long tag lines. Adding DelegateClientTokenExchange raises the OpenAPI schema count to 204, so the deliberate contract pin moves with it. Co-authored-by: Mecatl <noreply@mecatl.dev>
3a4af72 to
d92d44d
Compare
The api_audience exception is documented to apply to self-issued subject tokens only, never to ones derived from an external identity. The check looked only for an external_issuer claim, and only the token-exchange handler writes it. A token minted by redeeming an ID-JAG or a JWT-bearer assertion has an external-derived subject (issuer#subject) but no such claim, so a restricted delegate could redeem an ID-JAG and exchange the result into its own api_audience. That skips the operator's per-subject allowed resources and the audience the IdP chose. Treat a subject containing the issuer#subject separator as derived from an external identity too. Native login subjects are user IDs or synthesized tk- values and never contain it, and a re-exchange keeps the subject, so the rule holds across chains. If the subject format ever changed, the failure is safe: api_audience is refused. The fix reads the subject, not a new claim, so token shape, Cedar inputs and audit output are unchanged. api_audience is new in this branch, so no released behaviour changes. Example: a restricted broker with api_audience https://reports.api redeems an ID-JAG for https://idp.example.com#alice and gets a token for https://mcp.a. Exchanging that token into reports.api was allowed; it is now refused with invalid_target, like presenting the external token directly. Co-authored-by: Mecatl <noreply@mecatl.dev>
Summary
Why: A configured delegate client may exchange any self-issued token the server has issued, including tokens issued to unrelated clients. Those tokens are bearer credentials. A token that leaks from one client, or that a malicious dynamically registered client collects through normal consent, can be laundered through any delegate into that delegate's own downstream access. RFC 8693 leaves exchange policy to the authorization server, and RFC 8707 audience limits do not stop this when both clients share an audience, so the server has to bind the source itself.
What:
token_exchangeblock per delegate client withself_issued_subject_tokens(anyorrestricted),allowed_source_clientsandapi_audience.restrictedlimits the delegate to subject tokens issued to itself or to an allowed source client, and requires anymay_actto match.allowed_source_clientsandapi_audienceare valid only withrestricted. A configured delegate cannot be listed as a source client.any, so delegates without a block keep today's behaviour and the startup WARN. An explicitanyalso logs a WARN that names the client.may_actdoes not replace this check. The server never writesmay_actinto its own tokens, and when one is present the handler skips theclient_idbinding, so it cannot narrow access on the self-issued path.Flow: how the laundering works today
Flow: the same request with
restrictedand an empty source listThreats this closes and does not close
restricted?api_audiencewidens the audiences a delegate can reachmay_acton a self-issued tokenmay_actBuilds on #6953, which has merged. This PR targets
main.Type of change
Test plan
task test)task test-e2e)task lint-fix)Manual testing, run directly with
gorather than throughtask:go vet ./pkg/authserver/...: clean.go test -ldflags=-extldflags=-Wl,-w ./pkg/authserver/...: all packages pass, including the newhandler_source_policy_test.goanddelegate_client_trust_mode_test.go.go teston./cmd/thv-operator/..., excludingtest-integration: all packages pass. The envtest suites undertest-integrationwere not run.task test,task lint-fix, and E2E.Changes
pkg/authserver/config.go,inbound_grants.gotoken_exchangeblock, validation, grant_types rulepkg/authserver/server/tokenexchange/handler.go,factory.gocheckSourcePolicy, per-client policy wiringpkg/authserver/server_impl.gowarnBlanketSelfIssuedExchangepkg/authserver/server/registration/configured_client.godocs/arch/11,docs/arch/17docs/server/*,sdk/go/*Does this introduce a user-facing change?
Before: a delegate client could exchange any self-issued token. Its config had no
token_exchangeblock, and a client that setgrant_typeswas accepted without one.After:
token_exchangeblock behaves as before:any, with the startup WARN.self_issued_subject_tokens: restrictedwithallowed_source_clientsandapi_audience. It can then exchange only tokens issued to itself or to a listed source client.grant_typeswithout atoken_exchangeblock is now rejected at config validation. It must declare the block and an explicit mode (anyorrestricted).anynow logs a WARN that names the client.Special notes for reviewers
checkSourcePolicyruns beforecheckDelegationConsent. IncheckDelegationConsentthemay_actcase comes first and skips theclient_idcheck. That is whyrestrictedmust checkclient_idincheckSourcePolicy, not rely onmay_act.api_audiencecan be granted to a delegate without subject-token coverage, for self-issued tokens only. This is a deliberate operator-chosen exception. It deserves a close look, since it allows a token to reach an audience the subject token never covered.