stacklok / stacklok/toolhive

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

Open
#6,321 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

authentication authorization enhancement go needs-triage
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 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

  • pkg/auth/awssts/role_mapper.go — the CEL-matcher pattern ActorMatcher
    reuses verbatim.
  • Unmerged spiffee-authserver branch, 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 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.