Documentation
¶
Overview ¶
Package tokenexchange implements RFC 8693 token exchange for the authorization server. It provides validation of subject tokens that were issued by the same authorization server, enabling agents to act on behalf of users through delegation.
Index ¶
- func Factory(delegationLifespan time.Duration, trustedIssuers []TrustedIssuer, ...) (server.Factory, error)
- func ValidateJWKSURL(jwksURL string, insecureAllowHTTP, allowPrivateIPs bool) error
- func ValidateTrustedIssuers(trustedIssuers []TrustedIssuer, selfIssuer string) error
- type Handler
- func (*Handler) CanHandleTokenEndpointRequest(_ context.Context, requester fosite.AccessRequester) bool
- func (*Handler) CanSkipClientAuth(_ context.Context, _ fosite.AccessRequester) bool
- func (h *Handler) HandleTokenEndpointRequest(ctx context.Context, requester fosite.AccessRequester) error
- func (h *Handler) PopulateTokenEndpointResponse(ctx context.Context, requester fosite.AccessRequester, ...) error
- type MayActClaim
- type MultiIssuerTokenValidator
- type SelfIssuedTokenValidator
- type SubjectTokenValidator
- type TrustedIssuer
- type ValidatedClaims
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func Factory ¶
func Factory( delegationLifespan time.Duration, trustedIssuers []TrustedIssuer, configuredDelegateClients []string, ) (server.Factory, error)
Factory returns a server.Factory that creates a token exchange Handler. The delegationLifespan parameter sets the maximum lifetime for delegated tokens; the actual lifetime is the minimum of this value and the subject token's remaining lifetime. Returns an error if delegationLifespan is not in (0, server.MaxAccessTokenLifespan]: a zero or negative value would produce delegated tokens with an expiry already in the past, and a value above the access token ceiling would only be caught at request time by the per-request cap.
When trustedIssuers is non-empty, subject tokens are validated by a MultiIssuerTokenValidator wrapping the self-issued validator; otherwise the self-issued validator is used directly, preserving prior behavior exactly. Each TrustedIssuer carries its own InsecureAllowHTTP/AllowPrivateIPs (see NewMultiIssuerTokenValidator) — this Factory takes no validator-wide equivalent, so a self-issuer setting can never reach the external path through here.
configuredDelegateClients is the operator-configured list of delegate client IDs (Config.DelegateClients, projected down to just their ClientIDs by the caller). An empty list preserves existing behavior exactly. The trust source here is server config, not client storage: the set is read once at process construction, so removing a client from config revokes its trust on the next restart rather than requiring any explicit revocation step against storage.
func ValidateJWKSURL ¶ added in v0.42.1
ValidateJWKSURL checks that jwksURL parses, has a host, uses HTTPS unless insecureAllowHTTP permits plain HTTP — and only exactly the "http" scheme, not any other non-https scheme such as "file" or "ftp" — and, when the host is an IP literal, is not a private or loopback address unless allowPrivateIPs permits that. Both flags come from the specific TrustedIssuer being fetched (see ensureRegistered), never from a validator-wide or self-issuer setting. This prevents SSRF attacks where a compromised discovery document — or a hand-configured jwks_url — points to internal services.
This is the single implementation shared by the runtime choke point above (ensureRegistered, on every fetch) and pkg/authserver/config.go's config-time check (validateJWKSEndpointURL): the two must not drift out of sync, or a laxer runtime check would silently defeat the config-time guard.
func ValidateTrustedIssuers ¶ added in v0.42.1
func ValidateTrustedIssuers(trustedIssuers []TrustedIssuer, selfIssuer string) error
ValidateTrustedIssuers runs every structural check NewMultiIssuerTokenValidator performs on trustedIssuers — required fields, self-issuer collision, duplicate issuers, and ActorClaim reachability — without constructing a validator or any per-issuer HTTP client. Config validation calls this to fail before the live upstream DCR registration and storage creation that run between RunConfig.Validate and server construction; NewMultiIssuerTokenValidator repeats the same checks at server startup as defence in depth. Both route through validateTrustedIssuer, so the two can't drift out of sync.
Types ¶
type Handler ¶
type Handler struct {
*oauth2.HandleHelper
// contains filtered or unexported fields
}
Handler implements RFC 8693 token exchange for user-to-agent delegation.
When an authenticated OAuth client (the acting agent) presents a user's JWT as subject_token, the handler validates the token and issues a delegated JWT with sub=user and an act claim containing the client's identity, per RFC 8693 Section 4.1.
Subject tokens are intentionally reusable within their lifetime: per RFC 8693's security considerations, a token exchange does not invalidate the subject token, so the same subject token may be exchanged more than once. Replay is bounded by the delegated token's lifetime cap (min(subject-remaining, delegation)), not by single-use tracking; per-jti single-use enforcement is deferred to the broader M2M/sender-constrained- token effort.
func (*Handler) CanHandleTokenEndpointRequest ¶
func (*Handler) CanHandleTokenEndpointRequest(_ context.Context, requester fosite.AccessRequester) bool
CanHandleTokenEndpointRequest returns true if the request's grant_type is the RFC 8693 token exchange grant type.
func (*Handler) CanSkipClientAuth ¶
CanSkipClientAuth returns false because client authentication is required for all token exchange requests.
func (*Handler) HandleTokenEndpointRequest ¶
func (h *Handler) HandleTokenEndpointRequest(ctx context.Context, requester fosite.AccessRequester) error
HandleTokenEndpointRequest validates the token exchange request parameters, verifies the subject token, and constructs a delegated session with the act claim.
The delegated token's lifetime is the minimum of the subject token's remaining lifetime and the configured delegation lifespan.
func (*Handler) PopulateTokenEndpointResponse ¶
func (h *Handler) PopulateTokenEndpointResponse( ctx context.Context, requester fosite.AccessRequester, responder fosite.AccessResponder, ) error
PopulateTokenEndpointResponse issues the delegated access token and sets the RFC 8693 issued_token_type in the response.
type MayActClaim ¶
type MayActClaim struct {
Sub string `json:"sub"`
// Iss, when present, qualifies Sub's namespace. RFC 8693 §4.4: "the
// combination of the two claims 'iss' and 'sub' are sometimes necessary
// to uniquely identify an authorized actor." Without it, Sub alone could
// name a party in some other issuer's namespace while still being
// compared against a ToolHive client ID by checkDelegationConsent — a
// namespace-confusion bug, not just a missing feature. validateMayActShape
// requires Iss, when present, to equal this authorization server's own
// issuer, so by the time checkDelegationConsent reads Sub it is
// guaranteed to be in ToolHive's own client namespace.
Iss string `json:"iss,omitempty"`
}
MayActClaim represents the RFC 8693 §4.4 may_act claim from a subject token. It identifies the party authorized to act on behalf of the subject.
type MultiIssuerTokenValidator ¶ added in v0.41.0
type MultiIssuerTokenValidator struct {
// contains filtered or unexported fields
}
MultiIssuerTokenValidator validates subject tokens from the authorization server itself or from configured external OIDC issuers, delegating self-issued tokens to SelfIssuedTokenValidator and resolving external issuers' JWKS (via OIDC discovery if needed) to verify signature and claims.
A valid signature and audience alone would authorize ToolHive as a resource, not any particular client, as a delegate — a confused-deputy risk (CWE-863). validateExternalToken therefore requires a "may_act" claim or a matching AllowedActors entry (surfaced as ValidatedClaims.ExternalActor) before returning successfully. See docs/arch/17-token-exchange-delegation.md for the full consent-signal precedence and trust model.
func NewMultiIssuerTokenValidator ¶ added in v0.41.0
func NewMultiIssuerTokenValidator( selfValidator *SelfIssuedTokenValidator, selfIssuer string, trustedIssuers []TrustedIssuer, ) (*MultiIssuerTokenValidator, error)
NewMultiIssuerTokenValidator creates a validator that accepts tokens from the authorization server itself and from the provided trusted external issuers. Returns an error if selfValidator is nil, selfIssuer is empty, any TrustedIssuer is invalid (see validateTrustedIssuer), or an issuer's dedicated HTTP client cannot be built (see newExternalIssuerConfig for why each issuer gets its own).
func (*MultiIssuerTokenValidator) Validate ¶ added in v0.41.0
func (v *MultiIssuerTokenValidator) Validate(ctx context.Context, rawToken string) (*ValidatedClaims, error)
Validate parses the raw JWT to extract the issuer claim, then routes validation to either the self-issued validator or the appropriate external issuer validator. Returns an error if the issuer is not trusted.
type SelfIssuedTokenValidator ¶ added in v0.41.0
type SelfIssuedTokenValidator struct {
// contains filtered or unexported fields
}
SelfIssuedTokenValidator validates subject tokens presented during RFC 8693 token exchange. It verifies that the token was issued by this authorization server by checking the signature against the server's own JWKS, and validates standard JWT claims.
func NewSelfIssuedTokenValidator ¶ added in v0.41.0
func NewSelfIssuedTokenValidator( jwks *jose.JSONWebKeySet, issuer string, allowedAudiences []string, ) (*SelfIssuedTokenValidator, error)
NewSelfIssuedTokenValidator creates a new validator for subject tokens. The jwks parameter must be non-nil and contain only the authorization server's public signing keys (e.g. AuthorizationServerConfig.PublicJWKS) — the validator only ever verifies signatures, so it must not be handed private key material. The issuer parameter is the expected "iss" claim value. allowedAudiences is the set of audiences this server accepts in a subject token's "aud" claim; per the same secure default as AuthorizationServerConfig.AllowedAudiences, an empty allowedAudiences rejects every subject token rather than skipping the check.
func (*SelfIssuedTokenValidator) Validate ¶ added in v0.41.0
func (v *SelfIssuedTokenValidator) Validate(_ context.Context, rawToken string) (*ValidatedClaims, error)
Validate parses and verifies a raw JWT subject token. It checks the signature against the server's JWKS, validates issuer and audience, ensures the token is not expired, and requires a subject claim for delegation.
The subject token's "aud" claim is checked against allowedAudiences, but this validator deliberately does not require that the authorization server itself (i.e. the token endpoint) be among those audiences. RFC 8693 leaves subject-token validation criteria out of scope, and ToolHive's vMCP flow legitimately exchanges tokens addressed to a downstream/upstream resource rather than the AS. The residual cross-resource risk is mitigated elsewhere: the token is still pinned to the server-wide allowedAudiences, and the handler enforces the requested resource against the client's registered audiences.
Returns the validated claims on success, or a descriptive error on failure.
type SubjectTokenValidator ¶
type SubjectTokenValidator interface {
Validate(ctx context.Context, rawToken string) (*ValidatedClaims, error)
}
SubjectTokenValidator validates subject tokens presented during RFC 8693 token exchange.
type TrustedIssuer ¶ added in v0.41.0
type TrustedIssuer struct {
// IssuerURL is the expected "iss" claim value (exact match).
IssuerURL string `json:"issuer_url" yaml:"issuer_url"`
// ExpectedAudience is the expected "aud" claim value that must appear
// in the token's audience list (a resource/API identifier, not a
// client ID — required, but not enforced; see looksLikeResourceIdentifier).
// See docs/arch/17-token-exchange-delegation.md ("ID/access-token
// discrimination") for why and its limits.
ExpectedAudience string `json:"expected_audience" yaml:"expected_audience"`
// JWKSURL is the URL to fetch the issuer's JSON Web Key Set from.
// If empty, it is resolved via OIDC discovery at {IssuerURL}/.well-known/openid-configuration.
JWKSURL string `json:"jwks_url,omitempty" yaml:"jwks_url,omitempty"`
// InsecureAllowHTTP permits plain-HTTP OIDC discovery and JWKS fetches
// for THIS issuer only. Development and testing only — never set in
// production. Does not relax the private-IP guard; see AllowPrivateIPs.
// Deliberately per-issuer: this server's own InsecureAllowHTTP must not
// silently permit plaintext discovery for every trusted external issuer
// too — a network attacker who can intercept that traffic could
// substitute a JWKS and forge subject tokens for that issuer's
// namespace.
InsecureAllowHTTP bool `json:"insecure_allow_http,omitempty" yaml:"insecure_allow_http,omitempty"`
// AllowPrivateIPs permits OIDC discovery and JWKS fetches for THIS
// issuer to resolve to a private or loopback address. Use only when the
// issuer is hosted inside the same cluster and has no public endpoint.
AllowPrivateIPs bool `json:"allow_private_ips,omitempty" yaml:"allow_private_ips,omitempty"`
// ActorClaim names the claim identifying the client that requested the
// subject token from THIS EXTERNAL ISSUER (used by AllowedActors below).
// Values are in the external issuer's namespace, NOT ToolHive client
// IDs. Defaults to "azp"; use "appid" for Microsoft Entra v1, "cid" for
// Okta. The special value "client_id" reads ValidatedClaims.ClientID
// instead of Extra (assignClaim routes it to that field) — it is still
// the external token's client_id claim, not a ToolHive one.
ActorClaim string `json:"actor_claim,omitempty" yaml:"actor_claim,omitempty"`
// AllowedActors is the allowlist of ActorClaim values authorized to
// exchange a subject token from this issuer when it carries no
// "may_act" claim; empty means only may_act-bearing tokens are
// accepted. By itself names no ToolHive client — see
// AllowedDelegateClients and docs/arch/17-token-exchange-delegation.md
// ("Accepted limitations" #1).
AllowedActors []string `json:"allowed_actors,omitempty" yaml:"allowed_actors,omitempty"`
// AllowedDelegateClients restricts which ToolHive client IDs may
// exchange a subject token from this issuer, for BOTH consent paths.
// Required (validateTrustedIssuer rejects empty/absent); "*" permits
// any confidential client holding the grant. See
// docs/arch/17-token-exchange-delegation.md ("Accepted limitations" #1).
//nolint:lll // field tags require full JSON+YAML names
AllowedDelegateClients []string `json:"allowed_delegate_clients,omitempty" yaml:"allowed_delegate_clients,omitempty"`
}
TrustedIssuer configures an external OIDC issuer whose tokens are accepted as subject tokens during token exchange.
This type is reused verbatim as the wire schema for authserver.RunConfig.TrustedIssuers (deliberately, to avoid a parallel type that drifts — see the go-style rule against that). Its JSON/YAML tags are therefore part of the serialized RunConfig, which is reflected into docs/server/swagger.*; adding, renaming, or retagging a field here is a schema change, not a purely internal one.
type ValidatedClaims ¶
type ValidatedClaims struct {
// Subject is the user identity from the "sub" claim (required for delegation).
Subject string
// Issuer is the token issuer from the "iss" claim.
Issuer string
// Audience is the list of intended recipients from the "aud" claim.
Audience []string
// Expiry is the token expiration time from the "exp" claim.
Expiry time.Time
// IssuedAt is the token issuance time from the "iat" claim.
IssuedAt time.Time
// JWTID is the unique token identifier from the "jti" claim.
JWTID string
// Name is the user's display name from the custom "name" claim.
Name string
// Email is the user's email address from the custom "email" claim.
Email string
// ClientID is the OAuth client ID from the custom "client_id" claim.
ClientID string
// Scopes is the space-delimited scope string assembled from the "scope"
// or "scp" claim. RFC 9068 §2.2.1 spells it "scope" as a JSON string, but
// fosite's default JWT claims strategy (token/jwt/claims_jwt.go) writes
// scopes as a JSON array under "scp" instead, unless ScopeField is
// explicitly set to String or Both — this server does not set it, so a
// genuine ToolHive-issued access token used as a subject token carries
// "scp", not "scope". When both are present, "scope" wins. Empty if the
// subject token carries neither claim.
Scopes string
// MayAct holds the authorized actor from the "may_act" claim (RFC 8693 §4.4).
// Nil when the subject token does not carry a may_act claim.
MayAct *MayActClaim
// ExternalActor is the client identity that the external-issuer validation
// path has already authorized for delegation, via a per-issuer actor-claim
// allowlist match. It is set ONLY by that path (multi_issuer_validator.go),
// after the resolved actor claim is confirmed present in the issuer's
// AllowedActors — never populated from token claims by buildValidatedClaims
// or assignClaim. It is empty for self-issued tokens and for external
// tokens that carry a may_act claim (may_act is authoritative there
// instead). A non-empty value means the validator has already authorized
// this token's actor for delegation; it is not itself a raw claim.
ExternalActor string
// ExternalIssuer is set by the external-issuer validation path
// (validateExternalToken in multi_issuer_validator.go) to that issuer's
// IssuerURL, for EVERY external token it validates — unlike
// ExternalActor, this is set regardless of whether the token carries a
// may_act claim. It exists so the handler can record provenance (which
// issuer a delegation actually originated from) even for a
// may_act-bearing external token, which leaves ExternalActor unset.
// Empty for self-issued tokens. Like ExternalActor, it is never
// populated from token claims by buildValidatedClaims or assignClaim —
// it comes from the already-validated issuer config the token matched,
// not from anything token-supplied, so it cannot be spoofed via claims.
ExternalIssuer string
// AllowedDelegateClients is set for EVERY external token — like
// ExternalIssuer and unlike ExternalActor, it does not depend on whether
// the token carries a may_act claim (see validateExternalToken). That
// matters: the may_act path bypasses the AllowedActors allowlist
// entirely, so it is the path that most needs this restriction to still
// apply. It is set to that issuer's configured
// TrustedIssuer.AllowedDelegateClients, and is never populated
// from token claims by buildValidatedClaims or assignClaim, and the
// validator never compares it against anything: the validator does not
// know the authenticated ToolHive client, so checkDelegationConsent
// (handler.go) is the one that checks actorID against this list. Nil
// means the issuer did not configure AllowedDelegateClients, which is
// permissive — any ToolHive client may use the allowlisted external
// actor, same as before this field existed.
AllowedDelegateClients []string
// Extra contains all non-standard claims not captured by other fields.
Extra map[string]any
}
ValidatedClaims holds the verified claims extracted from a subject token. All fields are populated from a successfully validated JWT that was issued by this authorization server.