Repository navigation
Reduce delegate-client provisioning cost: TrustedIssuer actor matching and client-assertion auth #6321
Description
Problem
Today TrustedIssuer.AllowedActors and AllowedDelegateClients
(pkg/authserver/server/tokenexchange/multi_issuer_validator.go) are flat,
individually-enumerated lists of external actor / ToolHive client IDs (or the
wildcard "*", all-or-nothing). Onboarding a new delegating app today means:
(1) enumerate its external actor-claim value in allowed_actors, (2)
provision a distinct ToolHive confidential client (client_id + secret via
RunConfig / K8s Secret) and add it to allowed_delegate_clients. At real
deployment scale (many internal apps/agents wanting to delegate) this is a
real operational bar, and requires a ToolHive-minted secret to create,
rotate, and inject per app.
Proposed extension
Two additions to TrustedIssuer, both modeled on patterns already shipped
elsewhere in this codebase.
1. ActorMatcher — CEL-based actor consent, OR'd with AllowedActors
// ActorMatcher is an optional CEL expression evaluated against the subject
// token's claims (bound as `claims`, map[string]any) to decide whether the
// resolved actor is authorized to delegate — in addition to (not instead of)
// AllowedActors. Lets an operator trust a CLASS of external actors (e.g. "any
// client with Entra App Role `trusted-delegator`", "any Okta app with profile
// attribute trustTier=delegate-approved") instead of enumerating individual
// client IDs. Compiled once at startup (NewMultiIssuerTokenValidator); a
// compile error is a startup failure. Evaluation failure at request time is
// treated as no-match (fail closed), never as an allow.
ActorMatcher string `json:"actor_matcher,omitempty" yaml:"actor_matcher,omitempty"`Modeled directly on pkg/auth/awssts/role_mapper.go's RoleMapping.Matcher
— same toolhive-core/cel engine, same compiled-at-construction-time /
fail-closed-at-eval-time posture, no new CEL plumbing.
2. AllowClientAssertionAuth + ClientAssertionAudience — RFC 7523 JWT-bearer client auth against the same trusted issuer
// AllowClientAssertionAuth lets this issuer's tokens ALSO authenticate the
// OAuth client itself (RFC 7523 JWT-bearer client auth:
// client_assertion_type = urn:ietf:params:oauth:client-assertion-type:jwt-bearer),
// not only serve as subject_token. Verified against the SAME JWKS already
// built for subject-token validation (externalIssuerConfig's jwk.Cache) — no
// per-app key or secret ever stored in ToolHive. Gated by the SAME
// ActorClaim/AllowedActors/ActorMatcher check subject-token consent already
// uses.
AllowClientAssertionAuth bool `json:"allow_client_assertion_auth,omitempty" yaml:"allow_client_assertion_auth,omitempty"`
// ClientAssertionAudience is the expected "aud" on a client-assertion token
// from this issuer. Deliberately distinct from ExpectedAudience: a token
// minted for subject-token use must never double as a valid client
// assertion, or vice versa — a shared/overlapping audience would let one
// token type be replayed as the other.
ClientAssertionAudience string `json:"client_assertion_audience,omitempty" yaml:"client_assertion_audience,omitempty"`New fosite.ClientAuthenticationStrategy, modeled directly on the unmerged
spiffee-authserver branch's pkg/authserver/spiffe/strategy.go
(NewClientAuthStrategy): wraps the default strategy, falls through
untouched when the assertion's issuer doesn't match any TrustedIssuer with
AllowClientAssertionAuth: true. On match: verify signature against that
issuer's JWKS, check aud == ClientAssertionAudience, extract the actor via
ActorClaim, run it through AllowedActors/ActorMatcher, then
auto-register/look up a client keyed by the actor value (dynamic client_id,
mirroring strategy.go's ensureClientRegistered).
Concretely enables (verified against real platform docs, not assumed):
- Entra: one custom Application Permission (App Role) on a "ToolHive
Client Auth" resource app; any app granted that role via admin consent can
authenticate viaclient_credentials+scope=.../.default, verified
against the tenant's standard/discovery/v2.0/keys— no per-app
registration in ToolHive at all. - Okta: a custom scope on a Custom Authorization Server plus an explicit
per-client grant (POST /apps/{client_id}/grants— the 1:1-granularity
equivalent of Entra's role assignment), verified against that AS's own
/keysJWKS.
Requirements this MUST ship with, not defer
AllowedDelegateClientswildcard is the intended pairing, and must be
documented as such. Dynamic client IDs are unknowable ahead of time, so
an issuer usingAllowClientAssertionAuthwill typically need
AllowedDelegateClients: ["*"]— safe specifically because the real
gating already happened at theActorMatcher/app-role check, not because
the field's own protection is weaker. This needs to be explicit in
docs/validation messaging, not left for an operator to infer.- Scope/audience narrowing on auto-registered clients, to what the
specific issuer is configured for — not the server's full default set.
Thespiffee-authserverbranch's own tracker already flags "every
auto-registered client gets all supported scopes and all allowed
audiences" as a known weakness; don't inherit it here. - A registration cap (
spiffee-authserver'sMaxRegistrationspattern)
so an unbounded stream of distinct external actor values can't grow the
client store without limit. - See the AllowMayAct issue — wildcarding
AllowedDelegateClientsunder
this extension makes the existing may_act-bypasses-AllowedActors behavior
a live concern in more deployments, not fewer. That issue should land
alongside or before this one.
Prior art
pkg/auth/awssts/role_mapper.go— the CEL-matcher patternActorMatcher
reuses verbatim.- Unmerged
spiffee-authserverbranch,pkg/authserver/spiffe/{strategy,policy}.go
— the client-authentication-strategy shape, dynamic
client-ID-as-identity pattern, and both carried-over weaknesses (already
tracked in that branch's owndocs/spiffe-poc/tracker.md, referenced from
Token-exchange grant is unreachable: no way to provision a confidential client #6082 as "Option D"). - Token-exchange grant is unreachable: no way to provision a confidential client #6082's Option D recommendation: "the strongest answer for Kubernetes...
complementary rather than competing... consider harvesting it as its own
issue." This issue is that harvest, generalized to any OIDC trusted
issuer, not SPIFFE-specific.
Delegation is not credential access
A delegate allowed by wildcard or rule matching (ActorMatcher, AllowedDelegateClients: "*") may exchange tokens, but it never receives the delegated-access marker (#6404), and so never reads a user's stored upstream credentials. Only a redemption that passes subject mapping and explicit approval (#6887 "Delegated access: subject mapping and user approval") earns the marker.
Client-assertion audience: draft-ietf-oauth-rfc7523bis §4 requires a client assertion's aud to be this server's issuer alone. A separately configured ClientAssertionAudience (section 2 above) should not be introduced; the server-wide rule is tracked in #6886 "Authorization server: grants and ID-JAG issuance for delegated agents"; this issue follows it rather than restating it.
Tracked under #6887.
Activity