Reduce delegate-client provisioning cost: TrustedIssuer actor matching and client-assertion auth
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 2.2k
- Forks
- 300
- Avg merge
- 1d 15h
- Merged PRs (30d)
- 184
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
#6082 as "Option D"). - #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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with pkg/authserver/server/tokenexchange/multi_issuer_validator.go and compare its issuer validation with pkg/auth/awssts/role_mapper.go. Then study the unmerged spiffee-authserver branch's pkg/authserver/spiffe/strategy.go and policy.go, plus docs/spiffe-poc/tracker.md. Done means both matcher and client-assertion paths include the required consent checks, scope and audience narrowing, registration cap, and wildcard documentation; review the AllowMayAct issue first.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- authentication, backend-api-design, security
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100