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 via client_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
/keys JWKS.
Requirements this MUST ship with, not defer
AllowedDelegateClients wildcard is the intended pairing, and must be
documented as such. Dynamic client IDs are unknowable ahead of time, so
an issuer using AllowClientAssertionAuth will typically need
AllowedDelegateClients: ["*"] — safe specifically because the real
gating already happened at the ActorMatcher/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.
The spiffee-authserver branch'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's MaxRegistrations pattern)
so an unbounded stream of distinct external actor values can't grow the
client store without limit.
- See the AllowMayAct issue — wildcarding
AllowedDelegateClients under
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
Problem
Today
TrustedIssuer.AllowedActorsandAllowedDelegateClients(
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 realdeployment 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 shippedelsewhere in this codebase.
1.
ActorMatcher— CEL-based actor consent, OR'd withAllowedActorsModeled directly on
pkg/auth/awssts/role_mapper.go'sRoleMapping.Matcher— same
toolhive-core/celengine, 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 issuerNew
fosite.ClientAuthenticationStrategy, modeled directly on the unmergedspiffee-authserverbranch'spkg/authserver/spiffe/strategy.go(
NewClientAuthStrategy): wraps the default strategy, falls throughuntouched when the assertion's issuer doesn't match any
TrustedIssuerwithAllowClientAssertionAuth: true. On match: verify signature against thatissuer's JWKS, check
aud == ClientAssertionAudience, extract the actor viaActorClaim, run it throughAllowedActors/ActorMatcher, thenauto-register/look up a client keyed by the actor value (dynamic client_id,
mirroring
strategy.go'sensureClientRegistered).Concretely enables (verified against real platform docs, not assumed):
Client Auth" resource app; any app granted that role via admin consent can
authenticate via
client_credentials+scope=.../.default, verifiedagainst the tenant's standard
/discovery/v2.0/keys— no per-appregistration in ToolHive at all.
per-client grant (
POST /apps/{client_id}/grants— the 1:1-granularityequivalent 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 bedocumented as such. Dynamic client IDs are unknowable ahead of time, so
an issuer using
AllowClientAssertionAuthwill typically needAllowedDelegateClients: ["*"]— safe specifically because the realgating already happened at the
ActorMatcher/app-role check, not becausethe field's own protection is weaker. This needs to be explicit in
docs/validation messaging, not left for an operator to infer.
specific issuer is configured for — not the server's full default set.
The
spiffee-authserverbranch's own tracker already flags "everyauto-registered client gets all supported scopes and all allowed
audiences" as a known weakness; don't inherit it here.
spiffee-authserver'sMaxRegistrationspattern)so an unbounded stream of distinct external actor values can't grow the
client store without limit.
AllowedDelegateClientsunderthis 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 patternActorMatcherreuses verbatim.
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 own
docs/spiffe-poc/tracker.md, referenced fromToken-exchange grant is unreachable: no way to provision a confidential client #6082 as "Option D").
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.