diff --git a/docs/server/docs.go b/docs/server/docs.go index cc865cae51..6211c2ccbf 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -219,6 +219,20 @@ const docTemplate = `{ }, "type": "object" }, + "authserver.InboundGrantsRunConfig": { + "description": "InboundGrants declares canonical inbound grant configuration, including\nSPIFFE client authentication. See InboundGrantsRunConfig.", + "properties": { + "spiffe_client_auth": { + "description": "SPIFFEClientAuth associates SPIFFE principal patterns with explicit OAuth\nclient identities and permissions. See SPIFFEClientAuthRunConfig.", + "items": { + "$ref": "#/components/schemas/authserver.SPIFFEClientAuthRunConfig" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, "authserver.OAuth2UpstreamRunConfig": { "description": "OAuth2Config contains OAuth 2.0-specific configuration.\nRequired when Type is \"oauth2\", must be nil when Type is \"oidc\".", "properties": { @@ -414,6 +428,9 @@ const docTemplate = `{ "type": "array", "uniqueItems": false }, + "inbound_grants": { + "$ref": "#/components/schemas/authserver.InboundGrantsRunConfig" + }, "insecure_allow_confidential_over_loopback_http": { "description": "InsecureAllowConfidentialOverLoopbackHTTP opts in to confidential clients\nwhen Issuer is a plain-HTTP loopback URL. Without this flag, that\ncombination is rejected: a loopback http:// issuer is normally fine for\nlocal development (the traffic never leaves the machine), but client\nsecrets would otherwise travel over cleartext. Defaults to false. Has no\neffect when there are no confidential clients or Issuer is https.\n\nApplies identically to delegate clients and DCR-registered clients. The\nKubernetes CRD requires the explicit opt-in for a delegate client with an\nHTTP issuer; the shared transport validator enforces that its host is\nloopback — see EmbeddedAuthServerConfig's doc comment.\n\nprivate_key_jwt registration has no equivalent flag or transport\nrestriction: unlike confidential registration, it never returns a\nclient_secret (or any other secret) in the DCR response, so there is\nnothing here for cleartext HTTP to expose.", "type": "boolean" @@ -441,6 +458,14 @@ const docTemplate = `{ "signing_key_config": { "$ref": "#/components/schemas/authserver.SigningKeyRunConfig" }, + "spiffe_trust_domains": { + "description": "SPIFFETrustDomains declares SPIFFE trust roots. Each declaration must be\nreferenced by an InboundGrants.SPIFFEClientAuth entry.", + "items": { + "$ref": "#/components/schemas/authserver.SPIFFETrustDomainRunConfig" + }, + "type": "array", + "uniqueItems": false + }, "storage": { "$ref": "#/components/schemas/storage.RunConfig" }, @@ -466,6 +491,116 @@ const docTemplate = `{ }, "type": "object" }, + "authserver.SPIFFEBundleEndpointSourceRunConfig": { + "properties": { + "profile": { + "description": "Profile selects how the endpoint's TLS connection is authenticated:\nSPIFFEBundleEndpointProfileHTTPSWeb (Web PKI) or\nSPIFFEBundleEndpointProfileHTTPSSPIFFE (a separately distributed\nX.509-SVID root). Required, since the future bundle loader cannot\notherwise know which trust anchor to use for the initial connection.", + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + }, + "authserver.SPIFFEBundleSourceRunConfig": { + "description": "BundleSource declares exactly one future trust-bundle source. It is\nvalidated for shape only; fetching or loading a bundle from it is a\nlater step.", + "properties": { + "endpoint": { + "$ref": "#/components/schemas/authserver.SPIFFEBundleEndpointSourceRunConfig" + }, + "type": { + "type": "string" + }, + "workload_api": { + "$ref": "#/components/schemas/authserver.SPIFFEWorkloadAPIBundleSourceRunConfig" + } + }, + "type": "object" + }, + "authserver.SPIFFEClientAuthRunConfig": { + "properties": { + "audiences": { + "description": "Audiences are RFC 8693 token audiences this association may request.\nThis is an independent request dimension from Resources: it is not\nbounded by allowed_audiences (which is an RFC 8707 resource-URI list)\nand may contain non-URI logical audience identifiers.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "client_id": { + "description": "ClientID is the explicit OAuth client_id. It is never derived from a\nSPIFFE ID.", + "type": "string" + }, + "grant_types": { + "description": "GrantTypes are the OAuth grant types this association may use. Client\nauthentication does not by itself confer any grant.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "methods": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "principal_pattern": { + "description": "PrincipalPattern is a concrete SPIFFE ID or a terminal /* pattern within\nthe declared trust domain.", + "type": "string" + }, + "resources": { + "description": "Resources are RFC 8707 resource indicators this association may\nrequest. Must be a subset of the server's allowed_audiences allowlist\n(RunConfig.AllowedAudiences) — the same RFC 8707 resource-URI list\nDelegateClientRunConfig.Audiences is validated against. Distinct from\nAudiences: a resource permission does not imply the same value is also\na permitted token audience, or vice versa.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "scopes": { + "description": "Scopes are OAuth scopes granted to this association. They must be a\nsubset of the server's effective supported scopes.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "trust_domain_ref": { + "description": "TrustDomainRef identifies the SPIFFE trust-domain declaration governing\nthis association policy.", + "type": "string" + } + }, + "type": "object" + }, + "authserver.SPIFFETrustDomainRunConfig": { + "properties": { + "bundle_source": { + "$ref": "#/components/schemas/authserver.SPIFFEBundleSourceRunConfig" + }, + "methods": { + "description": "Methods explicitly enables the supported credential types for this trust\ndomain. No authentication method is enabled when the list is empty.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "name": { + "description": "Name uniquely identifies this declaration and is referenced by\nInboundGrants.SPIFFEClientAuth entries.", + "type": "string" + }, + "trust_domain": { + "description": "TrustDomain is the SPIFFE trust domain accepted by this declaration.", + "type": "string" + } + }, + "type": "object" + }, + "authserver.SPIFFEWorkloadAPIBundleSourceRunConfig": { + "type": "object" + }, "authserver.SigningKeyRunConfig": { "description": "SigningKeyConfig configures the signing key provider for JWT operations.\nIf nil or empty, an ephemeral signing key will be auto-generated (development only).", "properties": { diff --git a/docs/server/swagger.json b/docs/server/swagger.json index fd52337fa1..97d9998901 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -212,6 +212,20 @@ }, "type": "object" }, + "authserver.InboundGrantsRunConfig": { + "description": "InboundGrants declares canonical inbound grant configuration, including\nSPIFFE client authentication. See InboundGrantsRunConfig.", + "properties": { + "spiffe_client_auth": { + "description": "SPIFFEClientAuth associates SPIFFE principal patterns with explicit OAuth\nclient identities and permissions. See SPIFFEClientAuthRunConfig.", + "items": { + "$ref": "#/components/schemas/authserver.SPIFFEClientAuthRunConfig" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, "authserver.OAuth2UpstreamRunConfig": { "description": "OAuth2Config contains OAuth 2.0-specific configuration.\nRequired when Type is \"oauth2\", must be nil when Type is \"oidc\".", "properties": { @@ -407,6 +421,9 @@ "type": "array", "uniqueItems": false }, + "inbound_grants": { + "$ref": "#/components/schemas/authserver.InboundGrantsRunConfig" + }, "insecure_allow_confidential_over_loopback_http": { "description": "InsecureAllowConfidentialOverLoopbackHTTP opts in to confidential clients\nwhen Issuer is a plain-HTTP loopback URL. Without this flag, that\ncombination is rejected: a loopback http:// issuer is normally fine for\nlocal development (the traffic never leaves the machine), but client\nsecrets would otherwise travel over cleartext. Defaults to false. Has no\neffect when there are no confidential clients or Issuer is https.\n\nApplies identically to delegate clients and DCR-registered clients. The\nKubernetes CRD requires the explicit opt-in for a delegate client with an\nHTTP issuer; the shared transport validator enforces that its host is\nloopback — see EmbeddedAuthServerConfig's doc comment.\n\nprivate_key_jwt registration has no equivalent flag or transport\nrestriction: unlike confidential registration, it never returns a\nclient_secret (or any other secret) in the DCR response, so there is\nnothing here for cleartext HTTP to expose.", "type": "boolean" @@ -434,6 +451,14 @@ "signing_key_config": { "$ref": "#/components/schemas/authserver.SigningKeyRunConfig" }, + "spiffe_trust_domains": { + "description": "SPIFFETrustDomains declares SPIFFE trust roots. Each declaration must be\nreferenced by an InboundGrants.SPIFFEClientAuth entry.", + "items": { + "$ref": "#/components/schemas/authserver.SPIFFETrustDomainRunConfig" + }, + "type": "array", + "uniqueItems": false + }, "storage": { "$ref": "#/components/schemas/storage.RunConfig" }, @@ -459,6 +484,116 @@ }, "type": "object" }, + "authserver.SPIFFEBundleEndpointSourceRunConfig": { + "properties": { + "profile": { + "description": "Profile selects how the endpoint's TLS connection is authenticated:\nSPIFFEBundleEndpointProfileHTTPSWeb (Web PKI) or\nSPIFFEBundleEndpointProfileHTTPSSPIFFE (a separately distributed\nX.509-SVID root). Required, since the future bundle loader cannot\notherwise know which trust anchor to use for the initial connection.", + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object" + }, + "authserver.SPIFFEBundleSourceRunConfig": { + "description": "BundleSource declares exactly one future trust-bundle source. It is\nvalidated for shape only; fetching or loading a bundle from it is a\nlater step.", + "properties": { + "endpoint": { + "$ref": "#/components/schemas/authserver.SPIFFEBundleEndpointSourceRunConfig" + }, + "type": { + "type": "string" + }, + "workload_api": { + "$ref": "#/components/schemas/authserver.SPIFFEWorkloadAPIBundleSourceRunConfig" + } + }, + "type": "object" + }, + "authserver.SPIFFEClientAuthRunConfig": { + "properties": { + "audiences": { + "description": "Audiences are RFC 8693 token audiences this association may request.\nThis is an independent request dimension from Resources: it is not\nbounded by allowed_audiences (which is an RFC 8707 resource-URI list)\nand may contain non-URI logical audience identifiers.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "client_id": { + "description": "ClientID is the explicit OAuth client_id. It is never derived from a\nSPIFFE ID.", + "type": "string" + }, + "grant_types": { + "description": "GrantTypes are the OAuth grant types this association may use. Client\nauthentication does not by itself confer any grant.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "methods": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "principal_pattern": { + "description": "PrincipalPattern is a concrete SPIFFE ID or a terminal /* pattern within\nthe declared trust domain.", + "type": "string" + }, + "resources": { + "description": "Resources are RFC 8707 resource indicators this association may\nrequest. Must be a subset of the server's allowed_audiences allowlist\n(RunConfig.AllowedAudiences) — the same RFC 8707 resource-URI list\nDelegateClientRunConfig.Audiences is validated against. Distinct from\nAudiences: a resource permission does not imply the same value is also\na permitted token audience, or vice versa.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "scopes": { + "description": "Scopes are OAuth scopes granted to this association. They must be a\nsubset of the server's effective supported scopes.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "trust_domain_ref": { + "description": "TrustDomainRef identifies the SPIFFE trust-domain declaration governing\nthis association policy.", + "type": "string" + } + }, + "type": "object" + }, + "authserver.SPIFFETrustDomainRunConfig": { + "properties": { + "bundle_source": { + "$ref": "#/components/schemas/authserver.SPIFFEBundleSourceRunConfig" + }, + "methods": { + "description": "Methods explicitly enables the supported credential types for this trust\ndomain. No authentication method is enabled when the list is empty.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "name": { + "description": "Name uniquely identifies this declaration and is referenced by\nInboundGrants.SPIFFEClientAuth entries.", + "type": "string" + }, + "trust_domain": { + "description": "TrustDomain is the SPIFFE trust domain accepted by this declaration.", + "type": "string" + } + }, + "type": "object" + }, + "authserver.SPIFFEWorkloadAPIBundleSourceRunConfig": { + "type": "object" + }, "authserver.SigningKeyRunConfig": { "description": "SigningKeyConfig configures the signing key provider for JWT operations.\nIf nil or empty, an ephemeral signing key will be auto-generated (development only).", "properties": { diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index a764f24fd4..97cd175089 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -273,6 +273,20 @@ components: Required when IdentityFromToken is set. type: string type: object + authserver.InboundGrantsRunConfig: + description: |- + InboundGrants declares canonical inbound grant configuration, including + SPIFFE client authentication. See InboundGrantsRunConfig. + properties: + spiffe_client_auth: + description: |- + SPIFFEClientAuth associates SPIFFE principal patterns with explicit OAuth + client identities and permissions. See SPIFFEClientAuthRunConfig. + items: + $ref: '#/components/schemas/authserver.SPIFFEClientAuthRunConfig' + type: array + uniqueItems: false + type: object authserver.OAuth2UpstreamRunConfig: description: |- OAuth2Config contains OAuth 2.0-specific configuration. @@ -574,6 +588,8 @@ components: type: string type: array uniqueItems: false + inbound_grants: + $ref: '#/components/schemas/authserver.InboundGrantsRunConfig' insecure_allow_confidential_over_loopback_http: description: |- InsecureAllowConfidentialOverLoopbackHTTP opts in to confidential clients @@ -618,6 +634,14 @@ components: uniqueItems: false signing_key_config: $ref: '#/components/schemas/authserver.SigningKeyRunConfig' + spiffe_trust_domains: + description: |- + SPIFFETrustDomains declares SPIFFE trust roots. Each declaration must be + referenced by an InboundGrants.SPIFFEClientAuth entry. + items: + $ref: '#/components/schemas/authserver.SPIFFETrustDomainRunConfig' + type: array + uniqueItems: false storage: $ref: '#/components/schemas/storage.RunConfig' token_lifespans: @@ -650,6 +674,116 @@ components: type: array uniqueItems: false type: object + authserver.SPIFFEBundleEndpointSourceRunConfig: + properties: + profile: + description: |- + Profile selects how the endpoint's TLS connection is authenticated: + SPIFFEBundleEndpointProfileHTTPSWeb (Web PKI) or + SPIFFEBundleEndpointProfileHTTPSSPIFFE (a separately distributed + X.509-SVID root). Required, since the future bundle loader cannot + otherwise know which trust anchor to use for the initial connection. + type: string + url: + type: string + type: object + authserver.SPIFFEBundleSourceRunConfig: + description: |- + BundleSource declares exactly one future trust-bundle source. It is + validated for shape only; fetching or loading a bundle from it is a + later step. + properties: + endpoint: + $ref: '#/components/schemas/authserver.SPIFFEBundleEndpointSourceRunConfig' + type: + type: string + workload_api: + $ref: '#/components/schemas/authserver.SPIFFEWorkloadAPIBundleSourceRunConfig' + type: object + authserver.SPIFFEClientAuthRunConfig: + properties: + audiences: + description: |- + Audiences are RFC 8693 token audiences this association may request. + This is an independent request dimension from Resources: it is not + bounded by allowed_audiences (which is an RFC 8707 resource-URI list) + and may contain non-URI logical audience identifiers. + items: + type: string + type: array + uniqueItems: false + client_id: + description: |- + ClientID is the explicit OAuth client_id. It is never derived from a + SPIFFE ID. + type: string + grant_types: + description: |- + GrantTypes are the OAuth grant types this association may use. Client + authentication does not by itself confer any grant. + items: + type: string + type: array + uniqueItems: false + methods: + items: + type: string + type: array + uniqueItems: false + principal_pattern: + description: |- + PrincipalPattern is a concrete SPIFFE ID or a terminal /* pattern within + the declared trust domain. + type: string + resources: + description: |- + Resources are RFC 8707 resource indicators this association may + request. Must be a subset of the server's allowed_audiences allowlist + (RunConfig.AllowedAudiences) — the same RFC 8707 resource-URI list + DelegateClientRunConfig.Audiences is validated against. Distinct from + Audiences: a resource permission does not imply the same value is also + a permitted token audience, or vice versa. + items: + type: string + type: array + uniqueItems: false + scopes: + description: |- + Scopes are OAuth scopes granted to this association. They must be a + subset of the server's effective supported scopes. + items: + type: string + type: array + uniqueItems: false + trust_domain_ref: + description: |- + TrustDomainRef identifies the SPIFFE trust-domain declaration governing + this association policy. + type: string + type: object + authserver.SPIFFETrustDomainRunConfig: + properties: + bundle_source: + $ref: '#/components/schemas/authserver.SPIFFEBundleSourceRunConfig' + methods: + description: |- + Methods explicitly enables the supported credential types for this trust + domain. No authentication method is enabled when the list is empty. + items: + type: string + type: array + uniqueItems: false + name: + description: |- + Name uniquely identifies this declaration and is referenced by + InboundGrants.SPIFFEClientAuth entries. + type: string + trust_domain: + description: TrustDomain is the SPIFFE trust domain accepted by this declaration. + type: string + type: object + authserver.SPIFFEWorkloadAPIBundleSourceRunConfig: + type: object authserver.SigningKeyRunConfig: description: |- SigningKeyConfig configures the signing key provider for JWT operations. diff --git a/go.mod b/go.mod index 9a4e5eca2a..8ca4d22a53 100644 --- a/go.mod +++ b/go.mod @@ -47,6 +47,7 @@ require ( github.com/redis/go-redis/v9 v9.22.0 github.com/shirou/gopsutil/v4 v4.26.7 github.com/spf13/viper v1.21.0 + github.com/spiffe/go-spiffe/v2 v2.7.0 github.com/stacklok/toolhive-catalog v0.20260824.0 github.com/stacklok/toolhive-core v0.0.42 github.com/stretchr/testify v1.12.1 diff --git a/go.sum b/go.sum index 7601472037..71d5df10af 100644 --- a/go.sum +++ b/go.sum @@ -786,6 +786,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/spiffe/go-spiffe/v2 v2.7.0 h1:uXe1MflJoHw58wAUvxVlcM7WpKtijWG7I1UidcGh6g4= +github.com/spiffe/go-spiffe/v2 v2.7.0/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= github.com/stacklok/toolhive-catalog v0.20260824.0 h1:cFmxI3JVNarlX/XvfirTeiekLw4hBRdTwHtXayKJqH0= github.com/stacklok/toolhive-catalog v0.20260824.0/go.mod h1:7l8t1m10oDUtsGroq88TvSJvyBhADvTEF2n5IBedydA= github.com/stacklok/toolhive-core v0.0.42 h1:FqE5CfBQrSrbYyHarqsK6K0GDy1jHKwPAhgp9mwfujk= diff --git a/pkg/authserver/config.go b/pkg/authserver/config.go index 7eb7d7119c..9b0ddad433 100644 --- a/pkg/authserver/config.go +++ b/pkg/authserver/config.go @@ -230,6 +230,14 @@ type RunConfig struct { // // See DelegateClientRunConfig for the per-client field reference. DelegateClients []DelegateClientRunConfig `json:"delegate_clients,omitempty" yaml:"delegate_clients,omitempty"` + + // SPIFFETrustDomains declares SPIFFE trust roots. Each declaration must be + // referenced by an InboundGrants.SPIFFEClientAuth entry. + SPIFFETrustDomains []SPIFFETrustDomainRunConfig `json:"spiffe_trust_domains,omitempty" yaml:"spiffe_trust_domains,omitempty"` + + // InboundGrants declares canonical inbound grant configuration, including + // SPIFFE client authentication. See InboundGrantsRunConfig. + InboundGrants *InboundGrantsRunConfig `json:"inbound_grants,omitempty" yaml:"inbound_grants,omitempty"` } // DelegateClientRunConfig declares a pre-provisioned confidential OAuth @@ -298,9 +306,54 @@ func (c *RunConfig) Validate() error { c.ForceConfidentialRedirectURIs, c.AllowConfidentialClientRegistration); err != nil { return err } + if err := ValidateSPIFFETrust( + c.SPIFFETrustDomains, c.InboundGrants, c.ScopesSupported, c.AllowedAudiences, + ); err != nil { + return err + } + if err := validateSPIFFENotYetEnforced(c.SPIFFETrustDomains); err != nil { + return err + } return c.validateBaselineClientScopes() } +// validateSPIFFENotYetEnforced hard-rejects a non-empty SPIFFE trust +// configuration. ValidateSPIFFETrust above confirms the configuration is +// well-formed, but well-formed is not the same as enforced: nothing in this +// build ever verifies an X.509-SVID or JWT-SVID against the configured trust +// bundle, so a valid, non-empty SPIFFE trust configuration currently has no +// runtime authentication effect. Accepting it silently would let an operator +// believe SPIFFE client authentication is active when no credential is ever +// checked. This rejection must be removed by the future PR that adds real +// SVID verification against the configured trust bundle. +func validateSPIFFENotYetEnforced(trustDomains []SPIFFETrustDomainRunConfig) error { + if len(trustDomains) == 0 { + return nil + } + return fmt.Errorf( + "spiffe_trust_domains: SPIFFE client authentication is not yet enforced by this build " + + "(no X.509-SVID or JWT-SVID is verified against the configured trust bundle); " + + "remove this configuration until a verification consumer lands") +} + +// validateConfigSPIFFENotYetEnforced is validateSPIFFENotYetEnforced's +// Config-level counterpart: a caller that constructs Config directly (e.g. +// authserver.New) bypasses RunConfig.Validate() entirely, so the same +// fail-loud rejection must also apply to Config.SPIFFETrust -- otherwise a +// non-empty, well-formed SPIFFE trust policy could start a server through +// this path with no authentication consumer ever wired to it. Associations +// is nil-safe and empty for both a nil SPIFFETrust and one with no +// configured associations. +func validateConfigSPIFFENotYetEnforced(trust *SPIFFETrustConfig) error { + if len(trust.Associations()) == 0 { + return nil + } + return fmt.Errorf( + "spiffe_trust: SPIFFE client authentication is not yet enforced by this build " + + "(no X.509-SVID or JWT-SVID is verified against the configured trust bundle); " + + "remove this configuration until a verification consumer lands") +} + // validateBaselineClientScopes ensures every entry in BaselineClientScopes is // also present in ScopesSupported. If a baseline scope is not advertised by // ScopesSupported, the embedded DCR handler would later try to register a @@ -989,6 +1042,12 @@ type Config struct { // or environment-variable reference. See RunConfig.DelegateClients for the // serialized configuration. DelegateClients []DelegateClient + + // SPIFFETrust is the validated, immutable runtime SPIFFE trust model. It + // must be constructed with NewSPIFFETrustConfig; a nil value means no + // SPIFFE associations are configured. The serialized declarations live on + // RunConfig and are converted at the RunConfig-to-Config boundary. + SPIFFETrust *SPIFFETrustConfig } // DelegateClient is the resolved form of DelegateClientRunConfig: the secret @@ -1071,13 +1130,20 @@ func (c *Config) Validate() error { return err } - // RunConfig.Validate() also runs these checks (see the comment there for - // why: buildUpstreamConfigs's live DCR registration happens before this - // method is reached), but a caller that constructs Config directly bypasses - // that, same as the BaselineClientScopes check above. + return c.validateDelegationAndTrustConfig() +} + +// validateDelegationAndTrustConfig groups the delegate-client and SPIFFE +// trust checks. RunConfig.Validate() also runs these checks (see the +// comment there for why), while a caller that constructs Config directly +// bypasses them. +func (c *Config) validateDelegationAndTrustConfig() error { if err := c.validateDelegationConfig(); err != nil { return err } + if err := validateConfigSPIFFENotYetEnforced(c.SPIFFETrust); err != nil { + return err + } c.warnTrustedIssuerAudiences() slog.Debug("authserver config validation passed", diff --git a/pkg/authserver/config_test.go b/pkg/authserver/config_test.go index 760842fcf1..da693c6214 100644 --- a/pkg/authserver/config_test.go +++ b/pkg/authserver/config_test.go @@ -84,6 +84,32 @@ func TestValidateIssuerURL(t *testing.T) { } } +// mustNewSPIFFETrustConfig builds a well-formed, non-empty *SPIFFETrustConfig +// for tests asserting that Config.Validate() rejects it as not-yet-enforced. +func mustNewSPIFFETrustConfig(t *testing.T) *SPIFFETrustConfig { + t.Helper() + trust, err := NewSPIFFETrustConfig( + []SPIFFETrustDomainRunConfig{{ + Name: "production", + TrustDomain: "example.org", + Methods: []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodX509}, + BundleSource: validWorkloadAPIBundleSource(), + }}, + &InboundGrantsRunConfig{SPIFFEClientAuth: []SPIFFEClientAuthRunConfig{{ + TrustDomainRef: "production", + PrincipalPattern: "spiffe://example.org/ns/default/*", + ClientID: "agent-client", + Methods: []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodX509}, + Audiences: []string{"https://mcp.example.com"}, + Scopes: []string{"openid"}, + GrantTypes: []string{SPIFFEGrantTypeTokenExchange}, + }}}, + []string{"openid"}, []string{"https://mcp.example.com"}, + ) + require.NoError(t, err) + return trust +} + func TestConfigValidate(t *testing.T) { t.Parallel() @@ -164,6 +190,22 @@ func TestConfigValidate(t *testing.T) { {name: "valid nil key provider", config: Config{Issuer: "https://example.com", HMACSecrets: validHMAC, Upstreams: validUpstreams, AllowedAudiences: []string{"https://mcp.example.com"}}}, {name: "valid empty upstream name defaults", config: Config{Issuer: "https://example.com", KeyProvider: validKeyProvider, HMACSecrets: validHMAC, Upstreams: []UpstreamConfig{{Type: UpstreamProviderTypeOAuth2, OAuth2Config: validUpstream}}, AllowedAudiences: []string{"https://mcp.example.com"}}}, {name: "valid OIDC upstream", config: Config{Issuer: "https://example.com", KeyProvider: validKeyProvider, HMACSecrets: validHMAC, Upstreams: validOIDCUpstreams, AllowedAudiences: []string{"https://mcp.example.com"}}}, + + // SPIFFETrust is accepted by NewSPIFFETrustConfig as well-formed, but + // hard-rejected here too -- mirroring RunConfig.Validate's + // validateSPIFFENotYetEnforced -- since a caller that constructs + // Config directly (e.g. authserver.New) bypasses RunConfig entirely. + {name: "nil SPIFFETrust passes", config: Config{Issuer: "https://example.com", KeyProvider: validKeyProvider, HMACSecrets: validHMAC, Upstreams: validUpstreams, AllowedAudiences: []string{"https://mcp.example.com"}}}, + { + name: "well-formed Config.SPIFFETrust is rejected as not yet enforced", + config: Config{ + Issuer: "https://example.com", KeyProvider: validKeyProvider, HMACSecrets: validHMAC, + Upstreams: validUpstreams, AllowedAudiences: []string{"https://mcp.example.com"}, + SPIFFETrust: mustNewSPIFFETrustConfig(t), + }, + wantErr: true, + errMsg: "SPIFFE client authentication is not yet enforced", + }, } for _, tt := range tests { @@ -600,6 +642,34 @@ func TestRunConfigValidate(t *testing.T) { AllowPrivateKeyJWTRegistration: true, }, }, + // SPIFFE trust is accepted by ValidateSPIFFETrust as well-formed, but + // hard-rejected here until a real SVID verification consumer lands. + { + name: "no SPIFFE trust domains passes", + config: RunConfig{}, + }, + { + name: "well-formed SPIFFE trust configuration is rejected as not yet enforced", + config: RunConfig{ + SPIFFETrustDomains: []SPIFFETrustDomainRunConfig{{ + Name: "production", + TrustDomain: "example.org", + Methods: []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodX509}, + BundleSource: validWorkloadAPIBundleSource(), + }}, + InboundGrants: &InboundGrantsRunConfig{SPIFFEClientAuth: []SPIFFEClientAuthRunConfig{{ + TrustDomainRef: "production", + PrincipalPattern: "spiffe://example.org/ns/default/*", + ClientID: "agent-client", + Methods: []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodX509}, + Audiences: []string{"https://mcp.example.org/resource"}, + Scopes: []string{"openid"}, + GrantTypes: []string{SPIFFEGrantTypeTokenExchange}, + }}}, + }, + wantErr: true, + errMsg: "SPIFFE client authentication is not yet enforced", + }, } for _, tt := range tests { diff --git a/pkg/authserver/runner/embeddedauthserver.go b/pkg/authserver/runner/embeddedauthserver.go index 08a0a38fd8..b476389242 100644 --- a/pkg/authserver/runner/embeddedauthserver.go +++ b/pkg/authserver/runner/embeddedauthserver.go @@ -245,6 +245,13 @@ func newEmbeddedAuthServerWithStorage( return nil, fmt.Errorf("failed to resolve JWT-bearer grant policies: %w", err) } + spiffeTrust, err := authserver.NewSPIFFETrustConfig( + cfg.SPIFFETrustDomains, cfg.InboundGrants, cfg.ScopesSupported, cfg.AllowedAudiences, + ) + if err != nil { + return nil, fmt.Errorf("failed to build SPIFFE trust config: %w", err) + } + resolvedCfg := authserver.Config{ Issuer: cfg.Issuer, AuthorizationEndpointBaseURL: cfg.AuthorizationEndpointBaseURL, @@ -272,6 +279,7 @@ func newEmbeddedAuthServerWithStorage( // authorization-critical data is protected without a deep copy here. TrustedIssuers: trustedIssuers, DelegateClients: delegateClients, + SPIFFETrust: spiffeTrust, } // 8. Create the auth server. authserver.New also asserts the DCR diff --git a/pkg/authserver/spiffe_trust.go b/pkg/authserver/spiffe_trust.go new file mode 100644 index 0000000000..0c0e3c2b25 --- /dev/null +++ b/pkg/authserver/spiffe_trust.go @@ -0,0 +1,784 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package authserver + +import ( + "fmt" + "net" + "net/url" + "slices" + "strings" + + "github.com/spiffe/go-spiffe/v2/spiffeid" + + "github.com/stacklok/toolhive/pkg/authserver/server/registration" + "github.com/stacklok/toolhive/pkg/authserver/storage" + "github.com/stacklok/toolhive/pkg/networking" + "github.com/stacklok/toolhive/pkg/oauthproto" +) + +const ( + // SPIFFEAuthenticationMethodX509 authenticates a workload with an X.509-SVID. + SPIFFEAuthenticationMethodX509 SPIFFEAuthenticationMethod = "spiffe_x509" + // SPIFFEAuthenticationMethodJWT authenticates a workload with a JWT-SVID. + SPIFFEAuthenticationMethodJWT SPIFFEAuthenticationMethod = "spiffe_jwt" + + // SPIFFEGrantTypeTokenExchange is the only SPIFFE client grant supported by + // this configuration surface. + SPIFFEGrantTypeTokenExchange = oauthproto.GrantTypeTokenExchange + + // SPIFFEBundleSourceTypeEndpoint selects a HTTPS SPIFFE Bundle Endpoint. + SPIFFEBundleSourceTypeEndpoint SPIFFEBundleSourceType = "bundle_endpoint" + // SPIFFEBundleSourceTypeWorkloadAPI selects the local SPIFFE Workload API. + SPIFFEBundleSourceTypeWorkloadAPI SPIFFEBundleSourceType = "workload_api" + + // SPIFFEBundleEndpointProfileHTTPSWeb authenticates the bundle endpoint's + // TLS connection with a Web PKI certificate (the SPIFFE Bundle Endpoint + // "https_web" profile). + SPIFFEBundleEndpointProfileHTTPSWeb SPIFFEBundleEndpointProfile = "https_web" + // SPIFFEBundleEndpointProfileHTTPSSPIFFE authenticates the bundle + // endpoint's TLS connection with an X.509-SVID trusted by a separately + // distributed root (the SPIFFE Bundle Endpoint "https_spiffe" profile). + SPIFFEBundleEndpointProfileHTTPSSPIFFE SPIFFEBundleEndpointProfile = "https_spiffe" +) + +// SPIFFEAuthenticationMethod identifies the credential type permitted for a +// SPIFFE workload. Methods are explicit so introducing another credential type +// cannot silently broaden a policy. +type SPIFFEAuthenticationMethod string + +// SPIFFEBundleSourceType identifies the selected trust-bundle source. +type SPIFFEBundleSourceType string + +// SPIFFEBundleEndpointProfile identifies how a SPIFFE Bundle Endpoint's TLS +// connection is authenticated, per the SPIFFE Federation specification. +type SPIFFEBundleEndpointProfile string + +// SPIFFETrustDomainRunConfig declares one SPIFFE trust domain. Credential +// authentication is deliberately outside this configuration step. +type SPIFFETrustDomainRunConfig struct { + // Name uniquely identifies this declaration and is referenced by + // InboundGrants.SPIFFEClientAuth entries. + Name string `json:"name" yaml:"name"` + + // TrustDomain is the SPIFFE trust domain accepted by this declaration. + TrustDomain string `json:"trust_domain" yaml:"trust_domain"` + + // Methods explicitly enables the supported credential types for this trust + // domain. No authentication method is enabled when the list is empty. + Methods []SPIFFEAuthenticationMethod `json:"methods" yaml:"methods"` + + // BundleSource declares exactly one future trust-bundle source. It is + // validated for shape only; fetching or loading a bundle from it is a + // later step. + BundleSource SPIFFEBundleSourceRunConfig `json:"bundle_source" yaml:"bundle_source"` +} + +// SPIFFEBundleSourceRunConfig is a discriminated bundle-source declaration. +// Type determines which, and only which, source payload may be set. +type SPIFFEBundleSourceRunConfig struct { + Type SPIFFEBundleSourceType `json:"type" yaml:"type"` + + Endpoint *SPIFFEBundleEndpointSourceRunConfig `json:"endpoint,omitempty" yaml:"endpoint,omitempty"` + WorkloadAPI *SPIFFEWorkloadAPIBundleSourceRunConfig `json:"workload_api,omitempty" yaml:"workload_api,omitempty"` +} + +// SPIFFEBundleEndpointSourceRunConfig declares a HTTPS SPIFFE Bundle Endpoint. +type SPIFFEBundleEndpointSourceRunConfig struct { + URL string `json:"url" yaml:"url"` + + // Profile selects how the endpoint's TLS connection is authenticated: + // SPIFFEBundleEndpointProfileHTTPSWeb (Web PKI) or + // SPIFFEBundleEndpointProfileHTTPSSPIFFE (a separately distributed + // X.509-SVID root). Required, since the future bundle loader cannot + // otherwise know which trust anchor to use for the initial connection. + Profile SPIFFEBundleEndpointProfile `json:"profile" yaml:"profile"` +} + +// SPIFFEWorkloadAPIBundleSourceRunConfig selects the local SPIFFE Workload API. +// It deliberately has no payload; loading and deployment details are deferred +// to the bundle-loading implementation. +type SPIFFEWorkloadAPIBundleSourceRunConfig struct{} + +// InboundGrantsRunConfig declares canonical inbound grant configuration for +// separately declared trust roots. SPIFFE client authentication entries live +// here, alongside other inbound grant purposes, so SPIFFE is not a parallel +// trust path. +type InboundGrantsRunConfig struct { + // SPIFFEClientAuth associates SPIFFE principal patterns with explicit OAuth + // client identities and permissions. See SPIFFEClientAuthRunConfig. + SPIFFEClientAuth []SPIFFEClientAuthRunConfig `json:"spiffe_client_auth,omitempty" yaml:"spiffe_client_auth,omitempty"` +} + +// SPIFFEClientAuthRunConfig associates one SPIFFE principal pattern from a +// declared trust domain with an explicit OAuth client identity and permissions. +type SPIFFEClientAuthRunConfig struct { + // TrustDomainRef identifies the SPIFFE trust-domain declaration governing + // this association policy. + TrustDomainRef string `json:"trust_domain_ref" yaml:"trust_domain_ref"` + + // PrincipalPattern is a concrete SPIFFE ID or a terminal /* pattern within + // the declared trust domain. + PrincipalPattern string `json:"principal_pattern" yaml:"principal_pattern"` + + // ClientID is the explicit OAuth client_id. It is never derived from a + // SPIFFE ID. + ClientID string `json:"client_id" yaml:"client_id"` + + Methods []SPIFFEAuthenticationMethod `json:"methods" yaml:"methods"` + + // Resources are RFC 8707 resource indicators this association may + // request. Must be a subset of the server's allowed_audiences allowlist + // (RunConfig.AllowedAudiences) — the same RFC 8707 resource-URI list + // DelegateClientRunConfig.Audiences is validated against. Distinct from + // Audiences: a resource permission does not imply the same value is also + // a permitted token audience, or vice versa. + Resources []string `json:"resources,omitempty" yaml:"resources,omitempty"` + + // Audiences are RFC 8693 token audiences this association may request. + // This is an independent request dimension from Resources: it is not + // bounded by allowed_audiences (which is an RFC 8707 resource-URI list) + // and may contain non-URI logical audience identifiers. + Audiences []string `json:"audiences" yaml:"audiences"` + + // Scopes are OAuth scopes granted to this association. They must be a + // subset of the server's effective supported scopes. + Scopes []string `json:"scopes" yaml:"scopes"` + + // GrantTypes are the OAuth grant types this association may use. Client + // authentication does not by itself confer any grant. + GrantTypes []string `json:"grant_types" yaml:"grant_types"` +} + +// SPIFFEAuthorizationPolicy is the immutable authorization policy selected by a +// validated SPIFFE association. +type SPIFFEAuthorizationPolicy struct { + grantTypes []string + scopes []string + resources []string + audiences []string +} + +// GrantTypes returns a copy of the permitted OAuth grant types. +func (p SPIFFEAuthorizationPolicy) GrantTypes() []string { return slices.Clone(p.grantTypes) } + +// Scopes returns a copy of the permitted OAuth scopes. +func (p SPIFFEAuthorizationPolicy) Scopes() []string { return slices.Clone(p.scopes) } + +// Resources returns a copy of the permitted RFC 8707 resource indicators. +func (p SPIFFEAuthorizationPolicy) Resources() []string { return slices.Clone(p.resources) } + +// Audiences returns a copy of the permitted RFC 8693 token audiences. +func (p SPIFFEAuthorizationPolicy) Audiences() []string { return slices.Clone(p.audiences) } + +// matchSPIFFEPrincipalPattern reports whether principal matches pattern. A +// terminal /* matches descendants only at a path-segment boundary: /agent/* +// matches /agent/one but not /agent or /agent-two. +func matchSPIFFEPrincipalPattern(pattern, principal string) bool { + normalizedPattern, err := normalizeSPIFFEPrincipal(pattern, true) + if err != nil { + return false + } + normalizedPrincipal, err := normalizeSPIFFEPrincipal(principal, false) + if err != nil { + return false + } + if !strings.HasSuffix(normalizedPattern, "/*") { + return normalizedPattern == normalizedPrincipal + } + return strings.HasPrefix(normalizedPrincipal, strings.TrimSuffix(normalizedPattern, "*")) +} + +// NewSPIFFETrustConfig validates and normalizes SPIFFE trust declarations into +// an immutable runtime model. It does not load trust bundles or authenticate +// credentials. The zero value SPIFFETrustConfig{} is also valid and denotes no +// SPIFFE trust domains or associations configured — the same value this +// constructor returns for empty input — so callers do not need to avoid +// constructing it directly. +func NewSPIFFETrustConfig( + trustDomains []SPIFFETrustDomainRunConfig, + inboundGrants *InboundGrantsRunConfig, + scopesSupported []string, + allowedAudiences []string, +) (*SPIFFETrustConfig, error) { + trustDomainByName, err := validateSPIFFETrust(trustDomains, inboundGrants, scopesSupported, allowedAudiences) + if err != nil { + return nil, err + } + associations := inboundAssociations(inboundGrants) + config := &SPIFFETrustConfig{ + associations: make([]SPIFFEClientAuthConfig, 0, len(associations)), + trustDomains: make(map[string]SPIFFETrustDomain, len(trustDomainByName)), + } + for _, domain := range trustDomains { + validated := trustDomainByName[domain.Name] + config.trustDomains[domain.Name] = SPIFFETrustDomain{ + trustDomain: validated.trustDomain.String(), + methods: sortedSPIFFEMethods(validated.methods), + bundleSource: normalizeSPIFFEBundleSource(domain.BundleSource), + } + } + for _, association := range associations { + principal, err := normalizeSPIFFEPrincipal(association.PrincipalPattern, true) + if err != nil { + return nil, err + } + config.associations = append(config.associations, SPIFFEClientAuthConfig{ + trustDomainRef: association.TrustDomainRef, + principal: principal, + clientID: association.ClientID, + methods: slices.Clone(association.Methods), + authorization: SPIFFEAuthorizationPolicy{ + grantTypes: slices.Clone(association.GrantTypes), + scopes: slices.Clone(association.Scopes), + resources: slices.Clone(association.Resources), + audiences: slices.Clone(association.Audiences), + }, + }) + } + return config, nil +} + +// ValidateSPIFFETrust validates SPIFFE trust declarations without fetching +// bundles or validating credentials. +func ValidateSPIFFETrust( + trustDomains []SPIFFETrustDomainRunConfig, + inboundGrants *InboundGrantsRunConfig, + scopesSupported []string, + allowedAudiences []string, +) error { + _, err := validateSPIFFETrust(trustDomains, inboundGrants, scopesSupported, allowedAudiences) + return err +} + +// SPIFFETrustDomain is the immutable normalized form of a declared SPIFFE +// trust domain: its canonical trust domain string, enabled credential +// methods, and bundle-source declaration. Future X.509/JWT-SVID validators +// look this up by declaration name instead of re-parsing the raw RunConfig, +// so the canonical trust domain and enabled methods have exactly one +// authoritative source. +type SPIFFETrustDomain struct { + trustDomain string + methods []SPIFFEAuthenticationMethod + bundleSource SPIFFEBundleSourceConfig +} + +// TrustDomain returns the canonical SPIFFE trust domain string. +func (d SPIFFETrustDomain) TrustDomain() string { return d.trustDomain } + +// Methods returns a copy of the credential methods this trust domain enables. +func (d SPIFFETrustDomain) Methods() []SPIFFEAuthenticationMethod { return slices.Clone(d.methods) } + +// BundleSource returns the selected, immutable bundle-source declaration. +func (d SPIFFETrustDomain) BundleSource() SPIFFEBundleSourceConfig { return d.bundleSource } + +// SPIFFEBundleSourceConfig is the normalized bundle-source discriminator. +type SPIFFEBundleSourceConfig struct { + sourceType SPIFFEBundleSourceType + endpoint string + profile SPIFFEBundleEndpointProfile +} + +// Type returns the selected bundle-source type. +func (c SPIFFEBundleSourceConfig) Type() SPIFFEBundleSourceType { return c.sourceType } + +// Endpoint returns the configured Bundle Endpoint URL, or an empty string for +// a Workload API source. +func (c SPIFFEBundleSourceConfig) Endpoint() string { return c.endpoint } + +// Profile returns the configured Bundle Endpoint authentication profile, or +// an empty string for a Workload API source. +func (c SPIFFEBundleSourceConfig) Profile() SPIFFEBundleEndpointProfile { return c.profile } + +func normalizeSPIFFEBundleSource(source SPIFFEBundleSourceRunConfig) SPIFFEBundleSourceConfig { + if source.Endpoint == nil { + return SPIFFEBundleSourceConfig{sourceType: source.Type} + } + return SPIFFEBundleSourceConfig{ + sourceType: source.Type, + endpoint: source.Endpoint.URL, + profile: source.Endpoint.Profile, + } +} + +// SPIFFETrustConfig is the immutable normalized SPIFFE trust model used at +// runtime. Its zero value is valid and denotes no SPIFFE trust domains or +// associations configured — external packages may safely construct +// SPIFFETrustConfig{} directly. +type SPIFFETrustConfig struct { + associations []SPIFFEClientAuthConfig + trustDomains map[string]SPIFFETrustDomain +} + +// TrustDomain returns the normalized trust-domain record declared under name, +// and whether a declaration by that name exists. +func (c *SPIFFETrustConfig) TrustDomain(name string) (SPIFFETrustDomain, bool) { + if c == nil { + return SPIFFETrustDomain{}, false + } + domain, ok := c.trustDomains[name] + return domain, ok +} + +// Associations returns a defensive copy of the normalized association policies. +func (c *SPIFFETrustConfig) Associations() []SPIFFEClientAuthConfig { + if c == nil { + return nil + } + associations := make([]SPIFFEClientAuthConfig, len(c.associations)) + for i, association := range c.associations { + associations[i] = association.clone() + } + return associations +} + +// SPIFFEClientAuthConfig is an immutable normalized association and policy. +type SPIFFEClientAuthConfig struct { + trustDomainRef string + principal string + clientID string + methods []SPIFFEAuthenticationMethod + authorization SPIFFEAuthorizationPolicy +} + +// TrustDomainRef returns the configured trust-domain declaration name. +func (c SPIFFEClientAuthConfig) TrustDomainRef() string { return c.trustDomainRef } + +// Principal returns the canonical SPIFFE ID or terminal wildcard policy pattern. +func (c SPIFFEClientAuthConfig) Principal() string { return c.principal } + +// ClientID returns the configured OAuth client ID. +func (c SPIFFEClientAuthConfig) ClientID() string { return c.clientID } + +// Methods returns a copy of permitted authentication methods. +func (c SPIFFEClientAuthConfig) Methods() []SPIFFEAuthenticationMethod { + return slices.Clone(c.methods) +} + +// AuthorizationPolicy returns a defensive copy of the association policy. +func (c SPIFFEClientAuthConfig) AuthorizationPolicy() SPIFFEAuthorizationPolicy { + return SPIFFEAuthorizationPolicy{ + grantTypes: slices.Clone(c.authorization.grantTypes), + scopes: slices.Clone(c.authorization.scopes), + resources: slices.Clone(c.authorization.resources), + audiences: slices.Clone(c.authorization.audiences), + } +} + +func (c SPIFFEClientAuthConfig) clone() SPIFFEClientAuthConfig { + c.methods = slices.Clone(c.methods) + c.authorization = c.AuthorizationPolicy() + return c +} + +// inboundAssociations returns the configured SPIFFE client-auth entries, or +// nil when inboundGrants is nil. +func inboundAssociations(inboundGrants *InboundGrantsRunConfig) []SPIFFEClientAuthRunConfig { + if inboundGrants == nil { + return nil + } + return inboundGrants.SPIFFEClientAuth +} + +// validateSPIFFETrust validates SPIFFE trust declarations and their inbound +// associations without fetching bundles or validating credentials. It fails +// closed on ambiguity because later authentication must not depend on +// configuration order. It returns the validated trust-domain records so +// NewSPIFFETrustConfig can retain them without re-parsing. +func validateSPIFFETrust( + trustDomains []SPIFFETrustDomainRunConfig, + inboundGrants *InboundGrantsRunConfig, + scopesSupported []string, + allowedResources []string, +) (map[string]validatedSPIFFETrustDomain, error) { + trustDomainByName, err := validateSPIFFETrustDomains(trustDomains) + if err != nil { + return nil, err + } + associations := inboundAssociations(inboundGrants) + if len(associations) == 0 { + if len(trustDomains) != 0 { + return nil, fmt.Errorf("inbound_grants.spiffe_client_auth is required when spiffe_trust_domains is configured") + } + return trustDomainByName, nil + } + if err := validateSPIFFEClientAuth( + associations, trustDomainByName, scopesSupported, allowedResources, + ); err != nil { + return nil, err + } + return trustDomainByName, nil +} + +// sortedSPIFFEMethods returns the enabled methods in a stable order, since +// methods is stored as a set during validation. +func sortedSPIFFEMethods(methods map[SPIFFEAuthenticationMethod]struct{}) []SPIFFEAuthenticationMethod { + sorted := make([]SPIFFEAuthenticationMethod, 0, len(methods)) + for _, method := range []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodX509, SPIFFEAuthenticationMethodJWT} { + if _, ok := methods[method]; ok { + sorted = append(sorted, method) + } + } + return sorted +} + +type validatedSPIFFETrustDomain struct { + trustDomain spiffeid.TrustDomain + methods map[SPIFFEAuthenticationMethod]struct{} +} + +func validateSPIFFETrustDomains(domains []SPIFFETrustDomainRunConfig) (map[string]validatedSPIFFETrustDomain, error) { + byName := make(map[string]validatedSPIFFETrustDomain, len(domains)) + byTrustDomain := make(map[string]string, len(domains)) + for i, domain := range domains { + if domain.Name == "" { + return nil, fmt.Errorf("spiffe_trust_domains[%d]: name is required", i) + } + if _, exists := byName[domain.Name]; exists { + return nil, fmt.Errorf("spiffe_trust_domains[%d]: duplicate name %q", i, domain.Name) + } + trustDomain, err := parseTrustDomain(domain.TrustDomain) + if err != nil { + return nil, fmt.Errorf("spiffe_trust_domains[%d]: %w", i, err) + } + canonicalTrustDomain := trustDomain.String() + if existingName, exists := byTrustDomain[canonicalTrustDomain]; exists { + return nil, fmt.Errorf( + "spiffe_trust_domains[%d]: duplicate trust domain %q already declared as %q", + i, canonicalTrustDomain, existingName, + ) + } + methods, err := validateMethods(domain.Methods, fmt.Sprintf("spiffe_trust_domains[%d].methods", i)) + if err != nil { + return nil, err + } + if err := validateSPIFFEBundleSource(domain.BundleSource, i); err != nil { + return nil, err + } + byName[domain.Name] = validatedSPIFFETrustDomain{trustDomain: trustDomain, methods: methods} + byTrustDomain[canonicalTrustDomain] = domain.Name + } + return byName, nil +} + +func validateSPIFFEBundleSource(source SPIFFEBundleSourceRunConfig, index int) error { + switch source.Type { + case SPIFFEBundleSourceTypeEndpoint: + if source.Endpoint == nil || source.WorkloadAPI != nil { + return fmt.Errorf("spiffe_trust_domains[%d].bundle_source: type %q requires endpoint only", index, source.Type) + } + return validateSPIFFEBundleEndpoint(*source.Endpoint, index) + case SPIFFEBundleSourceTypeWorkloadAPI: + if source.WorkloadAPI == nil || source.Endpoint != nil { + return fmt.Errorf("spiffe_trust_domains[%d].bundle_source: type %q requires workload_api only", index, source.Type) + } + return nil + default: + return fmt.Errorf("spiffe_trust_domains[%d].bundle_source.type: unknown source type %q", index, source.Type) + } +} + +func validateSPIFFEBundleEndpoint(endpoint SPIFFEBundleEndpointSourceRunConfig, index int) error { + endpointURL := endpoint.URL + u, err := url.ParseRequestURI(endpointURL) + if err != nil || u.Scheme != "https" || u.Host == "" || u.Hostname() == "" { + return fmt.Errorf( + "spiffe_trust_domains[%d].bundle_source.endpoint.url must be an absolute HTTPS URL with a valid authority", + index, + ) + } + if u.User != nil || u.RawQuery != "" || u.Fragment != "" || + strings.Contains(endpointURL, "?") || strings.Contains(endpointURL, "#") || + net.ParseIP(u.Hostname()) != nil || networking.IsLoopbackHost(u.Hostname()) { + return fmt.Errorf( + "spiffe_trust_domains[%d].bundle_source.endpoint.url must not contain credentials, query, "+ + "fragment, an IP-literal host, or a loopback host", + index, + ) + } + switch endpoint.Profile { + case SPIFFEBundleEndpointProfileHTTPSWeb, SPIFFEBundleEndpointProfileHTTPSSPIFFE: + return nil + default: + return fmt.Errorf( + "spiffe_trust_domains[%d].bundle_source.endpoint.profile must be %q or %q", + index, SPIFFEBundleEndpointProfileHTTPSWeb, SPIFFEBundleEndpointProfileHTTPSSPIFFE, + ) + } +} + +func validateSPIFFEClientAuth( + entries []SPIFFEClientAuthRunConfig, + trustDomainByName map[string]validatedSPIFFETrustDomain, + scopesSupported []string, + allowedResources []string, +) error { + effectiveScopes := func() []string { + if len(scopesSupported) == 0 { + return registration.DefaultScopes + } + return scopesSupported + }() + seenPrincipals := make([]string, 0, len(entries)) + clientIDs := make(map[string]struct{}, len(entries)) + referencedTrustDomains := make(map[string]struct{}, len(entries)) + for i, entry := range entries { + trustDomain, ok := trustDomainByName[entry.TrustDomainRef] + if entry.TrustDomainRef == "" { + return fmt.Errorf("inbound_grants.spiffe_client_auth[%d]: trust_domain_ref is required", i) + } + if !ok { + return fmt.Errorf("inbound_grants.spiffe_client_auth[%d]: unknown trust_domain_ref %q", i, entry.TrustDomainRef) + } + principal, err := validateSPIFFEClientAssociation(entry, i, trustDomain, effectiveScopes, allowedResources) + if err != nil { + return err + } + if _, exists := clientIDs[entry.ClientID]; exists { + return fmt.Errorf("inbound_grants.spiffe_client_auth[%d]: duplicate client_id %q", i, entry.ClientID) + } + if overlapsSPIFFEPatterns(seenPrincipals, principal) { + return fmt.Errorf( + "inbound_grants.spiffe_client_auth[%d]: "+ + "principal_pattern %q overlaps an existing policy", + i, + entry.PrincipalPattern, + ) + } + seenPrincipals = append(seenPrincipals, principal) + clientIDs[entry.ClientID] = struct{}{} + referencedTrustDomains[entry.TrustDomainRef] = struct{}{} + } + for name := range trustDomainByName { + if _, ok := referencedTrustDomains[name]; !ok { + return fmt.Errorf("spiffe_trust_domains: trust domain %q is not referenced by spiffe_client_auth", name) + } + } + return nil +} + +func validateSPIFFEClientAssociation( + entry SPIFFEClientAuthRunConfig, + index int, + trustDomain validatedSPIFFETrustDomain, + effectiveScopes []string, + allowedResources []string, +) (string, error) { + principal, err := normalizeSPIFFEPrincipal(entry.PrincipalPattern, true) + if err != nil { + return "", fmt.Errorf("inbound_grants.spiffe_client_auth[%d].principal_pattern: %w", index, err) + } + principalID, err := parseSPIFFEID(strings.TrimSuffix(principal, "/*")) + if err != nil { + return "", fmt.Errorf("inbound_grants.spiffe_client_auth[%d].principal_pattern: %w", index, err) + } + if !principalID.MemberOf(trustDomain.trustDomain) { + return "", fmt.Errorf( + "inbound_grants.spiffe_client_auth[%d]: principal trust domain does not match %q", + index, + trustDomain.trustDomain, + ) + } + if entry.ClientID == "" { + return "", fmt.Errorf("inbound_grants.spiffe_client_auth[%d]: client_id is required", index) + } + if err := storage.ValidateRegisterableClientID(entry.ClientID); err != nil { + return "", fmt.Errorf("inbound_grants.spiffe_client_auth[%d]: client_id: %w", index, err) + } + if oauthproto.IsClientIDMetadataDocumentURL(entry.ClientID) { + return "", fmt.Errorf( + "inbound_grants.spiffe_client_auth[%d]: client_id must not be a client metadata document URL "+ + "(reserved for CIMD-resolved clients): %q", + index, entry.ClientID, + ) + } + fieldPrefix := fmt.Sprintf("inbound_grants.spiffe_client_auth[%d]", index) + methods, err := validateMethods(entry.Methods, fieldPrefix+".methods") + if err != nil { + return "", err + } + for method := range methods { + if _, enabled := trustDomain.methods[method]; !enabled { + return "", fmt.Errorf("%s.methods: method %q is not enabled by trust domain %q", fieldPrefix, method, entry.TrustDomainRef) + } + } + if err := validateSPIFFEClientAssociationPermissions(entry, index, fieldPrefix, effectiveScopes, allowedResources); err != nil { + return "", err + } + return principal, nil +} + +// validateSPIFFEClientAssociationPermissions validates the resources, +// audiences, scopes, and grant types an association is permitted to request. +// Resources (RFC 8707) and audiences (RFC 8693) are independent request +// dimensions: permission in one must never imply permission in the other, so +// only resources are bounded by the server's allowed_audiences allowlist. +func validateSPIFFEClientAssociationPermissions( + entry SPIFFEClientAuthRunConfig, + index int, + fieldPrefix string, + effectiveScopes []string, + allowedResources []string, +) error { + if err := validateSPIFFEResources(entry.Resources, fieldPrefix+".resources", allowedResources); err != nil { + return err + } + if err := validateDistinctNonEmpty(entry.Audiences, fieldPrefix+".audiences"); err != nil { + return err + } + if err := validateDistinctNonEmpty(entry.Scopes, fieldPrefix+".scopes"); err != nil { + return err + } + if err := registration.ValidateScopeSubset(entry.Scopes, effectiveScopes, fieldPrefix+".scopes"); err != nil { + return err + } + return validateSPIFFEGrants(entry.GrantTypes, index) +} + +func parseTrustDomain(trustDomain string) (spiffeid.TrustDomain, error) { + if trustDomain == "" { + return spiffeid.TrustDomain{}, fmt.Errorf("trust_domain is required") + } + parsed, err := spiffeid.TrustDomainFromString(trustDomain) + if err != nil { + return spiffeid.TrustDomain{}, fmt.Errorf("must be a valid SPIFFE trust domain: %w", err) + } + return parsed, nil +} + +func normalizeSPIFFEPrincipal(principal string, allowPattern bool) (string, error) { + if principal == "" { + return "", fmt.Errorf("is required") + } + wildcard := strings.HasSuffix(principal, "/*") + if strings.Contains(principal, "*") && (!allowPattern || !wildcard || strings.Count(principal, "*") != 1) { + return "", fmt.Errorf("must use only a terminal /* wildcard") + } + base := strings.TrimSuffix(principal, "/*") + id, err := parseSPIFFEID(base) + if err != nil { + return "", err + } + if id.Path() == "" && !wildcard { + return "", fmt.Errorf("must include a path or terminal /* wildcard") + } + if wildcard { + return id.String() + "/*", nil + } + return id.String(), nil +} + +func parseSPIFFEID(principal string) (spiffeid.ID, error) { + id, err := spiffeid.FromString(principal) + if err != nil { + return spiffeid.ID{}, fmt.Errorf("must be a SPIFFE ID: %w", err) + } + return id, nil +} + +func validateMethods(methods []SPIFFEAuthenticationMethod, field string) (map[SPIFFEAuthenticationMethod]struct{}, error) { + if len(methods) == 0 { + return nil, fmt.Errorf("%s is required", field) + } + seen := make(map[SPIFFEAuthenticationMethod]struct{}, len(methods)) + for _, method := range methods { + if method != SPIFFEAuthenticationMethodX509 && method != SPIFFEAuthenticationMethodJWT { + return nil, fmt.Errorf("%s: unknown method %q", field, method) + } + if _, exists := seen[method]; exists { + return nil, fmt.Errorf("%s: duplicate method %q", field, method) + } + seen[method] = struct{}{} + } + return seen, nil +} + +// validateSPIFFEResources validates the RFC 8707 resource indicators an +// association may request: each must be a syntactically valid absolute +// HTTP(S) URI and a member of the server's allowed_audiences allowlist, the +// same RFC 8707 resource-URI list DelegateClientRunConfig.Audiences is +// validated against (see validateDelegateClients in config.go). +func validateSPIFFEResources(values []string, field string, allowedResources []string) error { + if len(values) == 0 { + return nil + } + if err := validateResourceIndicators(values, field); err != nil { + return err + } + for _, value := range values { + if !slices.Contains(allowedResources, value) { + return fmt.Errorf("%s: resource %q is not allowed by allowed_audiences", field, value) + } + } + return nil +} + +func validateResourceIndicators(values []string, field string) error { + if len(values) == 0 { + return nil + } + if err := validateDistinctNonEmpty(values, field); err != nil { + return err + } + for _, value := range values { + u, err := url.ParseRequestURI(value) + if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User != nil || + u.Fragment != "" || strings.Contains(value, "#") { + return fmt.Errorf("%s: resource indicator %q must be an absolute HTTP(S) URI without a fragment", field, value) + } + } + return nil +} + +func validateDistinctNonEmpty(values []string, field string) error { + if len(values) == 0 { + return fmt.Errorf("%s is required", field) + } + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + if value == "" { + return fmt.Errorf("%s must not contain an empty value", field) + } + if _, exists := seen[value]; exists { + return fmt.Errorf("%s: duplicate value %q", field, value) + } + seen[value] = struct{}{} + } + return nil +} + +func validateSPIFFEGrants(grants []string, index int) error { + if len(grants) != 1 || grants[0] != SPIFFEGrantTypeTokenExchange { + return fmt.Errorf( + "inbound_grants.spiffe_client_auth[%d].grant_types must be exactly [%q]", + index, SPIFFEGrantTypeTokenExchange, + ) + } + return nil +} + +func overlapsSPIFFEPatterns(patterns []string, principal string) bool { + for _, pattern := range patterns { + if spiffePatternsOverlap(pattern, principal) { + return true + } + } + return false +} + +func spiffePatternsOverlap(first, second string) bool { + firstWildcard := strings.HasSuffix(first, "/*") + secondWildcard := strings.HasSuffix(second, "/*") + if !firstWildcard && !secondWildcard { + return first == second + } + if firstWildcard && secondWildcard { + return strings.HasPrefix(strings.TrimSuffix(first, "*"), strings.TrimSuffix(second, "*")) || + strings.HasPrefix(strings.TrimSuffix(second, "*"), strings.TrimSuffix(first, "*")) + } + if firstWildcard { + return matchSPIFFEPrincipalPattern(first, second) + } + return matchSPIFFEPrincipalPattern(second, first) +} diff --git a/pkg/authserver/spiffe_trust_test.go b/pkg/authserver/spiffe_trust_test.go new file mode 100644 index 0000000000..a7dfb8dcf4 --- /dev/null +++ b/pkg/authserver/spiffe_trust_test.go @@ -0,0 +1,319 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package authserver + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/authserver/storage" +) + +func TestNormalizeSPIFFEPrincipal(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + principal string + want string + wantErr string + }{ + {name: "rejects bare trust domain", principal: "spiffe://example.org", wantErr: "must include a path"}, + {name: "rejects uppercase trust domain", principal: "spiffe://EXAMPLE.ORG/ns/default/sa/agent", wantErr: "must be a SPIFFE ID"}, + {name: "rejects wildcard", principal: "spiffe://example.org/ns/default/*", wantErr: "terminal"}, + {name: "rejects port", principal: "spiffe://example.org:8080/ns/default/agent", wantErr: "must be a SPIFFE ID"}, + {name: "rejects userinfo", principal: "spiffe://user@example.org/ns/default/agent", wantErr: "must be a SPIFFE ID"}, + {name: "rejects unicode authority", principal: "spiffe://exämple.org/ns/default/agent", wantErr: "must be a SPIFFE ID"}, + {name: "allows underscore trust domain", principal: "spiffe://example_org/ns/default/agent", want: "spiffe://example_org/ns/default/agent"}, + {name: "rejects escaped path", principal: "spiffe://example.org/ns%2Fdefault/agent", wantErr: "must be a SPIFFE ID"}, + {name: "rejects query", principal: "spiffe://example.org/ns/default?x=y", wantErr: "must be a SPIFFE ID"}, + {name: "rejects dot segment", principal: "spiffe://example.org/ns/../agent", wantErr: "must be a SPIFFE ID"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := normalizeSPIFFEPrincipal(tt.principal, false) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestMatchSPIFFEPrincipalPattern(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pattern string + principal string + want bool + }{ + {name: "domain wildcard matches descendant", pattern: "spiffe://example.org/*", principal: "spiffe://example.org/ns/default/sa/agent", want: true}, + {name: "exact match", pattern: "spiffe://example.org/ns/default/sa/agent", principal: "spiffe://example.org/ns/default/sa/agent", want: true}, + {name: "terminal wildcard matches descendant", pattern: "spiffe://example.org/ns/default/*", principal: "spiffe://example.org/ns/default/sa/agent", want: true}, + {name: "terminal wildcard requires descendant", pattern: "spiffe://example.org/ns/default/*", principal: "spiffe://example.org/ns/default", want: false}, + {name: "terminal wildcard is segment safe", pattern: "spiffe://example.org/ns/default/*", principal: "spiffe://example.org/ns/defaulted/sa/agent", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, matchSPIFFEPrincipalPattern(tt.pattern, tt.principal)) + }) + } +} + +// validWorkloadAPIBundleSource is a minimal valid BundleSource declaration +// used by fixtures that don't exercise bundle-source validation directly. +func validWorkloadAPIBundleSource() SPIFFEBundleSourceRunConfig { + return SPIFFEBundleSourceRunConfig{ + Type: SPIFFEBundleSourceTypeWorkloadAPI, + WorkloadAPI: &SPIFFEWorkloadAPIBundleSourceRunConfig{}, + } +} + +func TestValidateSPIFFETrust(t *testing.T) { + t.Parallel() + + valid := func() ([]SPIFFETrustDomainRunConfig, *InboundGrantsRunConfig) { + return []SPIFFETrustDomainRunConfig{{ + Name: "production", + TrustDomain: "example.org", + Methods: []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodX509, SPIFFEAuthenticationMethodJWT}, + BundleSource: validWorkloadAPIBundleSource(), + }}, &InboundGrantsRunConfig{SPIFFEClientAuth: []SPIFFEClientAuthRunConfig{{ + TrustDomainRef: "production", + PrincipalPattern: "spiffe://example.org/ns/default/*", + ClientID: "agent-client", + Methods: []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodX509}, + Audiences: []string{"https://mcp.example.org/resource"}, + Scopes: []string{"openid"}, + GrantTypes: []string{SPIFFEGrantTypeTokenExchange}, + }}} + } + + tests := []struct { + name string + mutate func([]SPIFFETrustDomainRunConfig, *InboundGrantsRunConfig) + wantErr string + }{ + {name: "ordinary client_id", mutate: func([]SPIFFETrustDomainRunConfig, *InboundGrantsRunConfig) {}}, + {name: "client_id must not use synthetic prefix", mutate: func(_ []SPIFFETrustDomainRunConfig, grants *InboundGrantsRunConfig) { + grants.SPIFFEClientAuth[0].ClientID = storage.SyntheticClientIDPrefix + "agent-client" + }, wantErr: "reserved synthetic prefix"}, + {name: "missing association", mutate: func(_ []SPIFFETrustDomainRunConfig, grants *InboundGrantsRunConfig) { + grants.SPIFFEClientAuth = nil + }, wantErr: "spiffe_client_auth is required"}, + {name: "unknown trust domain", mutate: func(_ []SPIFFETrustDomainRunConfig, grants *InboundGrantsRunConfig) { + grants.SPIFFEClientAuth[0].TrustDomainRef = "missing" + }, wantErr: "unknown trust_domain_ref"}, + {name: "wrong trust domain", mutate: func(_ []SPIFFETrustDomainRunConfig, grants *InboundGrantsRunConfig) { + grants.SPIFFEClientAuth[0].PrincipalPattern = "spiffe://other.org/ns/default/agent" + }, wantErr: "does not match"}, + {name: "duplicate client", mutate: func(_ []SPIFFETrustDomainRunConfig, grants *InboundGrantsRunConfig) { + grants.SPIFFEClientAuth = append(grants.SPIFFEClientAuth, SPIFFEClientAuthRunConfig{ + TrustDomainRef: "production", PrincipalPattern: "spiffe://example.org/ns/other/agent", ClientID: "agent-client", + Methods: []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodJWT}, Audiences: []string{"https://mcp.example.org/other"}, + Scopes: []string{"openid"}, GrantTypes: []string{SPIFFEGrantTypeTokenExchange}, + }) + }, wantErr: "duplicate client_id"}, + {name: "exact parent does not overlap descendant wildcard", mutate: func(_ []SPIFFETrustDomainRunConfig, grants *InboundGrantsRunConfig) { + grants.SPIFFEClientAuth[0].PrincipalPattern = "spiffe://example.org/ns/default" + grants.SPIFFEClientAuth = append(grants.SPIFFEClientAuth, SPIFFEClientAuthRunConfig{ + TrustDomainRef: "production", PrincipalPattern: "spiffe://example.org/ns/default/*", ClientID: "other-client", + Methods: []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodJWT}, Audiences: []string{"https://mcp.example.org/other"}, + Scopes: []string{"openid"}, GrantTypes: []string{SPIFFEGrantTypeTokenExchange}, + }) + }}, + {name: "nested wildcards overlap", mutate: func(_ []SPIFFETrustDomainRunConfig, grants *InboundGrantsRunConfig) { + grants.SPIFFEClientAuth = append(grants.SPIFFEClientAuth, SPIFFEClientAuthRunConfig{ + TrustDomainRef: "production", PrincipalPattern: "spiffe://example.org/ns/default/sa/*", ClientID: "other-client", + Methods: []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodJWT}, Audiences: []string{"https://mcp.example.org/other"}, + Scopes: []string{"openid"}, GrantTypes: []string{SPIFFEGrantTypeTokenExchange}, + }) + }, wantErr: "overlaps"}, + {name: "audiences are required", mutate: func(_ []SPIFFETrustDomainRunConfig, grants *InboundGrantsRunConfig) { + grants.SPIFFEClientAuth[0].Audiences = nil + }, wantErr: "audiences is required"}, + {name: "audiences need not be in the resource allowlist", mutate: func(_ []SPIFFETrustDomainRunConfig, grants *InboundGrantsRunConfig) { + grants.SPIFFEClientAuth[0].Audiences = []string{"urn:example:logical-audience"} + }}, + {name: "resource indicator must be an absolute HTTP(S) URI", mutate: func(_ []SPIFFETrustDomainRunConfig, grants *InboundGrantsRunConfig) { + grants.SPIFFEClientAuth[0].Resources = []string{"not-a-uri"} + }, wantErr: "must be an absolute HTTP(S) URI"}, + {name: "resource in global allowlist is valid", mutate: func(_ []SPIFFETrustDomainRunConfig, grants *InboundGrantsRunConfig) { + grants.SPIFFEClientAuth[0].Resources = []string{"https://mcp.example.org/resource"} + }}, + {name: "resource must be in global allowlist", mutate: func(_ []SPIFFETrustDomainRunConfig, grants *InboundGrantsRunConfig) { + grants.SPIFFEClientAuth[0].Resources = []string{"https://unlisted.example.org/resource"} + }, wantErr: "resource \"https://unlisted.example.org/resource\" is not allowed by allowed_audiences"}, + {name: "client_id must not be a client metadata document URL", mutate: func(_ []SPIFFETrustDomainRunConfig, grants *InboundGrantsRunConfig) { + grants.SPIFFEClientAuth[0].ClientID = "https://cimd.example.org/client" + }, wantErr: "client_id must not be a client metadata document URL"}, + {name: "client_id must not be a malformed client metadata document URL", mutate: func(_ []SPIFFETrustDomainRunConfig, grants *InboundGrantsRunConfig) { + grants.SPIFFEClientAuth[0].ClientID = "https://example.org/%zz" + }, wantErr: "client_id must not be a client metadata document URL"}, + {name: "scope must be supported", mutate: func(_ []SPIFFETrustDomainRunConfig, grants *InboundGrantsRunConfig) { + grants.SPIFFEClientAuth[0].Scopes = []string{"admin"} + }, wantErr: "not in scopes_supported"}, + {name: "unknown method", mutate: func(_ []SPIFFETrustDomainRunConfig, grants *InboundGrantsRunConfig) { + grants.SPIFFEClientAuth[0].Methods = []SPIFFEAuthenticationMethod{"magic"} + }, wantErr: "unknown method"}, + {name: "method not enabled by trust domain", mutate: func(domains []SPIFFETrustDomainRunConfig, _ *InboundGrantsRunConfig) { + domains[0].Methods = []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodJWT} + }, wantErr: "not enabled by trust domain"}, + {name: "grant_types must be exactly token exchange", mutate: func(_ []SPIFFETrustDomainRunConfig, grants *InboundGrantsRunConfig) { + grants.SPIFFEClientAuth[0].GrantTypes = []string{"authorization_code"} + }, wantErr: "grant_types must be exactly"}, + {name: "grant_types is required", mutate: func(_ []SPIFFETrustDomainRunConfig, grants *InboundGrantsRunConfig) { + grants.SPIFFEClientAuth[0].GrantTypes = nil + }, wantErr: "grant_types must be exactly"}, + {name: "bundle_source type is required", mutate: func(domains []SPIFFETrustDomainRunConfig, _ *InboundGrantsRunConfig) { + domains[0].BundleSource = SPIFFEBundleSourceRunConfig{} + }, wantErr: "unknown source type"}, + {name: "bundle_source endpoint requires https", mutate: func(domains []SPIFFETrustDomainRunConfig, _ *InboundGrantsRunConfig) { + domains[0].BundleSource = SPIFFEBundleSourceRunConfig{ + Type: SPIFFEBundleSourceTypeEndpoint, + Endpoint: &SPIFFEBundleEndpointSourceRunConfig{URL: "http://bundle.example.org"}, + } + }, wantErr: "must be an absolute HTTPS URL"}, + {name: "bundle_source endpoint is valid", mutate: func(domains []SPIFFETrustDomainRunConfig, _ *InboundGrantsRunConfig) { + domains[0].BundleSource = SPIFFEBundleSourceRunConfig{ + Type: SPIFFEBundleSourceTypeEndpoint, + Endpoint: &SPIFFEBundleEndpointSourceRunConfig{ + URL: "https://bundle.example.org/bundle", Profile: SPIFFEBundleEndpointProfileHTTPSWeb, + }, + } + }}, + {name: "bundle_source endpoint requires a known profile", mutate: func(domains []SPIFFETrustDomainRunConfig, _ *InboundGrantsRunConfig) { + domains[0].BundleSource = SPIFFEBundleSourceRunConfig{ + Type: SPIFFEBundleSourceTypeEndpoint, + Endpoint: &SPIFFEBundleEndpointSourceRunConfig{URL: "https://bundle.example.org/bundle"}, + } + }, wantErr: "profile must be"}, + {name: "bundle_source endpoint must not mix payloads", mutate: func(domains []SPIFFETrustDomainRunConfig, _ *InboundGrantsRunConfig) { + domains[0].BundleSource = SPIFFEBundleSourceRunConfig{ + Type: SPIFFEBundleSourceTypeEndpoint, + Endpoint: &SPIFFEBundleEndpointSourceRunConfig{URL: "https://bundle.example.org/bundle"}, + WorkloadAPI: &SPIFFEWorkloadAPIBundleSourceRunConfig{}, + } + }, wantErr: "requires endpoint only"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + domains, grants := valid() + tt.mutate(domains, grants) + err := ValidateSPIFFETrust(domains, grants, []string{"openid"}, []string{"https://mcp.example.org/resource", "https://mcp.example.org/other"}) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + }) + } +} + +func TestValidateSPIFFETrustRejectsDuplicateCanonicalTrustDomains(t *testing.T) { + t.Parallel() + + err := ValidateSPIFFETrust( + []SPIFFETrustDomainRunConfig{ + { + Name: "production", TrustDomain: "example.org", + Methods: []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodX509}, BundleSource: validWorkloadAPIBundleSource(), + }, + { + Name: "secondary", TrustDomain: "example.org", + Methods: []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodJWT}, BundleSource: validWorkloadAPIBundleSource(), + }, + }, + nil, + nil, + nil, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate trust domain \"example.org\" already declared as \"production\"") +} + +func TestNewSPIFFETrustConfigDefensivelyCopiesAudiences(t *testing.T) { + t.Parallel() + + audiences := []string{"https://mcp.example.org/resource"} + resources := []string{"https://mcp.example.org/api"} + trust, err := NewSPIFFETrustConfig( + []SPIFFETrustDomainRunConfig{{ + Name: "production", TrustDomain: "example.org", Methods: []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodJWT}, + BundleSource: validWorkloadAPIBundleSource(), + }}, + &InboundGrantsRunConfig{SPIFFEClientAuth: []SPIFFEClientAuthRunConfig{{ + TrustDomainRef: "production", PrincipalPattern: "spiffe://example.org/ns/default/agent", ClientID: "agent-client", + Methods: []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodJWT}, Audiences: audiences, Resources: resources, + Scopes: []string{"openid"}, GrantTypes: []string{SPIFFEGrantTypeTokenExchange}, + }}}, + []string{"openid"}, []string{"https://mcp.example.org/resource", "https://mcp.example.org/api"}, + ) + require.NoError(t, err) + require.NotNil(t, trust) + + audiences[0] = "https://mutated.example.org" + resources[0] = "https://mutated.example.org" + policy := trust.Associations()[0].AuthorizationPolicy() + assert.Equal(t, []string{"https://mcp.example.org/resource"}, policy.Audiences()) + assert.Equal(t, []string{"https://mcp.example.org/api"}, policy.Resources()) + + policyAudiences := policy.Audiences() + policyAudiences[0] = "https://mutated.example.org" + assert.Equal(t, []string{"https://mcp.example.org/resource"}, trust.Associations()[0].AuthorizationPolicy().Audiences()) +} + +func TestSPIFFETrustConfigTrustDomainLookup(t *testing.T) { + t.Parallel() + + trust, err := NewSPIFFETrustConfig( + []SPIFFETrustDomainRunConfig{{ + Name: "production", TrustDomain: "example.org", + Methods: []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodJWT, SPIFFEAuthenticationMethodX509}, + BundleSource: SPIFFEBundleSourceRunConfig{ + Type: SPIFFEBundleSourceTypeEndpoint, + Endpoint: &SPIFFEBundleEndpointSourceRunConfig{ + URL: "https://bundle.example.org/bundle", Profile: SPIFFEBundleEndpointProfileHTTPSSPIFFE, + }, + }, + }}, + &InboundGrantsRunConfig{SPIFFEClientAuth: []SPIFFEClientAuthRunConfig{{ + TrustDomainRef: "production", PrincipalPattern: "spiffe://example.org/ns/default/agent", ClientID: "agent-client", + Methods: []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodJWT}, Audiences: []string{"https://mcp.example.org/resource"}, + Scopes: []string{"openid"}, GrantTypes: []string{SPIFFEGrantTypeTokenExchange}, + }}}, + []string{"openid"}, []string{"https://mcp.example.org/resource"}, + ) + require.NoError(t, err) + require.NotNil(t, trust) + + domain, ok := trust.TrustDomain("production") + require.True(t, ok) + assert.Equal(t, "example.org", domain.TrustDomain()) + assert.Equal(t, []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodX509, SPIFFEAuthenticationMethodJWT}, domain.Methods()) + assert.Equal(t, SPIFFEBundleSourceTypeEndpoint, domain.BundleSource().Type()) + assert.Equal(t, "https://bundle.example.org/bundle", domain.BundleSource().Endpoint()) + assert.Equal(t, SPIFFEBundleEndpointProfileHTTPSSPIFFE, domain.BundleSource().Profile()) + + _, ok = trust.TrustDomain("unknown") + assert.False(t, ok) + + var nilTrust *SPIFFETrustConfig + _, ok = nilTrust.TrustDomain("production") + assert.False(t, ok) +}