Skip to content

Reduce delegate-client provisioning cost: TrustedIssuer actor matching and client-assertion auth #6322

Description

@jhrozek

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

  1. 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.
  2. 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.
  3. 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.
  4. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions