Skip to content

Consent model for external OIDC subject tokens in multi-issuer token exchange #5989

Description

@jhrozek

Problem

The multi-issuer subject-token validator (MultiIssuerTokenValidator, pkg/authserver/server/tokenexchange/multi_issuer_validator.go) can validate subject tokens from trusted external OIDC issuers, but it is not wired into factory.go (which still builds NewSelfIssuedTokenValidator), and external tokens carry no client_id claim, so checkDelegationConsent (handler.go:302) fails them closed. External token exchange is unusable until a consent model exists.

Decision (from OAuth review)

"Trusted issuer + audience == us + valid signature/subject" authorizes ToolHive as a resource, not a specific client. Trusting such a token wholesale is a confused-deputy / CWE-863 risk.

Adopt a hybrid, fail-closed consent policy for external subject tokens:

  1. If the subject token carries may_act (RFC 8693 §4.4), it is authoritative: may_act.sub must equal the authenticated actor (actorID = the ToolHive confidential client performing the exchange, handler.go:104). Same rule as the self-issued path.
  2. Else read a per-issuer actor claim (default azp; operators may set appid for Entra v1 or cid for Okta) and require its value to appear in a per-issuer operator allowlist. This is what authorizes a specific external client's tokens to be exchanged here.
  3. Else reject.

The self-issued path (client_id binding) is unchanged.

Scope — what ships in THIS PR

Per the sequencing constraint below, all of the following land together:

  • TrustedIssuer fields (multi_issuer_validator.go:49): add ActorClaim string and AllowedActors []string.
  • External consent policy implementing steps 1–3 above (see Design below), fail-closed.
  • Config plumbing so operators can configure trusted issuers + their ActorClaim/AllowedActors (see Config below).
  • Wire MultiIssuerTokenValidator into factory.go and delete the TODO(#5989) on the type. This is the step that makes external tokens reachable — it MUST be in the same diff as the consent policy.
  • Clock-skew leeway (see below).
  • Unit tests for every consent branch (may_act match/mismatch, actor in/not-in allowlist, empty allowlist rejects, unknown actor claim rejects) and a factory-wiring test.

If the diff approaches the repo's 400-line limit, split the operator CRD/converter config surface into a follow-up PR and land the pkg/authserver side (RunConfig/Config/server config + validator + factory + consent) first — but the validator must NOT be wired into factory.go in any PR that lacks the consent policy.

Design (recommended)

Only MultiIssuerTokenValidator holds per-issuer config (ActorClaim/AllowedActors), and only the handler knows actorID. Split the policy accordingly:

  • In MultiIssuerTokenValidator.validateExternalToken (which already has issuerConfig): when the token has no may_act, resolve the actor claim from ValidatedClaims.Extra[issuerConfig.ActorClaim] (default "azp" when ActorClaim is empty), require it to be a non-empty string present in issuerConfig.AllowedActors, and fail closed otherwise. Empty AllowedActors ⇒ reject every token (mirror the existing empty-AllowedAudiences convention). When may_act is present, skip the allowlist check (may_act is the authoritative signal) and let the handler enforce it.
  • Do not populate ValidatedClaims.ClientID from the external actor claim (namespace-collision footgun). If the handler needs the resolved value, add a new field (e.g. ExternalActor string) — but the recommended split needs no new field, since the allowlist decision is made in the validator and may_act is enforced by the existing handler code.
  • checkDelegationConsent (handler.go:302) stays responsible for the may_actactorID comparison (already implemented, applies to both paths) and the self-issued client_id binding. External tokens that reach it have either passed the validator's allowlist or carry may_act.

Read the actor claim from Extra because azp/appid/cid are not well-known fields on ValidatedClaims (see assignClaim, validator.go:255).

Config plumbing

Mirror how AllowedAudiences / DelegationTokenLifespan already flow (grep those names to trace the exact pattern):

  • Serializable layer: RunConfig (pkg/authserver/config.go:36, JSON/YAML tags).
  • Resolved layer: Config (pkg/authserver/config.go:587).
  • Factory-visible layer: server.AuthorizationServerConfig (read by factory.go; already exposes AllowedAudiences and PublicJWKS()).
  • Operator: cmd/thv-operator/pkg/controllerutil/authserver.go and cmd/thv-operator/pkg/vmcpconfig/converter.go.

Add a TrustedIssuers list (issuer URL, expected audience, JWKS URL, ActorClaim, AllowedActors) through those same layers, then have factory.go construct NewMultiIssuerTokenValidator(selfValidator, issuer, trustedIssuers) when trusted issuers are configured, else keep the self-issued validator.

Clock-skew leeway

External IdP tokens are validated with zero leeway today (ValidateWithLeeway(expected, 0), multi_issuer_validator.go:217). Add ~30–60s leeway on the external path to tolerate clock skew between the external IdP and ToolHive. Keep zero leeway for the self-issued path (shares ToolHive's clock).

Sequencing constraint

The consent gate MUST ship in the same PR that first wires MultiIssuerTokenValidator into factory.go. Until that PR merges, the validator is inert; do not enable external tokens in production without the consent policy.

Out of scope (follow-up)

Error-code taxonomy: once external tokens are reachable, grant-level failures (untrusted issuer, wrong audience, expired) should map to invalid_grant per RFC 6749 §5.2 rather than the blanket invalid_request at handler.go:118. This needs typed/sentinel validator errors the handler can distinguish and touches both validators — track it as a separate PR to keep this one reviewable. Leave the existing TODO note (retargeted to the follow-up issue) in place.

Activity

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions