From f83e9b78fd970d720c6add3450269224db680643 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Sun, 30 Aug 2026 14:31:30 +0200 Subject: [PATCH 01/14] Normalize canonical inbound grants The SPIFFE client-auth epic needs a place to configure SPIFFE association policy without inventing a parallel trust/grant path next to the existing delegate-client and trusted-issuer configuration. As more inbound grant families (RFC 8693 token exchange, RFC 7523 JWT-bearer, SPIFFE) accumulate, they need one canonical surface to configure and reason about instead of three independent ones, without breaking deployments that already rely on the legacy fields. Add pkg/authserver/inbound_grants.go with NormalizeInboundGrants, which reconciles a new canonical RunConfig.InboundGrants surface (per-family token_exchange/jwt_bearer sub-configs whose issuer_policies reference a trusted_issuers entry by name) against the legacy top-level delegate_clients and the RFC 8693/7523 fields embedded directly on trusted_issuers. Legacy and canonical configuration for the same grant family are mutually exclusive and rejected at validation time; the two families are otherwise independent, and omitting inbound_grants entirely preserves released behavior. Thread the normalized result through RunConfig.Validate, the embedded-auth-server runner, and buildProvider/discovery, adding a DisableTokenExchange capability so RFC 8693 registration and discovery advertisement can be turned off together and can't drift out of sync. Add TrustedIssuer.Name so canonical issuer_policies can reference an issuer without duplicating its fields. SPIFFE client authentication (InboundGrants.SPIFFEClientAuth, defined in the previous commit) is deliberately kept a sibling of TokenExchange and JWTBearer here, not nested under either: SPIFFE authenticates a client, it does not by itself grant it anything, so making it subordinate to RFC 8693 enablement would mean disabling token exchange silently drops every SPIFFE association, and every SPIFFE-authenticated client would be implicitly token-exchange-capable. It is validated and wired directly from RunConfig.InboundGrants in RunConfig.Validate/embeddedauthserver.go, independent of this file's legacy/canonical projection, so authentication method and grant-family enablement stay separately configurable. Update docs/arch/17-token-exchange-delegation.md for the new inbound_grants shape and the now-conditional token-exchange discovery advertisement, and add a runner-level test proving the canonical delegate-client, SPIFFE-client, and jwt_bearer paths reach a running server (the existing tests only covered normalization in isolation). SPIFFE client-auth associations always require the token-exchange grant (the only grant type they may declare), independent of the legacy/canonical token-exchange projection above: NormalizeInboundGrants now sets Capabilities.TokenExchange true whenever InboundGrants.SPIFFEClientAuth is non-empty, so a SPIFFE-only configuration cannot leave it false and silently disable the RFC 8693 grant handler server-wide -- which would reject every SPIFFE client's own token requests before authentication is even checked. Guarded by a regression test in this package (not just the runner-level test above) since the equivalent fix was previously lost during a rebase when its only coverage lived one package away. DCR (RFC 7591 /oauth/register) now rejects a registration whose effective grant types include token-exchange when it is disabled server-wide, instead of accepting the client and only failing later, confusingly, at /oauth/token. The check runs on the post-defaulting grant types validateGrantTypes already computes (a private_key_jwt client with an empty grant_types is implicitly token-exchange-only), so it catches both the explicit and implicit cases the same way scope validation already gates DCR on ScopesSupported. Corrected two stale doc references caught in review: the SPIFFE client-policy field path (inbound_grants.spiffe_client_auth, not nested under token_exchange) and the JWT-bearer legacy/canonical conflict wording (family-wide across all issuers, not per-issuer). Refs #6200 Signed-off-by: Jakub Hrozek --- docs/arch/17-token-exchange-delegation.md | 74 +++- docs/server/docs.go | 127 +++++- docs/server/swagger.json | 127 +++++- docs/server/swagger.yaml | 102 ++++- pkg/authserver/config.go | 30 +- pkg/authserver/inbound_grants.go | 262 ++++++++++++ pkg/authserver/inbound_grants_test.go | 399 ++++++++++++++++++ pkg/authserver/runner/embeddedauthserver.go | 77 ++-- .../runner/embeddedauthserver_test.go | 169 ++++++++ pkg/authserver/server/handlers/dcr.go | 20 +- pkg/authserver/server/handlers/dcr_test.go | 102 +++++ pkg/authserver/server/handlers/discovery.go | 13 +- .../server/handlers/handlers_test.go | 30 +- pkg/authserver/server/provider.go | 6 + pkg/authserver/server/provider_test.go | 8 + .../server/tokenexchange/handler_test.go | 62 +++ .../tokenexchange/multi_issuer_validator.go | 8 + pkg/authserver/server_impl.go | 64 +-- pkg/authserver/server_test.go | 61 +++ pkg/authserver/spiffe_trust.go | 9 +- pkg/authserver/spiffe_trust_test.go | 6 +- pkg/runner/config_test.go | 50 +++ 22 files changed, 1694 insertions(+), 112 deletions(-) create mode 100644 pkg/authserver/inbound_grants.go create mode 100644 pkg/authserver/inbound_grants_test.go diff --git a/docs/arch/17-token-exchange-delegation.md b/docs/arch/17-token-exchange-delegation.md index ed21bf45c3..8cbc9b0f4a 100644 --- a/docs/arch/17-token-exchange-delegation.md +++ b/docs/arch/17-token-exchange-delegation.md @@ -73,6 +73,65 @@ binding. The Kubernetes operator also exposes `trusted_issuers` as `EmbeddedAuthServerConfig.trustedIssuers` — see [Kubernetes operator](#kubernetes-operator) below. +#### Canonical `inbound_grants` configuration + +The top-level `delegate_clients` and the RFC 8693/7523 fields embedded +directly on `trusted_issuers[*]` above are the legacy configuration shape. +`RunConfig.inbound_grants` (`authserver.InboundGrantsRunConfig`) is the +canonical replacement: it groups the same policy under +`inbound_grants.token_exchange` (delegate clients and per-issuer RFC 8693 +policy) and `inbound_grants.jwt_bearer` (per-issuer RFC 7523 policy), each +referencing a `trusted_issuers` entry by its `name` rather than embedding +policy fields on the issuer itself. SPIFFE client policy is configured +separately, as a sibling of both under `inbound_grants.spiffe_client_auth` +(described further below) — not +nested under `inbound_grants.token_exchange`, since a SPIFFE association +authenticates a client but does not by itself grant it anything: + +```yaml +issuer: https://auth.example.com +scopes_supported: [openid, profile] +allowed_audiences: [https://mcp.example.com] +trusted_issuers: + - name: reporting-idp + issuer_url: https://login.example-idp.com +inbound_grants: + token_exchange: + delegate_clients: + - client_id: reporting-delegate + client_secret_env_var: REPORTING_DELEGATE_CLIENT_SECRET + scopes: [openid] + audiences: [https://mcp.example.com] + issuer_policies: + - issuer_ref: reporting-idp + expected_audience: https://mcp.example.com + allowed_actors: [external-reporting-client] + allowed_delegate_clients: [reporting-delegate] +``` + +`NormalizeInboundGrants` (`pkg/authserver/inbound_grants.go`) reconciles both +shapes at validation time, per grant family: + +- If `inbound_grants.token_exchange` is set, any legacy `delegate_clients` or + RFC 8693 fields embedded on `trusted_issuers[*]` are rejected as a + configuration conflict — the two token-exchange sources are mutually + exclusive. Likewise for `inbound_grants.jwt_bearer` against a legacy + `trusted_issuers[*].jwt_bearer_grant`. +- The two grant families are independent: setting only + `inbound_grants.jwt_bearer` still lets legacy `delegate_clients`/RFC 8693 + fields enable token exchange, and vice versa — `inbound_grants` does not + take over both families just by being non-nil. +- `issuer_ref` resolves against `trusted_issuers[*].name`; an issuer without + a `name`, an unresolvable `issuer_ref`, or a duplicate `name`/`issuer_url` + fails validation. + +Existing deployments using only the legacy shape are unaffected: omitting +`inbound_grants` entirely preserves the released behavior, including RFC +8693 being enabled by default. SPIFFE client policy +(`inbound_grants.spiffe_client_auth`) has no legacy equivalent +and must reference a `spiffe_trust_domains` entry the same way issuer +policies reference `trusted_issuers`. + ### Token-only embedded authorization servers An embedded authorization server may omit upstream identity providers only when @@ -209,7 +268,11 @@ reuse IDs between the two mechanisms. Both `/.well-known/oauth-authorization-server` and `/.well-known/openid-configuration` advertise the token-exchange grant in -`grant_types_supported`. `token_endpoint_auth_methods_supported` always +`grant_types_supported` by default. Setting `inbound_grants` with +`token_exchange` omitted disables and stops advertising RFC 8693 entirely +(`Config.DisableTokenExchange`, `AuthorizationServerConfig.TokenExchangeEnabled`) +— the same flag governs both registration with fosite and discovery +advertisement, so they cannot drift out of sync. `token_endpoint_auth_methods_supported` always includes `none`; it also includes `client_secret_basic` and `client_secret_post` when confidential DCR is enabled or a static delegate client is configured, and includes `private_key_jwt` when @@ -636,7 +699,14 @@ involved. It is enabled per trusted issuer by setting `TrustedIssuer.JWTBearerGrant` (`jwt_bearer_grant` on a hand-written `authserver.RunConfig`, `jwtBearerGrant` on the operator's `TrustedIssuerConfig`) — independent of that issuer's RFC 8693 delegation -fields, though both may be configured on the same issuer. +fields, though both may be configured on the same issuer. This is the +legacy shape; a hand-written `RunConfig` may instead configure the same +policy under `inbound_grants.jwt_bearer.issuer_policies`, referencing the +issuer by `name` — see [Canonical `inbound_grants` +configuration](#canonical-inbound_grants-configuration). The two shapes are +mutually exclusive: setting `inbound_grants.jwt_bearer` while any +`trusted_issuers[*].jwt_bearer_grant` is still set anywhere in the config is +rejected, regardless of which issuer each one refers to. ### JWT-bearer configuration diff --git a/docs/server/docs.go b/docs/server/docs.go index 55fd152ecb..11e7b842ba 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -220,8 +220,11 @@ const docTemplate = `{ "type": "object" }, "authserver.InboundGrantsRunConfig": { - "description": "InboundGrants declares canonical inbound grant configuration, including\nSPIFFE client authentication. See InboundGrantsRunConfig.", + "description": "InboundGrants declares canonical inbound grant configuration, including\nSPIFFE client authentication, delegate clients, and issuer policy. A\nnon-nil value explicitly controls grant-family enablement.", "properties": { + "jwt_bearer": { + "$ref": "#/components/schemas/authserver.JWTBearerInboundGrantRunConfig" + }, "spiffe_client_auth": { "description": "SPIFFEClientAuth associates SPIFFE principal patterns with explicit OAuth\nclient identities and permissions. See SPIFFEClientAuthRunConfig.", "items": { @@ -229,6 +232,47 @@ const docTemplate = `{ }, "type": "array", "uniqueItems": false + }, + "token_exchange": { + "$ref": "#/components/schemas/authserver.TokenExchangeInboundGrantRunConfig" + } + }, + "type": "object" + }, + "authserver.JWTBearerInboundGrantRunConfig": { + "description": "JWTBearer configures RFC 7523 issuer policies.", + "properties": { + "issuer_policies": { + "items": { + "$ref": "#/components/schemas/authserver.JWTBearerIssuerPolicyRunConfig" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, + "authserver.JWTBearerIssuerPolicyRunConfig": { + "properties": { + "accepted_audiences": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "issuer_ref": { + "type": "string" + }, + "max_assertion_age": { + "type": "string" + }, + "subject_bindings": { + "items": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver_server_tokenexchange.JWTBearerSubjectBinding" + }, + "type": "array", + "uniqueItems": false } }, "type": "object" @@ -397,7 +441,7 @@ const docTemplate = `{ "$ref": "#/components/schemas/authserver.CIMDRunConfig" }, "delegate_clients": { - "description": "DelegateClients declares confidential OAuth clients to register at\nauthorization-server startup, including clients intended for RFC 8693\ntoken exchange.\n\nIndependent of AllowConfidentialClientRegistration: declaring a client\nhere does not require or enable self-service confidential DCR, and\nsetting that flag does not declare or enable any client here. They\ngovern different endpoints — this field is static configuration the\noperator controls directly, while the flag is admission policy for the\nunauthenticated /oauth/register endpoint.\n\nSee DelegateClientRunConfig for the per-client field reference.", + "description": "DelegateClients declares confidential OAuth clients to register at\nauthorization-server startup, including clients intended for RFC 8693\ntoken exchange.\n\nThis legacy field is deprecated; use InboundGrants.TokenExchange.DelegateClients.\n\nIndependent of AllowConfidentialClientRegistration: declaring a client\nhere does not require or enable self-service confidential DCR, and\nsetting that flag does not declare or enable any client here. They\ngovern different endpoints — this field is static configuration the\noperator controls directly, while the flag is admission policy for the\nunauthenticated /oauth/register endpoint.\n\nSee DelegateClientRunConfig for the per-client field reference.", "items": { "$ref": "#/components/schemas/authserver.DelegateClientRunConfig" }, @@ -473,7 +517,7 @@ const docTemplate = `{ "$ref": "#/components/schemas/authserver.TokenLifespanRunConfig" }, "trusted_issuers": { - "description": "TrustedIssuers lists external OIDC issuers whose tokens are accepted as\nRFC 8693 subject tokens or RFC 7523 JWT-bearer assertions. Issuers with\njwtBearerGrant enabled may be used for the JWT-bearer grant without an\nRFC 8693 delegation policy. Empty (the default) means only self-issued\nsubject tokens are accepted.\n\nSee tokenexchange.TrustedIssuer for the per-issuer field reference, and\ndocs/arch/17-token-exchange-delegation.md for the trust model, consent\nsignals, and operator-facing constraints (audience/scope bounding,\nsubject namespace qualification, required client binding) that aren't\nvisible from the config shape alone.", + "description": "TrustedIssuers lists external OIDC trust declarations.\n\nThis legacy field is deprecated; RFC 8693 and JWT-bearer policies embedded in these entries\nremain supported for compatibility. New configurations should put policy\nunder InboundGrants and reference a named trusted issuer.\n\nSee tokenexchange.TrustedIssuer for the per-issuer field reference, and\ndocs/arch/17-token-exchange-delegation.md for the trust model, consent\nsignals, and operator-facing constraints (audience/scope bounding,\nsubject namespace qualification, required client binding) that aren't\nvisible from the config shape alone.", "items": { "$ref": "#/components/schemas/tokenexchange.TrustedIssuer" }, @@ -623,6 +667,60 @@ const docTemplate = `{ }, "type": "object" }, + "authserver.TokenExchangeInboundGrantRunConfig": { + "description": "TokenExchange configures RFC 8693 inbound clients and issuer policies.", + "properties": { + "delegate_clients": { + "items": { + "$ref": "#/components/schemas/authserver.DelegateClientRunConfig" + }, + "type": "array", + "uniqueItems": false + }, + "issuer_policies": { + "items": { + "$ref": "#/components/schemas/authserver.TokenExchangeIssuerPolicyRunConfig" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, + "authserver.TokenExchangeIssuerPolicyRunConfig": { + "properties": { + "actor_claim": { + "type": "string" + }, + "actor_matcher": { + "type": "string" + }, + "allow_may_act": { + "type": "boolean" + }, + "allowed_actors": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "allowed_delegate_clients": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "expected_audience": { + "type": "string" + }, + "issuer_ref": { + "type": "string" + } + }, + "type": "object" + }, "authserver.TokenLifespanRunConfig": { "description": "TokenLifespans configures the duration that various tokens are valid.\nIf nil, defaults are applied (access: 1h, refresh: 7d, authCode: 10m).", "properties": { @@ -904,6 +1002,21 @@ const docTemplate = `{ }, "type": "object" }, + "github_com_stacklok_toolhive_pkg_authserver_server_tokenexchange.JWTBearerSubjectBinding": { + "properties": { + "allowed_resources": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "subject": { + "type": "string" + } + }, + "type": "object" + }, "github_com_stacklok_toolhive_pkg_authz.Config": { "description": "DEPRECATED: Middleware configuration.\nAuthzConfig contains the authorization configuration", "properties": { @@ -5416,7 +5529,7 @@ const docTemplate = `{ "type": "object" }, "tokenexchange.JWTBearerGrantPolicy": { - "description": "JWTBearerGrant optionally enables the plain RFC 7523 JWT-bearer grant.\nIt accepts assertions from this issuer without client authentication and\nlimits their maximum age, subjects, and RFC 8707 resources. It is\nindependent from RFC 8693 delegation policy.", + "description": "JWTBearerGrant optionally enables the plain RFC 7523 JWT-bearer grant.\nIt accepts assertions from this issuer without client authentication and\nlimits their maximum age, subjects, and RFC 8707 resources. It is\nindependent from RFC 8693 delegation policy.\n\nThis legacy field is deprecated; configure RFC 7523 policy under\ninbound_grants.jwt_bearer.issuer_policies.", "properties": { "accepted_audiences": { "description": "AcceptedAudiences is the set of \"this AS\" identity strings an\nassertion's \"aud\" claim must intersect — e.g. to support migrating\nthis server's issuer/token-endpoint URL, or exposing it under more\nthan one valid name. Each value uniquely identifies this\nauthorization server for this grant; it is NOT a resource/API\nidentifier — a bare resource audience is deliberately not accepted\nhere, that would let any RFC 8707 resource-scoped token satisfy the\ngrant instead of only tokens minted for this AS. Defaults to\n[tokenEndpoint] when empty, preserving prior exact-match behavior.", @@ -5493,7 +5606,7 @@ const docTemplate = `{ "type": "string" }, "expected_audience": { - "description": "ExpectedAudience is the expected \"aud\" claim value that must appear\nin an RFC 8693 subject token's audience list (a resource/API identifier,\nnot a client ID — required for delegation unless JWTBearerGrant is\nconfigured; see looksLikeResourceIdentifier). RFC 7523 assertions use\nthe token endpoint as their audience instead.\nSee docs/arch/17-token-exchange-delegation.md (\"ID/access-token\ndiscrimination\") for why and its limits.", + "description": "ExpectedAudience is the expected \"aud\" claim value that must appear\nin an RFC 8693 subject token's audience list (a resource/API identifier,\nnot a client ID — required for delegation unless JWTBearerGrant is\nconfigured; see looksLikeResourceIdentifier). RFC 7523 assertions use\nthe token endpoint as their audience instead.\n\nThis legacy field is deprecated; configure RFC 8693 policy under\ninbound_grants.token_exchange.issuer_policies.\nSee docs/arch/17-token-exchange-delegation.md (\"ID/access-token\ndiscrimination\") for why and its limits.", "type": "string" }, "insecure_allow_http": { @@ -5510,6 +5623,10 @@ const docTemplate = `{ }, "jwt_bearer_grant": { "$ref": "#/components/schemas/tokenexchange.JWTBearerGrantPolicy" + }, + "name": { + "description": "Name optionally identifies this trust declaration for canonical issuer_ref references.", + "type": "string" } }, "type": "object" diff --git a/docs/server/swagger.json b/docs/server/swagger.json index f5841802a5..d9ccb54cb0 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -213,8 +213,11 @@ "type": "object" }, "authserver.InboundGrantsRunConfig": { - "description": "InboundGrants declares canonical inbound grant configuration, including\nSPIFFE client authentication. See InboundGrantsRunConfig.", + "description": "InboundGrants declares canonical inbound grant configuration, including\nSPIFFE client authentication, delegate clients, and issuer policy. A\nnon-nil value explicitly controls grant-family enablement.", "properties": { + "jwt_bearer": { + "$ref": "#/components/schemas/authserver.JWTBearerInboundGrantRunConfig" + }, "spiffe_client_auth": { "description": "SPIFFEClientAuth associates SPIFFE principal patterns with explicit OAuth\nclient identities and permissions. See SPIFFEClientAuthRunConfig.", "items": { @@ -222,6 +225,47 @@ }, "type": "array", "uniqueItems": false + }, + "token_exchange": { + "$ref": "#/components/schemas/authserver.TokenExchangeInboundGrantRunConfig" + } + }, + "type": "object" + }, + "authserver.JWTBearerInboundGrantRunConfig": { + "description": "JWTBearer configures RFC 7523 issuer policies.", + "properties": { + "issuer_policies": { + "items": { + "$ref": "#/components/schemas/authserver.JWTBearerIssuerPolicyRunConfig" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, + "authserver.JWTBearerIssuerPolicyRunConfig": { + "properties": { + "accepted_audiences": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "issuer_ref": { + "type": "string" + }, + "max_assertion_age": { + "type": "string" + }, + "subject_bindings": { + "items": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver_server_tokenexchange.JWTBearerSubjectBinding" + }, + "type": "array", + "uniqueItems": false } }, "type": "object" @@ -390,7 +434,7 @@ "$ref": "#/components/schemas/authserver.CIMDRunConfig" }, "delegate_clients": { - "description": "DelegateClients declares confidential OAuth clients to register at\nauthorization-server startup, including clients intended for RFC 8693\ntoken exchange.\n\nIndependent of AllowConfidentialClientRegistration: declaring a client\nhere does not require or enable self-service confidential DCR, and\nsetting that flag does not declare or enable any client here. They\ngovern different endpoints — this field is static configuration the\noperator controls directly, while the flag is admission policy for the\nunauthenticated /oauth/register endpoint.\n\nSee DelegateClientRunConfig for the per-client field reference.", + "description": "DelegateClients declares confidential OAuth clients to register at\nauthorization-server startup, including clients intended for RFC 8693\ntoken exchange.\n\nThis legacy field is deprecated; use InboundGrants.TokenExchange.DelegateClients.\n\nIndependent of AllowConfidentialClientRegistration: declaring a client\nhere does not require or enable self-service confidential DCR, and\nsetting that flag does not declare or enable any client here. They\ngovern different endpoints — this field is static configuration the\noperator controls directly, while the flag is admission policy for the\nunauthenticated /oauth/register endpoint.\n\nSee DelegateClientRunConfig for the per-client field reference.", "items": { "$ref": "#/components/schemas/authserver.DelegateClientRunConfig" }, @@ -466,7 +510,7 @@ "$ref": "#/components/schemas/authserver.TokenLifespanRunConfig" }, "trusted_issuers": { - "description": "TrustedIssuers lists external OIDC issuers whose tokens are accepted as\nRFC 8693 subject tokens or RFC 7523 JWT-bearer assertions. Issuers with\njwtBearerGrant enabled may be used for the JWT-bearer grant without an\nRFC 8693 delegation policy. Empty (the default) means only self-issued\nsubject tokens are accepted.\n\nSee tokenexchange.TrustedIssuer for the per-issuer field reference, and\ndocs/arch/17-token-exchange-delegation.md for the trust model, consent\nsignals, and operator-facing constraints (audience/scope bounding,\nsubject namespace qualification, required client binding) that aren't\nvisible from the config shape alone.", + "description": "TrustedIssuers lists external OIDC trust declarations.\n\nThis legacy field is deprecated; RFC 8693 and JWT-bearer policies embedded in these entries\nremain supported for compatibility. New configurations should put policy\nunder InboundGrants and reference a named trusted issuer.\n\nSee tokenexchange.TrustedIssuer for the per-issuer field reference, and\ndocs/arch/17-token-exchange-delegation.md for the trust model, consent\nsignals, and operator-facing constraints (audience/scope bounding,\nsubject namespace qualification, required client binding) that aren't\nvisible from the config shape alone.", "items": { "$ref": "#/components/schemas/tokenexchange.TrustedIssuer" }, @@ -616,6 +660,60 @@ }, "type": "object" }, + "authserver.TokenExchangeInboundGrantRunConfig": { + "description": "TokenExchange configures RFC 8693 inbound clients and issuer policies.", + "properties": { + "delegate_clients": { + "items": { + "$ref": "#/components/schemas/authserver.DelegateClientRunConfig" + }, + "type": "array", + "uniqueItems": false + }, + "issuer_policies": { + "items": { + "$ref": "#/components/schemas/authserver.TokenExchangeIssuerPolicyRunConfig" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, + "authserver.TokenExchangeIssuerPolicyRunConfig": { + "properties": { + "actor_claim": { + "type": "string" + }, + "actor_matcher": { + "type": "string" + }, + "allow_may_act": { + "type": "boolean" + }, + "allowed_actors": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "allowed_delegate_clients": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "expected_audience": { + "type": "string" + }, + "issuer_ref": { + "type": "string" + } + }, + "type": "object" + }, "authserver.TokenLifespanRunConfig": { "description": "TokenLifespans configures the duration that various tokens are valid.\nIf nil, defaults are applied (access: 1h, refresh: 7d, authCode: 10m).", "properties": { @@ -897,6 +995,21 @@ }, "type": "object" }, + "github_com_stacklok_toolhive_pkg_authserver_server_tokenexchange.JWTBearerSubjectBinding": { + "properties": { + "allowed_resources": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "subject": { + "type": "string" + } + }, + "type": "object" + }, "github_com_stacklok_toolhive_pkg_authz.Config": { "description": "DEPRECATED: Middleware configuration.\nAuthzConfig contains the authorization configuration", "properties": { @@ -5409,7 +5522,7 @@ "type": "object" }, "tokenexchange.JWTBearerGrantPolicy": { - "description": "JWTBearerGrant optionally enables the plain RFC 7523 JWT-bearer grant.\nIt accepts assertions from this issuer without client authentication and\nlimits their maximum age, subjects, and RFC 8707 resources. It is\nindependent from RFC 8693 delegation policy.", + "description": "JWTBearerGrant optionally enables the plain RFC 7523 JWT-bearer grant.\nIt accepts assertions from this issuer without client authentication and\nlimits their maximum age, subjects, and RFC 8707 resources. It is\nindependent from RFC 8693 delegation policy.\n\nThis legacy field is deprecated; configure RFC 7523 policy under\ninbound_grants.jwt_bearer.issuer_policies.", "properties": { "accepted_audiences": { "description": "AcceptedAudiences is the set of \"this AS\" identity strings an\nassertion's \"aud\" claim must intersect — e.g. to support migrating\nthis server's issuer/token-endpoint URL, or exposing it under more\nthan one valid name. Each value uniquely identifies this\nauthorization server for this grant; it is NOT a resource/API\nidentifier — a bare resource audience is deliberately not accepted\nhere, that would let any RFC 8707 resource-scoped token satisfy the\ngrant instead of only tokens minted for this AS. Defaults to\n[tokenEndpoint] when empty, preserving prior exact-match behavior.", @@ -5486,7 +5599,7 @@ "type": "string" }, "expected_audience": { - "description": "ExpectedAudience is the expected \"aud\" claim value that must appear\nin an RFC 8693 subject token's audience list (a resource/API identifier,\nnot a client ID — required for delegation unless JWTBearerGrant is\nconfigured; see looksLikeResourceIdentifier). RFC 7523 assertions use\nthe token endpoint as their audience instead.\nSee docs/arch/17-token-exchange-delegation.md (\"ID/access-token\ndiscrimination\") for why and its limits.", + "description": "ExpectedAudience is the expected \"aud\" claim value that must appear\nin an RFC 8693 subject token's audience list (a resource/API identifier,\nnot a client ID — required for delegation unless JWTBearerGrant is\nconfigured; see looksLikeResourceIdentifier). RFC 7523 assertions use\nthe token endpoint as their audience instead.\n\nThis legacy field is deprecated; configure RFC 8693 policy under\ninbound_grants.token_exchange.issuer_policies.\nSee docs/arch/17-token-exchange-delegation.md (\"ID/access-token\ndiscrimination\") for why and its limits.", "type": "string" }, "insecure_allow_http": { @@ -5503,6 +5616,10 @@ }, "jwt_bearer_grant": { "$ref": "#/components/schemas/tokenexchange.JWTBearerGrantPolicy" + }, + "name": { + "description": "Name optionally identifies this trust declaration for canonical issuer_ref references.", + "type": "string" } }, "type": "object" diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index a44d2f8ab7..360cd6dc27 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -276,8 +276,11 @@ components: authserver.InboundGrantsRunConfig: description: |- InboundGrants declares canonical inbound grant configuration, including - SPIFFE client authentication. See InboundGrantsRunConfig. + SPIFFE client authentication, delegate clients, and issuer policy. A + non-nil value explicitly controls grant-family enablement. properties: + jwt_bearer: + $ref: '#/components/schemas/authserver.JWTBearerInboundGrantRunConfig' spiffe_client_auth: description: |- SPIFFEClientAuth associates SPIFFE principal patterns with explicit OAuth @@ -286,6 +289,34 @@ components: $ref: '#/components/schemas/authserver.SPIFFEClientAuthRunConfig' type: array uniqueItems: false + token_exchange: + $ref: '#/components/schemas/authserver.TokenExchangeInboundGrantRunConfig' + type: object + authserver.JWTBearerInboundGrantRunConfig: + description: JWTBearer configures RFC 7523 issuer policies. + properties: + issuer_policies: + items: + $ref: '#/components/schemas/authserver.JWTBearerIssuerPolicyRunConfig' + type: array + uniqueItems: false + type: object + authserver.JWTBearerIssuerPolicyRunConfig: + properties: + accepted_audiences: + items: + type: string + type: array + uniqueItems: false + issuer_ref: + type: string + max_assertion_age: + type: string + subject_bindings: + items: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_authserver_server_tokenexchange.JWTBearerSubjectBinding' + type: array + uniqueItems: false type: object authserver.OAuth2UpstreamRunConfig: description: |- @@ -518,6 +549,8 @@ components: authorization-server startup, including clients intended for RFC 8693 token exchange. + This legacy field is deprecated; use InboundGrants.TokenExchange.DelegateClients. + Independent of AllowConfidentialClientRegistration: declaring a client here does not require or enable self-service confidential DCR, and setting that flag does not declare or enable any client here. They @@ -649,11 +682,11 @@ components: $ref: '#/components/schemas/authserver.TokenLifespanRunConfig' trusted_issuers: description: |- - TrustedIssuers lists external OIDC issuers whose tokens are accepted as - RFC 8693 subject tokens or RFC 7523 JWT-bearer assertions. Issuers with - jwtBearerGrant enabled may be used for the JWT-bearer grant without an - RFC 8693 delegation policy. Empty (the default) means only self-issued - subject tokens are accepted. + TrustedIssuers lists external OIDC trust declarations. + + This legacy field is deprecated; RFC 8693 and JWT-bearer policies embedded in these entries + remain supported for compatibility. New configurations should put policy + under InboundGrants and reference a named trusted issuer. See tokenexchange.TrustedIssuer for the per-issuer field reference, and docs/arch/17-token-exchange-delegation.md for the trust model, consent @@ -811,6 +844,43 @@ components: This key is used for signing new tokens. type: string type: object + authserver.TokenExchangeInboundGrantRunConfig: + description: TokenExchange configures RFC 8693 inbound clients and issuer policies. + properties: + delegate_clients: + items: + $ref: '#/components/schemas/authserver.DelegateClientRunConfig' + type: array + uniqueItems: false + issuer_policies: + items: + $ref: '#/components/schemas/authserver.TokenExchangeIssuerPolicyRunConfig' + type: array + uniqueItems: false + type: object + authserver.TokenExchangeIssuerPolicyRunConfig: + properties: + actor_claim: + type: string + actor_matcher: + type: string + allow_may_act: + type: boolean + allowed_actors: + items: + type: string + type: array + uniqueItems: false + allowed_delegate_clients: + items: + type: string + type: array + uniqueItems: false + expected_audience: + type: string + issuer_ref: + type: string + type: object authserver.TokenLifespanRunConfig: description: |- TokenLifespans configures the duration that various tokens are valid. @@ -1099,6 +1169,16 @@ components: This is required and must match a configured upstream provider name. type: string type: object + github_com_stacklok_toolhive_pkg_authserver_server_tokenexchange.JWTBearerSubjectBinding: + properties: + allowed_resources: + items: + type: string + type: array + uniqueItems: false + subject: + type: string + type: object github_com_stacklok_toolhive_pkg_authz.Config: description: |- DEPRECATED: Middleware configuration. @@ -5074,6 +5154,9 @@ components: It accepts assertions from this issuer without client authentication and limits their maximum age, subjects, and RFC 8707 resources. It is independent from RFC 8693 delegation policy. + + This legacy field is deprecated; configure RFC 7523 policy under + inbound_grants.jwt_bearer.issuer_policies. properties: accepted_audiences: description: |- @@ -5186,6 +5269,9 @@ components: not a client ID — required for delegation unless JWTBearerGrant is configured; see looksLikeResourceIdentifier). RFC 7523 assertions use the token endpoint as their audience instead. + + This legacy field is deprecated; configure RFC 8693 policy under + inbound_grants.token_exchange.issuer_policies. See docs/arch/17-token-exchange-delegation.md ("ID/access-token discrimination") for why and its limits. type: string @@ -5210,6 +5296,10 @@ components: type: string jwt_bearer_grant: $ref: '#/components/schemas/tokenexchange.JWTBearerGrantPolicy' + name: + description: Name optionally identifies this trust declaration for canonical + issuer_ref references. + type: string type: object types.MiddlewareConfig: properties: diff --git a/pkg/authserver/config.go b/pkg/authserver/config.go index 525f5d67c1..e1d705fb22 100644 --- a/pkg/authserver/config.go +++ b/pkg/authserver/config.go @@ -124,11 +124,11 @@ type RunConfig struct { //nolint:lll // field tags require full JSON+YAML names InsecureAllowHTTP bool `json:"insecure_allow_http,omitempty" yaml:"insecure_allow_http,omitempty"` - // TrustedIssuers lists external OIDC issuers whose tokens are accepted as - // RFC 8693 subject tokens or RFC 7523 JWT-bearer assertions. Issuers with - // jwtBearerGrant enabled may be used for the JWT-bearer grant without an - // RFC 8693 delegation policy. Empty (the default) means only self-issued - // subject tokens are accepted. + // TrustedIssuers lists external OIDC trust declarations. + // + // This legacy field is deprecated; RFC 8693 and JWT-bearer policies embedded in these entries + // remain supported for compatibility. New configurations should put policy + // under InboundGrants and reference a named trusted issuer. // // See tokenexchange.TrustedIssuer for the per-issuer field reference, and // docs/arch/17-token-exchange-delegation.md for the trust model, consent @@ -222,6 +222,8 @@ type RunConfig struct { // authorization-server startup, including clients intended for RFC 8693 // token exchange. // + // This legacy field is deprecated; use InboundGrants.TokenExchange.DelegateClients. + // // Independent of AllowConfidentialClientRegistration: declaring a client // here does not require or enable self-service confidential DCR, and // setting that flag does not declare or enable any client here. They @@ -237,7 +239,8 @@ type RunConfig struct { SPIFFETrustDomains []SPIFFETrustDomainRunConfig `json:"spiffe_trust_domains,omitempty" yaml:"spiffe_trust_domains,omitempty"` // InboundGrants declares canonical inbound grant configuration, including - // SPIFFE client authentication. See InboundGrantsRunConfig. + // SPIFFE client authentication, delegate clients, and issuer policy. A + // non-nil value explicitly controls grant-family enablement. InboundGrants *InboundGrantsRunConfig `json:"inbound_grants,omitempty" yaml:"inbound_grants,omitempty"` } @@ -292,14 +295,18 @@ func (c *RunConfig) Validate() error { if err := validateAllowedAudiences(c.AllowedAudiences); err != nil { return err } - if err := validateDelegateClients(c.DelegateClients, c.ScopesSupported, c.AllowedAudiences); err != nil { + normalized, err := NormalizeInboundGrants(c) + if err != nil { + return err + } + if err := validateDelegateClients(normalized.DelegateClients, c.ScopesSupported, c.AllowedAudiences); err != nil { return err } - if err := validateTrustedIssuers(c.TrustedIssuers, c.Issuer, c.AllowedAudiences); err != nil { + if err := validateTrustedIssuers(normalized.TrustedIssuers, c.Issuer, c.AllowedAudiences); err != nil { return err } if err := ValidateConfidentialClientTransport( - c.AllowConfidentialClientRegistration || len(c.DelegateClients) > 0, c.InsecureAllowHTTP, + c.AllowConfidentialClientRegistration || len(normalized.DelegateClients) > 0, c.InsecureAllowHTTP, c.Issuer, c.InsecureAllowConfidentialOverLoopbackHTTP); err != nil { return err } @@ -1109,6 +1116,11 @@ type Config struct { // serialized configuration. DelegateClients []DelegateClient + // DisableTokenExchange prevents registration and advertisement of the RFC + // 8693 grant. The zero value preserves the released behavior. It is set only + // when canonical inbound_grants explicitly omits token_exchange. + DisableTokenExchange bool + // 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 diff --git a/pkg/authserver/inbound_grants.go b/pkg/authserver/inbound_grants.go new file mode 100644 index 0000000000..413049c3e7 --- /dev/null +++ b/pkg/authserver/inbound_grants.go @@ -0,0 +1,262 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package authserver + +import ( + "fmt" + "slices" + + tx "github.com/stacklok/toolhive/pkg/authserver/server/tokenexchange" +) + +// TokenExchangeInboundGrantRunConfig configures RFC 8693 inbound clients and issuer policies. +type TokenExchangeInboundGrantRunConfig struct { + DelegateClients []DelegateClientRunConfig `json:"delegate_clients,omitempty" yaml:"delegate_clients,omitempty"` + IssuerPolicies []TokenExchangeIssuerPolicyRunConfig `json:"issuer_policies,omitempty" yaml:"issuer_policies,omitempty"` +} + +// TokenExchangeIssuerPolicyRunConfig binds RFC 8693 policy to a trusted issuer declaration. +type TokenExchangeIssuerPolicyRunConfig struct { + IssuerRef string `json:"issuer_ref" yaml:"issuer_ref"` + ExpectedAudience string `json:"expected_audience" yaml:"expected_audience"` + ActorClaim string `json:"actor_claim,omitempty" yaml:"actor_claim,omitempty"` + AllowedActors []string `json:"allowed_actors,omitempty" yaml:"allowed_actors,omitempty"` + ActorMatcher string `json:"actor_matcher,omitempty" yaml:"actor_matcher,omitempty"` + AllowedDelegateClients []string `json:"allowed_delegate_clients" yaml:"allowed_delegate_clients"` + AllowMayAct bool `json:"allow_may_act,omitempty" yaml:"allow_may_act,omitempty"` +} + +// JWTBearerInboundGrantRunConfig configures RFC 7523 issuer policies. +type JWTBearerInboundGrantRunConfig struct { + IssuerPolicies []JWTBearerIssuerPolicyRunConfig `json:"issuer_policies,omitempty" yaml:"issuer_policies,omitempty"` +} + +// JWTBearerIssuerPolicyRunConfig binds RFC 7523 policy to a trusted issuer declaration. +type JWTBearerIssuerPolicyRunConfig struct { + IssuerRef string `json:"issuer_ref" yaml:"issuer_ref"` + MaxAssertionAge string `json:"max_assertion_age" yaml:"max_assertion_age"` + SubjectBindings []tx.JWTBearerSubjectBinding `json:"subject_bindings" yaml:"subject_bindings"` + AcceptedAudiences []string `json:"accepted_audiences,omitempty" yaml:"accepted_audiences,omitempty"` +} + +// InboundGrantCapabilities reports the effective grant families after normalization. +type InboundGrantCapabilities struct { + TokenExchange bool + JWTBearer bool +} + +// DeprecatedFieldPath identifies a populated legacy field and its canonical replacement. +type DeprecatedFieldPath struct { + Path string + Replacement string +} + +// NormalizedInboundGrants contains copied effective inputs consumed by existing runtime validators and registration. +type NormalizedInboundGrants struct { + DelegateClients []DelegateClientRunConfig + TrustedIssuers []tx.TrustedIssuer + Capabilities InboundGrantCapabilities + DeprecatedFields []DeprecatedFieldPath +} + +// NormalizeInboundGrants converts legacy and canonical declarations to the existing effective runtime structures. +// It never mutates cfg or any nested caller-owned slice. +func NormalizeInboundGrants(cfg *RunConfig) (*NormalizedInboundGrants, error) { + if cfg == nil { + return nil, fmt.Errorf("config is required") + } + + result := &NormalizedInboundGrants{ + DelegateClients: cloneDelegateClients(cfg.DelegateClients), + TrustedIssuers: cloneTrustedIssuers(cfg.TrustedIssuers), + Capabilities: InboundGrantCapabilities{ + TokenExchange: cfg.InboundGrants == nil, + }, + } + issuerByName, err := indexTrustedIssuers(result.TrustedIssuers) + if err != nil { + return nil, err + } + + legacyTokenExchange := len(cfg.DelegateClients) > 0 + for i := range result.TrustedIssuers { + issuer := &result.TrustedIssuers[i] + if hasLegacyTokenExchangePolicy(*issuer) { + legacyTokenExchange = true + result.DeprecatedFields = append(result.DeprecatedFields, DeprecatedFieldPath{ + Path: fmt.Sprintf("trusted_issuers[%d]", i), Replacement: "inbound_grants.token_exchange.issuer_policies", + }) + } + if issuer.JWTBearerGrant != nil { + result.Capabilities.JWTBearer = true + result.DeprecatedFields = append(result.DeprecatedFields, DeprecatedFieldPath{ + Path: fmt.Sprintf("trusted_issuers[%d].jwt_bearer_grant", i), Replacement: "inbound_grants.jwt_bearer.issuer_policies", + }) + } + } + if len(cfg.DelegateClients) > 0 { + result.DeprecatedFields = append(result.DeprecatedFields, DeprecatedFieldPath{ + Path: "delegate_clients", Replacement: "inbound_grants.token_exchange.delegate_clients", + }) + } + if cfg.InboundGrants == nil { + return result, nil + } + + if cfg.InboundGrants.TokenExchange != nil { + if legacyTokenExchange { + return nil, fmt.Errorf( + "inbound_grants.token_exchange conflicts with legacy delegate_clients " + + "or RFC 8693 policy in trusted_issuers", + ) + } + result.Capabilities.TokenExchange = true + result.DelegateClients = cloneDelegateClients(cfg.InboundGrants.TokenExchange.DelegateClients) + if err := applyTokenExchangePolicies(result.TrustedIssuers, issuerByName, + cfg.InboundGrants.TokenExchange.IssuerPolicies); err != nil { + return nil, err + } + } else { + result.Capabilities.TokenExchange = legacyTokenExchange + } + // SPIFFE client-auth associations always require the token-exchange grant + // (SPIFFEGrantTypeTokenExchange is the only grant type they may declare — + // see validateSPIFFEGrants), independent of the legacy/canonical + // token-exchange projection above. A SPIFFE-only configuration must not + // leave Capabilities.TokenExchange false: that would disable the RFC 8693 + // grant handler server-wide and reject every SPIFFE client's own token + // requests before authentication is even checked. + if len(cfg.InboundGrants.SPIFFEClientAuth) > 0 { + result.Capabilities.TokenExchange = true + } + + if cfg.InboundGrants.JWTBearer != nil { + if result.Capabilities.JWTBearer { + return nil, fmt.Errorf("inbound_grants.jwt_bearer conflicts with legacy trusted_issuers[*].jwt_bearer_grant") + } + result.Capabilities.JWTBearer = true + if err := applyJWTBearerPolicies(result.TrustedIssuers, issuerByName, + cfg.InboundGrants.JWTBearer.IssuerPolicies); err != nil { + return nil, err + } + } + return result, nil +} + +func indexTrustedIssuers(issuers []tx.TrustedIssuer) (map[string]int, error) { + byName := make(map[string]int, len(issuers)) + byURL := make(map[string]int, len(issuers)) + for i, issuer := range issuers { + if previous, ok := byURL[issuer.IssuerURL]; ok { + return nil, fmt.Errorf( + "trusted_issuers[%d].issuer_url duplicates trusted_issuers[%d].issuer_url %q (configured more than once)", + i, + previous, + issuer.IssuerURL, + ) + } + byURL[issuer.IssuerURL] = i + if issuer.Name == "" { + continue + } + if previous, ok := byName[issuer.Name]; ok { + return nil, fmt.Errorf("trusted_issuers[%d].name duplicates trusted_issuers[%d].name %q", i, previous, issuer.Name) + } + byName[issuer.Name] = i + } + return byName, nil +} + +func applyTokenExchangePolicies( + issuers []tx.TrustedIssuer, byName map[string]int, policies []TokenExchangeIssuerPolicyRunConfig, +) error { + seen := make(map[string]int, len(policies)) + for i, policy := range policies { + issuerIndex, err := resolveIssuerRef(byName, seen, policy.IssuerRef, + fmt.Sprintf("inbound_grants.token_exchange.issuer_policies[%d]", i)) + if err != nil { + return err + } + seen[policy.IssuerRef] = i + issuer := &issuers[issuerIndex] + issuer.ExpectedAudience = policy.ExpectedAudience + issuer.ActorClaim = policy.ActorClaim + issuer.AllowedActors = slices.Clone(policy.AllowedActors) + issuer.ActorMatcher = policy.ActorMatcher + issuer.AllowedDelegateClients = slices.Clone(policy.AllowedDelegateClients) + issuer.AllowMayAct = policy.AllowMayAct + } + return nil +} + +func applyJWTBearerPolicies( + issuers []tx.TrustedIssuer, byName map[string]int, policies []JWTBearerIssuerPolicyRunConfig, +) error { + seen := make(map[string]int, len(policies)) + for i, policy := range policies { + issuerIndex, err := resolveIssuerRef(byName, seen, policy.IssuerRef, + fmt.Sprintf("inbound_grants.jwt_bearer.issuer_policies[%d]", i)) + if err != nil { + return err + } + seen[policy.IssuerRef] = i + issuers[issuerIndex].JWTBearerGrant = &tx.JWTBearerGrantPolicy{ + MaxAssertionAge: policy.MaxAssertionAge, + SubjectBindings: cloneSubjectBindings(policy.SubjectBindings), + AcceptedAudiences: slices.Clone(policy.AcceptedAudiences), + } + } + return nil +} + +func resolveIssuerRef(byName map[string]int, seen map[string]int, ref, path string) (int, error) { + if ref == "" { + return 0, fmt.Errorf("%s.issuer_ref is required", path) + } + if previous, ok := seen[ref]; ok { + return 0, fmt.Errorf("%s.issuer_ref duplicates issuer policy [%d] for %q", path, previous, ref) + } + index, ok := byName[ref] + if !ok { + return 0, fmt.Errorf("%s.issuer_ref references unknown or unnamed trusted issuer %q", path, ref) + } + return index, nil +} + +func hasLegacyTokenExchangePolicy(issuer tx.TrustedIssuer) bool { + return issuer.ExpectedAudience != "" || issuer.ActorClaim != "" || len(issuer.AllowedActors) > 0 || + issuer.ActorMatcher != "" || len(issuer.AllowedDelegateClients) > 0 || issuer.AllowMayAct +} + +func cloneDelegateClients(clients []DelegateClientRunConfig) []DelegateClientRunConfig { + cloned := slices.Clone(clients) + for i := range cloned { + cloned[i].Scopes = slices.Clone(cloned[i].Scopes) + cloned[i].Audiences = slices.Clone(cloned[i].Audiences) + } + return cloned +} + +func cloneTrustedIssuers(issuers []tx.TrustedIssuer) []tx.TrustedIssuer { + cloned := slices.Clone(issuers) + for i := range cloned { + cloned[i].AllowedActors = slices.Clone(cloned[i].AllowedActors) + cloned[i].AllowedDelegateClients = slices.Clone(cloned[i].AllowedDelegateClients) + if cloned[i].JWTBearerGrant != nil { + policy := *cloned[i].JWTBearerGrant + policy.SubjectBindings = cloneSubjectBindings(policy.SubjectBindings) + policy.AcceptedAudiences = slices.Clone(policy.AcceptedAudiences) + cloned[i].JWTBearerGrant = &policy + } + } + return cloned +} + +func cloneSubjectBindings(bindings []tx.JWTBearerSubjectBinding) []tx.JWTBearerSubjectBinding { + cloned := slices.Clone(bindings) + for i := range cloned { + cloned[i].AllowedResources = slices.Clone(cloned[i].AllowedResources) + } + return cloned +} diff --git a/pkg/authserver/inbound_grants_test.go b/pkg/authserver/inbound_grants_test.go new file mode 100644 index 0000000000..da60973fbb --- /dev/null +++ b/pkg/authserver/inbound_grants_test.go @@ -0,0 +1,399 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package authserver + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + + "github.com/stacklok/toolhive/pkg/authserver/server/tokenexchange" +) + +func TestNormalizeInboundGrants(t *testing.T) { + t.Parallel() + + delegate := DelegateClientRunConfig{ + ClientID: "delegate", ClientSecretEnvVar: "DELEGATE_SECRET", + Scopes: []string{"openid"}, Audiences: []string{"https://mcp.example.com"}, + } + legacyJWT := &tokenexchange.JWTBearerGrantPolicy{ + MaxAssertionAge: "5m", + SubjectBindings: []tokenexchange.JWTBearerSubjectBinding{{ + Subject: "workload", AllowedResources: []string{"https://mcp.example.com"}, + }}, + AcceptedAudiences: []string{"https://auth.example.com/token"}, + } + issuer := tokenexchange.TrustedIssuer{Name: "idp", IssuerURL: "https://idp.example.com"} + + tests := []struct { + name string + cfg *RunConfig + want *NormalizedInboundGrants + }{ + { + name: "inbound grants absent preserves legacy default capability", + cfg: &RunConfig{}, + want: &NormalizedInboundGrants{Capabilities: InboundGrantCapabilities{TokenExchange: true}}, + }, + { + name: "inbound grants present with no families disables capabilities", + cfg: &RunConfig{InboundGrants: &InboundGrantsRunConfig{}}, + want: &NormalizedInboundGrants{}, + }, + { + // Regression test: SPIFFE client-auth associations always + // require the token-exchange grant (see validateSPIFFEGrants), + // so a SPIFFE-only configuration must not leave + // Capabilities.TokenExchange false -- that would disable the + // RFC 8693 grant handler server-wide. This exact fix was + // previously lost during a rebase because its only regression + // coverage lived in a different package (pkg/authserver/runner); + // this case guards it directly where the logic lives. + name: "SPIFFE client auth alone sets the token exchange capability", + cfg: &RunConfig{ + InboundGrants: &InboundGrantsRunConfig{ + SPIFFEClientAuth: []SPIFFEClientAuthRunConfig{{ + TrustDomainRef: "prod", + PrincipalPattern: "spiffe://example.org/ns/default/agent", + ClientID: "spiffe-agent", + Methods: []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodX509}, + Scopes: []string{"openid"}, + Audiences: []string{"https://mcp.example.com"}, + GrantTypes: []string{SPIFFEGrantTypeTokenExchange}, + }}, + }, + }, + want: &NormalizedInboundGrants{Capabilities: InboundGrantCapabilities{TokenExchange: true}}, + }, + { + name: "legacy delegate RFC8693 and JWT policies are preserved and deprecated", + cfg: &RunConfig{ + DelegateClients: []DelegateClientRunConfig{delegate}, + TrustedIssuers: []tokenexchange.TrustedIssuer{{ + Name: "idp", IssuerURL: "https://idp.example.com", ExpectedAudience: "https://mcp.example.com", + ActorClaim: "azp", AllowedActors: []string{"agent"}, ActorMatcher: `claims.team == "platform"`, + AllowedDelegateClients: []string{"delegate"}, AllowMayAct: true, JWTBearerGrant: legacyJWT, + }}, + }, + want: &NormalizedInboundGrants{ + DelegateClients: []DelegateClientRunConfig{delegate}, + TrustedIssuers: []tokenexchange.TrustedIssuer{{ + Name: "idp", IssuerURL: "https://idp.example.com", ExpectedAudience: "https://mcp.example.com", + ActorClaim: "azp", AllowedActors: []string{"agent"}, ActorMatcher: `claims.team == "platform"`, + AllowedDelegateClients: []string{"delegate"}, AllowMayAct: true, JWTBearerGrant: legacyJWT, + }}, + Capabilities: InboundGrantCapabilities{TokenExchange: true, JWTBearer: true}, + DeprecatedFields: []DeprecatedFieldPath{ + {Path: "trusted_issuers[0]", Replacement: "inbound_grants.token_exchange.issuer_policies"}, + {Path: "trusted_issuers[0].jwt_bearer_grant", Replacement: "inbound_grants.jwt_bearer.issuer_policies"}, + {Path: "delegate_clients", Replacement: "inbound_grants.token_exchange.delegate_clients"}, + }, + }, + }, + { + name: "canonical token exchange and JWT bearer policies are applied", + cfg: &RunConfig{ + TrustedIssuers: []tokenexchange.TrustedIssuer{issuer}, + InboundGrants: &InboundGrantsRunConfig{ + TokenExchange: &TokenExchangeInboundGrantRunConfig{ + DelegateClients: []DelegateClientRunConfig{delegate}, + IssuerPolicies: []TokenExchangeIssuerPolicyRunConfig{{ + IssuerRef: "idp", ExpectedAudience: "https://mcp.example.com", ActorClaim: "azp", + AllowedActors: []string{"agent"}, ActorMatcher: `claims.team == "platform"`, + AllowedDelegateClients: []string{"delegate"}, AllowMayAct: true, + }}, + }, + JWTBearer: &JWTBearerInboundGrantRunConfig{IssuerPolicies: []JWTBearerIssuerPolicyRunConfig{{ + IssuerRef: "idp", MaxAssertionAge: "5m", + SubjectBindings: []tokenexchange.JWTBearerSubjectBinding{{ + Subject: "workload", AllowedResources: []string{"https://mcp.example.com"}, + }}, + AcceptedAudiences: []string{"https://auth.example.com/token"}, + }}}, + }, + }, + want: &NormalizedInboundGrants{ + DelegateClients: []DelegateClientRunConfig{delegate}, + TrustedIssuers: []tokenexchange.TrustedIssuer{{ + Name: "idp", IssuerURL: "https://idp.example.com", ExpectedAudience: "https://mcp.example.com", + ActorClaim: "azp", AllowedActors: []string{"agent"}, ActorMatcher: `claims.team == "platform"`, + AllowedDelegateClients: []string{"delegate"}, AllowMayAct: true, JWTBearerGrant: legacyJWT, + }}, + Capabilities: InboundGrantCapabilities{TokenExchange: true, JWTBearer: true}, + }, + }, + { + name: "legacy token exchange coexists with canonical JWT bearer", + cfg: &RunConfig{ + DelegateClients: []DelegateClientRunConfig{delegate}, TrustedIssuers: []tokenexchange.TrustedIssuer{issuer}, + InboundGrants: &InboundGrantsRunConfig{JWTBearer: &JWTBearerInboundGrantRunConfig{ + IssuerPolicies: []JWTBearerIssuerPolicyRunConfig{{IssuerRef: "idp", MaxAssertionAge: "5m"}}, + }}, + }, + want: &NormalizedInboundGrants{ + DelegateClients: []DelegateClientRunConfig{delegate}, + TrustedIssuers: []tokenexchange.TrustedIssuer{{ + Name: "idp", IssuerURL: "https://idp.example.com", + JWTBearerGrant: &tokenexchange.JWTBearerGrantPolicy{MaxAssertionAge: "5m"}, + }}, + Capabilities: InboundGrantCapabilities{TokenExchange: true, JWTBearer: true}, + DeprecatedFields: []DeprecatedFieldPath{{ + Path: "delegate_clients", Replacement: "inbound_grants.token_exchange.delegate_clients", + }}, + }, + }, + { + name: "legacy JWT bearer coexists with canonical token exchange", + cfg: &RunConfig{ + TrustedIssuers: []tokenexchange.TrustedIssuer{{Name: "idp", IssuerURL: "https://idp.example.com", JWTBearerGrant: legacyJWT}}, + InboundGrants: &InboundGrantsRunConfig{TokenExchange: &TokenExchangeInboundGrantRunConfig{ + IssuerPolicies: []TokenExchangeIssuerPolicyRunConfig{{ + IssuerRef: "idp", ExpectedAudience: "https://mcp.example.com", AllowedDelegateClients: []string{"delegate"}, + }}, + }}, + }, + want: &NormalizedInboundGrants{ + TrustedIssuers: []tokenexchange.TrustedIssuer{{ + Name: "idp", IssuerURL: "https://idp.example.com", ExpectedAudience: "https://mcp.example.com", + AllowedDelegateClients: []string{"delegate"}, JWTBearerGrant: legacyJWT, + }}, + Capabilities: InboundGrantCapabilities{TokenExchange: true, JWTBearer: true}, + DeprecatedFields: []DeprecatedFieldPath{{ + Path: "trusted_issuers[0].jwt_bearer_grant", Replacement: "inbound_grants.jwt_bearer.issuer_policies", + }}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := NormalizeInboundGrants(tt.cfg) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestNormalizeInboundGrantsRejectsInvalidConfiguration(t *testing.T) { + t.Parallel() + + issuer := tokenexchange.TrustedIssuer{Name: "idp", IssuerURL: "https://idp.example.com"} + canonicalTokenExchange := &InboundGrantsRunConfig{TokenExchange: &TokenExchangeInboundGrantRunConfig{}} + canonicalJWT := &InboundGrantsRunConfig{JWTBearer: &JWTBearerInboundGrantRunConfig{}} + + tests := []struct { + name string + cfg *RunConfig + errText string + }{ + {name: "nil config", errText: "config is required"}, + { + name: "legacy delegate conflicts with canonical token exchange", + cfg: &RunConfig{DelegateClients: []DelegateClientRunConfig{{ClientID: "delegate"}}, InboundGrants: canonicalTokenExchange}, + errText: "inbound_grants.token_exchange conflicts with legacy delegate_clients", + }, + { + name: "legacy RFC8693 policy conflicts with canonical token exchange", + cfg: &RunConfig{TrustedIssuers: []tokenexchange.TrustedIssuer{{IssuerURL: "https://idp.example.com", ExpectedAudience: "aud"}}, InboundGrants: canonicalTokenExchange}, + errText: "inbound_grants.token_exchange conflicts with legacy delegate_clients or RFC 8693 policy", + }, + { + name: "legacy JWT policy conflicts with canonical JWT bearer", + cfg: &RunConfig{TrustedIssuers: []tokenexchange.TrustedIssuer{{IssuerURL: "https://idp.example.com", JWTBearerGrant: &tokenexchange.JWTBearerGrantPolicy{}}}, InboundGrants: canonicalJWT}, + errText: "inbound_grants.jwt_bearer conflicts with legacy", + }, + { + name: "duplicate issuer names", + cfg: &RunConfig{TrustedIssuers: []tokenexchange.TrustedIssuer{issuer, {Name: "idp", IssuerURL: "https://other.example.com"}}}, + errText: `trusted_issuers[1].name duplicates trusted_issuers[0].name "idp"`, + }, + { + name: "duplicate issuer URLs", + cfg: &RunConfig{TrustedIssuers: []tokenexchange.TrustedIssuer{issuer, {Name: "other", IssuerURL: "https://idp.example.com"}}}, + errText: `trusted_issuers[1].issuer_url duplicates trusted_issuers[0].issuer_url "https://idp.example.com"`, + }, + { + name: "empty token exchange issuer ref", + cfg: &RunConfig{TrustedIssuers: []tokenexchange.TrustedIssuer{issuer}, InboundGrants: &InboundGrantsRunConfig{ + TokenExchange: &TokenExchangeInboundGrantRunConfig{IssuerPolicies: []TokenExchangeIssuerPolicyRunConfig{{}}}, + }}, + errText: "inbound_grants.token_exchange.issuer_policies[0].issuer_ref is required", + }, + { + name: "unknown JWT bearer issuer ref", + cfg: &RunConfig{TrustedIssuers: []tokenexchange.TrustedIssuer{issuer}, InboundGrants: &InboundGrantsRunConfig{ + JWTBearer: &JWTBearerInboundGrantRunConfig{IssuerPolicies: []JWTBearerIssuerPolicyRunConfig{{IssuerRef: "other"}}}, + }}, + errText: `issuer_ref references unknown or unnamed trusted issuer "other"`, + }, + { + name: "duplicate token exchange issuer ref", + cfg: &RunConfig{TrustedIssuers: []tokenexchange.TrustedIssuer{issuer}, InboundGrants: &InboundGrantsRunConfig{ + TokenExchange: &TokenExchangeInboundGrantRunConfig{IssuerPolicies: []TokenExchangeIssuerPolicyRunConfig{{IssuerRef: "idp"}, {IssuerRef: "idp"}}}, + }}, + errText: `issuer_ref duplicates issuer policy [0] for "idp"`, + }, + { + name: "duplicate JWT bearer issuer ref", + cfg: &RunConfig{TrustedIssuers: []tokenexchange.TrustedIssuer{issuer}, InboundGrants: &InboundGrantsRunConfig{ + JWTBearer: &JWTBearerInboundGrantRunConfig{IssuerPolicies: []JWTBearerIssuerPolicyRunConfig{{IssuerRef: "idp"}, {IssuerRef: "idp"}}}, + }}, + errText: `issuer_ref duplicates issuer policy [0] for "idp"`, + }, + { + name: "unnamed legacy issuer cannot be referenced", + cfg: &RunConfig{TrustedIssuers: []tokenexchange.TrustedIssuer{{IssuerURL: "https://idp.example.com"}}, InboundGrants: &InboundGrantsRunConfig{ + TokenExchange: &TokenExchangeInboundGrantRunConfig{IssuerPolicies: []TokenExchangeIssuerPolicyRunConfig{{IssuerRef: "idp"}}}, + }}, + errText: `issuer_ref references unknown or unnamed trusted issuer "idp"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := NormalizeInboundGrants(tt.cfg) + require.Error(t, err) + assert.Nil(t, got) + assert.Contains(t, err.Error(), tt.errText) + }) + } +} + +func TestNormalizeInboundGrantsDeepCopiesCallerInput(t *testing.T) { + t.Parallel() + + cfg := &RunConfig{ + TrustedIssuers: []tokenexchange.TrustedIssuer{{Name: "idp", IssuerURL: "https://idp.example.com"}}, + InboundGrants: &InboundGrantsRunConfig{ + TokenExchange: &TokenExchangeInboundGrantRunConfig{ + DelegateClients: []DelegateClientRunConfig{{ClientID: "delegate", Scopes: []string{"openid"}, Audiences: []string{"resource"}}}, + IssuerPolicies: []TokenExchangeIssuerPolicyRunConfig{{ + IssuerRef: "idp", AllowedActors: []string{"actor"}, AllowedDelegateClients: []string{"delegate"}, + }}, + }, + JWTBearer: &JWTBearerInboundGrantRunConfig{IssuerPolicies: []JWTBearerIssuerPolicyRunConfig{{ + IssuerRef: "idp", SubjectBindings: []tokenexchange.JWTBearerSubjectBinding{{ + Subject: "workload", AllowedResources: []string{"resource"}, + }}, AcceptedAudiences: []string{"token-endpoint"}, + }}}, + }, + } + before := cloneRunConfigForTest(t, cfg) + + got, err := NormalizeInboundGrants(cfg) + require.NoError(t, err) + assert.Equal(t, before, cfg, "normalization must not mutate its input") + + got.DelegateClients[0].Scopes[0] = "changed" + got.DelegateClients[0].Audiences[0] = "changed" + got.TrustedIssuers[0].AllowedActors[0] = "changed" + got.TrustedIssuers[0].AllowedDelegateClients[0] = "changed" + got.TrustedIssuers[0].JWTBearerGrant.SubjectBindings[0].AllowedResources[0] = "changed" + got.TrustedIssuers[0].JWTBearerGrant.AcceptedAudiences[0] = "changed" + assert.Equal(t, before, cfg, "mutating normalized nested values must not mutate the input") + + cfg.InboundGrants.TokenExchange.DelegateClients[0].Scopes[0] = "source-change" + cfg.InboundGrants.TokenExchange.IssuerPolicies[0].AllowedActors[0] = "source-change" + cfg.InboundGrants.JWTBearer.IssuerPolicies[0].SubjectBindings[0].AllowedResources[0] = "source-change" + assert.Equal(t, "changed", got.DelegateClients[0].Scopes[0]) + assert.Equal(t, "changed", got.TrustedIssuers[0].AllowedActors[0]) + assert.Equal(t, "changed", got.TrustedIssuers[0].JWTBearerGrant.SubjectBindings[0].AllowedResources[0]) +} + +func TestInboundGrantsRunConfigSerializationLayouts(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + unmarshal func([]byte, *RunConfig) error + marshal func(*RunConfig) ([]byte, error) + serializedFields []string + wantCapabilities InboundGrantCapabilities + wantSPIFFEClients int + }{ + { + name: "canonical JSON", + input: `{"trusted_issuers":[{"name":"idp","issuer_url":"https://idp.example.com","expected_audience":""}],"inbound_grants":{"spiffe_client_auth":[{"trust_domain_ref":"prod","principal_pattern":"spiffe://example.org/agent","client_id":"agent","methods":["spiffe_x509"],"audiences":["resource"],"scopes":["openid"],"grant_types":["urn:ietf:params:oauth:grant-type:token-exchange"]}],"token_exchange":{"delegate_clients":[{"client_id":"delegate","scopes":["openid"],"audiences":["resource"]}],"issuer_policies":[{"issuer_ref":"idp","expected_audience":"resource","allowed_delegate_clients":["delegate"]}]},"jwt_bearer":{"issuer_policies":[{"issuer_ref":"idp","max_assertion_age":"5m","subject_bindings":[{"subject":"workload","allowed_resources":["resource"]}],"accepted_audiences":["token-endpoint"]}]}}}`, + unmarshal: func(data []byte, cfg *RunConfig) error { return json.Unmarshal(data, cfg) }, + marshal: func(cfg *RunConfig) ([]byte, error) { return json.Marshal(cfg) }, + serializedFields: []string{`"inbound_grants"`, `"token_exchange"`, `"jwt_bearer"`, `"spiffe_client_auth"`, `"issuer_ref"`}, + wantCapabilities: InboundGrantCapabilities{TokenExchange: true, JWTBearer: true}, wantSPIFFEClients: 1, + }, + { + name: "canonical YAML", + input: "trusted_issuers:\n - name: idp\n issuer_url: https://idp.example.com\n expected_audience: \"\"\ninbound_grants:\n spiffe_client_auth:\n - trust_domain_ref: prod\n principal_pattern: spiffe://example.org/agent\n client_id: agent\n methods: [spiffe_x509]\n audiences: [resource]\n scopes: [openid]\n grant_types: [\"urn:ietf:params:oauth:grant-type:token-exchange\"]\n token_exchange:\n issuer_policies:\n - issuer_ref: idp\n expected_audience: resource\n allowed_delegate_clients: [delegate]\n jwt_bearer:\n issuer_policies:\n - issuer_ref: idp\n max_assertion_age: 5m\n subject_bindings:\n - subject: workload\n allowed_resources: [resource]\n", + unmarshal: func(data []byte, cfg *RunConfig) error { return yaml.Unmarshal(data, cfg) }, + marshal: func(cfg *RunConfig) ([]byte, error) { return yaml.Marshal(cfg) }, + serializedFields: []string{"inbound_grants:", "token_exchange:", "jwt_bearer:", "spiffe_client_auth:", "issuer_ref:"}, + wantCapabilities: InboundGrantCapabilities{TokenExchange: true, JWTBearer: true}, wantSPIFFEClients: 1, + }, + { + name: "released legacy JSON", + input: `{"delegate_clients":[{"client_id":"delegate","scopes":["openid"],"audiences":["resource"]}],"trusted_issuers":[{"issuer_url":"https://idp.example.com","expected_audience":"resource","allowed_actors":["agent"],"allowed_delegate_clients":["delegate"],"jwt_bearer_grant":{"max_assertion_age":"5m","subject_bindings":[{"subject":"workload","allowed_resources":["resource"]}]}}]}`, + unmarshal: func(data []byte, cfg *RunConfig) error { return json.Unmarshal(data, cfg) }, + marshal: func(cfg *RunConfig) ([]byte, error) { return json.Marshal(cfg) }, + serializedFields: []string{`"delegate_clients"`, `"trusted_issuers"`, `"expected_audience"`, `"jwt_bearer_grant"`}, + wantCapabilities: InboundGrantCapabilities{TokenExchange: true, JWTBearer: true}, + }, + { + name: "released legacy YAML", + input: "delegate_clients:\n - client_id: delegate\n scopes: [openid]\n audiences: [resource]\ntrusted_issuers:\n - issuer_url: https://idp.example.com\n expected_audience: resource\n allowed_actors: [agent]\n allowed_delegate_clients: [delegate]\n jwt_bearer_grant:\n max_assertion_age: 5m\n subject_bindings:\n - subject: workload\n allowed_resources: [resource]\n", + unmarshal: func(data []byte, cfg *RunConfig) error { return yaml.Unmarshal(data, cfg) }, + marshal: func(cfg *RunConfig) ([]byte, error) { return yaml.Marshal(cfg) }, + serializedFields: []string{"delegate_clients:", "trusted_issuers:", "expected_audience:", "jwt_bearer_grant:"}, + wantCapabilities: InboundGrantCapabilities{TokenExchange: true, JWTBearer: true}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var cfg RunConfig + require.NoError(t, tt.unmarshal([]byte(tt.input), &cfg)) + + normalized, err := NormalizeInboundGrants(&cfg) + require.NoError(t, err) + assert.Equal(t, tt.wantCapabilities, normalized.Capabilities) + gotSPIFFEClients := 0 + if cfg.InboundGrants != nil { + gotSPIFFEClients = len(cfg.InboundGrants.SPIFFEClientAuth) + } + assert.Equal(t, tt.wantSPIFFEClients, gotSPIFFEClients) + + encoded, err := tt.marshal(&cfg) + require.NoError(t, err) + for _, field := range tt.serializedFields { + assert.Contains(t, string(encoded), field) + } + + var roundTripped RunConfig + require.NoError(t, tt.unmarshal(encoded, &roundTripped)) + if len(cfg.Upstreams) == 0 && len(roundTripped.Upstreams) == 0 { + cfg.Upstreams = nil + roundTripped.Upstreams = nil + } + if len(cfg.AllowedAudiences) == 0 && len(roundTripped.AllowedAudiences) == 0 { + cfg.AllowedAudiences = nil + roundTripped.AllowedAudiences = nil + } + assert.Equal(t, cfg, roundTripped) + }) + } +} + +func cloneRunConfigForTest(t *testing.T, cfg *RunConfig) *RunConfig { + t.Helper() + data, err := json.Marshal(cfg) + require.NoError(t, err) + var cloned RunConfig + require.NoError(t, json.Unmarshal(data, &cloned)) + return &cloned +} diff --git a/pkg/authserver/runner/embeddedauthserver.go b/pkg/authserver/runner/embeddedauthserver.go index fa7ce6cf57..e9e09f22f3 100644 --- a/pkg/authserver/runner/embeddedauthserver.go +++ b/pkg/authserver/runner/embeddedauthserver.go @@ -83,11 +83,6 @@ func NewEmbeddedAuthServer(ctx context.Context, cfg *authserver.RunConfig) (*Emb if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("invalid run config: %w", err) } - delegateClients, err := resolveDelegateClients(cfg.DelegateClients) - if err != nil { - return nil, err - } - // Create the storage backend FIRST so the DCR resolver and the auth // server share the same persistence. Both MemoryStorage and RedisStorage // satisfy storage.DCRCredentialStore (verified by package-level var _ @@ -100,7 +95,7 @@ func NewEmbeddedAuthServer(ctx context.Context, cfg *authserver.RunConfig) (*Emb if err != nil { return nil, fmt.Errorf("failed to create storage: %w", err) } - return newEmbeddedAuthServerWithStorage(ctx, cfg, stor, delegateClients) + return newEmbeddedAuthServerWithStorage(ctx, cfg, stor, nil) } // NewEmbeddedAuthServerWithStorage is the exported core constructor that @@ -141,6 +136,33 @@ func NewEmbeddedAuthServerWithStorage( return newEmbeddedAuthServerWithStorage(ctx, cfg, stor, nil) } +func prepareInboundGrantConfiguration( + cfg *authserver.RunConfig, + delegateClients []authserver.DelegateClient, +) (*authserver.NormalizedInboundGrants, []authserver.DelegateClient, *authserver.SPIFFETrustConfig, error) { + normalized, err := authserver.NormalizeInboundGrants(cfg) + if err != nil { + return nil, nil, nil, fmt.Errorf("normalize inbound grants: %w", err) + } + if delegateClients == nil && len(normalized.DelegateClients) > 0 { + delegateClients, err = resolveDelegateClients(normalized.DelegateClients) + if err != nil { + return nil, nil, nil, err + } + } + // SPIFFE client authentication is independent of the legacy/canonical + // token-exchange projection above: it is read straight from cfg.InboundGrants, + // never through NormalizedInboundGrants, so authentication method and + // grant-family enablement stay separately configurable. + spiffeTrust, err := authserver.NewSPIFFETrustConfig( + cfg.SPIFFETrustDomains, cfg.InboundGrants, cfg.ScopesSupported, cfg.AllowedAudiences, + ) + if err != nil { + return nil, nil, nil, fmt.Errorf("build SPIFFE trust config: %w", err) + } + return normalized, delegateClients, spiffeTrust, nil +} + func newEmbeddedAuthServerWithStorage( ctx context.Context, cfg *authserver.RunConfig, @@ -176,8 +198,13 @@ func newEmbeddedAuthServerWithStorage( // otherwise skip the check. Placed inside the deferred-cleanup gate above so // a validation failure still closes the caller-supplied storage per the // resource-ownership contract. - var err error - delegateClients, err = validateAndResolveDelegateClients(cfg, delegateClients) + if cfg == nil { + return nil, fmt.Errorf("config is required") + } + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("invalid run config: %w", err) + } + normalized, delegateClients, spiffeTrust, err := prepareInboundGrantConfiguration(cfg, delegateClients) if err != nil { return nil, err } @@ -240,18 +267,11 @@ func newEmbeddedAuthServerWithStorage( // for BaselineClientScopes, low cardinality in practice for the others). cimdEnabled, cimdCacheMaxSize, cimdCacheFallbackTTL := resolveCIMDConfig(cfg.CIMD) - trustedIssuers, err := tokenexchange.ResolveJWTBearerGrantPolicies(cfg.TrustedIssuers) + trustedIssuers, err := tokenexchange.ResolveJWTBearerGrantPolicies(normalized.TrustedIssuers) if err != nil { 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, @@ -277,9 +297,10 @@ func newEmbeddedAuthServerWithStorage( // slice is still shared with cfg. NewMultiIssuerTokenValidator's // constructor clones AllowedActors per issuer before use, so the // authorization-critical data is protected without a deep copy here. - TrustedIssuers: trustedIssuers, - DelegateClients: delegateClients, - SPIFFETrust: spiffeTrust, + TrustedIssuers: trustedIssuers, + DelegateClients: delegateClients, + DisableTokenExchange: !normalized.Capabilities.TokenExchange, + SPIFFETrust: spiffeTrust, } // 8. Create the auth server. authserver.New also asserts the DCR @@ -732,24 +753,6 @@ func resolveSecret(file, envVar string) (string, error) { return "", nil } -// validateAndResolveDelegateClients validates cfg and resolves delegate-client -// secret references for direct constructor callers. -func validateAndResolveDelegateClients( - cfg *authserver.RunConfig, - delegateClients []authserver.DelegateClient, -) ([]authserver.DelegateClient, error) { - if cfg == nil { - return nil, fmt.Errorf("config is required") - } - if err := cfg.Validate(); err != nil { - return nil, fmt.Errorf("invalid run config: %w", err) - } - if delegateClients != nil || len(cfg.DelegateClients) == 0 { - return delegateClients, nil - } - return resolveDelegateClients(cfg.DelegateClients) -} - // resolveDelegateClients resolves secret references and copies authorization // permissions before configuration crosses into the running authorization server. func resolveDelegateClients(clients []authserver.DelegateClientRunConfig) ([]authserver.DelegateClient, error) { diff --git a/pkg/authserver/runner/embeddedauthserver_test.go b/pkg/authserver/runner/embeddedauthserver_test.go index 6ad4286862..a1479cc669 100644 --- a/pkg/authserver/runner/embeddedauthserver_test.go +++ b/pkg/authserver/runner/embeddedauthserver_test.go @@ -2344,6 +2344,175 @@ func TestNewEmbeddedAuthServer_TrustedIssuers(t *testing.T) { }) } +// TestNewEmbeddedAuthServer_CanonicalInboundGrants pins the +// RunConfig.InboundGrants -> Config wiring added by +// authserver.NormalizeInboundGrants and prepareInboundGrantConfiguration: a +// canonical delegate client and SPIFFE client resolve through the same +// startup path as their legacy equivalents, canonical jwt_bearer policy +// reaches discovery, and inbound_grants.token_exchange being explicitly +// omitted (while another family is set) actually disables and stops +// advertising RFC 8693 rather than merely being validated in isolation +// (TestNormalizeInboundGrants, in pkg/authserver, already covers the pure +// normalization; this proves it reaches a running server). +func TestNewEmbeddedAuthServer_CanonicalInboundGrants(t *testing.T) { + t.Parallel() + + base := func() *authserver.RunConfig { + return &authserver.RunConfig{ + SchemaVersion: authserver.CurrentSchemaVersion, + Issuer: "https://auth.example.com", + Upstreams: []authserver.UpstreamRunConfig{ + { + Name: "test-upstream", + Type: authserver.UpstreamProviderTypeOAuth2, + OAuth2Config: &authserver.OAuth2UpstreamRunConfig{ + AuthorizationEndpoint: "https://example.com/authorize", + TokenEndpoint: "https://example.com/token", + ClientID: "test-client-id", + RedirectURI: "https://auth.example.com/oauth/callback", + }, + }, + }, + ScopesSupported: []string{"openid"}, + AllowedAudiences: []string{"https://mcp.example.com"}, + } + } + + discoveryGrantTypes := func(t *testing.T, srv *EmbeddedAuthServer) []string { + t.Helper() + handler, ok := srv.Routes()["/.well-known/oauth-authorization-server"] + require.True(t, ok, "discovery route must be registered") + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/.well-known/oauth-authorization-server", nil) + handler.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + var metadata oauthproto.AuthorizationServerMetadata + require.NoError(t, json.NewDecoder(rec.Body).Decode(&metadata)) + return metadata.GrantTypesSupported + } + + t.Run("canonical delegate client alone builds a server with token exchange enabled", func(t *testing.T) { + t.Parallel() + + secretFile := filepath.Join(t.TempDir(), "delegate-secret") + require.NoError(t, os.WriteFile(secretFile, []byte("s3cr3t-value-that-is-long-enough-for-32-chars"), 0o600)) + + cfg := base() + cfg.InboundGrants = &authserver.InboundGrantsRunConfig{ + TokenExchange: &authserver.TokenExchangeInboundGrantRunConfig{ + DelegateClients: []authserver.DelegateClientRunConfig{{ + ClientID: "coding-agent", + ClientSecretFile: secretFile, + Scopes: []string{"openid"}, + Audiences: []string{"https://mcp.example.com"}, + }}, + }, + } + + srv, err := NewEmbeddedAuthServer(context.Background(), cfg) + require.NoError(t, err) + require.NotNil(t, srv) + t.Cleanup(func() { _ = srv.Close() }) + + assert.Contains(t, discoveryGrantTypes(t, srv), oauthproto.GrantTypeTokenExchange, + "canonical delegate client must not disable token exchange") + }) + + // A SPIFFE-only configuration cannot be exercised through + // NewEmbeddedAuthServer here: RunConfig.Validate() hard-rejects a + // non-empty spiffe_trust_domains until a real SVID-verification + // consumer lands (see config.go's validateSPIFFENotYetEnforced). This + // test instead calls prepareInboundGrantConfiguration directly -- the + // package-private function NewEmbeddedAuthServer would otherwise reach + // after cfg.Validate() -- to prove the underlying capability logic + // still holds: SPIFFE client-auth presence must force + // Capabilities.TokenExchange true independent of the legacy/canonical + // token-exchange projection, so a SPIFFE-only config does not silently + // disable the RFC 8693 grant handler once the hard-reject above is + // lifted. + t.Run("SPIFFE client auth alone sets the token exchange capability", func(t *testing.T) { + t.Parallel() + + cfg := base() + cfg.SPIFFETrustDomains = []authserver.SPIFFETrustDomainRunConfig{{ + Name: "prod", + TrustDomain: "example.org", + Methods: []authserver.SPIFFEAuthenticationMethod{authserver.SPIFFEAuthenticationMethodX509}, + BundleSource: authserver.SPIFFEBundleSourceRunConfig{ + Type: authserver.SPIFFEBundleSourceTypeWorkloadAPI, + WorkloadAPI: &authserver.SPIFFEWorkloadAPIBundleSourceRunConfig{}, + }, + }} + cfg.InboundGrants = &authserver.InboundGrantsRunConfig{ + SPIFFEClientAuth: []authserver.SPIFFEClientAuthRunConfig{{ + TrustDomainRef: "prod", + PrincipalPattern: "spiffe://example.org/ns/default/agent", + ClientID: "spiffe-agent", + Methods: []authserver.SPIFFEAuthenticationMethod{authserver.SPIFFEAuthenticationMethodX509}, + Scopes: []string{"openid"}, + Audiences: []string{"https://mcp.example.com"}, + GrantTypes: []string{authserver.SPIFFEGrantTypeTokenExchange}, + }}, + } + + normalized, _, spiffeTrust, err := prepareInboundGrantConfiguration(cfg, nil) + require.NoError(t, err) + require.NotNil(t, spiffeTrust) + assert.True(t, normalized.Capabilities.TokenExchange, + "SPIFFE client auth alone must not leave the token exchange capability disabled") + }) + + t.Run("inbound_grants with token_exchange omitted disables and stops advertising RFC 8693", func(t *testing.T) { + t.Parallel() + + cfg := base() + cfg.TrustedIssuers = []tokenexchange.TrustedIssuer{{Name: "idp", IssuerURL: "https://idp.example.com"}} + cfg.InboundGrants = &authserver.InboundGrantsRunConfig{ + JWTBearer: &authserver.JWTBearerInboundGrantRunConfig{ + IssuerPolicies: []authserver.JWTBearerIssuerPolicyRunConfig{{ + IssuerRef: "idp", + MaxAssertionAge: "5m", + SubjectBindings: []tokenexchange.JWTBearerSubjectBinding{{ + Subject: "workload", + AllowedResources: []string{"https://mcp.example.com"}, + }}, + }}, + }, + } + + srv, err := NewEmbeddedAuthServer(context.Background(), cfg) + require.NoError(t, err) + require.NotNil(t, srv) + t.Cleanup(func() { _ = srv.Close() }) + + grantTypes := discoveryGrantTypes(t, srv) + assert.NotContains(t, grantTypes, oauthproto.GrantTypeTokenExchange, + "inbound_grants present with token_exchange omitted must disable RFC 8693") + assert.Contains(t, grantTypes, oauthproto.GrantTypeJWTBearer, + "canonical jwt_bearer policy must still be advertised") + }) + + t.Run("canonical token exchange conflicts with legacy delegate_clients", func(t *testing.T) { + t.Parallel() + + cfg := base() + cfg.DelegateClients = []authserver.DelegateClientRunConfig{{ + ClientID: "legacy-delegate", + Scopes: []string{"openid"}, + Audiences: []string{"https://mcp.example.com"}, + }} + cfg.InboundGrants = &authserver.InboundGrantsRunConfig{ + TokenExchange: &authserver.TokenExchangeInboundGrantRunConfig{}, + } + + _, err := NewEmbeddedAuthServer(context.Background(), cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "conflicts with legacy delegate_clients") + }) +} + func TestResolveCIMDConfig(t *testing.T) { t.Parallel() diff --git a/pkg/authserver/server/handlers/dcr.go b/pkg/authserver/server/handlers/dcr.go index 0366da49b7..20fd6de122 100644 --- a/pkg/authserver/server/handlers/dcr.go +++ b/pkg/authserver/server/handlers/dcr.go @@ -237,9 +237,27 @@ func (h *Handler) validateDCRRequest( ) (*oauthproto.DynamicClientRegistrationRequest, *registration.DCRError) { validated, dcrErr := registration.ValidateDCRRequest( req, h.config.AllowConfidentialClientRegistration, h.config.AllowPrivateKeyJWTRegistration) - if dcrErr != nil || !h.tokenOnly { + if dcrErr != nil { return validated, dcrErr } + // validated.GrantTypes is the effective, post-defaulting grant set (e.g. an + // empty grant_types on a private_key_jwt request defaults to token exchange + // inside registration.validateGrantTypes), so this check catches both an + // explicit and an implicit token-exchange request. Without it, DCR would + // register a client whose GetGrantTypes() durably includes token exchange + // even though the RFC 8693 factory is never registered at the token + // endpoint when disabled, and every token request against that client + // would fail confusingly with unsupported_grant_type instead of being + // rejected here at registration time. + if !h.config.TokenExchangeEnabled && slices.Contains(validated.GrantTypes, oauthproto.GrantTypeTokenExchange) { + return nil, ®istration.DCRError{ + Error: registration.DCRErrorInvalidClientMetadata, + ErrorDescription: "token exchange is disabled on this authorization server", + } + } + if !h.tokenOnly { + return validated, nil + } if validated.TokenEndpointAuthMethod == oauthproto.TokenEndpointAuthMethodPrivateKeyJWT && slices.Contains(validated.GrantTypes, oauthproto.GrantTypeTokenExchange) { return validated, nil diff --git a/pkg/authserver/server/handlers/dcr_test.go b/pkg/authserver/server/handlers/dcr_test.go index 655cec4a7a..46e1a26a9c 100644 --- a/pkg/authserver/server/handlers/dcr_test.go +++ b/pkg/authserver/server/handlers/dcr_test.go @@ -190,6 +190,7 @@ func testRegisterClientHandlerPrivateKeyJWTResponseAndClient(t *testing.T, token Config: &fosite.Config{AccessTokenIssuer: "https://test-authserver"}, ScopesSupported: registration.DefaultScopes, AllowPrivateKeyJWTRegistration: true, + TokenExchangeEnabled: true, }, } body, err := json.Marshal(oauthproto.DynamicClientRegistrationRequest{ @@ -233,6 +234,107 @@ func testRegisterClientHandlerPrivateKeyJWTResponseAndClient(t *testing.T, token assert.Equal(t, jwks.Keys[0].Algorithm, oidc.GetJSONWebKeys().Keys[0].Algorithm) assert.Equal(t, jwks.Keys[0].Use, oidc.GetJSONWebKeys().Keys[0].Use) } + +// TestRegisterClientHandler_TokenExchangeDisabledRejectsGrant confirms that DCR +// rejects a registration whose effective grant types include RFC 8693 token +// exchange when the server has token exchange disabled, both when the client +// requests the grant explicitly and when it is defaulted implicitly for a +// private_key_jwt client. It also confirms the same request succeeds when +// token exchange is enabled, so the working path is unaffected. +func TestRegisterClientHandler_TokenExchangeDisabledRejectsGrant(t *testing.T) { + t.Parallel() + + jwks := &jose.JSONWebKeySet{Keys: []jose.JSONWebKey{{ + Key: testRSAPublicKey(t), + KeyID: "handler-key", + Use: "sig", + Algorithm: string(jose.RS256), + }}} + + tests := []struct { + name string + requestBody oauthproto.DynamicClientRegistrationRequest + tokenExchangeEnabled bool + expectedStatus int + expectedErrDesc string // non-empty means expect an error + }{ + { + name: "explicit token-exchange grant rejected when disabled", + requestBody: oauthproto.DynamicClientRegistrationRequest{ + RedirectURIs: []string{"https://example.com/callback"}, + GrantTypes: []string{oauthproto.GrantTypeTokenExchange}, + TokenEndpointAuthMethod: oauthproto.TokenEndpointAuthMethodPrivateKeyJWT, + JWKS: jwks, + TokenEndpointAuthSigningAlg: string(jose.RS256), + }, + tokenExchangeEnabled: false, + expectedStatus: http.StatusBadRequest, + expectedErrDesc: "token exchange is disabled", + }, + { + name: "implicit token-exchange default rejected when disabled", + requestBody: oauthproto.DynamicClientRegistrationRequest{ + RedirectURIs: []string{"https://example.com/callback"}, + TokenEndpointAuthMethod: oauthproto.TokenEndpointAuthMethodPrivateKeyJWT, + JWKS: jwks, + TokenEndpointAuthSigningAlg: string(jose.RS256), + }, + tokenExchangeEnabled: false, + expectedStatus: http.StatusBadRequest, + expectedErrDesc: "token exchange is disabled", + }, + { + name: "same request succeeds when token exchange enabled", + requestBody: oauthproto.DynamicClientRegistrationRequest{ + RedirectURIs: []string{"https://example.com/callback"}, + GrantTypes: []string{oauthproto.GrantTypeTokenExchange}, + TokenEndpointAuthMethod: oauthproto.TokenEndpointAuthMethodPrivateKeyJWT, + JWKS: jwks, + TokenEndpointAuthSigningAlg: string(jose.RS256), + }, + tokenExchangeEnabled: true, + expectedStatus: http.StatusCreated, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + stor := mocks.NewMockStorage(ctrl) + if tc.expectedStatus == http.StatusCreated { + stor.EXPECT().RegisterClient(gomock.Any(), gomock.Any()).Return(nil) + } + handler := &Handler{ + storage: stor, + config: &server.AuthorizationServerConfig{ + Config: &fosite.Config{AccessTokenIssuer: "https://test-authserver"}, + ScopesSupported: registration.DefaultScopes, + AllowPrivateKeyJWTRegistration: true, + TokenExchangeEnabled: tc.tokenExchangeEnabled, + }, + } + + body, err := json.Marshal(tc.requestBody) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/oauth/register", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.RegisterClientHandler(w, req) + + require.Equal(t, tc.expectedStatus, w.Code) + if tc.expectedErrDesc != "" { + var errResp registration.DCRError + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errResp)) + assert.Equal(t, registration.DCRErrorInvalidClientMetadata, errResp.Error) + assert.Contains(t, errResp.ErrorDescription, tc.expectedErrDesc) + } + }) + } +} + func TestRegisterClientHandler_ScopeInResponse(t *testing.T) { t.Parallel() diff --git a/pkg/authserver/server/handlers/discovery.go b/pkg/authserver/server/handlers/discovery.go index 0b6af499ec..a69a3b1226 100644 --- a/pkg/authserver/server/handlers/discovery.go +++ b/pkg/authserver/server/handlers/discovery.go @@ -164,15 +164,12 @@ func (h *Handler) tokenEndpointAuthSigningAlgorithms() []string { return registration.SupportedSigningAlgorithms() } -// grantTypesSupported returns the grant_types_supported list for discovery. -// RFC 8693 token exchange is always registered with fosite (buildProvider wires -// it unconditionally, even with no trusted issuers, to preserve -// self-issued token exchange), so it's always advertised. The RFC 7523 -// JWT-bearer grant, by contrast, is only registered when at least one -// trusted issuer opts in — advertising it unconditionally would claim -// support the token endpoint doesn't actually have. +// grantTypesSupported returns only grant families registered with fosite. func (h *Handler) grantTypesSupported() []string { - grantTypes := []string{sharedobauth.GrantTypeTokenExchange} + grantTypes := make([]string, 0, 4) + if h.config.TokenExchangeEnabled { + grantTypes = append(grantTypes, sharedobauth.GrantTypeTokenExchange) + } if !h.tokenOnly { grantTypes = append(grantTypes, string(fosite.GrantTypeAuthorizationCode), diff --git a/pkg/authserver/server/handlers/handlers_test.go b/pkg/authserver/server/handlers/handlers_test.go index d5ecf58d0a..411a4f9295 100644 --- a/pkg/authserver/server/handlers/handlers_test.go +++ b/pkg/authserver/server/handlers/handlers_test.go @@ -20,6 +20,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "slices" "testing" "time" @@ -43,6 +44,7 @@ type testSetupOptions struct { AllowConfidentialClientRegistration bool AllowPrivateKeyJWTRegistration bool HasStaticDelegateClients bool + DisableTokenExchange bool JWTBearerGrantEnabled bool } @@ -76,6 +78,7 @@ func testSetupWithOptions(t *testing.T, opts testSetupOptions) *Handler { AllowConfidentialClientRegistration: opts.AllowConfidentialClientRegistration, AllowPrivateKeyJWTRegistration: opts.AllowPrivateKeyJWTRegistration, HasStaticDelegateClients: opts.HasStaticDelegateClients, + DisableTokenExchange: opts.DisableTokenExchange, JWTBearerGrantEnabled: opts.JWTBearerGrantEnabled, AccessTokenLifespan: time.Hour, RefreshTokenLifespan: time.Hour * 24, @@ -430,19 +433,25 @@ func TestWellKnownRoutes(t *testing.T) { } } -func TestDiscoveryHandlers_JWTBearerGrant(t *testing.T) { +func TestDiscoveryHandlers_InboundGrantCapabilities(t *testing.T) { t.Parallel() for _, tc := range []struct { - name string - enabled bool + name string + disableTokenExchange bool + jwtBearerGrantEnabled bool }{ - {"enabled", true}, - {"disabled", false}, + {name: "historical token exchange default"}, + {name: "token exchange and JWT bearer", jwtBearerGrantEnabled: true}, + {name: "all inbound grants disabled", disableTokenExchange: true}, + {name: "JWT bearer independent of token exchange", disableTokenExchange: true, jwtBearerGrantEnabled: true}, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() - handler := testSetupWithOptions(t, testSetupOptions{JWTBearerGrantEnabled: tc.enabled}) + handler := testSetupWithOptions(t, testSetupOptions{ + DisableTokenExchange: tc.disableTokenExchange, + JWTBearerGrantEnabled: tc.jwtBearerGrantEnabled, + }) for _, endpoint := range []struct { name string @@ -460,11 +469,10 @@ func TestDiscoveryHandlers_JWTBearerGrant(t *testing.T) { var meta sharedobauth.AuthorizationServerMetadata require.NoError(t, json.NewDecoder(rec.Body).Decode(&meta)) - if tc.enabled { - assert.Contains(t, meta.GrantTypesSupported, sharedobauth.GrantTypeJWTBearer) - } else { - assert.NotContains(t, meta.GrantTypesSupported, sharedobauth.GrantTypeJWTBearer) - } + assert.Equal(t, !tc.disableTokenExchange, + slices.Contains(meta.GrantTypesSupported, sharedobauth.GrantTypeTokenExchange)) + assert.Equal(t, tc.jwtBearerGrantEnabled, + slices.Contains(meta.GrantTypesSupported, sharedobauth.GrantTypeJWTBearer)) }) } }) diff --git a/pkg/authserver/server/provider.go b/pkg/authserver/server/provider.go index e37edfbb7b..420c1e3082 100644 --- a/pkg/authserver/server/provider.go +++ b/pkg/authserver/server/provider.go @@ -101,6 +101,8 @@ type AuthorizationServerConfig struct { // auth method. See authserver.Config.ForceConfidentialRedirectURIs for the // full semantics. ForceConfidentialRedirectURIs []string + // TokenExchangeEnabled indicates whether RFC 8693 is registered and advertised. + TokenExchangeEnabled bool // JWTBearerGrantEnabled indicates that at least one trusted issuer has the // RFC 7523 JWT-bearer grant configured. Discovery advertises // urn:ietf:params:oauth:grant-type:jwt-bearer in grant_types_supported @@ -162,6 +164,9 @@ type AuthorizationServerParams struct { // auth method. See authserver.Config.ForceConfidentialRedirectURIs for the // full semantics. ForceConfidentialRedirectURIs []string + // DisableTokenExchange prevents RFC 8693 registration and advertisement. + // The zero value preserves the historical enabled behavior. + DisableTokenExchange bool // JWTBearerGrantEnabled indicates that at least one trusted issuer has the // RFC 7523 JWT-bearer grant configured. See AuthorizationServerConfig's // field of the same name. @@ -332,6 +337,7 @@ func NewAuthorizationServerConfig(cfg *AuthorizationServerParams) (*Authorizatio AllowPrivateKeyJWTRegistration: cfg.AllowPrivateKeyJWTRegistration, HasStaticDelegateClients: cfg.HasStaticDelegateClients, ForceConfidentialRedirectURIs: cfg.ForceConfidentialRedirectURIs, + TokenExchangeEnabled: !cfg.DisableTokenExchange, JWTBearerGrantEnabled: cfg.JWTBearerGrantEnabled, }, nil } diff --git a/pkg/authserver/server/provider_test.go b/pkg/authserver/server/provider_test.go index 052cae3000..92213f8a15 100644 --- a/pkg/authserver/server/provider_test.go +++ b/pkg/authserver/server/provider_test.go @@ -54,6 +54,7 @@ func TestNewAuthorizationServerConfig(t *testing.T) { assert.Equal(t, params.AccessTokenLifespan, authzServerConfig.AccessTokenLifespan) assert.Equal(t, params.RefreshTokenLifespan, authzServerConfig.RefreshTokenLifespan) assert.Equal(t, params.AuthCodeLifespan, authzServerConfig.AuthorizeCodeLifespan) + assert.True(t, authzServerConfig.TokenExchangeEnabled, "zero-value params preserve released token exchange behavior") // Verify signing key is set require.NotNil(t, authzServerConfig.SigningKey) @@ -78,11 +79,14 @@ func TestNewAuthorizationServerConfig_ConfidentialClientCapabilities(t *testing. allowConfidential bool allowPrivateKeyJWT bool hasStaticDelegateClient bool + disableTokenExchange bool + jwtBearerGrantEnabled bool }{ {name: "public only", allowConfidential: false, hasStaticDelegateClient: false}, {name: "confidential DCR", allowConfidential: true, hasStaticDelegateClient: false}, {name: "private-key JWT registration", allowPrivateKeyJWT: true, hasStaticDelegateClient: false}, {name: "static delegate client", allowConfidential: false, hasStaticDelegateClient: true}, + {name: "JWT bearer without token exchange", disableTokenExchange: true, jwtBearerGrantEnabled: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -99,11 +103,15 @@ func TestNewAuthorizationServerConfig_ConfidentialClientCapabilities(t *testing. AllowConfidentialClientRegistration: tt.allowConfidential, AllowPrivateKeyJWTRegistration: tt.allowPrivateKeyJWT, HasStaticDelegateClients: tt.hasStaticDelegateClient, + DisableTokenExchange: tt.disableTokenExchange, + JWTBearerGrantEnabled: tt.jwtBearerGrantEnabled, }) require.NoError(t, err) assert.Equal(t, tt.allowConfidential, config.AllowConfidentialClientRegistration) assert.Equal(t, tt.allowPrivateKeyJWT, config.AllowPrivateKeyJWTRegistration) assert.Equal(t, tt.hasStaticDelegateClient, config.HasStaticDelegateClients) + assert.Equal(t, !tt.disableTokenExchange, config.TokenExchangeEnabled) + assert.Equal(t, tt.jwtBearerGrantEnabled, config.JWTBearerGrantEnabled) }) } } diff --git a/pkg/authserver/server/tokenexchange/handler_test.go b/pkg/authserver/server/tokenexchange/handler_test.go index bdc300c609..a15c777ed6 100644 --- a/pkg/authserver/server/tokenexchange/handler_test.go +++ b/pkg/authserver/server/tokenexchange/handler_test.go @@ -1902,6 +1902,68 @@ func TestTokenExchangeHandler_ActChainProvenance(t *testing.T) { }) } +// TestBuildActClaim_NestedActOnlyCarriesSubWhenAnActorWasResolved locks in +// the correct RFC 8693 §4.1 invariant for the nested act.act object: "sub" +// is not a required member (the RFC only gives "iss"+"sub" as an example of +// what "might be necessary to uniquely identify an actor"), so it must be +// present when an external actor was actually resolved (the allowlist +// path's ExternalActor) and absent otherwise (the may_act-bearing path, +// which names its delegate directly via the outermost act.sub instead — +// see resolveActorIdentity and checkDelegationConsent). A "sub" claim was +// previously, incorrectly, backfilled from the external subject token's +// own subject on the no-ExternalActor path; that misrepresented the +// external principal as a prior actor in the delegation chain (they never +// acted) and reintroduced their bare, unqualified subject into a claim +// audit tooling parses as "acting parties" (see delegatedSubject's doc +// comment for why the qualified form exists in the first place). +func TestBuildActClaim_NestedActOnlyCarriesSubWhenAnActorWasResolved(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + claims *ValidatedClaims + wantSub string + wantNoSub bool + }{ + { + name: "allowlist path nests the allowlisted external actor", + claims: &ValidatedClaims{ + Subject: "ext-user-456", + ExternalIssuer: testExternalIssuer, + ExternalActor: "ext-agent", + }, + wantSub: "ext-agent", + }, + { + name: "may_act path with no ExternalActor omits the nested sub", + claims: &ValidatedClaims{ + Subject: "ext-user-456", + ExternalIssuer: testExternalIssuer, + ExternalActor: "", + }, + wantNoSub: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + act, err := buildActClaim(tt.claims, testIssuer, testAgentClientID) + require.NoError(t, err) + + nested, ok := act["act"].(map[string]any) + require.True(t, ok, "external delegation must nest under act.act") + assert.Equal(t, testExternalIssuer, nested["iss"]) + if tt.wantNoSub { + _, hasSub := nested["sub"] + assert.False(t, hasSub, "no actor was resolved, so the nested act must not carry a sub") + return + } + assert.Equal(t, tt.wantSub, nested["sub"]) + }) + } +} + func TestChainToAct_EmptyIdentityHop(t *testing.T) { t.Parallel() diff --git a/pkg/authserver/server/tokenexchange/multi_issuer_validator.go b/pkg/authserver/server/tokenexchange/multi_issuer_validator.go index 3fd92dc733..70279fe7dd 100644 --- a/pkg/authserver/server/tokenexchange/multi_issuer_validator.go +++ b/pkg/authserver/server/tokenexchange/multi_issuer_validator.go @@ -147,6 +147,8 @@ type JWTBearerGrantPolicy struct { // into docs/server/swagger.*; adding, renaming, or retagging a field here is // a schema change, not a purely internal one. type TrustedIssuer struct { + // Name optionally identifies this trust declaration for canonical issuer_ref references. + Name string `json:"name,omitempty" yaml:"name,omitempty"` // 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 @@ -154,6 +156,9 @@ type TrustedIssuer struct { // not a client ID — required for delegation unless JWTBearerGrant is // configured; see looksLikeResourceIdentifier). RFC 7523 assertions use // the token endpoint as their audience instead. + // + // This legacy field is deprecated; configure RFC 8693 policy under + // inbound_grants.token_exchange.issuer_policies. // 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"` @@ -223,6 +228,9 @@ type TrustedIssuer struct { // It accepts assertions from this issuer without client authentication and // limits their maximum age, subjects, and RFC 8707 resources. It is // independent from RFC 8693 delegation policy. + // + // This legacy field is deprecated; configure RFC 7523 policy under + // inbound_grants.jwt_bearer.issuer_policies. JWTBearerGrant *JWTBearerGrantPolicy `json:"jwt_bearer_grant,omitempty" yaml:"jwt_bearer_grant,omitempty"` } diff --git a/pkg/authserver/server_impl.go b/pkg/authserver/server_impl.go index 48300be24a..c938b89926 100644 --- a/pkg/authserver/server_impl.go +++ b/pkg/authserver/server_impl.go @@ -203,6 +203,7 @@ func newServer(ctx context.Context, cfg Config, stor storage.Storage) (_ *server AllowPrivateKeyJWTRegistration: cfg.AllowPrivateKeyJWTRegistration, HasStaticDelegateClients: len(cfg.DelegateClients) > 0, ForceConfidentialRedirectURIs: cfg.ForceConfidentialRedirectURIs, + DisableTokenExchange: cfg.DisableTokenExchange, JWTBearerGrantEnabled: JWTBearerGrantEnabled(cfg.TrustedIssuers), } authServerConfig, err := oauthserver.NewAuthorizationServerConfig(oauthParams) @@ -357,12 +358,16 @@ func JWTBearerGrantEnabled(trustedIssuers []tokenexchange.TrustedIssuer) bool { } // buildProvider assembles the fosite OAuth2 provider, registering the RFC 8693 -// token-exchange handler as an extension grant alongside the standard grants. +// token-exchange handler and/or the RFC 7523 JWT-bearer handler as extension +// grants alongside the standard grants -- whichever of the two are enabled +// for cfg (token exchange can be disabled via canonical inbound grants +// configuration; JWT-bearer is enabled per JWTBearerGrantEnabled). // // It returns the shared MultiIssuerTokenValidator (nil when no TrustedIssuers -// are configured) so newServer can hold it and release its per-issuer JWKS -// worker pools on shutdown. On its own error paths it shuts that validator down -// before returning, since the caller never receives it. +// are configured, or when neither enabled grant would use it) so newServer +// can hold it and release its per-issuer JWKS worker pools on shutdown. On +// its own error paths it shuts that validator down before returning, since +// the caller never receives it. func buildProvider( cfg Config, authServerConfig *oauthserver.AuthorizationServerConfig, stor storage.Storage, ) (_ fosite.OAuth2Provider, _ *tokenexchange.MultiIssuerTokenValidator, retErr error) { @@ -372,19 +377,28 @@ func buildProvider( } jwtBearerEnabled := JWTBearerGrantEnabled(cfg.TrustedIssuers) - // Built once, up front, whenever any trusted issuer is configured, and - // handed to both factories below: otherwise each factory closure would - // build its own MultiIssuerTokenValidator over the same trusted issuers at + // Built once, up front, and handed to whichever factories below actually + // need it: otherwise each factory closure would build its own + // MultiIssuerTokenValidator over the same trusted issuers at // fosite-compose time, doubling every issuer's JWKS cache and background // refresh goroutines — and, buried in a handler, leaving them unreachable // for shutdown. authServerConfig is the exact *AuthorizationServerConfig // each factory closure would otherwise receive at call time (see // createProvider/NewAuthorizationServer), so building it here first is - // equivalent. NewSharedTrustedIssuerValidator returns nil when there are no - // trusted issuers. - shared, err := tokenexchange.NewSharedTrustedIssuerValidator(authServerConfig, cfg.TrustedIssuers) - if err != nil { - return nil, nil, fmt.Errorf("failed to create shared trusted-issuer validator: %w", err) + // equivalent. Skipped entirely (shared stays nil) when neither grant that + // consumes it is enabled -- trusted issuers configured with token + // exchange disabled and JWT-bearer not enabled is a reachable, if + // pointless, configuration, and building the validator anyway would + // start live per-issuer JWKS refresh goroutines with no consumer for the + // life of the server. NewSharedTrustedIssuerValidator itself also + // returns nil when there are no trusted issuers. + var shared *tokenexchange.MultiIssuerTokenValidator + if !cfg.DisableTokenExchange || jwtBearerEnabled { + var err error + shared, err = tokenexchange.NewSharedTrustedIssuerValidator(authServerConfig, cfg.TrustedIssuers) + if err != nil { + return nil, nil, fmt.Errorf("failed to create shared trusted-issuer validator: %w", err) + } } // Release the validator's JWKS worker pools if we fail before returning it // to newServer, which otherwise owns its shutdown. @@ -396,23 +410,23 @@ func buildProvider( } }() - tokenExchangeFactory, err := tokenexchange.FactoryWithSharedTrustedIssuerValidator( - cfg.DelegationTokenLifespan, cfg.TrustedIssuers, delegateClientIDs, shared) - if err != nil { - return nil, nil, fmt.Errorf("failed to create token exchange factory: %w", err) - } - if !jwtBearerEnabled { - provider, err := createProvider(authServerConfig, stor, tokenExchangeFactory) + factories := make([]oauthserver.Factory, 0, 2) + if !cfg.DisableTokenExchange { + tokenExchangeFactory, err := tokenexchange.FactoryWithSharedTrustedIssuerValidator( + cfg.DelegationTokenLifespan, cfg.TrustedIssuers, delegateClientIDs, shared) if err != nil { - return nil, nil, err + return nil, nil, fmt.Errorf("failed to create token exchange factory: %w", err) } - return provider, shared, nil + factories = append(factories, tokenExchangeFactory) } - jwtBearerFactory, err := tokenexchange.JWTBearerIssuanceFactory(cfg.TrustedIssuers, shared) - if err != nil { - return nil, nil, fmt.Errorf("failed to create JWT-bearer factory: %w", err) + if jwtBearerEnabled { + jwtBearerFactory, err := tokenexchange.JWTBearerIssuanceFactory(cfg.TrustedIssuers, shared) + if err != nil { + return nil, nil, fmt.Errorf("failed to create JWT-bearer factory: %w", err) + } + factories = append(factories, jwtBearerFactory) } - provider, err := createProvider(authServerConfig, stor, tokenExchangeFactory, jwtBearerFactory) + provider, err := createProvider(authServerConfig, stor, factories...) if err != nil { return nil, nil, err } diff --git a/pkg/authserver/server_test.go b/pkg/authserver/server_test.go index f0a70b5765..900720efca 100644 --- a/pkg/authserver/server_test.go +++ b/pkg/authserver/server_test.go @@ -211,6 +211,67 @@ func TestNewServer_Success(t *testing.T) { } } +// TestNewServer_TrustedIssuerWithBothGrantsDisabled pins that buildProvider +// does not start a MultiIssuerTokenValidator's per-issuer JWKS refresh +// workers when a trusted issuer is configured but neither token exchange nor +// JWT-bearer would ever consume it -- a reachable, if pointless, +// configuration (nothing rejects a trusted issuer that no enabled grant +// references). Building the validator anyway would run those goroutines for +// the life of the server with no consumer. +func TestNewServer_TrustedIssuerWithBothGrantsDisabled(t *testing.T) { + t.Parallel() + + jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"keys":[]}`)) + })) + t.Cleanup(jwksServer.Close) + + baseCfg := func(disableTokenExchange bool) Config { + return Config{ + Issuer: "https://example.com", + KeyProvider: keys.NewGeneratingProvider(keys.DefaultAlgorithm), + HMACSecrets: &servercrypto.HMACSecrets{Current: validHMACSecret()}, + Upstreams: []UpstreamConfig{{Name: "default", Type: UpstreamProviderTypeOAuth2, OAuth2Config: validUpstreamConfig()}}, + AllowedAudiences: []string{"https://mcp.example.com"}, + DisableTokenExchange: disableTokenExchange, + TrustedIssuers: []tokenexchange.TrustedIssuer{{ + IssuerURL: "https://issuer.example.com", + ExpectedAudience: "https://mcp.example.com", + JWKSURL: jwksServer.URL, + InsecureAllowHTTP: true, + AllowPrivateIPs: true, + AllowedDelegateClients: []string{"*"}, + }}, + } + } + + t.Run("token exchange disabled and no JWT-bearer grant leaves the validator nil", func(t *testing.T) { + t.Parallel() + stor := storage.NewMemoryStorage() + cfg := baseCfg(true) + + srv, err := newServer(context.Background(), cfg, stor) + require.NoError(t, err) + t.Cleanup(func() { _ = srv.Close() }) + + assert.Nil(t, srv.trustedIssuerValidator, + "a trusted issuer that neither enabled grant consumes must not start a live JWKS validator") + }) + + t.Run("token exchange enabled builds the validator", func(t *testing.T) { + t.Parallel() + stor := storage.NewMemoryStorage() + cfg := baseCfg(false) + + srv, err := newServer(context.Background(), cfg, stor) + require.NoError(t, err) + t.Cleanup(func() { _ = srv.Close() }) + + assert.NotNil(t, srv.trustedIssuerValidator, + "a trusted issuer consumed by an enabled grant must build the shared validator") + }) +} + // capturingSlogHandler records log records for assertions. slog's default // handler is process-global, so tests using it must not run in parallel with // other slog-capturing tests. diff --git a/pkg/authserver/spiffe_trust.go b/pkg/authserver/spiffe_trust.go index 0c0e3c2b25..000bed6eff 100644 --- a/pkg/authserver/spiffe_trust.go +++ b/pkg/authserver/spiffe_trust.go @@ -104,11 +104,18 @@ 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. +// trust path, and so client authentication and grant-family enablement +// (token exchange, JWT-bearer) remain independently configurable. 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"` + + // TokenExchange configures RFC 8693 inbound clients and issuer policies. + TokenExchange *TokenExchangeInboundGrantRunConfig `json:"token_exchange,omitempty" yaml:"token_exchange,omitempty"` + + // JWTBearer configures RFC 7523 issuer policies. + JWTBearer *JWTBearerInboundGrantRunConfig `json:"jwt_bearer,omitempty" yaml:"jwt_bearer,omitempty"` } // SPIFFEClientAuthRunConfig associates one SPIFFE principal pattern from a diff --git a/pkg/authserver/spiffe_trust_test.go b/pkg/authserver/spiffe_trust_test.go index b933f80877..5d2db71fa3 100644 --- a/pkg/authserver/spiffe_trust_test.go +++ b/pkg/authserver/spiffe_trust_test.go @@ -85,12 +85,13 @@ func TestValidateSPIFFETrust(t *testing.T) { t.Parallel() valid := func() ([]SPIFFETrustDomainRunConfig, *InboundGrantsRunConfig) { - return []SPIFFETrustDomainRunConfig{{ + domains := []SPIFFETrustDomainRunConfig{{ Name: "production", TrustDomain: "example.org", Methods: []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodX509, SPIFFEAuthenticationMethodJWT}, BundleSource: validWorkloadAPIBundleSource(), - }}, &InboundGrantsRunConfig{SPIFFEClientAuth: []SPIFFEClientAuthRunConfig{{ + }} + grants := &InboundGrantsRunConfig{SPIFFEClientAuth: []SPIFFEClientAuthRunConfig{{ TrustDomainRef: "production", PrincipalPattern: "spiffe://example.org/ns/default/*", ClientID: "agent-client", @@ -99,6 +100,7 @@ func TestValidateSPIFFETrust(t *testing.T) { Scopes: []string{"openid"}, GrantTypes: []string{SPIFFEGrantTypeTokenExchange}, }}} + return domains, grants } tests := []struct { diff --git a/pkg/runner/config_test.go b/pkg/runner/config_test.go index 4c7d0fcf9d..4d2bb61080 100644 --- a/pkg/runner/config_test.go +++ b/pkg/runner/config_test.go @@ -2190,6 +2190,48 @@ func TestRunConfig_WriteJSON_ReadJSON_EmbeddedAuthServer(t *testing.T) { }, ScopesSupported: []string{"openid", "profile", "email"}, AllowedAudiences: []string{"https://api.example.com", "https://mcp.example.com"}, + SPIFFETrustDomains: []authserver.SPIFFETrustDomainRunConfig{ + { + Name: "production", + TrustDomain: "example.org", + Methods: []authserver.SPIFFEAuthenticationMethod{authserver.SPIFFEAuthenticationMethodX509}, + BundleSource: authserver.SPIFFEBundleSourceRunConfig{ + Type: authserver.SPIFFEBundleSourceTypeWorkloadAPI, + WorkloadAPI: &authserver.SPIFFEWorkloadAPIBundleSourceRunConfig{}, + }, + }, + { + Name: "development", + TrustDomain: "dev.example.org", + Methods: []authserver.SPIFFEAuthenticationMethod{authserver.SPIFFEAuthenticationMethodJWT}, + BundleSource: authserver.SPIFFEBundleSourceRunConfig{ + Type: authserver.SPIFFEBundleSourceTypeWorkloadAPI, + WorkloadAPI: &authserver.SPIFFEWorkloadAPIBundleSourceRunConfig{}, + }, + }, + }, + InboundGrants: &authserver.InboundGrantsRunConfig{ + SPIFFEClientAuth: []authserver.SPIFFEClientAuthRunConfig{ + { + TrustDomainRef: "production", + PrincipalPattern: "spiffe://example.org/ns/default/agent", + ClientID: "spiffe-client", + Methods: []authserver.SPIFFEAuthenticationMethod{authserver.SPIFFEAuthenticationMethodX509}, + Scopes: []string{"openid"}, + Audiences: []string{"https://mcp.example.com"}, + GrantTypes: []string{authserver.SPIFFEGrantTypeTokenExchange}, + }, + { + TrustDomainRef: "development", + PrincipalPattern: "spiffe://dev.example.org/ns/default/agent", + ClientID: "development-spiffe-client", + Methods: []authserver.SPIFFEAuthenticationMethod{authserver.SPIFFEAuthenticationMethodJWT}, + Scopes: []string{"profile"}, + Audiences: []string{"https://api.example.com"}, + GrantTypes: []string{authserver.SPIFFEGrantTypeTokenExchange}, + }, + }, + }, }, } @@ -2254,6 +2296,14 @@ func TestRunConfig_WriteJSON_ReadJSON_EmbeddedAuthServer(t *testing.T) { // Verify scopes and audiences assert.Equal(t, []string{"openid", "profile", "email"}, authConfig.ScopesSupported, "ScopesSupported should match") assert.Equal(t, []string{"https://api.example.com", "https://mcp.example.com"}, authConfig.AllowedAudiences, "AllowedAudiences should match") + require.Len(t, authConfig.SPIFFETrustDomains, 2) + assert.Equal(t, []authserver.SPIFFEAuthenticationMethod{authserver.SPIFFEAuthenticationMethodX509}, authConfig.SPIFFETrustDomains[0].Methods) + assert.Equal(t, []authserver.SPIFFEAuthenticationMethod{authserver.SPIFFEAuthenticationMethodJWT}, authConfig.SPIFFETrustDomains[1].Methods) + require.NotNil(t, authConfig.InboundGrants) + spiffeClients := authConfig.InboundGrants.SPIFFEClientAuth + require.Len(t, spiffeClients, 2) + assert.Equal(t, []string{"https://mcp.example.com"}, spiffeClients[0].Audiences) + assert.Equal(t, []string{"https://api.example.com"}, spiffeClients[1].Audiences) }) t.Run("serializes and deserializes with OIDC upstream", func(t *testing.T) { From d2b327f8ae82c91753cef6244d29a51f49e94991 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Wed, 2 Sep 2026 12:41:44 +0200 Subject: [PATCH 02/14] Stop nesting a phantom issuer-only actor in the act claim An act object identifies a party that acted (RFC 8693 SS4.1); an issuer alone identifies no party. buildActClaim was nesting a bare {"iss": externalIssuer} under act.act whenever a subject token came from a trusted external issuer with no resolved actor (the may_act and ActorMatcher-only paths), misrepresenting the issuer as a prior actor in the delegation chain that pkg/audit consumes as "the full chain of acting parties." Move that provenance fact to its own top-level external_issuer claim, set whenever ExternalIssuer is non-empty regardless of whether a genuine actor was also resolved. Nesting under act.act now happens only when there is a real actor (ExternalActor) to nest. Cedar's generic claim_ prefixing exposes external_issuer with no extra work, same as act. Reduce buildActClaim's added depth to one level (instead of two) on the no-actor external path, so its prior-chain depth budget matches the self-issued path exactly. Signed-off-by: Jakub Hrozek --- docs/arch/17-token-exchange-delegation.md | 43 ++- pkg/authserver/integration_test.go | 30 ++- .../server/tokenexchange/handler.go | 97 ++++--- .../server/tokenexchange/handler_test.go | 244 +++++++++++++++--- 4 files changed, 312 insertions(+), 102 deletions(-) diff --git a/docs/arch/17-token-exchange-delegation.md b/docs/arch/17-token-exchange-delegation.md index 8cbc9b0f4a..26678150c9 100644 --- a/docs/arch/17-token-exchange-delegation.md +++ b/docs/arch/17-token-exchange-delegation.md @@ -622,19 +622,36 @@ delegate_clients: external subject can never collide with one. Scope names are not qualified this way and remain the operator's responsibility to keep disjoint across issuers. -3. **Provenance is recorded for every external token.** The RFC 8693 §4.1 - `act` claim records who acted: its outer hop always contains ToolHive's - issuer and client ID. The external issuer is nested one level in — - `ValidatedClaims.ExternalIssuer` is set for every token validated by the - external-issuer path, whether or not it also carries `may_act`. The nested - entry additionally carries `sub` (the allowlisted actor claim) when the - allowlist path resolved one; a `may_act`-bearing external token yields - `act = {iss: , sub: , act: {iss: -}}` — no client-namespace actor to report there, but the - issuer is still recorded. Either way, Cedar authorizers key on `sub` and do - not read `act` — it is an audit trail, not an access control. (AWS STS role - mapping can read arbitrary claims including `act` via its CEL matcher, so - "authorizers" here means Cedar specifically, not every consumer.) +3. **Provenance is recorded for every external token, but never as a + phantom actor.** The RFC 8693 §4.1 `act` claim identifies parties that + acted: its outer hop always contains ToolHive's issuer and client ID, + and a genuine external actor — the allowlist path's allowlisted actor + claim — nests one level in, e.g. `act = {iss: , sub: + , act: {iss: , sub: }}`. + A `may_act`-bearing or `ActorMatcher`-only external token has no + client-namespace actor to nest — `may_act.sub` already names the + delegate directly via the outer hop — so `act` stays a single hop for + those; nesting a bare `{iss: }` there would misrepresent + the issuer as a prior actor to any RFC-8693-aware consumer walking the + chain, including this codebase's own audit tooling (`pkg/audit`'s + `DelegationChain`, documented as "the full chain of acting parties"). + The external issuer is instead always recorded as its own top-level + `external_issuer` claim — set whenever `ValidatedClaims.ExternalIssuer` + is non-empty, regardless of whether an actor was also nested under + `act` — so operators and policy authors have one consistent place to + check for "was an external issuer involved," rather than sometimes + inside `act` and sometimes not. It is single-hop: it names only the + immediate exchange's own external contribution, not a re-exchanged + token's earlier external issuer, if any. `act` is not excluded from + Cedar's generic claim exposure (`preprocessClaims` prefixes every claim + key, `act` and `external_issuer` included, so they surface as + `context.claim_act` and `context.claim_external_issuer`), so a policy + CAN key on `context.claim_act.sub` — see + `pkg/authz/authorizers/cedar/core_test.go` for worked examples gating on + an actor's SPIFFE ID this way. Most policies still key on `sub` alone, + since `act` is populated for audit provenance rather than as the + primary access-control signal, but an operator authoring delegation + policy should not assume it is unreachable. 4. **`may_act` trust is a per-issuer opt-in, and it bypasses more than one thing.** `allow_may_act` is false by default because an enabled issuer bypasses BOTH `allowedActors` and `actorMatcher` (external actor diff --git a/pkg/authserver/integration_test.go b/pkg/authserver/integration_test.go index 7543248c9d..ef595fe811 100644 --- a/pkg/authserver/integration_test.go +++ b/pkg/authserver/integration_test.go @@ -1466,14 +1466,15 @@ func TestIntegration_TokenExchange_TrustedExternalIssuer(t *testing.T) { "the outer act hop must carry ToolHive's own issuer alongside sub") // may_act carries no ExternalActor (see ValidatedClaims.ExternalActor's - // doc comment), but the external issuer must still be recorded — this - // is the path that bypasses the allowlist entirely, so it needs the - // audit trail at least as much as the allowlist path does. - nested, ok := act["act"].(map[string]any) - require.True(t, ok, "external issuer must still be nested even without an allowlisted actor") - assert.Equal(t, idpServer.URL, nested["iss"]) - _, hasSub := nested["sub"] - assert.False(t, hasSub, "no client-namespace actor claim exists to report on the may_act path") + // doc comment), so there is no client-namespace actor to nest under + // act.act -- an act entry identifies a party that acted, and an + // issuer alone identifies no party. The external issuer is still + // recorded, as its own top-level claim rather than a phantom hop: + // this is the path that bypasses the allowlist entirely, so it needs + // the audit trail at least as much as the allowlist path does. + assert.Nil(t, act["act"], "no actor was resolved, so act must not nest an issuer-only phantom hop") + assert.Equal(t, idpServer.URL, claims["external_issuer"], + "the external issuer must still be recorded, as its own top-level claim") }) t.Run("may_act-bearing token rejected when issuer has not opted in", func(t *testing.T) { @@ -1702,12 +1703,13 @@ func TestIntegration_TokenExchange_TrustedExternalIssuer(t *testing.T) { require.True(t, ok, "delegated token must carry an 'act' claim") assert.Equal(t, agentClientID, act["sub"], "outermost act.sub must be the ToolHive acting client") - nested, ok := act["act"].(map[string]any) - require.True(t, ok, "external issuer provenance must still be nested") - assert.Equal(t, idpServer.URL, nested["iss"], "nested act.iss is the external issuer") - _, hasSub := nested["sub"] - assert.False(t, hasSub, - "a matcher-only authorization resolves no actor claim, so there is no client-namespace value to report") + // A matcher-only authorization resolves no actor claim, so there is + // no client-namespace value to nest under act.act -- an issuer alone + // identifies no party. The external issuer is still recorded, as its + // own top-level claim. + assert.Nil(t, act["act"], "no actor was resolved, so act must not nest an issuer-only phantom hop") + assert.Equal(t, idpServer.URL, claims["external_issuer"], + "the external issuer must still be recorded, as its own top-level claim") }) t.Run("actor matcher false with no allowlist match rejected", func(t *testing.T) { diff --git a/pkg/authserver/server/tokenexchange/handler.go b/pkg/authserver/server/tokenexchange/handler.go index 303b8658f9..b1344b445a 100644 --- a/pkg/authserver/server/tokenexchange/handler.go +++ b/pkg/authserver/server/tokenexchange/handler.go @@ -205,11 +205,19 @@ func (h *Handler) HandleTokenEndpointRequest(ctx context.Context, requester fosi }, ) - act, err := buildActClaim(validatedClaims, h.issuer, actorSub) + act, externalIssuer, err := buildActClaim(validatedClaims, h.issuer, actorSub) if err != nil { return err } delegatedSession.JWTClaims.Extra["act"] = act + // external_issuer records that this exchange (or, on re-exchange, a + // prior one in the chain) involved a trusted external issuer, + // independent of whether a client-namespace actor was also resolved + // (see buildActClaim's doc comment for why this is not nested inside + // act, and for how it is carried forward across re-exchanges). + if externalIssuer != "" { + delegatedSession.JWTClaims.Extra["external_issuer"] = externalIssuer + } // Compute the delegated token lifetime: the shorter of the subject token's // remaining lifetime and the configured delegation lifespan. @@ -445,43 +453,55 @@ func delegatedSubject(validatedClaims *ValidatedClaims) string { } // buildActClaim assembles the RFC 8693 Section 4.1 "act" claim for the -// delegated token. The outermost act.sub is always actorID (the ToolHive -// client) — every downstream consumer reads that as "who is acting", and it -// must not change regardless of how the subject token was obtained. +// delegated token, and separately reports the external issuer of the +// original external hop (if any) for the caller to record as its own +// top-level claim. This is either the external issuer this exchange itself +// resolved, or — when this exchange has none of its own (a self-issued +// subject token being re-exchanged) — the value already carried in the +// subject token's own "external_issuer" claim, so the fact that an external +// issuer was ever involved survives across re-exchanges (up to whatever +// depth maxDelegationDepth still permits) instead of disappearing after one +// hop. +// +// externalIssuer is deliberately NOT nested inside act: RFC 8693 §4.1 +// defines act as identifying a party that acted, and an issuer alone names +// no party — "the combination of the two claims iss and sub might be +// necessary to uniquely identify an actor" (§4.1) treats iss only as a +// qualifier for an already-identified sub, never as a standalone actor. +// Nesting a bare {"iss": ...} entry under act.act would present a +// non-actor as a prior actor to any RFC-8693-aware consumer that walks the +// chain -- including this codebase's own pkg/audit, whose +// extractDelegationChainFromIdentity is documented as extracting "the full +// chain of acting parties." A may_act-bearing or ActorMatcher-only external +// token has no client-namespace actor to report (see ExternalActor's doc +// comment), so for those the act claim stops at the outer, genuinely-acting +// hop, and the external issuer is reported only via the returned string. +// +// The outermost act.sub is always actorID (the ToolHive client) — every +// downstream consumer reads that as "who is acting", and it must not change +// regardless of how the subject token was obtained. // // Extracted from HandleTokenEndpointRequest rather than inlined: the external // provenance nesting, the prior-chain depth gate, and the encoded-size gate // below add several branches that have nothing to do with the surrounding // request plumbing, and multiple of them reject the request outright. -func buildActClaim(validatedClaims *ValidatedClaims, issuer, actorID string) (map[string]any, error) { +func buildActClaim(validatedClaims *ValidatedClaims, issuer, actorID string) (map[string]any, string, error) { act := map[string]any{"iss": issuer, "sub": actorID} nestUnder := act newLevels := 1 // When the subject token came from a trusted external issuer // (ValidatedClaims.ExternalIssuer is set only there — see - // multi_issuer_validator.go), nest that issuer one level in, together - // with the allowlisted actor when one was resolved. RFC 8693 §4.1 - // anticipates exactly this: "the combination of the two claims 'iss' and - // 'sub' might be necessary to uniquely identify an actor." Without this, - // the issued token would carry no record that the delegation originated - // externally at all — including for a may_act-bearing external token, - // which leaves ExternalActor unset because may_act.sub already names the - // delegate directly via the actorID binding above. That token still has - // an external issuer worth recording, so nesting here is keyed on - // ExternalIssuer, not ExternalActor: the allowlist path's accepted - // any-ToolHive-client scope limitation (see checkDelegationConsent) - // depends on this provenance being auditable after the fact, and a - // may_act-bearing external token — the path that bypasses the allowlist - // entirely — needs it at least as much. - if validatedClaims.ExternalIssuer != "" { - external := map[string]any{"iss": validatedClaims.ExternalIssuer} - // ExternalActor is only present on the allowlist path (see its doc - // comment) — a may_act-bearing external token has no client-namespace - // actor claim to report, so the nested entry there carries "iss" only. - if validatedClaims.ExternalActor != "" { - external["sub"] = validatedClaims.ExternalActor - } + // multi_issuer_validator.go), nest that issuer's allowlisted actor one + // level in, when one was resolved: RFC 8693 §4.1 anticipates exactly + // this, "the combination of the two claims 'iss' and 'sub' might be + // necessary to uniquely identify an actor." A may_act-bearing or + // ActorMatcher-only external token has no such actor to nest (see + // ExternalActor's doc comment) — its act claim stays a single hop, and + // the caller-returned externalIssuer below is this exchange's only + // record that an external issuer was involved. + if validatedClaims.ExternalActor != "" { + external := map[string]any{"iss": validatedClaims.ExternalIssuer, "sub": validatedClaims.ExternalActor} act["act"] = external nestUnder = external newLevels = 2 @@ -503,7 +523,7 @@ func buildActClaim(validatedClaims *ValidatedClaims, issuer, actorID string) (ma if chain.Malformed { // MalformedReason is a closed, value-free enum; it is surfaced to the // client and MUST stay that way — never interpolate claim contents here. - return nil, errorsx.WithStack(fosite.ErrInvalidRequest.WithHintf( + return nil, "", errorsx.WithStack(fosite.ErrInvalidRequest.WithHintf( "The subject token's delegation chain is malformed (%s).", chain.MalformedReason)) } // len(Chain) is the prior chain's depth: the parser appends exactly one @@ -512,7 +532,7 @@ func buildActClaim(validatedClaims *ValidatedClaims, issuer, actorID string) (ma // on top — one for the acting client, two when an external issuer is // also nested — so the resulting chain never exceeds the cap. if len(chain.Chain)+newLevels > maxDelegationDepth { - return nil, errorsx.WithStack(fosite.ErrInvalidRequest.WithHint( + return nil, "", errorsx.WithStack(fosite.ErrInvalidRequest.WithHint( "The subject token's delegation chain is too deep.")) } // Rebuild the nested act from the parsed chain rather than nesting @@ -533,21 +553,32 @@ func buildActClaim(validatedClaims *ValidatedClaims, issuer, actorID string) (ma // displacing it. normalizedAct, err := chainToAct(chain.Chain) if err != nil { - return nil, errorsx.WithStack(fosite.ErrInvalidRequest.WithHint( + return nil, "", errorsx.WithStack(fosite.ErrInvalidRequest.WithHint( "The subject token's delegation chain contains an empty actor.")) } nestUnder["act"] = normalizedAct } encodedAct, err := json.Marshal(act) if err != nil { - return nil, errorsx.WithStack(fosite.ErrInvalidRequest.WithHint( + return nil, "", errorsx.WithStack(fosite.ErrInvalidRequest.WithHint( "The subject token's delegation chain cannot be serialized.")) } if len(encodedAct) > maxActClaimSize { - return nil, errorsx.WithStack(fosite.ErrInvalidRequest.WithHint( + return nil, "", errorsx.WithStack(fosite.ErrInvalidRequest.WithHint( "The subject token's delegation chain is too large.")) } - return act, nil + + // Under the current handler structure there is only ever one external + // hop, so "original" and "most recent" coincide today; if a design ever + // introduces multiple external-issuer hops, this will need to change to + // keep tracking the original rather than the latest. + externalIssuer := validatedClaims.ExternalIssuer + if externalIssuer == "" { + if prior, ok := validatedClaims.Extra["external_issuer"].(string); ok { + externalIssuer = prior + } + } + return act, externalIssuer, nil } // chainToAct rebuilds a nested RFC 8693 act structure from a parsed diff --git a/pkg/authserver/server/tokenexchange/handler_test.go b/pkg/authserver/server/tokenexchange/handler_test.go index a15c777ed6..413892b789 100644 --- a/pkg/authserver/server/tokenexchange/handler_test.go +++ b/pkg/authserver/server/tokenexchange/handler_test.go @@ -1772,6 +1772,8 @@ func TestTokenExchangeHandler_ActChainProvenance(t *testing.T) { assert.Equal(t, "ext-agent", nested["sub"]) assert.Equal(t, testExternalIssuer, nested["iss"]) assert.Nil(t, nested["act"], "no prior chain to nest further") + assert.Equal(t, testExternalIssuer, sess.JWTClaims.Extra["external_issuer"], + "external_issuer is always recorded, even when a genuine actor was also nested under act") }) t.Run("self-issued delegation stays a single level with no external nesting", func(t *testing.T) { @@ -1790,6 +1792,8 @@ func TestTokenExchangeHandler_ActChainProvenance(t *testing.T) { assert.Equal(t, testAgentClientID, act["sub"]) assert.Equal(t, testIssuer, act["iss"], "outermost act.iss is always this server's own issuer") assert.Nil(t, act["act"], "self-issued delegation must not nest an external actor") + assert.NotContains(t, sess.JWTClaims.Extra, "external_issuer", + "self-issued delegation involves no external issuer to record") }) // #5989 fix: an external issuer choosing a "sub" equal to a native @@ -1825,7 +1829,7 @@ func TestTokenExchangeHandler_ActChainProvenance(t *testing.T) { // actor authorization, but AllowedDelegateClients still applies. Only // covered at the checkDelegationConsent unit level until now — this // exercises it through the full handler. - t.Run("external token carrying may_act still nests the issuer, with no actor to report", func(t *testing.T) { + t.Run("external token carrying may_act records the issuer without nesting a phantom actor", func(t *testing.T) { t.Parallel() h := newTestHandlerWithValidator(multiValidator) @@ -1844,20 +1848,20 @@ func TestTokenExchangeHandler_ActChainProvenance(t *testing.T) { require.True(t, ok, "act claim must be a map") assert.Equal(t, testAgentClientID, act["sub"]) - // External actor authorization is bypassed, but this path still records where - // the delegation came from (ValidatedClaims.ExternalIssuer is set - // unconditionally by validateExternalToken) — it just has no - // client-namespace actor claim to report, since may_act.sub already - // named the delegate directly via the outermost act.sub above. - nested, ok := act["act"].(map[string]any) - require.True(t, ok, "external issuer must be nested even on the may_act path") - assert.Equal(t, testExternalIssuer, nested["iss"]) - _, hasSub := nested["sub"] - assert.False(t, hasSub, "no ExternalActor exists on the may_act path") + // External actor authorization is bypassed, and may_act.sub already + // named the delegate directly via the outermost act.sub above, so + // there is no client-namespace actor to nest. The external issuer is + // still recorded (ValidatedClaims.ExternalIssuer is set unconditionally + // by validateExternalToken) -- but as its own top-level claim, not as + // a phantom, issuer-only hop under act.act: an act entry identifies a + // party that acted, and an issuer alone identifies no party. + assert.Nil(t, act["act"], "no actor was resolved, so act must not nest an issuer-only phantom hop") + assert.Equal(t, testExternalIssuer, sess.JWTClaims.Extra["external_issuer"], + "the external issuer must still be recorded, as its own top-level claim") }) // maxDelegationDepth (10) bounds the prior chain depth + newLevels. The - // external wrapper adds a level of its own (newLevels=2) versus the + // external-actor wrapper adds a level of its own (newLevels=2) versus the // self-issued path (newLevels=1), so the external path's prior chain must // be one level shallower to fit: // self-issued: depth 9 accepted (9+1=10); depth 10 rejected (10+1=11>10) @@ -1900,30 +1904,105 @@ func TestTokenExchangeHandler_ActChainProvenance(t *testing.T) { require.True(t, errors.As(err, &rfcErr), "expected fosite RFC6749Error") assert.Contains(t, rfcErr.Reason(), "too deep") }) + + // reExchange re-submits a first exchange's resulting session as a new + // subject_token: the delegated token's own "iss" is always this server's + // own issuer (see delegatedSubject's doc comment), so a second exchange + // validates it via the self-issued path, not the external-issuer path. + // It carries forward exactly the claims a real re-exchange would see on + // the wire — client_id, the nested act chain, and external_issuer. + reExchange := func(t *testing.T, h *Handler, sess *session.Session) (*fosite.AccessRequest, error) { + t.Helper() + extra := map[string]any{"client_id": testAgentClientID} + if act, ok := sess.JWTClaims.Extra["act"]; ok { + extra["act"] = act + } + if externalIssuer, ok := sess.JWTClaims.Extra["external_issuer"]; ok { + extra["external_issuer"] = externalIssuer + } + claims := validClaims() + claims.Subject = sess.JWTClaims.Subject + return requestWith(t, h, tj.signToken(t, claims, extra)) + } + + // #6473 regression: re-exchanging a delegated token that itself recorded + // an external_issuer must not silently drop that provenance just because + // the second exchange validates via the self-issued path. + t.Run("external_issuer survives a self-issued re-exchange (may_act path)", func(t *testing.T) { + t.Parallel() + h := newTestHandlerWithValidator(multiValidator) + + claims := externalClaims() + claims.Audience = jwt.Audience{testExternalAudience, testIssuer} + token := externalJWKS.signToken(t, claims, map[string]any{ + "may_act": map[string]any{"sub": testAgentClientID, "iss": testIssuer}, + }) + + firstReq, err := requestWith(t, h, token) + require.NoError(t, err) + firstSess, ok := firstReq.GetSession().(*session.Session) + require.True(t, ok, "session should be *session.Session") + require.Equal(t, testExternalIssuer, firstSess.JWTClaims.Extra["external_issuer"], + "first exchange must record the external issuer") + + secondReq, err := reExchange(t, h, firstSess) + require.NoError(t, err) + secondSess, ok := secondReq.GetSession().(*session.Session) + require.True(t, ok, "session should be *session.Session") + assert.Equal(t, testExternalIssuer, secondSess.JWTClaims.Extra["external_issuer"], + "external_issuer must survive a self-issued re-exchange, not just the first hop") + }) + + t.Run("external_issuer survives a self-issued re-exchange (ActorMatcher-only path)", func(t *testing.T) { + t.Parallel() + actorMatcherValidator := newMultiValidator(t, tj, []TrustedIssuer{{ + IssuerURL: testExternalIssuer, + ExpectedAudience: testExternalAudience, + JWKSURL: jwksServer.URL + "/jwks", + ActorMatcher: `claims.azp == "trusted-app"`, + AllowedDelegateClients: []string{testAgentClientID}, + }}) + h := newTestHandlerWithValidator(actorMatcherValidator) + + claims := externalClaims() + claims.Audience = jwt.Audience{testExternalAudience, testIssuer} + token := externalJWKS.signToken(t, claims, map[string]any{"azp": "trusted-app"}) + + firstReq, err := requestWith(t, h, token) + require.NoError(t, err) + firstSess, ok := firstReq.GetSession().(*session.Session) + require.True(t, ok, "session should be *session.Session") + require.Equal(t, testExternalIssuer, firstSess.JWTClaims.Extra["external_issuer"], + "first exchange must record the external issuer even with no actor resolved") + firstAct, ok := firstSess.JWTClaims.Extra["act"].(map[string]any) + require.True(t, ok, "act claim must be a map") + assert.Nil(t, firstAct["act"], "ActorMatcher alone resolves no actor to nest") + + secondReq, err := reExchange(t, h, firstSess) + require.NoError(t, err) + secondSess, ok := secondReq.GetSession().(*session.Session) + require.True(t, ok, "session should be *session.Session") + assert.Equal(t, testExternalIssuer, secondSess.JWTClaims.Extra["external_issuer"], + "external_issuer must survive a self-issued re-exchange even when no client-namespace actor was ever nested") + }) } -// TestBuildActClaim_NestedActOnlyCarriesSubWhenAnActorWasResolved locks in -// the correct RFC 8693 §4.1 invariant for the nested act.act object: "sub" -// is not a required member (the RFC only gives "iss"+"sub" as an example of -// what "might be necessary to uniquely identify an actor"), so it must be -// present when an external actor was actually resolved (the allowlist -// path's ExternalActor) and absent otherwise (the may_act-bearing path, -// which names its delegate directly via the outermost act.sub instead — -// see resolveActorIdentity and checkDelegationConsent). A "sub" claim was -// previously, incorrectly, backfilled from the external subject token's -// own subject on the no-ExternalActor path; that misrepresented the -// external principal as a prior actor in the delegation chain (they never -// acted) and reintroduced their bare, unqualified subject into a claim -// audit tooling parses as "acting parties" (see delegatedSubject's doc -// comment for why the qualified form exists in the first place). -func TestBuildActClaim_NestedActOnlyCarriesSubWhenAnActorWasResolved(t *testing.T) { +// TestBuildActClaim_ExternalIssuerIsNeverNestedAsAPhantomActor locks in the +// design fix for a category error: an act object identifies a party that +// acted (RFC 8693 §4.1), so a bare {"iss": externalIssuer} entry -- naming +// no party -- must never be nested under act.act. When an actual actor was +// resolved (the allowlist path's ExternalActor), it nests correctly with +// both iss and sub. When no actor was resolved (the may_act-bearing or +// ActorMatcher-only path), act stays a single hop and the external issuer +// is reported only through buildActClaim's separate return value, which the +// caller records as its own external_issuer claim -- never inside act. +func TestBuildActClaim_ExternalIssuerIsNeverNestedAsAPhantomActor(t *testing.T) { t.Parallel() tests := []struct { - name string - claims *ValidatedClaims - wantSub string - wantNoSub bool + name string + claims *ValidatedClaims + wantNestedActor bool }{ { name: "allowlist path nests the allowlisted external actor", @@ -1932,34 +2011,115 @@ func TestBuildActClaim_NestedActOnlyCarriesSubWhenAnActorWasResolved(t *testing. ExternalIssuer: testExternalIssuer, ExternalActor: "ext-agent", }, - wantSub: "ext-agent", + wantNestedActor: true, }, { - name: "may_act path with no ExternalActor omits the nested sub", + name: "may_act path with no ExternalActor nests nothing under act", claims: &ValidatedClaims{ Subject: "ext-user-456", ExternalIssuer: testExternalIssuer, ExternalActor: "", }, - wantNoSub: true, + wantNestedActor: false, + }, + { + // newLevels stays 1 (not 2) on the no-actor path, so a prior + // chain of depth maxDelegationDepth-1 must fit exactly here, + // unlike the external-actor case in + // "external delegation at depth 8 fits exactly...", which only + // admits depth-8 because its newLevels is 2. Pins the depth + // arithmetic for the no-actor-plus-prior-chain combination, + // which no other test exercises. + name: "may_act path with a prior chain: no actor wrapper means one more level of prior chain fits", + claims: &ValidatedClaims{ + Subject: "ext-user-456", + ExternalIssuer: testExternalIssuer, + ExternalActor: "", + Extra: map[string]any{"act": nestedActChain(maxDelegationDepth - 1)}, + }, + wantNestedActor: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - act, err := buildActClaim(tt.claims, testIssuer, testAgentClientID) + act, externalIssuer, err := buildActClaim(tt.claims, testIssuer, testAgentClientID) require.NoError(t, err) - nested, ok := act["act"].(map[string]any) - require.True(t, ok, "external delegation must nest under act.act") - assert.Equal(t, testExternalIssuer, nested["iss"]) - if tt.wantNoSub { - _, hasSub := nested["sub"] - assert.False(t, hasSub, "no actor was resolved, so the nested act must not carry a sub") + // The external issuer is always reported via the return value, + // regardless of whether an actor was also resolved. + assert.Equal(t, testExternalIssuer, externalIssuer) + + if !tt.wantNestedActor { + if prior, ok := tt.claims.Extra["act"]; ok { + assert.Equal(t, prior, act["act"], + "with no actor to nest, the prior chain must hang directly under the outer hop, unchanged") + } else { + assert.Nil(t, act["act"], "no actor was resolved, so act must not nest a phantom, issuer-only hop") + } return } - assert.Equal(t, tt.wantSub, nested["sub"]) + nested, ok := act["act"].(map[string]any) + require.True(t, ok, "a genuine actor must nest under act.act") + assert.Equal(t, testExternalIssuer, nested["iss"]) + assert.Equal(t, "ext-agent", nested["sub"]) + }) + } +} + +// TestBuildActClaim_ExternalIssuerCarriesForwardOnReExchange covers the fix +// for the provenance-loss bug: a self-issued subject token being re-exchanged +// validates via SelfIssuedTokenValidator, which never sets +// ValidatedClaims.ExternalIssuer, so without reading back the subject +// token's own "external_issuer" claim, a second exchange would silently drop +// the fact that an earlier hop involved an external issuer. +func TestBuildActClaim_ExternalIssuerCarriesForwardOnReExchange(t *testing.T) { + t.Parallel() + + const freshIssuer = "https://fresh-external-issuer.example.com" + const priorIssuer = "https://prior-external-issuer.example.com" + + tests := []struct { + name string + claims *ValidatedClaims + want string + }{ + { + name: "a fresh external issuer this hop wins over a stale prior one", + claims: &ValidatedClaims{ + ExternalIssuer: freshIssuer, + Extra: map[string]any{"external_issuer": priorIssuer}, + }, + want: freshIssuer, + }, + { + name: "no fresh external issuer this hop carries the prior one forward", + claims: &ValidatedClaims{ + Extra: map[string]any{"external_issuer": priorIssuer}, + }, + want: priorIssuer, + }, + { + name: "neither this hop nor the subject token has an external issuer", + claims: &ValidatedClaims{}, + want: "", + }, + { + name: "a non-string prior external_issuer is ignored, not carried forward", + claims: &ValidatedClaims{ + Extra: map[string]any{"external_issuer": 12345}, + }, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, externalIssuer, err := buildActClaim(tt.claims, testIssuer, testAgentClientID) + require.NoError(t, err) + assert.Equal(t, tt.want, externalIssuer) }) } } From f0f350d73f4796a74623a92569d8ca4b34142bad Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Thu, 3 Sep 2026 11:58:40 +0200 Subject: [PATCH 03/14] Reject private_key_jwt registration with token exchange disabled private_key_jwt registrations can only ever request the RFC 8693 token-exchange grant (see validateGrantTypes/validateResponseTypes in pkg/authserver/server/registration/dcr.go) -- there is no independent use of this auth method. Allowing AllowPrivateKeyJWTRegistration while canonical configuration disables token exchange would admit clients that can never successfully authenticate, discovering the mismatch only when every such registration is rejected at /oauth/register. Reject the contradictory configuration at startup instead. Signed-off-by: Jakub Hrozek --- pkg/authserver/config.go | 22 ++++++++++++++++++++++ pkg/authserver/config_test.go | 1 + 2 files changed, 23 insertions(+) diff --git a/pkg/authserver/config.go b/pkg/authserver/config.go index e1d705fb22..f0d48da816 100644 --- a/pkg/authserver/config.go +++ b/pkg/authserver/config.go @@ -1156,6 +1156,10 @@ func (c *Config) Validate() error { return err } + if err := c.validatePrivateKeyJWTRequiresTokenExchange(); err != nil { + return err + } + if c.AuthorizationEndpointBaseURL != "" { if err := validateIssuerURL(c.AuthorizationEndpointBaseURL, c.InsecureAllowHTTP); err != nil { return fmt.Errorf("authorization_endpoint_base_url: %w", err) @@ -1263,6 +1267,24 @@ func (c *Config) validateConfidentialClientConfig() error { return ValidateForceConfidentialRedirectURIs(c.ForceConfidentialRedirectURIs, c.AllowConfidentialClientRegistration) } +// validatePrivateKeyJWTRequiresTokenExchange rejects +// AllowPrivateKeyJWTRegistration combined with a disabled token-exchange +// grant. private_key_jwt registrations can only ever request the RFC 8693 +// token-exchange grant (see validateGrantTypes/validateResponseTypes in +// pkg/authserver/server/registration/dcr.go): there is no independent use +// of this auth method outside token exchange. Allowing registration while +// token exchange is disabled would admit clients that can never +// successfully authenticate, so reject the combination outright rather +// than let it surface later as a confusing runtime rejection. +func (c *Config) validatePrivateKeyJWTRequiresTokenExchange() error { + if c.AllowPrivateKeyJWTRegistration && c.DisableTokenExchange { + return fmt.Errorf( + "allow_private_key_jwt_registration requires token exchange to be enabled: " + + "private_key_jwt registrations are token-exchange-only (RFC 8693)") + } + return nil +} + // validateCIMDBounds rejects invalid CIMD cache bounds when CIMD is enabled. // When CIMD is disabled the cache fields are ignored. func (c *Config) validateCIMDBounds() error { diff --git a/pkg/authserver/config_test.go b/pkg/authserver/config_test.go index 393c86fa71..4c6ac4eff9 100644 --- a/pkg/authserver/config_test.go +++ b/pkg/authserver/config_test.go @@ -184,6 +184,7 @@ func TestConfigValidate(t *testing.T) { // Confidential-client transport gate (same predicate RunConfig.Validate uses) {name: "confidential clients combined with insecure HTTP rejects", config: Config{Issuer: "http://example.com", KeyProvider: validKeyProvider, HMACSecrets: validHMAC, Upstreams: validUpstreams, AllowedAudiences: []string{"https://mcp.example.com"}, AllowConfidentialClientRegistration: true, InsecureAllowHTTP: true}, wantErr: true, errMsg: "allow_confidential_client_registration cannot be combined with insecure_allow_http"}, {name: "private-key JWT registration combined with insecure HTTP passes (no secret to protect)", config: Config{Issuer: "http://example.com", KeyProvider: validKeyProvider, HMACSecrets: validHMAC, Upstreams: validUpstreams, AllowedAudiences: []string{"https://mcp.example.com"}, AllowPrivateKeyJWTRegistration: true, InsecureAllowHTTP: true}}, + {name: "private-key JWT registration combined with token exchange disabled rejects", config: Config{Issuer: "https://example.com", KeyProvider: validKeyProvider, HMACSecrets: validHMAC, Upstreams: validUpstreams, AllowedAudiences: []string{"https://mcp.example.com"}, AllowPrivateKeyJWTRegistration: true, DisableTokenExchange: true}, wantErr: true, errMsg: "token-exchange-only"}, // Valid configs {name: "valid minimal", config: Config{Issuer: "https://example.com", KeyProvider: validKeyProvider, HMACSecrets: validHMAC, Upstreams: validUpstreams, AllowedAudiences: []string{"https://mcp.example.com"}}}, From bd00e0c3c3ae8017a996ab2612ae3fbb6e9c0b58 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Sun, 30 Aug 2026 15:52:54 +0200 Subject: [PATCH 04/14] Register static SPIFFE clients Configured workload associations need restart-safe OAuth client records before either SVID authentication method can use them. Build immutable SPIFFE associations, materialize narrowed static clients at startup, and reject collisions instead of silently replacing existing clients. Refs #6200 Signed-off-by: Jakub Hrozek --- pkg/authserver/runner/embeddedauthserver.go | 64 +- .../embeddedauthserver_integration_test.go | 123 ++++ .../runner/embeddedauthserver_test.go | 559 +++++++++++++++++- .../server/registration/spiffe_client.go | 77 +++ .../server/registration/spiffe_client_test.go | 61 ++ .../tokenexchange/jwt_bearer_handler.go | 7 +- pkg/authserver/server_impl.go | 56 +- pkg/authserver/server_test.go | 55 ++ pkg/authserver/spiffe_association_registry.go | 65 ++ .../spiffe_association_registry_test.go | 87 +++ pkg/authserver/spiffe_preflight.go | 26 + pkg/authserver/storage/spiffe_decorator.go | 97 +++ .../storage/spiffe_decorator_test.go | 118 ++++ pkg/authserver/storage/unwrap.go | 17 + 14 files changed, 1377 insertions(+), 35 deletions(-) create mode 100644 pkg/authserver/runner/embeddedauthserver_integration_test.go create mode 100644 pkg/authserver/server/registration/spiffe_client.go create mode 100644 pkg/authserver/server/registration/spiffe_client_test.go create mode 100644 pkg/authserver/spiffe_association_registry.go create mode 100644 pkg/authserver/spiffe_association_registry_test.go create mode 100644 pkg/authserver/spiffe_preflight.go create mode 100644 pkg/authserver/storage/spiffe_decorator.go create mode 100644 pkg/authserver/storage/spiffe_decorator_test.go create mode 100644 pkg/authserver/storage/unwrap.go diff --git a/pkg/authserver/runner/embeddedauthserver.go b/pkg/authserver/runner/embeddedauthserver.go index e9e09f22f3..3c677a4cd4 100644 --- a/pkg/authserver/runner/embeddedauthserver.go +++ b/pkg/authserver/runner/embeddedauthserver.go @@ -144,6 +144,7 @@ func prepareInboundGrantConfiguration( if err != nil { return nil, nil, nil, fmt.Errorf("normalize inbound grants: %w", err) } + if delegateClients == nil && len(normalized.DelegateClients) > 0 { delegateClients, err = resolveDelegateClients(normalized.DelegateClients) if err != nil { @@ -160,15 +161,32 @@ func prepareInboundGrantConfiguration( if err != nil { return nil, nil, nil, fmt.Errorf("build SPIFFE trust config: %w", err) } + // SPIFFE client authentication exclusively uses the token-exchange grant + // (validateSPIFFEGrants enforces this), so configuring any SPIFFE + // association implies token-exchange capability independent of + // legacy/canonical token-exchange enablement. + normalized.Capabilities.TokenExchange = normalized.Capabilities.TokenExchange || hasSPIFFEClientAuth(cfg) return normalized, delegateClients, spiffeTrust, nil } +// hasSPIFFEClientAuth reports whether cfg declares any SPIFFE client-auth +// association. +func hasSPIFFEClientAuth(cfg *authserver.RunConfig) bool { + return cfg.InboundGrants != nil && len(cfg.InboundGrants.SPIFFEClientAuth) > 0 +} + func newEmbeddedAuthServerWithStorage( ctx context.Context, cfg *authserver.RunConfig, stor storage.Storage, delegateClients []authserver.DelegateClient, ) (retEAS *EmbeddedAuthServer, retErr error) { + // Validate required inputs before the deferred cleanup is installed: cfg is + // dereferenced during validation and stor is closed by that cleanup. + if err := validateEmbeddedAuthServerInputs(cfg, stor); err != nil { + return nil, err + } + // From here on, any error must close stor before returning. // // Both errors are passed through dcr.SanitizeErrorForLog before being @@ -198,9 +216,6 @@ func newEmbeddedAuthServerWithStorage( // otherwise skip the check. Placed inside the deferred-cleanup gate above so // a validation failure still closes the caller-supplied storage per the // resource-ownership contract. - if cfg == nil { - return nil, fmt.Errorf("config is required") - } if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("invalid run config: %w", err) } @@ -209,6 +224,10 @@ func newEmbeddedAuthServerWithStorage( return nil, err } + if err := authserver.PreflightSPIFFEStaticClientCollisions(ctx, stor, spiffeTrust); err != nil { + return nil, fmt.Errorf("preflight SPIFFE static client collisions: %w", err) + } + // 1. Create key provider from RunConfig.SigningKeyConfig keyProvider, err := createKeyProvider(cfg.SigningKeyConfig) if err != nil { @@ -248,12 +267,9 @@ func newEmbeddedAuthServerWithStorage( } // 6. Parse delegation token lifespan if configured. - var delegationLifespan time.Duration - if cfg.DelegationTokenLifespan != "" { - delegationLifespan, err = time.ParseDuration(cfg.DelegationTokenLifespan) - if err != nil { - return nil, fmt.Errorf("invalid delegation token lifespan: %w", err) - } + delegationLifespan, err := parseOptionalDuration(cfg.DelegationTokenLifespan, "delegation token lifespan") + if err != nil { + return nil, err } // 7. Build the resolved Config. @@ -297,8 +313,11 @@ func newEmbeddedAuthServerWithStorage( // slice is still shared with cfg. NewMultiIssuerTokenValidator's // constructor clones AllowedActors per issuer before use, so the // authorization-critical data is protected without a deep copy here. - TrustedIssuers: trustedIssuers, - DelegateClients: delegateClients, + TrustedIssuers: trustedIssuers, + DelegateClients: delegateClients, + // SPIFFE client authentication factors into Capabilities.TokenExchange + // already (see prepareInboundGrantConfiguration): it exclusively uses + // the token-exchange grant, independent of legacy/canonical enablement. DisableTokenExchange: !normalized.Capabilities.TokenExchange, SPIFFETrust: spiffeTrust, } @@ -413,6 +432,18 @@ func (e *EmbeddedAuthServer) RegisterHandlers(mux *http.ServeMux) { } } +// validateEmbeddedAuthServerInputs rejects required inputs before construction +// can dereference cfg or install cleanup that closes stor. +func validateEmbeddedAuthServerInputs(cfg *authserver.RunConfig, stor storage.Storage) error { + if cfg == nil { + return fmt.Errorf("config is required") + } + if stor == nil { + return fmt.Errorf("storage is required") + } + return nil +} + // createKeyProvider creates a KeyProvider from SigningKeyRunConfig. // Returns a GeneratingProvider if config is nil or empty (development mode). func createKeyProvider(cfg *authserver.SigningKeyRunConfig) (keys.KeyProvider, error) { @@ -476,6 +507,17 @@ func loadHMACSecrets(files []string) (*servercrypto.HMACSecrets, error) { return secrets, nil } +func parseOptionalDuration(value, name string) (time.Duration, error) { + if value == "" { + return 0, nil + } + duration, err := time.ParseDuration(value) + if err != nil { + return 0, fmt.Errorf("invalid %s: %w", name, err) + } + return duration, nil +} + // parseTokenLifespans parses duration strings from TokenLifespanRunConfig. // Returns zero values for unset durations (defaults applied by authserver). func parseTokenLifespans(cfg *authserver.TokenLifespanRunConfig) (access, refresh, authCode time.Duration, err error) { diff --git a/pkg/authserver/runner/embeddedauthserver_integration_test.go b/pkg/authserver/runner/embeddedauthserver_integration_test.go new file mode 100644 index 0000000000..509a143ccc --- /dev/null +++ b/pkg/authserver/runner/embeddedauthserver_integration_test.go @@ -0,0 +1,123 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//go:build integration + +package runner + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/ory/fosite" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + + "github.com/stacklok/toolhive/pkg/authserver" + "github.com/stacklok/toolhive/pkg/authserver/storage" +) + +func TestIntegration_EmbeddedAuthServer_SPIFFERedisRestartAndCollision(t *testing.T) { + t.Skip("RunConfig.Validate() now hard-rejects any non-empty spiffe_trust_domains " + + "(config.go's validateSPIFFENotYetEnforced, per PR #6467 review) until a real " + + "SVID-verification consumer lands, so a server can no longer be constructed with " + + "a SPIFFE association configured at all -- there is no way to exercise the " + + "Redis-backed restart/collision behavior this test proved through " + + "NewEmbeddedAuthServerWithStorage without routing around cfg.Validate() in " + + "production code. Re-enable this test -- unmodified -- when the future PR that " + + "adds real SVID verification removes the hard-reject.") + + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + t.Cleanup(cancel) + + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{ + Image: "redis:7-alpine", + ExposedPorts: []string{"6379/tcp"}, + WaitingFor: wait.ForListeningPort("6379/tcp"), + }, + Started: true, + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) }) + + host, err := container.Host(ctx) + require.NoError(t, err) + port, err := container.MappedPort(ctx, "6379/tcp") + require.NoError(t, err) + newStorage := func() *storage.RedisStorage { + return storage.NewRedisStorageWithClient(redis.NewClient(&redis.Options{ + Addr: fmt.Sprintf("%s:%s", host, port.Port()), + }), "integration:spiffe:") + } + config := func(includeAssociation bool) authserver.RunConfig { + cfg := authserver.RunConfig{ + SchemaVersion: authserver.CurrentSchemaVersion, + Issuer: "https://auth.example.com", + ScopesSupported: []string{"openid"}, + AllowedAudiences: []string{"https://mcp.example.com"}, + // A static OAuth2 upstream satisfies runtime configuration validation + // without discovery or DCR network I/O during server construction. + Upstreams: []authserver.UpstreamRunConfig{{ + Name: "static-upstream", + Type: authserver.UpstreamProviderTypeOAuth2, + OAuth2Config: &authserver.OAuth2UpstreamRunConfig{ + AuthorizationEndpoint: "https://upstream.example.com/authorize", + TokenEndpoint: "https://upstream.example.com/token", + ClientID: "test-client-id", + RedirectURI: "https://auth.example.com/oauth/callback", + }, + }}, + } + if !includeAssociation { + return cfg + } + cfg.SPIFFETrustDomains = []authserver.SPIFFETrustDomainRunConfig{{ + Name: "production", TrustDomain: "example.org", + Methods: []authserver.SPIFFEAuthenticationMethod{authserver.SPIFFEAuthenticationMethodX509}, + BundleSource: authserver.SPIFFEBundleSourceRunConfig{ + Type: authserver.SPIFFEBundleSourceTypeWorkloadAPI, + WorkloadAPI: &authserver.SPIFFEWorkloadAPIBundleSourceRunConfig{}, + }, + }} + cfg.InboundGrants = &authserver.InboundGrantsRunConfig{ + SPIFFEClientAuth: []authserver.SPIFFEClientAuthRunConfig{{ + TrustDomainRef: "production", + PrincipalPattern: "spiffe://example.org/ns/default/agent", + ClientID: "spiffe-client", + Methods: []authserver.SPIFFEAuthenticationMethod{authserver.SPIFFEAuthenticationMethodX509}, + Scopes: []string{"openid"}, + Audiences: []string{"https://mcp.example.com"}, + GrantTypes: []string{authserver.SPIFFEGrantTypeTokenExchange}, + }}, + } + return cfg + } + + firstStorage := newStorage() + require.NoError(t, firstStorage.RegisterClient(ctx, &fosite.DefaultClient{ID: "dynamic-client"})) + initial := config(true) + first, err := NewEmbeddedAuthServerWithStorage(ctx, &initial, firstStorage) + require.NoError(t, err) + require.NoError(t, first.Close()) + + secondStorage := newStorage() + removed := config(false) + second, err := NewEmbeddedAuthServerWithStorage(ctx, &removed, secondStorage) + require.NoError(t, err) + _, err = secondStorage.GetClient(ctx, "dynamic-client") + require.NoError(t, err) + _, err = secondStorage.GetClient(ctx, "spiffe-client") + require.ErrorIs(t, err, storage.ErrNotFound) + require.NoError(t, second.Close()) + + collisionStorage := newStorage() + require.NoError(t, collisionStorage.RegisterClient(ctx, &fosite.DefaultClient{ID: "spiffe-client"})) + collision := config(true) + _, err = NewEmbeddedAuthServerWithStorage(ctx, &collision, collisionStorage) + require.ErrorIs(t, err, storage.ErrAlreadyExists) +} diff --git a/pkg/authserver/runner/embeddedauthserver_test.go b/pkg/authserver/runner/embeddedauthserver_test.go index a1479cc669..0e3fde85b5 100644 --- a/pkg/authserver/runner/embeddedauthserver_test.go +++ b/pkg/authserver/runner/embeddedauthserver_test.go @@ -17,15 +17,21 @@ import ( "log/slog" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" + "strings" "sync" "sync/atomic" "testing" "time" + "github.com/alicebob/miniredis/v2" + "github.com/ory/fosite" + "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" "github.com/stacklok/toolhive/pkg/authserver" servercrypto "github.com/stacklok/toolhive/pkg/authserver/server/crypto" @@ -1702,7 +1708,558 @@ func newMockAuthorizationServer(t *testing.T) (*httptest.Server, *int32) { return server, &total } -// TestBuildUpstreamConfigs_DCR verifies the end-to-end DCR wiring inside +func TestNewEmbeddedAuthServerWithStorage_RequiredInputs(t *testing.T) { + t.Parallel() + + t.Run("nil config returns error without panic or cleanup", func(t *testing.T) { + t.Parallel() + + tracker := &closeTrackingStorage{Storage: storage.NewMemoryStorage()} + t.Cleanup(func() { require.NoError(t, tracker.Storage.Close()) }) + + server, err := NewEmbeddedAuthServerWithStorage(context.Background(), nil, tracker) + require.Nil(t, server) + require.EqualError(t, err, "config is required") + assert.Zero(t, tracker.closeCount.Load()) + }) + + t.Run("nil storage returns error", func(t *testing.T) { + t.Parallel() + + server, err := NewEmbeddedAuthServerWithStorage(context.Background(), &authserver.RunConfig{}, nil) + require.Nil(t, server) + require.EqualError(t, err, "storage is required") + }) +} + +// TestNewEmbeddedAuthServer_SPIFFEAndJWTBearerGrant ensures the two independent +// inbound token-exchange configurations construct together through RunConfig. +func TestNewEmbeddedAuthServer_SPIFFEAndJWTBearerGrant(t *testing.T) { + t.Parallel() + t.Skip("RunConfig.Validate() now hard-rejects any non-empty spiffe_trust_domains " + + "(config.go's validateSPIFFENotYetEnforced, per PR #6467 review) until a real " + + "SVID-verification consumer lands, so a server can no longer be constructed with " + + "a SPIFFE association configured at all -- there is no way to exercise this " + + "combination through NewEmbeddedAuthServer without routing around cfg.Validate() " + + "in production code. Re-enable this test -- unmodified -- when the future PR that " + + "adds real SVID verification removes the hard-reject.") + + cfg := &authserver.RunConfig{ + SchemaVersion: authserver.CurrentSchemaVersion, + Issuer: "https://auth.example.com", + ScopesSupported: []string{"openid", "profile"}, + AllowedAudiences: []string{"https://mcp.example.com"}, + Upstreams: []authserver.UpstreamRunConfig{{ + Name: "static-upstream", + Type: authserver.UpstreamProviderTypeOAuth2, + OAuth2Config: &authserver.OAuth2UpstreamRunConfig{ + AuthorizationEndpoint: "https://upstream.example.com/authorize", + TokenEndpoint: "https://upstream.example.com/token", + ClientID: "test-client-id", + RedirectURI: "https://auth.example.com/oauth/callback", + }, + }}, + SPIFFETrustDomains: []authserver.SPIFFETrustDomainRunConfig{{ + Name: "production", + TrustDomain: "example.org", + Methods: []authserver.SPIFFEAuthenticationMethod{authserver.SPIFFEAuthenticationMethodX509}, + BundleSource: authserver.SPIFFEBundleSourceRunConfig{ + Type: authserver.SPIFFEBundleSourceTypeWorkloadAPI, + WorkloadAPI: &authserver.SPIFFEWorkloadAPIBundleSourceRunConfig{}, + }, + }}, + InboundGrants: &authserver.InboundGrantsRunConfig{ + SPIFFEClientAuth: []authserver.SPIFFEClientAuthRunConfig{{ + TrustDomainRef: "production", + PrincipalPattern: "spiffe://example.org/ns/default/agent", + ClientID: "spiffe-client", + Methods: []authserver.SPIFFEAuthenticationMethod{authserver.SPIFFEAuthenticationMethodX509}, + Scopes: []string{"openid"}, + Audiences: []string{"https://mcp.example.com"}, + GrantTypes: []string{authserver.SPIFFEGrantTypeTokenExchange}, + }}, + }, + TrustedIssuers: []tokenexchange.TrustedIssuer{{ + IssuerURL: "https://issuer.example.com", + JWTBearerGrant: &tokenexchange.JWTBearerGrantPolicy{ + MaxAssertionAge: "5m", + SubjectBindings: []tokenexchange.JWTBearerSubjectBinding{{ + Subject: "workload", + AllowedResources: []string{"https://mcp.example.com"}, + }}, + }, + }}, + } + + srv, err := NewEmbeddedAuthServer(context.Background(), cfg) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, srv.Close()) }) +} + +// TestNewEmbeddedAuthServer_SPIFFEAndCIMD exercises the RunConfig conversion +// and the production storage-decoration order with both features enabled. +func TestNewEmbeddedAuthServer_SPIFFEAndCIMD(t *testing.T) { + t.Parallel() + t.Skip("RunConfig.Validate() now hard-rejects any non-empty spiffe_trust_domains " + + "(config.go's validateSPIFFENotYetEnforced, per PR #6467 review) until a real " + + "SVID-verification consumer lands, so a server can no longer be constructed with " + + "a SPIFFE association configured at all -- there is no way to exercise this " + + "combination through NewEmbeddedAuthServer without routing around cfg.Validate() " + + "in production code. Re-enable this test -- unmodified -- when the future PR that " + + "adds real SVID verification removes the hard-reject.") + + cfg := &authserver.RunConfig{ + SchemaVersion: authserver.CurrentSchemaVersion, + Issuer: "https://auth.example.com", + ScopesSupported: []string{"openid", "profile"}, + AllowedAudiences: []string{"https://mcp.example.com"}, + CIMD: &authserver.CIMDRunConfig{ + Enabled: true, + CacheMaxSize: 16, + CacheFallbackTTL: "5m", + }, + Upstreams: []authserver.UpstreamRunConfig{{ + Name: "static-upstream", + Type: authserver.UpstreamProviderTypeOAuth2, + OAuth2Config: &authserver.OAuth2UpstreamRunConfig{ + AuthorizationEndpoint: "https://upstream.example.com/authorize", + TokenEndpoint: "https://upstream.example.com/token", + ClientID: "test-client-id", + RedirectURI: "https://auth.example.com/oauth/callback", + }, + }}, + SPIFFETrustDomains: []authserver.SPIFFETrustDomainRunConfig{{ + Name: "production", + TrustDomain: "example.org", + Methods: []authserver.SPIFFEAuthenticationMethod{authserver.SPIFFEAuthenticationMethodX509}, + BundleSource: authserver.SPIFFEBundleSourceRunConfig{ + Type: authserver.SPIFFEBundleSourceTypeWorkloadAPI, + WorkloadAPI: &authserver.SPIFFEWorkloadAPIBundleSourceRunConfig{}, + }, + }}, + InboundGrants: &authserver.InboundGrantsRunConfig{ + SPIFFEClientAuth: []authserver.SPIFFEClientAuthRunConfig{{ + TrustDomainRef: "production", + PrincipalPattern: "spiffe://example.org/ns/default/agent", + ClientID: "spiffe-client", + Methods: []authserver.SPIFFEAuthenticationMethod{authserver.SPIFFEAuthenticationMethodX509}, + Scopes: []string{"openid"}, + Audiences: []string{"https://mcp.example.com"}, + GrantTypes: []string{authserver.SPIFFEGrantTypeTokenExchange}, + }}, + }, + } + + srv, err := NewEmbeddedAuthServer(context.Background(), cfg) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, srv.Close()) }) +} + +// TestNewEmbeddedAuthServerWithStorage_SPIFFECollisionPrecedesDCR verifies that +// the deterministic static-ID collision gate runs before upstream DCR can issue +// a registration request. +func TestNewEmbeddedAuthServerWithStorage_SPIFFECollisionPrecedesDCR(t *testing.T) { + t.Parallel() + t.Skip("RunConfig.Validate() now hard-rejects any non-empty spiffe_trust_domains " + + "(config.go's validateSPIFFENotYetEnforced, per PR #6467 review) before " + + "construction ever reaches the SPIFFE storage decorator, so the collision-vs-DCR " + + "ordering this test proved is no longer observable through NewEmbeddedAuthServerWithStorage " + + "-- it now fails even earlier, for a different reason, without the ordering property " + + "itself being tested. Re-enable this test -- unmodified -- when the future PR that " + + "adds real SVID verification removes the hard-reject.") + + upstreamServer, requestCount := newMockAuthorizationServer(t) + stor := storage.NewMemoryStorage() + require.NoError(t, stor.RegisterClient(context.Background(), &fosite.DefaultClient{ID: "spiffe-client"})) + + cfg := &authserver.RunConfig{ + SchemaVersion: authserver.CurrentSchemaVersion, + Issuer: upstreamServer.URL, + InsecureAllowHTTP: true, + ScopesSupported: []string{"openid", "profile"}, + AllowedAudiences: []string{"https://mcp.example.com"}, + SPIFFETrustDomains: []authserver.SPIFFETrustDomainRunConfig{{ + Name: "production", + TrustDomain: "example.org", + Methods: []authserver.SPIFFEAuthenticationMethod{authserver.SPIFFEAuthenticationMethodX509}, + BundleSource: authserver.SPIFFEBundleSourceRunConfig{ + Type: authserver.SPIFFEBundleSourceTypeWorkloadAPI, + WorkloadAPI: &authserver.SPIFFEWorkloadAPIBundleSourceRunConfig{}, + }, + }}, + InboundGrants: &authserver.InboundGrantsRunConfig{ + SPIFFEClientAuth: []authserver.SPIFFEClientAuthRunConfig{{ + TrustDomainRef: "production", + PrincipalPattern: "spiffe://example.org/ns/default/agent", + ClientID: "spiffe-client", + Methods: []authserver.SPIFFEAuthenticationMethod{authserver.SPIFFEAuthenticationMethodX509}, + Scopes: []string{"openid"}, + Audiences: []string{"https://mcp.example.com"}, + GrantTypes: []string{authserver.SPIFFEGrantTypeTokenExchange}, + }}, + }, + Upstreams: []authserver.UpstreamRunConfig{{ + Name: "dcr-upstream", + Type: authserver.UpstreamProviderTypeOAuth2, + OAuth2Config: &authserver.OAuth2UpstreamRunConfig{ + AuthorizationEndpoint: upstreamServer.URL + "/authorize", + TokenEndpoint: upstreamServer.URL + "/token", + Scopes: []string{"openid", "profile"}, + DCRConfig: &authserver.DCRUpstreamConfig{DiscoveryURL: upstreamServer.URL + "/.well-known/oauth-authorization-server"}, + }, + }}, + } + + _, err := NewEmbeddedAuthServerWithStorage(context.Background(), cfg, stor) + require.ErrorIs(t, err, storage.ErrAlreadyExists) + assert.Zero(t, atomic.LoadInt32(requestCount), "static collision must precede DCR network I/O") +} + +func TestEmbeddedAuthServer_SPIFFESerializedRestartPolicy(t *testing.T) { + t.Parallel() + + newConfig := func(scope string, includeAssociation bool) authserver.RunConfig { + cfg := authserver.RunConfig{ + SchemaVersion: authserver.CurrentSchemaVersion, + Issuer: "https://auth.example.com", + ScopesSupported: []string{"openid", "profile"}, + AllowedAudiences: []string{"https://mcp.example.com"}, + // A static OAuth2 upstream satisfies runtime configuration validation + // without discovery or DCR network I/O during server construction. + Upstreams: []authserver.UpstreamRunConfig{{ + Name: "static-upstream", + Type: authserver.UpstreamProviderTypeOAuth2, + OAuth2Config: &authserver.OAuth2UpstreamRunConfig{ + AuthorizationEndpoint: "https://upstream.example.com/authorize", + TokenEndpoint: "https://upstream.example.com/token", + ClientID: "test-client-id", + RedirectURI: "https://auth.example.com/oauth/callback", + }, + }}, + } + if !includeAssociation { + return cfg + } + cfg.SPIFFETrustDomains = []authserver.SPIFFETrustDomainRunConfig{{ + Name: "production", + TrustDomain: "example.org", + Methods: []authserver.SPIFFEAuthenticationMethod{authserver.SPIFFEAuthenticationMethodX509}, + BundleSource: authserver.SPIFFEBundleSourceRunConfig{ + Type: authserver.SPIFFEBundleSourceTypeWorkloadAPI, + WorkloadAPI: &authserver.SPIFFEWorkloadAPIBundleSourceRunConfig{}, + }, + }} + cfg.InboundGrants = &authserver.InboundGrantsRunConfig{ + SPIFFEClientAuth: []authserver.SPIFFEClientAuthRunConfig{{ + TrustDomainRef: "production", + PrincipalPattern: "spiffe://example.org/ns/default/agent", + ClientID: "spiffe-client", + Methods: []authserver.SPIFFEAuthenticationMethod{authserver.SPIFFEAuthenticationMethodX509}, + Scopes: []string{scope}, + Audiences: []string{"https://mcp.example.com"}, + GrantTypes: []string{authserver.SPIFFEGrantTypeTokenExchange}, + }}, + } + return cfg + } + decode := func(t *testing.T, cfg authserver.RunConfig, yamlFormat bool) authserver.RunConfig { + t.Helper() + var encoded []byte + var err error + if yamlFormat { + encoded, err = yaml.Marshal(cfg) + } else { + encoded, err = json.Marshal(cfg) + } + require.NoError(t, err) + + var decoded authserver.RunConfig + if yamlFormat { + err = yaml.Unmarshal(encoded, &decoded) + } else { + err = json.Unmarshal(encoded, &decoded) + } + require.NoError(t, err) + return decoded + } + assertAuthority := func(t *testing.T, cfg authserver.RunConfig, scope string) { + t.Helper() + trust, err := authserver.NewSPIFFETrustConfig( + cfg.SPIFFETrustDomains, cfg.InboundGrants, cfg.ScopesSupported, cfg.AllowedAudiences, + ) + require.NoError(t, err) + associations := trust.Associations() + require.Len(t, associations, 1) + assert.Equal(t, []string{scope}, associations[0].AuthorizationPolicy().Scopes()) + } + + for _, yamlFormat := range []bool{false, true} { + yamlFormat := yamlFormat + format := "JSON" + if yamlFormat { + format = "YAML" + } + t.Run(format, func(t *testing.T) { + t.Parallel() + + // A non-empty spiffe_trust_domains is hard-rejected by + // RunConfig.Validate() until a real SVID-verification consumer + // lands (see config.go's validateSPIFFENotYetEnforced), so the + // "initial"/"changed" cases below prove serialization fidelity + // and authority reconstruction directly against + // NewSPIFFETrustConfig -- unaffected by that policy-layer + // rejection -- and separately confirm the rejection itself + // survives a JSON/YAML round trip, rather than constructing a + // full server. + initial := decode(t, newConfig("openid", true), yamlFormat) + assertAuthority(t, initial, "openid") + require.ErrorContains(t, initial.Validate(), "not yet enforced", + "a decoded non-empty SPIFFE configuration must still be hard-rejected") + + changed := decode(t, newConfig("profile", true), yamlFormat) + assertAuthority(t, changed, "profile") + require.ErrorContains(t, changed.Validate(), "not yet enforced", + "a decoded non-empty SPIFFE configuration must still be hard-rejected") + + removed := decode(t, newConfig("", false), yamlFormat) + trust, err := authserver.NewSPIFFETrustConfig( + removed.SPIFFETrustDomains, nil, removed.ScopesSupported, removed.AllowedAudiences, + ) + require.NoError(t, err) + assert.Empty(t, trust.Associations()) + registry, err := authserver.NewSPIFFEAssociationRegistry(trust) + require.NoError(t, err) + require.NotNil(t, registry) + // Every restart uses fresh memory; an empty (SPIFFE-removed) + // configuration is the only shape in this test that can still + // build a full server, since it does not trip the hard-reject. + for range 2 { + stor := storage.NewMemoryStorage() + server, err := NewEmbeddedAuthServerWithStorage(context.Background(), &removed, stor) + require.NoError(t, err) + require.NoError(t, server.Close()) + } + }) + } +} + +// sessionRecordingStorage observes token-session writes without adding a test +// hook to production storage. It embeds the real memory backend so every +// unoverridden storage operation retains its production behavior. +type sessionRecordingStorage struct { + *storage.MemoryStorage + authorizeCodeSessions atomic.Int32 + accessTokenSessions atomic.Int32 + refreshTokenSessions atomic.Int32 +} + +func (s *sessionRecordingStorage) CreateAuthorizeCodeSession(ctx context.Context, code string, request fosite.Requester) error { + s.authorizeCodeSessions.Add(1) + return s.MemoryStorage.CreateAuthorizeCodeSession(ctx, code, request) +} + +func (s *sessionRecordingStorage) CreateAccessTokenSession(ctx context.Context, signature string, request fosite.Requester) error { + s.accessTokenSessions.Add(1) + return s.MemoryStorage.CreateAccessTokenSession(ctx, signature, request) +} + +func (s *sessionRecordingStorage) CreateRefreshTokenSession( + ctx context.Context, + signature string, + accessSignature string, + request fosite.Requester, +) error { + s.refreshTokenSessions.Add(1) + return s.MemoryStorage.CreateRefreshTokenSession(ctx, signature, accessSignature, request) +} + +func TestEmbeddedAuthServer_SPIFFEAssociationDoesNotAuthenticateClient(t *testing.T) { + t.Parallel() + t.Skip("RunConfig.Validate() now hard-rejects any non-empty spiffe_trust_domains " + + "(config.go's validateSPIFFENotYetEnforced, per PR #6467 review) until a real " + + "SVID-verification consumer lands, so a live server can no longer be constructed " + + "with a SPIFFE association configured at all -- there is no path to this HTTP-level " + + "proof that does not route around cfg.Validate() in production code, which would " + + "reopen exactly the loophole that hard-reject exists to close. The underlying " + + "mechanism this test proved end-to-end (a SPIFFE client carries no secret and is " + + "never public, so fosite's own credential checks reject it, spoofed X-SPIFFE-ID " + + "header or not) is still covered at the unit level by " + + "registration.TestNewSPIFFEClient (GetHashedSecret is nil, IsPublic is false). " + + "Re-enable this test -- unmodified -- when the future PR that adds real SVID " + + "verification removes the hard-reject.") + + newConfig := func(scope string, includeAssociation bool) authserver.RunConfig { + cfg := authserver.RunConfig{ + SchemaVersion: authserver.CurrentSchemaVersion, + Issuer: "https://auth.example.com", + ScopesSupported: []string{"openid", "profile"}, + AllowedAudiences: []string{"https://mcp.example.com"}, + Upstreams: []authserver.UpstreamRunConfig{{ + Name: "static-upstream", + Type: authserver.UpstreamProviderTypeOAuth2, + OAuth2Config: &authserver.OAuth2UpstreamRunConfig{ + AuthorizationEndpoint: "https://upstream.example.com/authorize", + TokenEndpoint: "https://upstream.example.com/token", + ClientID: "upstream-client", + RedirectURI: "https://auth.example.com/oauth/callback", + }, + }}, + } + if !includeAssociation { + return cfg + } + cfg.SPIFFETrustDomains = []authserver.SPIFFETrustDomainRunConfig{{ + Name: "production", + TrustDomain: "example.org", + Methods: []authserver.SPIFFEAuthenticationMethod{ + authserver.SPIFFEAuthenticationMethodX509, + authserver.SPIFFEAuthenticationMethodJWT, + }, + BundleSource: authserver.SPIFFEBundleSourceRunConfig{ + Type: authserver.SPIFFEBundleSourceTypeWorkloadAPI, + WorkloadAPI: &authserver.SPIFFEWorkloadAPIBundleSourceRunConfig{}, + }, + }} + cfg.InboundGrants = &authserver.InboundGrantsRunConfig{ + SPIFFEClientAuth: []authserver.SPIFFEClientAuthRunConfig{{ + TrustDomainRef: "production", + PrincipalPattern: "spiffe://example.org/ns/default/agent", + ClientID: "spiffe-client", + Methods: []authserver.SPIFFEAuthenticationMethod{ + authserver.SPIFFEAuthenticationMethodX509, + authserver.SPIFFEAuthenticationMethodJWT, + }, + Scopes: []string{scope}, + Audiences: []string{"https://mcp.example.com"}, + GrantTypes: []string{authserver.SPIFFEGrantTypeTokenExchange}, + }}, + } + return cfg + } + serialize := func(t *testing.T, cfg authserver.RunConfig) authserver.RunConfig { + t.Helper() + encoded, err := json.Marshal(cfg) + require.NoError(t, err) + var decoded authserver.RunConfig + require.NoError(t, json.Unmarshal(encoded, &decoded)) + require.NoError(t, decoded.Validate()) + return decoded + } + assertStaticAuthority := func(t *testing.T, cfg authserver.RunConfig, scope string) { + t.Helper() + trust, err := authserver.NewSPIFFETrustConfig( + cfg.SPIFFETrustDomains, cfg.InboundGrants, cfg.ScopesSupported, cfg.AllowedAudiences, + ) + require.NoError(t, err) + associations := trust.Associations() + require.Len(t, associations, 1) + association := associations[0] + assert.Equal(t, "spiffe-client", association.ClientID()) + policy := association.AuthorizationPolicy() + assert.Equal(t, []string{scope}, policy.Scopes()) + assert.Equal(t, []string{"https://mcp.example.com"}, policy.Audiences()) + } + + newServer := func(t *testing.T, cfg authserver.RunConfig) (*sessionRecordingStorage, *httptest.Server) { + t.Helper() + stor := &sessionRecordingStorage{MemoryStorage: storage.NewMemoryStorage()} + embedded, err := NewEmbeddedAuthServerWithStorage(context.Background(), &cfg, stor) + require.NoError(t, err) + httpServer := httptest.NewServer(embedded.Handler()) + t.Cleanup(func() { + httpServer.Close() + require.NoError(t, embedded.Close()) + }) + return stor, httpServer + } + assertUnauthenticated := func(t *testing.T, serverURL string, stor *sessionRecordingStorage, spoofedHeader bool) { + t.Helper() + req, err := http.NewRequest(http.MethodPost, serverURL+"/oauth/token", strings.NewReader(url.Values{ + "grant_type": {authserver.SPIFFEGrantTypeTokenExchange}, + "subject_token": {"unvalidated-subject-token"}, + "subject_token_type": {"urn:ietf:params:oauth:token-type:access_token"}, + "client_id": {"spiffe-client"}, + "scope": {"openid"}, + "resource": {"https://mcp.example.com"}, + }.Encode())) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if spoofedHeader { + req.Header.Set("X-SPIFFE-ID", "spiffe://example.org/ns/default/agent") + } + resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req) + require.NoError(t, err) + defer resp.Body.Close() + var body map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + assert.Equal(t, "invalid_client", body["error"]) + assert.NotContains(t, body, "access_token") + assert.NotContains(t, body, "refresh_token") + assert.NotContains(t, body, "id_token") + assert.Zero(t, stor.authorizeCodeSessions.Load()) + assert.Zero(t, stor.accessTokenSessions.Load()) + assert.Zero(t, stor.refreshTokenSessions.Load()) + } + + initial := serialize(t, newConfig("openid", true)) + assertStaticAuthority(t, initial, "openid") + stor, httpServer := newServer(t, initial) + assertUnauthenticated(t, httpServer.URL, stor, false) + assertUnauthenticated(t, httpServer.URL, stor, true) + + // A fresh-memory restart reconstructs the static association from the same + // serialized operator-equivalent configuration and still does not authenticate it. + restarted := serialize(t, initial) + stor, httpServer = newServer(t, restarted) + assertStaticAuthority(t, restarted, "openid") + assertUnauthenticated(t, httpServer.URL, stor, true) + + changed := serialize(t, newConfig("profile", true)) + _, _ = newServer(t, changed) + assertStaticAuthority(t, changed, "profile") + + removed := serialize(t, newConfig("", false)) + _, _ = newServer(t, removed) + trust, err := authserver.NewSPIFFETrustConfig( + removed.SPIFFETrustDomains, nil, removed.ScopesSupported, removed.AllowedAudiences, + ) + require.NoError(t, err) + assert.Empty(t, trust.Associations()) + registry, err := authserver.NewSPIFFEAssociationRegistry(trust) + require.NoError(t, err) + require.NotNil(t, registry) + + // Redis restarts retain dynamic registrations but reconstruct static authority + // exclusively from the current serialized configuration. + redisServer := miniredis.RunT(t) + newRedisStorage := func() storage.Storage { + client := redis.NewClient(&redis.Options{Addr: redisServer.Addr()}) + return storage.NewRedisStorageWithClient(client, "test:spiffe:") + } + redisStorage := newRedisStorage() + require.NoError(t, redisStorage.RegisterClient(context.Background(), &fosite.DefaultClient{ID: "dynamic-client"})) + embedded, err := NewEmbeddedAuthServerWithStorage(context.Background(), &initial, redisStorage) + require.NoError(t, err) + require.NoError(t, embedded.Close()) + + redisStorage = newRedisStorage() + embedded, err = NewEmbeddedAuthServerWithStorage(context.Background(), &removed, redisStorage) + require.NoError(t, err) + _, err = redisStorage.GetClient(context.Background(), "dynamic-client") + require.NoError(t, err) + _, err = redisStorage.GetClient(context.Background(), "spiffe-client") + require.ErrorIs(t, err, storage.ErrNotFound) + require.NoError(t, embedded.Close()) + + // A durable row with a currently static ID is a startup collision, rather + // than authority that can be silently shadowed by configuration. + collisionStorage := newRedisStorage() + require.NoError(t, collisionStorage.RegisterClient(context.Background(), &fosite.DefaultClient{ID: "spiffe-client"})) + _, err = NewEmbeddedAuthServerWithStorage(context.Background(), &initial, collisionStorage) + require.ErrorIs(t, err, storage.ErrAlreadyExists) +} + // buildUpstreamConfigs: on first call it registers with the mock AS and // overlays the resolved client_id/client_secret; on second call it hits the // in-memory store and issues zero additional HTTP requests; and neither call diff --git a/pkg/authserver/server/registration/spiffe_client.go b/pkg/authserver/server/registration/spiffe_client.go new file mode 100644 index 0000000000..a8c7a3bbe4 --- /dev/null +++ b/pkg/authserver/server/registration/spiffe_client.go @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package registration + +import ( + "fmt" + "slices" + + "github.com/ory/fosite" + + "github.com/stacklok/toolhive/pkg/oauthproto" +) + +// SPIFFEClient is the immutable OAuth client representation of a configured +// SPIFFE principal association. It is neither public nor secret-bearing. A +// future credential-validation implementation will authenticate its SPIFFE +// credentials; this configuration-only implementation does not authenticate +// any credentials. +type SPIFFEClient struct { + id string + grantTypes fosite.Arguments + scopes fosite.Arguments + audiences []string +} + +// NewSPIFFEClient creates an immutable, secretless, non-public client for a +// configured SPIFFE association. GetAudience returns the configured allowlist; +// token exchange checks both audience and resource request parameters against it. +func NewSPIFFEClient(id string, scopes, audiences []string) (*SPIFFEClient, error) { + if id == "" { + return nil, fmt.Errorf("SPIFFE client ID is required") + } + if len(scopes) == 0 || len(audiences) == 0 { + return nil, fmt.Errorf("SPIFFE client scopes and audiences are required") + } + + return &SPIFFEClient{ + id: id, + grantTypes: fosite.Arguments{oauthproto.GrantTypeTokenExchange}, + scopes: slices.Clone(scopes), + audiences: slices.Clone(audiences), + }, nil +} + +// GetID returns the configured association client ID. +func (c *SPIFFEClient) GetID() string { return c.id } + +// GetHashedSecret returns nil because no OAuth client secret is assigned. Future +// SPIFFE credential validation is outside this configuration-only implementation. +func (*SPIFFEClient) GetHashedSecret() []byte { return nil } + +// GetRedirectURIs returns nil because SPIFFE clients do not use authorization redirects. +func (*SPIFFEClient) GetRedirectURIs() []string { return nil } + +// GetGrantTypes returns a copy of the configured grant types. +func (c *SPIFFEClient) GetGrantTypes() fosite.Arguments { return slices.Clone(c.grantTypes) } + +// GetResponseTypes returns nil because SPIFFE clients do not use authorization responses. +func (*SPIFFEClient) GetResponseTypes() fosite.Arguments { return nil } + +// GetScopes returns a copy of the configured scopes. +func (c *SPIFFEClient) GetScopes() fosite.Arguments { return slices.Clone(c.scopes) } + +// Audiences returns a copy of the allowed token-exchange target identifiers. +func (c *SPIFFEClient) Audiences() []string { return slices.Clone(c.audiences) } + +// GetAudience returns the allowed token-exchange target identifiers. The token +// exchange handler checks both the RFC 8693 audience and RFC 8707 resource +// request parameters against this Fosite client field. +func (c *SPIFFEClient) GetAudience() fosite.Arguments { return slices.Clone(c.audiences) } + +// IsPublic returns false so Fosite does not treat unauthenticated requests as +// public-client requests. Future SPIFFE credential validation remains separate. +func (*SPIFFEClient) IsPublic() bool { return false } + +var _ fosite.Client = (*SPIFFEClient)(nil) diff --git a/pkg/authserver/server/registration/spiffe_client_test.go b/pkg/authserver/server/registration/spiffe_client_test.go new file mode 100644 index 0000000000..232acd9eb4 --- /dev/null +++ b/pkg/authserver/server/registration/spiffe_client_test.go @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package registration + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewSPIFFEClient(t *testing.T) { + t.Parallel() + + scopes := []string{"openid"} + audiences := []string{"https://api.example.com"} + client, err := NewSPIFFEClient("spiffe-client", scopes, audiences) + require.NoError(t, err) + + scopes[0] = "changed" + audiences[0] = "changed" + + assert.Equal(t, "spiffe-client", client.GetID()) + assert.Nil(t, client.GetHashedSecret()) + assert.Nil(t, client.GetRedirectURIs()) + assert.Nil(t, client.GetResponseTypes()) + assert.False(t, client.IsPublic()) + assert.Equal(t, "urn:ietf:params:oauth:grant-type:token-exchange", client.GetGrantTypes()[0]) + assert.Equal(t, "openid", client.GetScopes()[0]) + assert.Equal(t, []string{"https://api.example.com"}, client.Audiences()) + assert.Equal(t, []string{"https://api.example.com"}, []string(client.GetAudience())) + + client.GetScopes()[0] = "mutated" + client.Audiences()[0] = "mutated" + client.GetAudience()[0] = "mutated" + assert.Equal(t, "openid", client.GetScopes()[0]) + assert.Equal(t, []string{"https://api.example.com"}, client.Audiences()) + assert.Equal(t, []string{"https://api.example.com"}, []string(client.GetAudience())) + + _, err = NewSPIFFEClient("disabled-exchange", nil, nil) + require.Error(t, err) +} + +func TestNewSPIFFEClient_RequiresID(t *testing.T) { + t.Parallel() + + _, err := NewSPIFFEClient("", nil, nil) + require.Error(t, err) +} + +func TestNewSPIFFEClient_RequiresAudiences(t *testing.T) { + t.Parallel() + + _, err := NewSPIFFEClient( + "spiffe-client", + []string{"openid"}, + nil, + ) + require.EqualError(t, err, "SPIFFE client scopes and audiences are required") +} diff --git a/pkg/authserver/server/tokenexchange/jwt_bearer_handler.go b/pkg/authserver/server/tokenexchange/jwt_bearer_handler.go index ac7fa19f22..3ff5bcfcf7 100644 --- a/pkg/authserver/server/tokenexchange/jwt_bearer_handler.go +++ b/pkg/authserver/server/tokenexchange/jwt_bearer_handler.go @@ -448,8 +448,11 @@ func JWTBearerIssuanceFactory(trustedIssuers []TrustedIssuer, shared *MultiIssue func assertionJWTConsumer(rawStorage fosite.Storage) (storage.AssertionJWTConsumer, error) { baseStorage := rawStorage - if decorated, ok := rawStorage.(*storage.CIMDStorageDecorator); ok { - baseStorage = decorated.Unwrap() + // Peel every storage decorator, not just one: the SPIFFE static-client + // overlay wraps the CIMD decorator, and AssertionJWTConsumer is not part + // of storage.Storage, so it is not promoted through an embedded decorator. + if stor, ok := rawStorage.(storage.Storage); ok { + baseStorage = storage.Unwrap(stor) } consumer, ok := baseStorage.(storage.AssertionJWTConsumer) if !ok { diff --git a/pkg/authserver/server_impl.go b/pkg/authserver/server_impl.go index c938b89926..b59765e7f8 100644 --- a/pkg/authserver/server_impl.go +++ b/pkg/authserver/server_impl.go @@ -166,12 +166,17 @@ func newServer(ctx context.Context, cfg Config, stor storage.Storage) (_ *server // provably safe for the production backends; surfacing a bad backend as // a constructor error keeps misconfiguration fail-loud at boot rather // than at first DCR resolve. - baseStore := unwrapStorage(stor) + baseStore := storage.Unwrap(stor) dcrStore, ok := baseStore.(storage.DCRCredentialStore) if !ok { return nil, fmt.Errorf("storage backend %T does not implement storage.DCRCredentialStore", baseStore) } + stor, err := decorateStorageForSPIFFE(ctx, cfg, stor) + if err != nil { + return nil, err + } + if err := registerDelegateClients(ctx, stor, cfg.DelegateClients); err != nil { return nil, err } @@ -245,15 +250,7 @@ func newServer(ctx context.Context, cfg Config, stor storage.Storage) (_ *server return nil, err } - // Wrap storage with the CIMD decorator before constructing the fosite provider - // so that GetClient calls for HTTPS client_id values are intercepted at the - // fosite level (not just the handler level). - stor, err = decorateStorageForCIMD(cfg, stor) - if err != nil { - return nil, err - } - - // Create fosite provider with the (possibly decorated) storage. + // Create fosite provider with the configured storage decorators. slog.Debug("creating fosite OAuth2 provider") fositeProvider, trustedIssuerValidator, err := buildProvider(cfg, authServerConfig, stor) if err != nil { @@ -313,6 +310,33 @@ func registerDelegateClients(ctx context.Context, stor storage.Storage, delegate return nil } +// decorateStorageForSPIFFE resolves the immutable association registry from the +// validated SPIFFE trust model and installs the static overlay outside CIMD. +// A nil cfg.SPIFFETrust means no SPIFFE associations are configured, which +// yields a nil registry and leaves the storage chain unchanged. +func decorateStorageForSPIFFE(ctx context.Context, cfg Config, stor storage.Storage) (storage.Storage, error) { + registry, err := NewSPIFFEAssociationRegistry(cfg.SPIFFETrust) + if err != nil { + return nil, fmt.Errorf("create SPIFFE association registry: %w", err) + } + + // Install dynamic CIMD lookup before the static SPIFFE overlay so configured + // clients always take precedence over remotely resolved HTTPS client IDs. + stor, err = decorateStorageForCIMD(cfg, stor) + if err != nil { + return nil, err + } + clients, err := registry.staticClients() + if err != nil { + return nil, fmt.Errorf("build SPIFFE static clients: %w", err) + } + stor, err = storage.NewSPIFFEStorageDecorator(ctx, stor, clients) + if err != nil { + return nil, fmt.Errorf("initialize SPIFFE client overlay: %w", err) + } + return stor, nil +} + // decorateStorageForCIMD wraps stor with the CIMD decorator when CIMD is // enabled, so GetClient calls for HTTPS client_id values are intercepted at // the fosite level (not just the handler level). @@ -588,21 +612,11 @@ func createProvider( ) } -// unwrapStorage peels off one decorator layer if the storage implements -// Unwrap(), returning the concrete backend. Both newServer (DCRCredentialStore -// assertion) and runLegacyMigration (RedisStorage type assertion) need this. -func unwrapStorage(stor storage.Storage) storage.Storage { - if unwrapper, ok := stor.(interface{ Unwrap() storage.Storage }); ok { - return unwrapper.Unwrap() - } - return stor -} - // runLegacyMigration runs one-shot Redis data migrations before handlers are // constructed. It is a no-op for non-Redis backends and passes through any // decorator wrapping so the concrete type can be reached. func runLegacyMigration(ctx context.Context, stor storage.Storage, upstreams []UpstreamConfig) error { - base := unwrapStorage(stor) + base := storage.Unwrap(stor) rs, ok := base.(*storage.RedisStorage) if !ok { return nil diff --git a/pkg/authserver/server_test.go b/pkg/authserver/server_test.go index 900720efca..69cf9368d7 100644 --- a/pkg/authserver/server_test.go +++ b/pkg/authserver/server_test.go @@ -482,6 +482,61 @@ func TestNewServer_CIMDEnabled_WrapsStorage(t *testing.T) { // A regression that reintroduced per-call allocation would leave the // refresher's own singleflight test green, so this asserts instance identity // at the server boundary instead. +func TestNewServer_SPIFFEAndCIMD_WrapStorageInOrder(t *testing.T) { + t.Parallel() + + trust, err := NewSPIFFETrustConfig( + []SPIFFETrustDomainRunConfig{{ + Name: "production", + TrustDomain: "example.org", + Methods: []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodX509}, + BundleSource: SPIFFEBundleSourceRunConfig{ + Type: SPIFFEBundleSourceTypeWorkloadAPI, + WorkloadAPI: &SPIFFEWorkloadAPIBundleSourceRunConfig{}, + }, + }}, + &InboundGrantsRunConfig{SPIFFEClientAuth: []SPIFFEClientAuthRunConfig{{ + TrustDomainRef: "production", + PrincipalPattern: "spiffe://example.org/ns/default/agent", + ClientID: "spiffe-client", + Methods: []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodX509}, + Scopes: []string{"openid"}, + Audiences: []string{"https://mcp.example.com"}, + GrantTypes: []string{SPIFFEGrantTypeTokenExchange}, + }}}, + []string{"openid", "profile"}, + []string{"https://mcp.example.com"}, + ) + require.NoError(t, err) + + stor := storage.NewMemoryStorage() + t.Cleanup(func() { _ = stor.Close() }) + cfg := Config{ + CIMDEnabled: true, + CIMDCacheMaxSize: 16, + CIMDCacheFallbackTTL: 5 * time.Minute, + SPIFFETrust: trust, + } + + // decorateStorageForSPIFFE is exercised directly, not through newServer, + // because Config.SPIFFETrust is hard-rejected by Config.Validate() (and + // therefore by newServer, which calls it) until a real SVID-verification + // consumer lands -- see validateConfigSPIFFENotYetEnforced. This test's + // actual subject, the storage-decoration order, lives entirely below + // that policy gate. + decorated, err := decorateStorageForSPIFFE(context.Background(), cfg, stor) + require.NoError(t, err) + + spiffeStorage, ok := decorated.(*storage.SPIFFEStorageDecorator) + require.True(t, ok, "SPIFFE static clients must wrap the CIMD storage layer") + _, ok = spiffeStorage.Unwrap().(*storage.CIMDStorageDecorator) + assert.True(t, ok, "CIMD must remain below the SPIFFE static client overlay") + + client, err := decorated.GetClient(context.Background(), "spiffe-client") + require.NoError(t, err) + assert.Equal(t, "spiffe-client", client.GetID()) +} + func TestNewServer_UpstreamRefresherSharedInstance(t *testing.T) { t.Parallel() diff --git a/pkg/authserver/spiffe_association_registry.go b/pkg/authserver/spiffe_association_registry.go new file mode 100644 index 0000000000..aea39ac26c --- /dev/null +++ b/pkg/authserver/spiffe_association_registry.go @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package authserver + +import ( + "fmt" + + "github.com/ory/fosite" + + "github.com/stacklok/toolhive/pkg/authserver/server/registration" +) + +// SPIFFEAssociationRegistry is the immutable runtime index of validated SPIFFE +// associations. It selects policy only; it neither accepts nor authenticates a +// SPIFFE credential. +type SPIFFEAssociationRegistry struct { + byPattern map[string]SPIFFEClientAuthConfig + byClientID map[string]SPIFFEClientAuthConfig +} + +// NewSPIFFEAssociationRegistry creates an immutable lookup registry from a +// trust configuration. A nil trust configuration represents an absent SPIFFE +// configuration and returns nil without enabling SPIFFE clients. +func NewSPIFFEAssociationRegistry(trust *SPIFFETrustConfig) (*SPIFFEAssociationRegistry, error) { + if trust == nil { + return nil, nil + } + + associations := trust.Associations() + registry := &SPIFFEAssociationRegistry{ + byPattern: make(map[string]SPIFFEClientAuthConfig, len(associations)), + byClientID: make(map[string]SPIFFEClientAuthConfig, len(associations)), + } + for _, association := range associations { + if _, exists := registry.byPattern[association.Principal()]; exists { + return nil, fmt.Errorf("duplicate SPIFFE association pattern %q", association.Principal()) + } + if _, exists := registry.byClientID[association.ClientID()]; exists { + return nil, fmt.Errorf("duplicate SPIFFE association client ID %q", association.ClientID()) + } + registry.byPattern[association.Principal()] = association.clone() + registry.byClientID[association.ClientID()] = association.clone() + } + return registry, nil +} + +// staticClients builds immutable OAuth clients for all configured associations. +func (r *SPIFFEAssociationRegistry) staticClients() (map[string]fosite.Client, error) { + if r == nil { + return nil, nil + } + clients := make(map[string]fosite.Client, len(r.byClientID)) + for clientID, association := range r.byClientID { + policy := association.AuthorizationPolicy() + client, err := registration.NewSPIFFEClient( + association.ClientID(), policy.Scopes(), policy.Audiences(), + ) + if err != nil { + return nil, fmt.Errorf("SPIFFE client %q: %w", clientID, err) + } + clients[clientID] = client + } + return clients, nil +} diff --git a/pkg/authserver/spiffe_association_registry_test.go b/pkg/authserver/spiffe_association_registry_test.go new file mode 100644 index 0000000000..4b03d0e74d --- /dev/null +++ b/pkg/authserver/spiffe_association_registry_test.go @@ -0,0 +1,87 @@ +// 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" +) + +func TestSPIFFEAssociationRegistryRebuildsChangedAndRemovedAuthority(t *testing.T) { + t.Parallel() + + initial := newTestSPIFFEAssociationRegistry(t, []SPIFFEClientAuthRunConfig{testSPIFFEAssociation("client", "openid")}) + changed := newTestSPIFFEAssociationRegistry(t, []SPIFFEClientAuthRunConfig{testSPIFFEAssociation("client", "profile")}) + removedTrust, err := NewSPIFFETrustConfig(nil, nil, []string{"openid", "profile"}, []string{"https://resource.example.com"}) + require.NoError(t, err) + assert.Empty(t, removedTrust.Associations()) + removed, err := NewSPIFFEAssociationRegistry(removedTrust) + require.NoError(t, err) + + initialClients, err := initial.staticClients() + require.NoError(t, err) + initialClient, found := initialClients["client"] + require.True(t, found) + assert.Equal(t, []string{"openid"}, []string(initialClient.GetScopes())) + + changedClients, err := changed.staticClients() + require.NoError(t, err) + changedClient, found := changedClients["client"] + require.True(t, found) + assert.Equal(t, []string{"profile"}, []string(changedClient.GetScopes())) + + removedClients, err := removed.staticClients() + require.NoError(t, err) + assert.Empty(t, removedClients) +} + +func TestSPIFFEAssociationRegistryKeepsClientAudiencesIsolated(t *testing.T) { + t.Parallel() + + first := testSPIFFEAssociation("first-client", "openid") + first.Audiences = []string{"https://resource.example.com"} + second := testSPIFFEAssociation("second-client", "profile") + second.PrincipalPattern = "spiffe://example.org/ns/default/other-agent" + second.Audiences = []string{"https://audience.example.com"} + + registry := newTestSPIFFEAssociationRegistry(t, []SPIFFEClientAuthRunConfig{first, second}) + clients, err := registry.staticClients() + require.NoError(t, err) + + assert.Equal(t, []string{"https://resource.example.com"}, []string(clients["first-client"].GetAudience())) + assert.Equal(t, []string{"https://audience.example.com"}, []string(clients["second-client"].GetAudience())) +} + +func newTestSPIFFEAssociationRegistry(t *testing.T, associations []SPIFFEClientAuthRunConfig) *SPIFFEAssociationRegistry { + t.Helper() + + trust, err := NewSPIFFETrustConfig([]SPIFFETrustDomainRunConfig{{ + Name: "production", + TrustDomain: "example.org", + Methods: []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodX509, SPIFFEAuthenticationMethodJWT}, + BundleSource: SPIFFEBundleSourceRunConfig{ + Type: SPIFFEBundleSourceTypeWorkloadAPI, + WorkloadAPI: &SPIFFEWorkloadAPIBundleSourceRunConfig{}, + }, + }}, &InboundGrantsRunConfig{SPIFFEClientAuth: associations}, []string{"openid", "profile"}, + []string{"https://resource.example.com", "https://audience.example.com"}) + require.NoError(t, err) + registry, err := NewSPIFFEAssociationRegistry(trust) + require.NoError(t, err) + return registry +} + +func testSPIFFEAssociation(clientID, scope string) SPIFFEClientAuthRunConfig { + return SPIFFEClientAuthRunConfig{ + TrustDomainRef: "production", + PrincipalPattern: "spiffe://example.org/ns/default/agent", + ClientID: clientID, + Methods: []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodX509}, + Scopes: []string{scope}, + Audiences: []string{"https://audience.example.com"}, + GrantTypes: []string{SPIFFEGrantTypeTokenExchange}, + } +} diff --git a/pkg/authserver/spiffe_preflight.go b/pkg/authserver/spiffe_preflight.go new file mode 100644 index 0000000000..7d09cd1e46 --- /dev/null +++ b/pkg/authserver/spiffe_preflight.go @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package authserver + +import ( + "context" + "fmt" + + "github.com/stacklok/toolhive/pkg/authserver/storage" +) + +// PreflightSPIFFEStaticClientCollisions rejects static client IDs that already +// exist in durable storage. It runs before upstream DCR registration so a +// collision cannot leave an orphaned upstream registration. +func PreflightSPIFFEStaticClientCollisions(ctx context.Context, base storage.Storage, trust *SPIFFETrustConfig) error { + registry, err := NewSPIFFEAssociationRegistry(trust) + if err != nil { + return fmt.Errorf("create SPIFFE association registry: %w", err) + } + clients, err := registry.staticClients() + if err != nil { + return err + } + return storage.PreflightSPIFFEStaticClientCollisions(ctx, base, clients) +} diff --git a/pkg/authserver/storage/spiffe_decorator.go b/pkg/authserver/storage/spiffe_decorator.go new file mode 100644 index 0000000000..648fbd78e8 --- /dev/null +++ b/pkg/authserver/storage/spiffe_decorator.go @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package storage + +import ( + "context" + "errors" + "fmt" + "maps" + + "github.com/ory/fosite" + + "github.com/stacklok/toolhive/pkg/authserver/server/registration" +) + +// SPIFFEStorageDecorator resolves immutable configured SPIFFE clients before +// the dynamic DCR/CIMD storage backend. Static clients exist only in this +// overlay; they are never persisted or eligible for registration. +type SPIFFEStorageDecorator struct { + Storage + clients map[string]fosite.Client +} + +// NewSPIFFEStorageDecorator overlays static SPIFFE clients over base. It checks +// the durable backend for client-ID collisions before creating the overlay. +func NewSPIFFEStorageDecorator(ctx context.Context, base Storage, clients map[string]fosite.Client) (Storage, error) { + if base == nil { + return nil, fmt.Errorf("storage is required") + } + if len(clients) == 0 { + return base, nil + } + privateClients := maps.Clone(clients) + for clientID, client := range privateClients { + if client == nil { + return nil, fmt.Errorf("static SPIFFE client %q is required", clientID) + } + if client.GetID() != clientID { + return nil, fmt.Errorf("static SPIFFE client map key %q does not match client ID %q", clientID, client.GetID()) + } + } + if err := preflightDurableCollisions(ctx, base, privateClients); err != nil { + return nil, err + } + return &SPIFFEStorageDecorator{Storage: base, clients: privateClients}, nil +} + +// PreflightSPIFFEStaticClientCollisions checks configured static client IDs +// against durable storage without creating a persistent reservation. +func PreflightSPIFFEStaticClientCollisions(ctx context.Context, base Storage, clients map[string]fosite.Client) error { + if base == nil { + return fmt.Errorf("storage is required") + } + return preflightDurableCollisions(ctx, base, clients) +} + +func preflightDurableCollisions(ctx context.Context, base Storage, clients map[string]fosite.Client) error { + for clientID := range clients { + if client, err := Unwrap(base).GetClient(ctx, clientID); err == nil { + kind := "operator-declared" + if registration.DCRIssued(client) { + kind = "DCR-issued" + } + return fmt.Errorf( + "%w: static SPIFFE client ID %q collides with an existing %s client; "+ + "remove the stale registration from durable storage before configuring this association", + ErrAlreadyExists, clientID, kind, + ) + } else if !errors.Is(err, ErrNotFound) && !errors.Is(err, fosite.ErrNotFound) { + return fmt.Errorf("check durable client collision for SPIFFE client ID %q: %w", clientID, err) + } + } + return nil +} + +// GetClient returns a configured SPIFFE client before consulting the dynamic backend. +func (d *SPIFFEStorageDecorator) GetClient(ctx context.Context, id string) (fosite.Client, error) { + if client, ok := d.clients[id]; ok { + return client, nil + } + return d.Storage.GetClient(ctx, id) +} + +// RegisterClient rejects reserved static SPIFFE IDs and delegates other registrations. +func (d *SPIFFEStorageDecorator) RegisterClient(ctx context.Context, client fosite.Client) error { + if client == nil { + return fmt.Errorf("register client: client is required") + } + if _, reserved := d.clients[client.GetID()]; reserved { + return fmt.Errorf("%w: client ID %q is reserved for a static SPIFFE client", ErrAlreadyExists, client.GetID()) + } + return d.Storage.RegisterClient(ctx, client) +} + +// Unwrap returns the dynamic DCR/CIMD storage backend. +func (d *SPIFFEStorageDecorator) Unwrap() Storage { return d.Storage } diff --git a/pkg/authserver/storage/spiffe_decorator_test.go b/pkg/authserver/storage/spiffe_decorator_test.go new file mode 100644 index 0000000000..11809a0acb --- /dev/null +++ b/pkg/authserver/storage/spiffe_decorator_test.go @@ -0,0 +1,118 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package storage + +import ( + "context" + "testing" + + "github.com/ory/fosite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/authserver/server/registration" +) + +func testSPIFFEClients(t *testing.T) map[string]fosite.Client { + t.Helper() + client, err := registration.NewSPIFFEClient( + "spiffe-client", + []string{"openid"}, + []string{"https://api.example.com"}, + ) + require.NoError(t, err) + return map[string]fosite.Client{client.GetID(): client} +} + +func TestSPIFFEStorageDecoratorStaticClients(t *testing.T) { + t.Parallel() + base := NewMemoryStorage() + t.Cleanup(func() { _ = base.Close() }) + + decorated, err := NewSPIFFEStorageDecorator(context.Background(), base, testSPIFFEClients(t)) + require.NoError(t, err) + client, err := decorated.GetClient(context.Background(), "spiffe-client") + require.NoError(t, err) + assert.Equal(t, "spiffe-client", client.GetID()) + assert.Empty(t, client.GetResponseTypes()) + require.ErrorIs(t, decorated.RegisterClient(context.Background(), &fosite.DefaultClient{ID: "spiffe-client"}), ErrAlreadyExists) +} + +func TestSPIFFEStorageDecoratorRejectsInvalidClients(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + clients map[string]fosite.Client + wantErr string + }{ + { + name: "nil client", + clients: map[string]fosite.Client{"spiffe-client": nil}, + wantErr: `static SPIFFE client "spiffe-client" is required`, + }, + { + name: "map key does not match client ID", + clients: map[string]fosite.Client{ + "map-key": &fosite.DefaultClient{ID: "client-id"}, + }, + wantErr: `map key "map-key" does not match client ID "client-id"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + base := NewMemoryStorage() + t.Cleanup(func() { _ = base.Close() }) + + _, err := NewSPIFFEStorageDecorator(context.Background(), base, tt.clients) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +func TestSPIFFEStorageDecoratorClonesClientMap(t *testing.T) { + t.Parallel() + ctx := context.Background() + base := NewMemoryStorage() + t.Cleanup(func() { _ = base.Close() }) + clients := testSPIFFEClients(t) + + decorated, err := NewSPIFFEStorageDecorator(ctx, base, clients) + require.NoError(t, err) + delete(clients, "spiffe-client") + clients["added-client"] = &fosite.DefaultClient{ID: "added-client"} + + client, err := decorated.GetClient(ctx, "spiffe-client") + require.NoError(t, err) + assert.Equal(t, "spiffe-client", client.GetID()) + require.ErrorIs(t, decorated.RegisterClient(ctx, &fosite.DefaultClient{ID: "spiffe-client"}), ErrAlreadyExists) + + _, err = decorated.GetClient(ctx, "added-client") + require.ErrorIs(t, err, ErrNotFound) + require.NoError(t, decorated.RegisterClient(ctx, &fosite.DefaultClient{ID: "added-client"})) +} + +func TestSPIFFEStorageDecoratorRejectsDurableCollision(t *testing.T) { + t.Parallel() + base := NewMemoryStorage() + t.Cleanup(func() { _ = base.Close() }) + require.NoError(t, base.RegisterClient(context.Background(), &fosite.DefaultClient{ID: "spiffe-client"})) + + _, err := NewSPIFFEStorageDecorator(context.Background(), base, testSPIFFEClients(t)) + require.ErrorIs(t, err, ErrAlreadyExists) + assert.Contains(t, err.Error(), "operator-declared") + assert.Contains(t, err.Error(), "remove the stale registration") +} + +func TestSPIFFEStorageDecoratorLeavesEmptyClientsUnchanged(t *testing.T) { + t.Parallel() + base := NewMemoryStorage() + t.Cleanup(func() { _ = base.Close() }) + decorated, err := NewSPIFFEStorageDecorator(context.Background(), base, nil) + require.NoError(t, err) + assert.Same(t, base, decorated) +} diff --git a/pkg/authserver/storage/unwrap.go b/pkg/authserver/storage/unwrap.go new file mode 100644 index 0000000000..77b05e402a --- /dev/null +++ b/pkg/authserver/storage/unwrap.go @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package storage + +// Unwrap returns the innermost storage backend by recursively peeling storage +// decorators that expose Unwrap. It is used at construction boundaries that +// require the durable backend, such as static-client collision checks. +func Unwrap(stor Storage) Storage { + for { + unwrapper, ok := stor.(interface{ Unwrap() Storage }) + if !ok { + return stor + } + stor = unwrapper.Unwrap() + } +} From e0d4d752bee6d82b0142ea0734a8db4c75871044 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Fri, 28 Aug 2026 23:23:45 +0200 Subject: [PATCH 05/14] Harden static client registration Restart reconstruction must not overwrite dynamic registrations or weaken the existing DCR replacement contract. Separate configured-client insertion from replacement and enforce duplicate behavior consistently in memory and Redis storage. Refs #6200 Signed-off-by: Jakub Hrozek --- pkg/authserver/server_test.go | 27 ++++++++++++++++++-------- pkg/authserver/storage/memory.go | 27 +++++++++++++++----------- pkg/authserver/storage/memory_test.go | 25 ++++++------------------ pkg/authserver/storage/redis.go | 28 +++++++++++++++++++++++++-- pkg/authserver/storage/types.go | 18 ++++++----------- 5 files changed, 73 insertions(+), 52 deletions(-) diff --git a/pkg/authserver/server_test.go b/pkg/authserver/server_test.go index 69cf9368d7..3db0c3ad71 100644 --- a/pkg/authserver/server_test.go +++ b/pkg/authserver/server_test.go @@ -595,6 +595,15 @@ func TestNewServer_RegistersDelegateClientsBeforeUpstreamConstruction(t *testing ctx := t.Context() stor := storage.NewMemoryStorage() t.Cleanup(func() { _ = stor.Close() }) + + // Seed a DCR-issued client under the same ID as the configured delegate + // client. The static delegate client is authoritative, so registering it + // must replace this DCR registration rather than being rejected as a + // duplicate. + dcrClient, err := registration.NewConfidentialPlain(registration.Config{ID: "delegate", Secret: "old-secret"}) + require.NoError(t, err) + require.NoError(t, stor.RegisterClient(ctx, dcrClient)) + cfg := Config{ Issuer: "https://example.com", KeyProvider: keys.NewGeneratingProvider(keys.DefaultAlgorithm), @@ -617,19 +626,21 @@ func TestNewServer_RegistersDelegateClientsBeforeUpstreamConstruction(t *testing } cfg.UpstreamFactory = factory - _, err := newServer(ctx, cfg, stor) - require.ErrorIs(t, err, assert.AnError) - // Startup registration is an upsert. Replacing a same-ID DCR client makes - // it permanent and unmarked rather than retaining DCR eviction semantics. - dcrClient, err := registration.NewConfidentialPlain(registration.Config{ID: "delegate", Secret: "old-secret"}) - require.NoError(t, err) - require.NoError(t, stor.RegisterClient(ctx, dcrClient)) - _, err = newServer(ctx, cfg, stor) + // First boot: static registration is an upsert. Replacing a same-ID DCR + // client makes it permanent and unmarked rather than retaining DCR + // eviction semantics. + _, err := newServer(ctx, cfg, stor) require.ErrorIs(t, err, assert.AnError) client, err := stor.GetClient(ctx, "delegate") require.NoError(t, err) assert.False(t, registration.DCRIssued(client)) + + // Second boot: registerDelegateClients runs on every startup, so + // re-registering the same static client (not a DCR duplicate) must not + // fail the server on restart. + _, err = newServer(ctx, cfg, stor) + require.ErrorIs(t, err, assert.AnError) } // closeCountingProvider is an OAuth2Provider that implements the optional diff --git a/pkg/authserver/storage/memory.go b/pkg/authserver/storage/memory.go index 07967f316b..af62e1e891 100644 --- a/pkg/authserver/storage/memory.go +++ b/pkg/authserver/storage/memory.go @@ -449,15 +449,11 @@ func getExpirationFromRequester(request fosite.Requester, tokenType fosite.Token return expTime } -// RegisterClient adds or updates a client in the storage. -// This is useful for setting up test clients. -// -// When the client map is at maxClients, the oldest DCR-issued registration -// that has aged past minClientAge is evicted to make room. A pre-provisioned -// client (no registration.DCRIssued marker) is never evicted; if no current -// DCR-issued client is old enough to evict, RegisterClient returns -// ErrClientCapacity. Re-registering an existing ID moves it to the back of the -// eviction queue. +// RegisterClient creates or replaces a client in storage. A DCR-issued +// registration is create-only and returns ErrAlreadyExists when a client with +// the same ID is already registered; an operator-declared static/delegate +// client is authoritative and always replaces any existing registration, +// including one DCR issued. func (s *MemoryStorage) RegisterClient(_ context.Context, client fosite.Client) error { id := client.GetID() if err := ValidateRegisterableClientID(id); err != nil { @@ -469,8 +465,17 @@ func (s *MemoryStorage) RegisterClient(_ context.Context, client fosite.Client) now := time.Now() if _, exists := s.clients[id]; exists { - // Refresh the eviction position: an actively re-registering client is - // not the oldest. + // DCR is unauthenticated (the /register endpoint has no caller + // identity), so a DCR-issued registration must never clobber an + // existing client. Operator-declared static/delegate clients are + // authoritative and may replace any existing registration, including + // one DCR issued: this is how a static overlay promotes a client that + // first appeared via DCR into permanent configuration. + if registration.DCRIssued(client) { + return fmt.Errorf("%w: client %q", ErrAlreadyExists, id) + } + // Refresh the eviction position: the replacing registration is not + // the oldest, and clientOrder must not carry a stale duplicate entry. s.clientOrder = slices.DeleteFunc(s.clientOrder, func(e clientOrderEntry) bool { return e.id == id }) } else if s.maxClients > 0 && len(s.clients) >= s.maxClients { if idx := s.oldestEvictableClientIndex(now); idx >= 0 { diff --git a/pkg/authserver/storage/memory_test.go b/pkg/authserver/storage/memory_test.go index c40f134001..0a27c2a477 100644 --- a/pkg/authserver/storage/memory_test.go +++ b/pkg/authserver/storage/memory_test.go @@ -23,7 +23,6 @@ import ( "errors" "fmt" "net/url" - "strconv" "sync" "sync/atomic" "testing" @@ -274,8 +273,7 @@ func TestMemoryStorage_StaticClientReplacesDCRClient(t *testing.T) { // TestMemoryStorage_RegisterClient_Bounded pins the anti-DoS cap: the client // map is bounded by maxClients with oldest-first eviction among DCR-issued -// clients only, a re-registered client refreshes its eviction position, and -// the survivors still authenticate. +// clients only, and duplicate registrations fail without changing stored state. func TestMemoryStorage_RegisterClient_Bounded(t *testing.T) { t.Parallel() @@ -290,31 +288,20 @@ func TestMemoryStorage_RegisterClient_Bounded(t *testing.T) { require.NoError(t, s.RegisterClient(ctx, dcrClient(t, id))) } - // Re-register client-1: it is no longer the oldest, so the next overflow - // evicts client-2 instead. - require.NoError(t, s.RegisterClient(ctx, dcrClient(t, "client-1"))) + // A duplicate does not update the original registration or eviction order. + require.ErrorIs(t, s.RegisterClient(ctx, dcrClient(t, "client-1")), ErrAlreadyExists) - // Overflow: client-2 (now oldest) is evicted; everyone else survives. + // Overflow evicts the oldest client. require.NoError(t, s.RegisterClient(ctx, dcrClient(t, "client-4"))) - _, err := s.GetClient(ctx, "client-2") + _, err := s.GetClient(ctx, "client-1") requireNotFoundError(t, err) - for _, id := range []string{"client-1", "client-3", "client-4"} { + for _, id := range []string{"client-2", "client-3", "client-4"} { client, err := s.GetClient(ctx, id) require.NoError(t, err, "surviving client %q must still authenticate", id) assert.Equal(t, id, client.GetID()) } - - // Capacity is retained under continued registration pressure: the next - // registration always evicts the oldest aged DCR-issued client. - for i := range 10 { - require.NoError(t, s.RegisterClient(ctx, dcrClient(t, "overflow-"+strconv.Itoa(i)))) - } - s.mu.RLock() - size := len(s.clients) - s.mu.RUnlock() - assert.LessOrEqual(t, size, 3, "client map must never exceed maxClients") } // TestMemoryStorage_RegisterClient_MinAgeGraceWindow pins the eviction floor: diff --git a/pkg/authserver/storage/redis.go b/pkg/authserver/storage/redis.go index 4ec0d41146..b6f409e339 100644 --- a/pkg/authserver/storage/redis.go +++ b/pkg/authserver/storage/redis.go @@ -312,7 +312,11 @@ func clientFromStored(stored storedClient, hasTTL bool) fosite.Client { return oidcClient } -// RegisterClient adds or updates a client in the storage. +// RegisterClient creates or replaces a client in storage. A DCR-issued +// registration is create-only and atomically returns ErrAlreadyExists when a +// client with the same ID is already registered; an operator-declared +// static/delegate client is authoritative and always replaces any existing +// registration, including one DCR issued. func (s *RedisStorage) RegisterClient(ctx context.Context, client fosite.Client) error { if err := ValidateRegisterableClientID(client.GetID()); err != nil { return err @@ -363,7 +367,27 @@ func (s *RedisStorage) RegisterClient(ctx context.Context, client fosite.Client) ttl = DefaultDCRClientTTL } - return s.client.Set(ctx, key, data, ttl).Err() + // DCR is unauthenticated (the /register endpoint has no caller identity), + // so a DCR-issued registration must never clobber an existing client: + // SetNX only creates. Operator-declared static/delegate clients are + // authoritative and may replace any existing registration, including one + // DCR issued: plain Set with ttl 0 always overwrites and also clears an + // inherited DCR expiry, which is exactly what promoting a DCR client to + // permanent configuration requires. + if registration.DCRIssued(client) { + created, err := s.client.SetNX(ctx, key, data, ttl).Result() + if err != nil { + return fmt.Errorf("failed to register client: %w", err) + } + if !created { + return fmt.Errorf("%w: client %q", ErrAlreadyExists, client.GetID()) + } + return nil + } + if err := s.client.Set(ctx, key, data, ttl).Err(); err != nil { + return fmt.Errorf("failed to register client: %w", err) + } + return nil } // GetClient loads the client by its ID. diff --git a/pkg/authserver/storage/types.go b/pkg/authserver/storage/types.go index a2dbf90392..52f72f5aab 100644 --- a/pkg/authserver/storage/types.go +++ b/pkg/authserver/storage/types.go @@ -598,18 +598,12 @@ type ClientRegistry interface { // ClientManager provides client lookup (GetClient) fosite.ClientManager - // RegisterClient registers a new OAuth client. This supports both static - // configuration and dynamic client registration (RFC 7591). - // - // This is an upsert, not an insert: both backends store()/SET the client - // unconditionally, overwriting any existing row with the same ID rather - // than rejecting it. Re-registering an existing ID is the only mechanism - // that renews a DCR-issued client's TTL on the CIMD write-through path - // (see CIMDStorageDecorator.fetch), so callers rely on this upsert - // behavior, not merely tolerate it. The one exception is the in-memory - // backend at capacity: when the client map is full and no DCR-issued - // client is old enough to evict, RegisterClient returns ErrClientCapacity - // instead of completing the upsert. + // RegisterClient registers a new OAuth client. + // This supports both static configuration and dynamic client registration (RFC 7591). + // A DCR-issued client is create-only: it returns ErrAlreadyExists if a + // client with the same ID already exists. A static/operator-declared + // client is authoritative and replaces any existing client with the same + // ID, including one that was DCR-issued. RegisterClient(ctx context.Context, client fosite.Client) error // RenewClientTTL extends the registration TTL of a DCR-issued client (public or From f8ed890c5d9b87aa236836d86f433d6a7c0e5f30 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Fri, 28 Aug 2026 17:37:54 +0200 Subject: [PATCH 06/14] Hide configured back-channel clients Static workload clients have no interactive redirect flow, and exposing them through authorization lookup would permit enumeration and accidental browser use. Filter configured back-channel clients from authorization requests while leaving their token-endpoint registration available. Refs #6200 Signed-off-by: Jakub Hrozek --- pkg/authserver/server/handlers/authorize.go | 41 ++++++++ .../server/handlers/authorize_test.go | 98 +++++++++++++++++++ 2 files changed, 139 insertions(+) diff --git a/pkg/authserver/server/handlers/authorize.go b/pkg/authserver/server/handlers/authorize.go index dda866b4a5..d54fc6691a 100644 --- a/pkg/authserver/server/handlers/authorize.go +++ b/pkg/authserver/server/handlers/authorize.go @@ -18,6 +18,7 @@ import ( "github.com/stacklok/toolhive/pkg/authserver/server/registration" "github.com/stacklok/toolhive/pkg/authserver/storage" "github.com/stacklok/toolhive/pkg/authserver/upstream" + "github.com/stacklok/toolhive/pkg/oauthproto" ) // upstreamAuthSecrets holds cryptographic values needed for upstream IDP authorization. @@ -48,6 +49,14 @@ func newUpstreamAuthSecrets() *upstreamAuthSecrets { func (h *Handler) AuthorizeHandler(w http.ResponseWriter, req *http.Request) { ctx := req.Context() + // Back-channel-only clients must be indistinguishable from unknown clients + // at this unauthenticated endpoint. This runs before redirect URI matching + // (including the loopback matcher below), which would otherwise expose that + // the client is configured through an invalid_request response. + if h.rejectBackChannelOnlyAuthorizeClient(ctx, w, req) { + return + } + // See rewriteLoopbackRedirectURI's doc comment for what this does and why. rewrittenFrom := h.rewriteLoopbackRedirectURI(ctx, req) @@ -144,6 +153,38 @@ func (h *Handler) AuthorizeHandler(w http.ResponseWriter, req *http.Request) { http.Redirect(w, req, upstreamURL, http.StatusFound) } +// rejectBackChannelOnlyAuthorizeClient rejects a configured client that has no +// authorization response types before fosite validates redirect_uri. It returns +// false for unknown clients so fosite retains responsibility for its normal +// client lookup and error handling. +func (h *Handler) rejectBackChannelOnlyAuthorizeClient(ctx context.Context, w http.ResponseWriter, req *http.Request) bool { + if err := req.ParseForm(); err != nil { + return false + } + clientID := req.Form.Get("client_id") + if clientID == "" { + return false + } + client, err := h.storage.GetClient(ctx, clientID) + if err != nil || client == nil || !isBackChannelOnlyClient(client) { + return false + } + authorizeRequest := fosite.NewAuthorizeRequest() + authorizeRequest.Client = client + h.provider.WriteAuthorizeError( + ctx, + w, + authorizeRequest, + fosite.ErrInvalidClient.WithHint("The requested OAuth 2.0 Client does not exist."), + ) + return true +} + +func isBackChannelOnlyClient(client fosite.Client) bool { + return len(client.GetResponseTypes()) == 0 || + client.GetGrantTypes().ExactOne(oauthproto.GrantTypeTokenExchange) +} + // loopbackAuthorizeRequester wraps a fosite.AuthorizeRequester to make the // client's real, dynamic-port loopback redirect_uri the error-redirect target // for fosite's WriteAuthorizeError, instead of the registered portless diff --git a/pkg/authserver/server/handlers/authorize_test.go b/pkg/authserver/server/handlers/authorize_test.go index e6061177ee..8e0a1fbf10 100644 --- a/pkg/authserver/server/handlers/authorize_test.go +++ b/pkg/authserver/server/handlers/authorize_test.go @@ -4,6 +4,7 @@ package handlers import ( + "context" "errors" "net/http" "net/http/httptest" @@ -11,7 +12,9 @@ import ( "strings" "testing" + "github.com/alicebob/miniredis/v2" "github.com/ory/fosite" + "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/time/rate" @@ -19,6 +22,7 @@ import ( "github.com/stacklok/toolhive/pkg/authserver/server" servercrypto "github.com/stacklok/toolhive/pkg/authserver/server/crypto" "github.com/stacklok/toolhive/pkg/authserver/server/registration" + "github.com/stacklok/toolhive/pkg/authserver/storage" "github.com/stacklok/toolhive/pkg/oauthproto" ) @@ -72,6 +76,100 @@ func TestAuthorizeHandler_ClientNotFound(t *testing.T) { assert.Contains(t, rec.Body.String(), "invalid_client") } +func TestAuthorizeHandler_BackChannelOnlyClientsMatchMissingClient(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + client func(t *testing.T) fosite.Client + }{ + { + name: "SPIFFE client", + client: func(t *testing.T) fosite.Client { + t.Helper() + client, err := registration.NewSPIFFEClient( + "spiffe-client", + []string{"openid"}, + []string{"https://mcp.example.com"}, + ) + require.NoError(t, err) + return client + }, + }, + { + name: "delegate client", + client: func(t *testing.T) fosite.Client { + t.Helper() + client, err := registration.NewStaticDelegateClient(registration.Config{ + ID: "delegate-client", + Secret: "test-secret", + GrantTypes: []string{"urn:ietf:params:oauth:grant-type:token-exchange"}, + Scopes: []string{"openid"}, + Audience: []string{"https://mcp.example.com"}, + }) + require.NoError(t, err) + return client + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + handler, state, _ := handlerTestSetup(t) + client := tt.client(t) + state.clients[client.GetID()] = client + + missing := httptest.NewRecorder() + handler.AuthorizeHandler(missing, httptest.NewRequest(http.MethodGet, + "/oauth/authorize?client_id=missing-client&redirect_uri=https://invalid.example/callback", nil)) + + configured := httptest.NewRecorder() + handler.AuthorizeHandler(configured, httptest.NewRequest(http.MethodGet, + "/oauth/authorize?client_id="+client.GetID()+"&redirect_uri=https://invalid.example/callback", nil)) + + require.Equal(t, http.StatusUnauthorized, configured.Code) + assert.Equal(t, missing.Code, configured.Code) + assert.Equal(t, missing.Body.String(), configured.Body.String()) + assert.Contains(t, configured.Body.String(), "invalid_client") + }) + } +} + +func TestAuthorizeHandler_RedisLoadedDelegateClientMatchesMissingClient(t *testing.T) { + t.Parallel() + + handler, _, _ := handlerTestSetup(t) + mr := miniredis.RunT(t) + redisClient := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + stor := storage.NewRedisStorageWithClient(redisClient, "test:authorize:") + t.Cleanup(func() { _ = stor.Close() }) + + client, err := registration.NewStaticDelegateClient(registration.Config{ + ID: "delegate-client", + Secret: "test-secret", + GrantTypes: []string{oauthproto.GrantTypeTokenExchange}, + Scopes: []string{"openid"}, + Audience: []string{"https://mcp.example.com"}, + }) + require.NoError(t, err) + require.NoError(t, stor.RegisterClient(context.Background(), client)) + handler.storage = stor + + missing := httptest.NewRecorder() + handler.AuthorizeHandler(missing, httptest.NewRequest(http.MethodGet, + "/oauth/authorize?client_id=missing-client&redirect_uri=https://invalid.example/callback", nil)) + + configured := httptest.NewRecorder() + handler.AuthorizeHandler(configured, httptest.NewRequest(http.MethodGet, + "/oauth/authorize?client_id=delegate-client&redirect_uri=https://invalid.example/callback", nil)) + + require.Equal(t, http.StatusUnauthorized, configured.Code) + assert.Equal(t, missing.Code, configured.Code) + assert.Equal(t, missing.Body.String(), configured.Body.String()) + assert.Contains(t, configured.Body.String(), "invalid_client") +} + func TestAuthorizeHandler_InvalidRedirectURI(t *testing.T) { t.Parallel() handler, _, _ := handlerTestSetup(t) From 1c2bb5767014abc71fd3461048cc6e9b969fb603 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Mon, 31 Aug 2026 21:13:10 +0200 Subject: [PATCH 07/14] Close remaining client-registration and replay-forwarding gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reviewer (JAORMX) found four remaining issues in how this branch registers and reserves static SPIFFE clients: RegisterClient let any caller overwrite any existing client just by omitting a marker, the SPIFFE overlay never durably reserved its client IDs so a rolling deployment could let an old replica hand the same ID to a DCR registration, JWT-bearer replay protection bypassed every storage decorator by unwrapping straight to the base backend, and the /authorize back-channel guard inferred client class from metadata shape instead of an explicit marker. This commit closes all four, designed with an oauth-expert/go-architect review pair per finding and implemented and adversarially re-reviewed in two rounds before landing. Registration is now uniformly create-only. `RegisterClient` on both storage backends no longer branches on whether the incoming client carries the DCR-issued marker — it always fails if a client with that ID exists, full stop, so no caller (present or future) can silently overwrite an existing registration by simply forgetting to mark it. The only path that can ever replace a client is the new `ClientRegistry.ReconcileConfiguredClient`, which creates on first use and otherwise requires the existing record to be non-DCR-issued and have a matching fingerprint (scopes, audience, grant/response types, public flag — never the secret, so delegate-client secret rotation still reconciles) before replacing it. Delegate-client startup registration now goes through this method instead of `RegisterClient`. Static SPIFFE client IDs are now durably reserved, not just preflight-checked. The overlay previously only read durable storage to detect a collision before serving clients in-process; nothing was ever written, so an older replica mid-rollout could still win a race and DCR-register the same ID with a different client shape. Construction now calls `ReconcileConfiguredClient` against the underlying backend with an inert placeholder for each configured ID — never the real `*SPIFFEClient` object, since persisting that directly into Redis would have degraded on read-back into a usable, unauthenticated confidential client (Redis flattens a client's fields to JSON, and `fosite.DefaultClient` substitutes real grant/response types when the stored field reads back empty). The placeholder is instead marked with a `storedClient.Reserved` bit that `clientFromStored` checks before trusting anything else in the row, so it reconstructs as genuinely unusable — no grant type, no response type, no secret — independent of backend. It keeps the real association's scopes/audience so the fingerprint check can tell "same config restarting" (idempotent) from "a different, colliding association" (a loud startup failure instead of silent divergence). The live overlay is unchanged: it still serves the real client in-process, exactly as before. The reconcile call against Redis uses a bounded WATCH/MULTI retry loop, since go-redis does not itself retry a concurrent write. JWT-bearer replay protection no longer bypasses the storage decorator chain. It used to call `storage.Unwrap`, peeling every decorator down to the base backend before checking for replay-consumption support — so a decorator sitting in between could never intercept or audit that call, and a future one could silently lose the capability by omitting an undocumented `Unwrap` method. `SPIFFEStorageDecorator` now forwards `ConsumeAssertionJWT` one level down, the same way `CIMDStorageDecorator` already did, and the lookup asserts the capability directly on the outermost storage instead of unwrapping past the chain. The /authorize back-channel guard is now marker-driven for the client types this stack introduces. `isBackChannelOnlyClient` inferred "no interactive flow" from metadata shape alone (empty response types, or an exact token-exchange grant) — a future client class sharing that shape by coincidence would be silently and incorrectly hidden. `registration.SPIFFEClient` and the durable placeholder now carry an explicit `BackChannelOnly` marker (mirroring the existing `DCRIssued` marker pattern) that the guard checks first; the metadata-shape inference remains as a fallback for delegate clients and any other existing client type, unchanged. Refs #6200 Signed-off-by: Jakub Hrozek --- .../embeddedauthserver_integration_test.go | 25 ++- .../runner/embeddedauthserver_test.go | 25 ++- pkg/authserver/server/handlers/authorize.go | 11 +- .../server/handlers/authorize_test.go | 17 +- pkg/authserver/server/registration/client.go | 26 +++ .../server/registration/spiffe_client.go | 1 + .../tokenexchange/jwt_bearer_handler.go | 22 +- .../tokenexchange/jwt_bearer_handler_test.go | 78 ++++++- pkg/authserver/server_impl.go | 2 +- pkg/authserver/server_test.go | 85 +++++--- pkg/authserver/storage/memory.go | 80 +++++-- pkg/authserver/storage/memory_test.go | 133 ++++++++++- pkg/authserver/storage/mocks/mock_storage.go | 28 +++ pkg/authserver/storage/redis.go | 206 +++++++++++++++--- pkg/authserver/storage/redis_test.go | 65 +++++- pkg/authserver/storage/spiffe_decorator.go | 137 ++++++++++-- .../storage/spiffe_decorator_test.go | 204 ++++++++++++++++- pkg/authserver/storage/types.go | 54 ++++- 18 files changed, 1042 insertions(+), 157 deletions(-) diff --git a/pkg/authserver/runner/embeddedauthserver_integration_test.go b/pkg/authserver/runner/embeddedauthserver_integration_test.go index 509a143ccc..cb16f60023 100644 --- a/pkg/authserver/runner/embeddedauthserver_integration_test.go +++ b/pkg/authserver/runner/embeddedauthserver_integration_test.go @@ -54,6 +54,11 @@ func TestIntegration_EmbeddedAuthServer_SPIFFERedisRestartAndCollision(t *testin Addr: fmt.Sprintf("%s:%s", host, port.Port()), }), "integration:spiffe:") } + newStorageWithPrefix := func(prefix string) *storage.RedisStorage { + return storage.NewRedisStorageWithClient(redis.NewClient(&redis.Options{ + Addr: fmt.Sprintf("%s:%s", host, port.Port()), + }), prefix) + } config := func(includeAssociation bool) authserver.RunConfig { cfg := authserver.RunConfig{ SchemaVersion: authserver.CurrentSchemaVersion, @@ -111,11 +116,25 @@ func TestIntegration_EmbeddedAuthServer_SPIFFERedisRestartAndCollision(t *testin require.NoError(t, err) _, err = secondStorage.GetClient(ctx, "dynamic-client") require.NoError(t, err) - _, err = secondStorage.GetClient(ctx, "spiffe-client") - require.ErrorIs(t, err, storage.ErrNotFound) + // The static association is gone from the current config, but the earlier + // SPIFFE-enabled boot durably claimed "spiffe-client" in this same Redis + // keyspace to close the cross-replica registration race; that durable + // claim is not retracted just because a later boot's config no longer + // configures the association. The claim must remain unauthenticatable: + // no secret and not a public client, so it can never pass client + // authentication for any grant. (fosite.DefaultClient.GetGrantTypes + // applies its own single-grant default when the underlying field reads + // back empty from Redis, so "no grant types" is asserted directly + // against MemoryStorage in storage/spiffe_decorator_test.go instead.) + claimed, err := secondStorage.GetClient(ctx, "spiffe-client") + require.NoError(t, err, "the earlier durable claim for spiffe-client must persist") + require.Nil(t, claimed.GetHashedSecret()) + require.False(t, claimed.IsPublic()) require.NoError(t, second.Close()) - collisionStorage := newStorage() + // Isolated keyspace so this collision seed isn't itself rejected by the + // durable claim the earlier steps above left behind. + collisionStorage := newStorageWithPrefix("integration:spiffe-collision:") require.NoError(t, collisionStorage.RegisterClient(ctx, &fosite.DefaultClient{ID: "spiffe-client"})) collision := config(true) _, err = NewEmbeddedAuthServerWithStorage(ctx, &collision, collisionStorage) diff --git a/pkg/authserver/runner/embeddedauthserver_test.go b/pkg/authserver/runner/embeddedauthserver_test.go index 0e3fde85b5..33f9b597b9 100644 --- a/pkg/authserver/runner/embeddedauthserver_test.go +++ b/pkg/authserver/runner/embeddedauthserver_test.go @@ -2248,13 +2248,30 @@ func TestEmbeddedAuthServer_SPIFFEAssociationDoesNotAuthenticateClient(t *testin require.NoError(t, err) _, err = redisStorage.GetClient(context.Background(), "dynamic-client") require.NoError(t, err) - _, err = redisStorage.GetClient(context.Background(), "spiffe-client") - require.ErrorIs(t, err, storage.ErrNotFound) + // The static association is gone from the current config, but the earlier + // SPIFFE-enabled boot durably claimed "spiffe-client" to close the + // cross-replica registration race; that durable claim is not retracted + // just because a later boot's config no longer configures the + // association. The claim must remain structurally unauthenticatable: no + // secret and not a public client, so it can never pass client + // authentication for any grant. (fosite.DefaultClient.GetGrantTypes + // applies its own single-grant default when the underlying field reads + // back empty from Redis — see storedClientFingerprintsEqual in redis.go + // — so the "no grant types" property is asserted directly against + // MemoryStorage in storage/spiffe_decorator_test.go instead of here.) + claimed, err := redisStorage.GetClient(context.Background(), "spiffe-client") + require.NoError(t, err, "the earlier durable claim for spiffe-client must persist") + assert.Nil(t, claimed.GetHashedSecret()) + assert.False(t, claimed.IsPublic()) require.NoError(t, embedded.Close()) // A durable row with a currently static ID is a startup collision, rather - // than authority that can be silently shadowed by configuration. - collisionStorage := newRedisStorage() + // than authority that can be silently shadowed by configuration. Uses an + // isolated Redis keyspace so the collision seed isn't itself rejected by + // the durable claim left behind by the earlier steps above. + collisionRedis := miniredis.RunT(t) + collisionStorage := storage.NewRedisStorageWithClient( + redis.NewClient(&redis.Options{Addr: collisionRedis.Addr()}), "test:spiffe-collision:") require.NoError(t, collisionStorage.RegisterClient(context.Background(), &fosite.DefaultClient{ID: "spiffe-client"})) _, err = NewEmbeddedAuthServerWithStorage(context.Background(), &initial, collisionStorage) require.ErrorIs(t, err, storage.ErrAlreadyExists) diff --git a/pkg/authserver/server/handlers/authorize.go b/pkg/authserver/server/handlers/authorize.go index d54fc6691a..a8f9f0dae1 100644 --- a/pkg/authserver/server/handlers/authorize.go +++ b/pkg/authserver/server/handlers/authorize.go @@ -180,8 +180,17 @@ func (h *Handler) rejectBackChannelOnlyAuthorizeClient(ctx context.Context, w ht return true } +// isBackChannelOnlyClient reports whether client has no interactive +// /authorize flow. It checks the explicit registration.BackChannelOnly +// marker first -- the authoritative answer for client types that carry it +// (registration.SPIFFEClient and the SPIFFE storage decorator's durable +// placeholder) -- and falls back to the pre-existing metadata-shape +// inference (empty response types, or grant types exactly +// token-exchange) for every other client type, such as a delegate client, +// so their current /authorize-hiding behaviour is unchanged. func isBackChannelOnlyClient(client fosite.Client) bool { - return len(client.GetResponseTypes()) == 0 || + return registration.BackChannelOnly(client) || + len(client.GetResponseTypes()) == 0 || client.GetGrantTypes().ExactOne(oauthproto.GrantTypeTokenExchange) } diff --git a/pkg/authserver/server/handlers/authorize_test.go b/pkg/authserver/server/handlers/authorize_test.go index 8e0a1fbf10..8432a4145f 100644 --- a/pkg/authserver/server/handlers/authorize_test.go +++ b/pkg/authserver/server/handlers/authorize_test.go @@ -80,8 +80,9 @@ func TestAuthorizeHandler_BackChannelOnlyClientsMatchMissingClient(t *testing.T) t.Parallel() tests := []struct { - name string - client func(t *testing.T) fosite.Client + name string + client func(t *testing.T) fosite.Client + explicitMarker bool }{ { name: "SPIFFE client", @@ -95,6 +96,7 @@ func TestAuthorizeHandler_BackChannelOnlyClientsMatchMissingClient(t *testing.T) require.NoError(t, err) return client }, + explicitMarker: true, }, { name: "delegate client", @@ -110,6 +112,11 @@ func TestAuthorizeHandler_BackChannelOnlyClientsMatchMissingClient(t *testing.T) require.NoError(t, err) return client }, + // A delegate client is rejected via the pre-existing metadata-shape + // inference (isBackChannelOnlyClient's fallback), not the explicit + // registration.BackChannelOnly marker -- delegate clients are out of + // scope for that marker and must keep their current behaviour. + explicitMarker: false, }, } @@ -120,6 +127,12 @@ func TestAuthorizeHandler_BackChannelOnlyClientsMatchMissingClient(t *testing.T) client := tt.client(t) state.clients[client.GetID()] = client + // Prove classification for this client type is directional: the + // explicit marker is set (SPIFFE) or is not (delegate), rather than + // both client types happening to reach the same /authorize outcome + // via the same mechanism. + assert.Equal(t, tt.explicitMarker, registration.BackChannelOnly(client)) + missing := httptest.NewRecorder() handler.AuthorizeHandler(missing, httptest.NewRequest(http.MethodGet, "/oauth/authorize?client_id=missing-client&redirect_uri=https://invalid.example/callback", nil)) diff --git a/pkg/authserver/server/registration/client.go b/pkg/authserver/server/registration/client.go index 8524ba0b3a..033f6f7b50 100644 --- a/pkg/authserver/server/registration/client.go +++ b/pkg/authserver/server/registration/client.go @@ -200,6 +200,32 @@ type dcrIssuedMarker struct{} func (dcrIssuedMarker) dcrIssued() {} +// backChannelOnly marks a client with no interactive /authorize flow -- it +// must never be resolvable there, regardless of what its response types or +// grant types happen to look like. Explicit, not inferred, so a future +// client class does not get silently hidden from /authorize just because it +// happens to share metadata shape with a back-channel-only client. +type backChannelOnly interface { + backChannelOnly() +} + +// BackChannelOnly reports whether client is explicitly marked as having no +// interactive /authorize flow. +func BackChannelOnly(client fosite.Client) bool { + _, ok := client.(backChannelOnly) + return ok +} + +// BackChannelOnlyMarker is embedded anonymously by a client type -- in this +// package or any other -- to mark it as never resolvable at /authorize. The +// backChannelOnly method it carries is unexported, but Go resolves interface +// satisfaction for a promoted method by the method's defining package, not +// the embedder's, so embedding this exported struct is sufficient for the +// embedding type to satisfy the unexported backChannelOnly interface above. +type BackChannelOnlyMarker struct{} + +func (BackChannelOnlyMarker) backChannelOnly() {} + // publicClient is the DCR-issued public client shape: an OIDC client (so the // "none" method is recorded and enforced). RFC 8252 Section 7.3 loopback // dynamic-port matching for native apps is provided separately by diff --git a/pkg/authserver/server/registration/spiffe_client.go b/pkg/authserver/server/registration/spiffe_client.go index a8c7a3bbe4..e0a238e113 100644 --- a/pkg/authserver/server/registration/spiffe_client.go +++ b/pkg/authserver/server/registration/spiffe_client.go @@ -18,6 +18,7 @@ import ( // credentials; this configuration-only implementation does not authenticate // any credentials. type SPIFFEClient struct { + BackChannelOnlyMarker id string grantTypes fosite.Arguments scopes fosite.Arguments diff --git a/pkg/authserver/server/tokenexchange/jwt_bearer_handler.go b/pkg/authserver/server/tokenexchange/jwt_bearer_handler.go index 3ff5bcfcf7..33f175df10 100644 --- a/pkg/authserver/server/tokenexchange/jwt_bearer_handler.go +++ b/pkg/authserver/server/tokenexchange/jwt_bearer_handler.go @@ -446,17 +446,21 @@ func JWTBearerIssuanceFactory(trustedIssuers []TrustedIssuer, shared *MultiIssue }, nil } +// assertionJWTConsumer resolves rawStorage's AssertionJWTConsumer capability. +// Every decorator in the actual composition chain must itself implement (and +// forward, one level down) AssertionJWTConsumer for this to succeed -- there +// is no automatic bypass via storage.Unwrap. That is deliberate: a decorator +// that sits between rawStorage and the innermost backend gets an explicit, +// visible opportunity to intercept or audit assertion-JWT consumption, rather +// than being silently skipped past. See CIMDStorageDecorator.ConsumeAssertionJWT +// and SPIFFEStorageDecorator.ConsumeAssertionJWT for the two production +// decorators that forward it today. A future decorator that omits this method +// breaks visibly, right here, with an error naming the offending type -- not +// silently, by having its ConsumeAssertionJWT logic (if any) never run. func assertionJWTConsumer(rawStorage fosite.Storage) (storage.AssertionJWTConsumer, error) { - baseStorage := rawStorage - // Peel every storage decorator, not just one: the SPIFFE static-client - // overlay wraps the CIMD decorator, and AssertionJWTConsumer is not part - // of storage.Storage, so it is not promoted through an embedded decorator. - if stor, ok := rawStorage.(storage.Storage); ok { - baseStorage = storage.Unwrap(stor) - } - consumer, ok := baseStorage.(storage.AssertionJWTConsumer) + consumer, ok := rawStorage.(storage.AssertionJWTConsumer) if !ok { - return nil, fmt.Errorf("JWT-bearer storage %T does not implement storage.AssertionJWTConsumer", baseStorage) + return nil, fmt.Errorf("JWT-bearer storage %T does not implement storage.AssertionJWTConsumer", rawStorage) } return consumer, nil } diff --git a/pkg/authserver/server/tokenexchange/jwt_bearer_handler_test.go b/pkg/authserver/server/tokenexchange/jwt_bearer_handler_test.go index c93a3e4707..1fb6a8e54c 100644 --- a/pkg/authserver/server/tokenexchange/jwt_bearer_handler_test.go +++ b/pkg/authserver/server/tokenexchange/jwt_bearer_handler_test.go @@ -20,6 +20,7 @@ import ( "github.com/stretchr/testify/require" "github.com/stacklok/toolhive/pkg/authserver/server" + "github.com/stacklok/toolhive/pkg/authserver/server/registration" "github.com/stacklok/toolhive/pkg/authserver/server/session" "github.com/stacklok/toolhive/pkg/authserver/storage" "github.com/stacklok/toolhive/pkg/oauthproto" @@ -381,23 +382,46 @@ func (assertionConsumerStorage) ConsumeAssertionJWT(context.Context, string, str return nil } -func TestAssertionJWTConsumer_UnwrapsCIMDAtConstruction(t *testing.T) { +func TestAssertionJWTConsumer(t *testing.T) { t.Parallel() tests := []struct { name string - base storage.Storage + build func(t *testing.T) fosite.Storage wantErr string }{ - {name: "unsupported backend fails", base: storageWithoutAssertionConsumer{}, wantErr: "does not implement"}, - {name: "supported backend succeeds", base: assertionConsumerStorage{}}, + { + name: "bare storage without the capability fails", + build: func(*testing.T) fosite.Storage { return storageWithoutAssertionConsumer{} }, + wantErr: "does not implement", + }, + { + name: "storage implementing the capability directly succeeds", + build: func(*testing.T) fosite.Storage { return assertionConsumerStorage{} }, + }, + { + // CIMDStorageDecorator itself always implements + // storage.AssertionJWTConsumer (it forwards one level down to + // whatever it wraps), so it satisfies the interface here + // regardless of what its wrapped backend supports -- a wrapped + // backend lacking the capability only surfaces as an error when + // ConsumeAssertionJWT is actually called (see + // TestCIMDStorageDecorator_ConsumeAssertionJWTFailsClosedWithoutBackendCapability + // in the storage package). + name: "CIMD decorator satisfies the interface via its own forwarding method", + build: func(t *testing.T) fosite.Storage { + t.Helper() + decorated, err := storage.NewCIMDStorageDecorator(storageWithoutAssertionConsumer{}, + storage.CIMDDecoratorConfig{Enabled: true, CacheMaxSize: 1, FallbackTTL: time.Minute}) + require.NoError(t, err) + return decorated + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - decorated, err := storage.NewCIMDStorageDecorator(tt.base, storage.CIMDDecoratorConfig{Enabled: true, CacheMaxSize: 1, FallbackTTL: time.Minute}) - require.NoError(t, err) - consumer, err := assertionJWTConsumer(decorated) + consumer, err := assertionJWTConsumer(tt.build(t)) if tt.wantErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantErr) @@ -410,6 +434,46 @@ func TestAssertionJWTConsumer_UnwrapsCIMDAtConstruction(t *testing.T) { } } +// TestAssertionJWTConsumer_ForwardsThroughFullSPIFFEDecoratorChain is an +// end-to-end positive proof, against the real production chain shape +// (SPIFFEStorageDecorator wrapping CIMDStorageDecorator wrapping +// MemoryStorage), that JWT-bearer replay protection still resolves and +// functions correctly after the storage.Unwrap bypass fix (PR #6474 review). +// It does not by itself distinguish "forwarded one level at a time" from +// "unwrapped straight to the base" -- both reach the same MemoryStorage here. +// The actual regression gate for that distinction is the +// "CIMD decorator satisfies the interface via its own forwarding method" +// subtest of TestAssertionJWTConsumer above, which uses a backend that only +// a correct one-level forward (not a full Unwrap) can resolve. +func TestAssertionJWTConsumer_ForwardsThroughFullSPIFFEDecoratorChain(t *testing.T) { + t.Parallel() + ctx := context.Background() + + base := storage.NewMemoryStorage() + t.Cleanup(func() { _ = base.Close() }) + + cimdDecorated, err := storage.NewCIMDStorageDecorator(base, storage.CIMDDecoratorConfig{ + Enabled: true, CacheMaxSize: 1, FallbackTTL: time.Minute, + }) + require.NoError(t, err) + + spiffeClient, err := registration.NewSPIFFEClient( + "spiffe-client", []string{"openid"}, []string{"https://mcp.example.com"}) + require.NoError(t, err) + chain, err := storage.NewSPIFFEStorageDecorator( + ctx, cimdDecorated, map[string]fosite.Client{spiffeClient.GetID(): spiffeClient}) + require.NoError(t, err) + + consumer, err := assertionJWTConsumer(chain) + require.NoError(t, err) + + exp := time.Now().Add(time.Hour) + require.NoError(t, consumer.ConsumeAssertionJWT(ctx, "jwt-bearer", "https://issuer.example", "chain-jti", exp)) + require.ErrorIs(t, + consumer.ConsumeAssertionJWT(ctx, "jwt-bearer", "https://issuer.example", "chain-jti", exp), + fosite.ErrJTIKnown) +} + type storageWithoutAssertionConsumer struct{ storage.Storage } type testAssertionJWTConsumer struct{ err error } diff --git a/pkg/authserver/server_impl.go b/pkg/authserver/server_impl.go index b59765e7f8..e8f3d99355 100644 --- a/pkg/authserver/server_impl.go +++ b/pkg/authserver/server_impl.go @@ -299,7 +299,7 @@ func registerDelegateClients(ctx context.Context, stor storage.Storage, delegate if err != nil { return fmt.Errorf("failed to create delegate client %q: %w", delegateClient.ClientID, err) } - if err := stor.RegisterClient(ctx, client); err != nil { + if err := stor.ReconcileConfiguredClient(ctx, client); err != nil { return fmt.Errorf("failed to register delegate client %q: %w", delegateClient.ClientID, err) } slog.Warn("delegate client has blanket self-issued token exchange rights: "+ diff --git a/pkg/authserver/server_test.go b/pkg/authserver/server_test.go index 3db0c3ad71..d54cac7bdf 100644 --- a/pkg/authserver/server_test.go +++ b/pkg/authserver/server_test.go @@ -589,21 +589,14 @@ func TestNewUpstreamTokenRefresher_NilWhenNoUpstreams(t *testing.T) { } } -func TestNewServer_RegistersDelegateClientsBeforeUpstreamConstruction(t *testing.T) { +// TestNewServer_DelegateClientReconciliation covers registerDelegateClients' +// use of ReconcileConfiguredClient: registering a delegate client is +// create-only against a DCR-issued collision (the security fix -- an +// operator-declared client must never silently overwrite a DCR +// registration), and idempotent across a restart with unchanged config. +func TestNewServer_DelegateClientReconciliation(t *testing.T) { t.Parallel() - ctx := t.Context() - stor := storage.NewMemoryStorage() - t.Cleanup(func() { _ = stor.Close() }) - - // Seed a DCR-issued client under the same ID as the configured delegate - // client. The static delegate client is authoritative, so registering it - // must replace this DCR registration rather than being rejected as a - // duplicate. - dcrClient, err := registration.NewConfidentialPlain(registration.Config{ID: "delegate", Secret: "old-secret"}) - require.NoError(t, err) - require.NoError(t, stor.RegisterClient(ctx, dcrClient)) - cfg := Config{ Issuer: "https://example.com", KeyProvider: keys.NewGeneratingProvider(keys.DefaultAlgorithm), @@ -617,30 +610,56 @@ func TestNewServer_RegistersDelegateClientsBeforeUpstreamConstruction(t *testing }}, } - factory := func(ctx context.Context, _ *UpstreamConfig) (upstream.OAuth2Provider, error) { - client, err := stor.GetClient(ctx, "delegate") + t.Run("refuses to overwrite a DCR-issued collision, before upstream construction", func(t *testing.T) { + t.Parallel() + ctx := t.Context() + stor := storage.NewMemoryStorage() + t.Cleanup(func() { _ = stor.Close() }) + + dcrClient, err := registration.NewConfidentialPlain(registration.Config{ID: "delegate", Secret: "old-secret"}) require.NoError(t, err) - assert.False(t, registration.DCRIssued(client)) - assert.False(t, client.IsPublic()) - return nil, assert.AnError - } + require.NoError(t, stor.RegisterClient(ctx, dcrClient)) - cfg.UpstreamFactory = factory + factoryCalled := false + factory := func(context.Context, *UpstreamConfig) (upstream.OAuth2Provider, error) { + factoryCalled = true + return nil, assert.AnError + } + subCfg := cfg + subCfg.UpstreamFactory = factory - // First boot: static registration is an upsert. Replacing a same-ID DCR - // client makes it permanent and unmarked rather than retaining DCR - // eviction semantics. - _, err := newServer(ctx, cfg, stor) - require.ErrorIs(t, err, assert.AnError) - client, err := stor.GetClient(ctx, "delegate") - require.NoError(t, err) - assert.False(t, registration.DCRIssued(client)) + _, err = newServer(ctx, subCfg, stor) + require.ErrorIs(t, err, storage.ErrAlreadyExists) + assert.False(t, factoryCalled, "delegate client registration must run before upstream construction") - // Second boot: registerDelegateClients runs on every startup, so - // re-registering the same static client (not a DCR duplicate) must not - // fail the server on restart. - _, err = newServer(ctx, cfg, stor) - require.ErrorIs(t, err, assert.AnError) + client, err := stor.GetClient(ctx, "delegate") + require.NoError(t, err, "the original DCR-issued registration must be untouched") + assert.True(t, registration.DCRIssued(client)) + }) + + t.Run("restart with unchanged config is idempotent", func(t *testing.T) { + t.Parallel() + ctx := t.Context() + stor := storage.NewMemoryStorage() + t.Cleanup(func() { _ = stor.Close() }) + + factory := func(context.Context, *UpstreamConfig) (upstream.OAuth2Provider, error) { + return nil, assert.AnError + } + subCfg := cfg + subCfg.UpstreamFactory = factory + + // registerDelegateClients runs on every startup; re-registering the + // same static client across a simulated restart must not fail. + _, err := newServer(ctx, subCfg, stor) + require.ErrorIs(t, err, assert.AnError) + _, err = newServer(ctx, subCfg, stor) + require.ErrorIs(t, err, assert.AnError) + + client, err := stor.GetClient(ctx, "delegate") + require.NoError(t, err) + assert.False(t, registration.DCRIssued(client)) + }) } // closeCountingProvider is an OAuth2Provider that implements the optional diff --git a/pkg/authserver/storage/memory.go b/pkg/authserver/storage/memory.go index af62e1e891..e76b5efbf7 100644 --- a/pkg/authserver/storage/memory.go +++ b/pkg/authserver/storage/memory.go @@ -74,6 +74,13 @@ type MemoryStorage struct { mu sync.RWMutex // clients maps client_id -> Client for client lookup (fosite.ClientManager). + // A SPIFFE static-client durable placeholder (see staticClientPlaceholder + // in spiffe_decorator.go) is stored here as the live inertPlaceholderClient + // Go value, so its overridden GetGrantTypes/GetResponseTypes are called + // directly on every read — unlike RedisStorage, which serializes the + // client's fields and must re-derive the same guarantee on read-back via + // storedClient.Reserved (see clientFromStored in redis.go). No equivalent + // marker is needed here. clients map[string]fosite.Client // clientOrder is the least-recently-proven-used order of clients: a @@ -449,11 +456,11 @@ func getExpirationFromRequester(request fosite.Requester, tokenType fosite.Token return expTime } -// RegisterClient creates or replaces a client in storage. A DCR-issued -// registration is create-only and returns ErrAlreadyExists when a client with -// the same ID is already registered; an operator-declared static/delegate -// client is authoritative and always replaces any existing registration, -// including one DCR issued. +// RegisterClient creates a client in storage. Always create-only: it returns +// ErrAlreadyExists when a client with the same ID is already registered, +// regardless of the new client's origin. /oauth/register is unauthenticated, +// so this must never clobber an existing client; operator-declared clients +// reconcile through ReconcileConfiguredClient instead. func (s *MemoryStorage) RegisterClient(_ context.Context, client fosite.Client) error { id := client.GetID() if err := ValidateRegisterableClientID(id); err != nil { @@ -463,21 +470,56 @@ func (s *MemoryStorage) RegisterClient(_ context.Context, client fosite.Client) s.mu.Lock() defer s.mu.Unlock() - now := time.Now() if _, exists := s.clients[id]; exists { - // DCR is unauthenticated (the /register endpoint has no caller - // identity), so a DCR-issued registration must never clobber an - // existing client. Operator-declared static/delegate clients are - // authoritative and may replace any existing registration, including - // one DCR issued: this is how a static overlay promotes a client that - // first appeared via DCR into permanent configuration. - if registration.DCRIssued(client) { - return fmt.Errorf("%w: client %q", ErrAlreadyExists, id) - } - // Refresh the eviction position: the replacing registration is not - // the oldest, and clientOrder must not carry a stale duplicate entry. - s.clientOrder = slices.DeleteFunc(s.clientOrder, func(e clientOrderEntry) bool { return e.id == id }) - } else if s.maxClients > 0 && len(s.clients) >= s.maxClients { + return fmt.Errorf("%w: client %q", ErrAlreadyExists, id) + } + return s.insertClientLocked(id, client) +} + +// ReconcileConfiguredClient applies an operator-declared client: creates it +// if absent, idempotently replaces a matching-fingerprint configured client +// (the restart-with-unchanged-config case), or refuses with ErrAlreadyExists +// when the existing record is DCR-issued or a different configured client. +// See the ClientRegistry interface doc for the full contract. +func (s *MemoryStorage) ReconcileConfiguredClient(_ context.Context, client fosite.Client) error { + if registration.DCRIssued(client) { + return fmt.Errorf("configured client %q must not carry the DCR-issued marker", client.GetID()) + } + id := client.GetID() + if err := ValidateRegisterableClientID(id); err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + + existing, exists := s.clients[id] + if !exists { + return s.insertClientLocked(id, client) + } + if registration.DCRIssued(existing) { + return fmt.Errorf("%w: client %q is DCR-issued, refusing to overwrite with a configured client", + ErrAlreadyExists, id) + } + if !clientFingerprintsEqual(existing, client) { + return fmt.Errorf("%w: client %q is already registered as a different configured client", + ErrAlreadyExists, id) + } + + // Idempotent restart with unchanged config: refresh the eviction position + // and replace the stored value so secret rotation still takes effect. + s.clientOrder = slices.DeleteFunc(s.clientOrder, func(e clientOrderEntry) bool { return e.id == id }) + s.clientOrder = append(s.clientOrder, clientOrderEntry{id: id, touchedAt: time.Now()}) + s.clients[id] = client + return nil +} + +// insertClientLocked inserts client under id, evicting the oldest eligible +// DCR-issued client if the map is at capacity. Callers must hold s.mu and +// must have already verified that no client currently exists at id. +func (s *MemoryStorage) insertClientLocked(id string, client fosite.Client) error { + now := time.Now() + if s.maxClients > 0 && len(s.clients) >= s.maxClients { if idx := s.oldestEvictableClientIndex(now); idx >= 0 { victim := s.clientOrder[idx].id s.clientOrder = append(s.clientOrder[:idx], s.clientOrder[idx+1:]...) diff --git a/pkg/authserver/storage/memory_test.go b/pkg/authserver/storage/memory_test.go index 0a27c2a477..408cad5ee8 100644 --- a/pkg/authserver/storage/memory_test.go +++ b/pkg/authserver/storage/memory_test.go @@ -251,23 +251,132 @@ func TestMemoryStorage_RegisterClient_RejectsSyntheticPrefix(t *testing.T) { }) } -func TestMemoryStorage_StaticClientReplacesDCRClient(t *testing.T) { - withStorage(t, func(ctx context.Context, s *MemoryStorage) { - dcrClient := dcrClient(t, "delegate") - require.True(t, registration.DCRIssued(dcrClient)) - require.NoError(t, s.RegisterClient(ctx, dcrClient)) +// TestMemoryStorage_RegisterClient_AlwaysCreateOnly pins the security fix: +// RegisterClient never overwrites an existing client, regardless of whether +// the existing registration is DCR-issued or pre-provisioned. A caller that +// needs authoritative replacement of an operator-declared client must use +// ReconcileConfiguredClient instead. +func TestMemoryStorage_RegisterClient_AlwaysCreateOnly(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + existing fosite.Client + }{ + {"existing is DCR-issued", nil}, // replaced with dcrClient(t, id) below + {"existing is pre-provisioned", &mockClient{id: "existing"}}, + } - staticClient, err := registration.NewStaticDelegateClient(registration.Config{ - ID: "delegate", Secret: "new-secret", GrantTypes: []string{"urn:ietf:params:oauth:grant-type:token-exchange"}, - Scopes: []string{"openid"}, Audience: []string{"https://mcp.example"}, + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctx := t.Context() + s := NewMemoryStorage() + defer s.Close() + + existing := tt.existing + if existing == nil { + existing = dcrClient(t, "existing") + } + require.NoError(t, s.RegisterClient(ctx, existing)) + + err := s.RegisterClient(ctx, &mockClient{id: "existing", public: true}) + require.ErrorIs(t, err, ErrAlreadyExists) + + // The original registration must be untouched. + retrieved, err := s.GetClient(ctx, "existing") + require.NoError(t, err) + assert.Equal(t, existing, retrieved) }) + } +} + +// TestMemoryStorage_ReconcileConfiguredClient covers the create/idempotent/ +// reject matrix ReconcileConfiguredClient must implement: create when +// absent, no-op (replace with an equivalent record) when the existing record +// is itself configured and fingerprint-equal, and refuse with +// ErrAlreadyExists when the existing record is DCR-issued or a different +// configured client. +func TestMemoryStorage_ReconcileConfiguredClient(t *testing.T) { + t.Parallel() + + newConfigured := func(id string, scopes []string) fosite.Client { + return &mockClient{id: id, scopes: scopes, public: false} + } + + t.Run("creates when absent", func(t *testing.T) { + t.Parallel() + ctx := t.Context() + s := NewMemoryStorage() + defer s.Close() + + client := newConfigured("configured", []string{"openid"}) + require.NoError(t, s.ReconcileConfiguredClient(ctx, client)) + + retrieved, err := s.GetClient(ctx, "configured") require.NoError(t, err) - require.NoError(t, s.RegisterClient(ctx, staticClient)) + assert.Equal(t, client, retrieved) + }) - retrieved, err := s.GetClient(ctx, "delegate") + t.Run("idempotent on matching fingerprint", func(t *testing.T) { + t.Parallel() + ctx := t.Context() + s := NewMemoryStorage() + defer s.Close() + + first := newConfigured("configured", []string{"openid"}) + require.NoError(t, s.ReconcileConfiguredClient(ctx, first)) + + // A second call with an equivalent (but not identical) client value -- + // simulating a restart re-deriving the same configuration -- succeeds. + second := newConfigured("configured", []string{"openid"}) + require.NoError(t, s.ReconcileConfiguredClient(ctx, second)) + + retrieved, err := s.GetClient(ctx, "configured") + require.NoError(t, err) + assert.Equal(t, second, retrieved) + }) + + t.Run("refuses to overwrite a DCR-issued client", func(t *testing.T) { + t.Parallel() + ctx := t.Context() + s := NewMemoryStorage() + defer s.Close() + + require.NoError(t, s.RegisterClient(ctx, dcrClient(t, "configured"))) + + err := s.ReconcileConfiguredClient(ctx, newConfigured("configured", []string{"openid"})) + require.ErrorIs(t, err, ErrAlreadyExists) + }) + + t.Run("refuses a different configured client at the same ID", func(t *testing.T) { + t.Parallel() + ctx := t.Context() + s := NewMemoryStorage() + defer s.Close() + + first := newConfigured("configured", []string{"openid"}) + require.NoError(t, s.ReconcileConfiguredClient(ctx, first)) + + different := newConfigured("configured", []string{"profile"}) + err := s.ReconcileConfiguredClient(ctx, different) + require.ErrorIs(t, err, ErrAlreadyExists) + + // The original registration must be untouched. + retrieved, err := s.GetClient(ctx, "configured") require.NoError(t, err) - assert.False(t, registration.DCRIssued(retrieved)) - assert.NoError(t, registration.SHA256Hasher.Compare(ctx, retrieved.GetHashedSecret(), []byte("new-secret"))) + assert.Equal(t, first, retrieved) + }) + + t.Run("rejects a client carrying the DCR-issued marker", func(t *testing.T) { + t.Parallel() + ctx := t.Context() + s := NewMemoryStorage() + defer s.Close() + + err := s.ReconcileConfiguredClient(ctx, dcrClient(t, "configured")) + require.Error(t, err) + assert.Contains(t, err.Error(), "must not carry the DCR-issued marker") }) } diff --git a/pkg/authserver/storage/mocks/mock_storage.go b/pkg/authserver/storage/mocks/mock_storage.go index 24c81b0817..a69d4636db 100644 --- a/pkg/authserver/storage/mocks/mock_storage.go +++ b/pkg/authserver/storage/mocks/mock_storage.go @@ -230,6 +230,20 @@ func (mr *MockClientRegistryMockRecorder) GetClient(ctx, id any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClient", reflect.TypeOf((*MockClientRegistry)(nil).GetClient), ctx, id) } +// ReconcileConfiguredClient mocks base method. +func (m *MockClientRegistry) ReconcileConfiguredClient(ctx context.Context, client fosite.Client) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReconcileConfiguredClient", ctx, client) + ret0, _ := ret[0].(error) + return ret0 +} + +// ReconcileConfiguredClient indicates an expected call of ReconcileConfiguredClient. +func (mr *MockClientRegistryMockRecorder) ReconcileConfiguredClient(ctx, client any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReconcileConfiguredClient", reflect.TypeOf((*MockClientRegistry)(nil).ReconcileConfiguredClient), ctx, client) +} + // RegisterClient mocks base method. func (m *MockClientRegistry) RegisterClient(ctx context.Context, client fosite.Client) error { m.ctrl.T.Helper() @@ -1004,6 +1018,20 @@ func (mr *MockStorageMockRecorder) LoadPendingAuthorization(ctx, state any) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadPendingAuthorization", reflect.TypeOf((*MockStorage)(nil).LoadPendingAuthorization), ctx, state) } +// ReconcileConfiguredClient mocks base method. +func (m *MockStorage) ReconcileConfiguredClient(ctx context.Context, client fosite.Client) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReconcileConfiguredClient", ctx, client) + ret0, _ := ret[0].(error) + return ret0 +} + +// ReconcileConfiguredClient indicates an expected call of ReconcileConfiguredClient. +func (mr *MockStorageMockRecorder) ReconcileConfiguredClient(ctx, client any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReconcileConfiguredClient", reflect.TypeOf((*MockStorage)(nil).ReconcileConfiguredClient), ctx, client) +} + // RegisterClient mocks base method. func (m *MockStorage) RegisterClient(ctx context.Context, client fosite.Client) error { m.ctrl.T.Helper() diff --git a/pkg/authserver/storage/redis.go b/pkg/authserver/storage/redis.go index b6f409e339..cf6a166659 100644 --- a/pkg/authserver/storage/redis.go +++ b/pkg/authserver/storage/redis.go @@ -201,6 +201,15 @@ type storedClient struct { // is not compensated for — it predates confidential DCR support entirely, // so it cannot be DCR-issued. DCRIssued bool `json:"dcr_issued,omitempty"` + // Reserved is true when the row is a SPIFFE static-client durable + // placeholder (see staticClientPlaceholder in spiffe_decorator.go) — + // never a real, authenticatable client. It is checked before GrantTypes/ + // ResponseTypes/Secret/Public are trusted on read: clientFromStored + // re-wraps a Reserved row in the same inert type the placeholder was + // built with, regardless of what those other fields happen to contain, + // so the row cannot become issuable via fosite's own zero-value + // defaulting (see clientFromStored and inertPlaceholderClient). + Reserved bool `json:"reserved,omitempty"` // JSONWebKeys stores only the inline public keys used by private_key_jwt. // JSONWebKeysURI and other assertion material are intentionally not stored. JSONWebKeys *jose.JSONWebKeySet `json:"jwks,omitempty"` @@ -274,6 +283,24 @@ func publicJSONWebKeySet(jwks *jose.JSONWebKeySet) *jose.JSONWebKeySet { // never marked regardless of TTL: it predates confidential DCR support // entirely and cannot be DCR-issued. func clientFromStored(stored storedClient, hasTTL bool) fosite.Client { + // A reserved SPIFFE placeholder is re-wrapped in the exact inert type it + // was built with, ignoring whatever GrantTypes/ResponseTypes/Secret/ + // Public happen to be on the row: a bare *fosite.DefaultClient would + // otherwise satisfy fosite's own zero-value defaulting for + // GetGrantTypes/GetResponseTypes (["authorization_code"] / ["code"]) on + // this exact reconstruction path, undoing the guarantee the placeholder + // exists to provide. This check must run before the method-based branch + // below: a reserved row never carries a TokenEndpointAuthMethod, but even + // if it somehow did, Reserved must still win. + if stored.Reserved { + return inertPlaceholderClient{DefaultClient: &fosite.DefaultClient{ + ID: stored.ID, + Scopes: stored.Scopes, + Audience: stored.Audience, + Public: false, + }} + } + method := stored.TokenEndpointAuthMethod if method == "" { client := &fosite.DefaultClient{ @@ -312,18 +339,15 @@ func clientFromStored(stored storedClient, hasTTL bool) fosite.Client { return oidcClient } -// RegisterClient creates or replaces a client in storage. A DCR-issued -// registration is create-only and atomically returns ErrAlreadyExists when a -// client with the same ID is already registered; an operator-declared -// static/delegate client is authoritative and always replaces any existing -// registration, including one DCR issued. -func (s *RedisStorage) RegisterClient(ctx context.Context, client fosite.Client) error { - if err := ValidateRegisterableClientID(client.GetID()); err != nil { - return err - } - - key := redisKey(s.keyPrefix, KeyTypeClient, client.GetID()) - +// buildStoredClient converts client to its serializable form. Record the +// registered auth method when the client exposes one. Clients that don't +// implement fosite.OpenIDConnectClient (e.g. pre-provisioned confidential +// clients built as bare *fosite.DefaultClient) leave the field empty on +// purpose: Public alone carries the meaning it already carries, and +// clientFromStored treats the empty method as a legacy row. Do NOT substitute +// a "none" fallback here — that would silently reclassify a confidential row +// as public on read-back. +func buildStoredClient(client fosite.Client) storedClient { stored := storedClient{ ID: client.GetID(), Secret: client.GetHashedSecret(), @@ -334,19 +358,29 @@ func (s *RedisStorage) RegisterClient(ctx context.Context, client fosite.Client) Audience: client.GetAudience(), Public: client.IsPublic(), } - // Record the registered auth method when the client exposes one. Clients - // that don't implement fosite.OpenIDConnectClient (e.g. pre-provisioned - // confidential clients built as bare *fosite.DefaultClient) leave the field - // empty on purpose: Public alone carries the meaning it already carries, - // and GetClient treats the empty method as a legacy row. Do NOT substitute - // a "none" fallback here — that would silently reclassify a confidential - // row as public on read-back. if oidcClient, ok := client.(fosite.OpenIDConnectClient); ok { stored.TokenEndpointAuthMethod = oidcClient.GetTokenEndpointAuthMethod() stored.JSONWebKeys = publicJSONWebKeySet(oidcClient.GetJSONWebKeys()) stored.TokenEndpointAuthSigningAlgorithm = oidcClient.GetTokenEndpointAuthSigningAlgorithm() } stored.DCRIssued = registration.DCRIssued(client) + stored.Reserved = isReservedPlaceholder(client) + return stored +} + +// RegisterClient creates a client in storage. Always create-only via SetNX: +// it atomically returns ErrAlreadyExists when a client with the same ID is +// already registered, regardless of the new client's origin. /oauth/register +// is unauthenticated, so this must never clobber an existing client; +// operator-declared clients reconcile through ReconcileConfiguredClient +// instead. +func (s *RedisStorage) RegisterClient(ctx context.Context, client fosite.Client) error { + if err := ValidateRegisterableClientID(client.GetID()); err != nil { + return err + } + + key := redisKey(s.keyPrefix, KeyTypeClient, client.GetID()) + stored := buildStoredClient(client) data, err := json.Marshal(stored) //nolint:gosec // G117 - internal Redis storage serialization, not exposed to users if err != nil { @@ -367,27 +401,129 @@ func (s *RedisStorage) RegisterClient(ctx context.Context, client fosite.Client) ttl = DefaultDCRClientTTL } - // DCR is unauthenticated (the /register endpoint has no caller identity), - // so a DCR-issued registration must never clobber an existing client: - // SetNX only creates. Operator-declared static/delegate clients are - // authoritative and may replace any existing registration, including one - // DCR issued: plain Set with ttl 0 always overwrites and also clears an - // inherited DCR expiry, which is exactly what promoting a DCR client to - // permanent configuration requires. + created, err := s.client.SetNX(ctx, key, data, ttl).Result() + if err != nil { + return fmt.Errorf("failed to register client: %w", err) + } + if !created { + return fmt.Errorf("%w: client %q", ErrAlreadyExists, client.GetID()) + } + return nil +} + +// storedClientFingerprintsEqual mirrors clientFingerprintsEqual but compares +// the raw serialized fields directly rather than through the fosite.Client +// interface returned by clientFromStored. This matters because +// fosite.DefaultClient.GetGrantTypes and GetResponseTypes each silently +// substitute a single-element default (["authorization_code"] / ["code"]) +// whenever the underlying field is empty (see clientFromStored) — comparing +// through the reconstructed client would report a false mismatch between +// two rows that both deliberately carry no grant/response types, such as +// the SPIFFE static-client placeholder in spiffe_decorator.go reconciling +// against itself. Comparing the stored bytes directly sidesteps that +// defaulting entirely. +func storedClientFingerprintsEqual(a, b storedClient) bool { + return sameStringSet(a.Scopes, b.Scopes) && + sameStringSet(a.Audience, b.Audience) && + sameStringSet(a.GrantTypes, b.GrantTypes) && + sameStringSet(a.ResponseTypes, b.ResponseTypes) && + a.Public == b.Public +} + +// maxConfiguredClientReconcileRetries bounds the retry loop +// ReconcileConfiguredClient runs around its WATCH/MULTI transaction. go-redis +// does not retry Watch internally: a concurrent write to the watched key aborts +// the pipelined EXEC with redis.TxFailedErr, which Watch returns to the caller +// unwrapped (see $GOMODCACHE/github.com/redis/go-redis/v9@*/tx.go). Retrying a +// small, fixed number of times lets a real concurrent write during the exact +// race this feature exists to close (see preflightDurableCollisions in +// spiffe_decorator.go) resolve on its own rather than failing the caller's +// startup path with a spurious error. +const maxConfiguredClientReconcileRetries = 5 + +// ReconcileConfiguredClient applies an operator-declared client: creates it +// if absent, idempotently replaces a matching-fingerprint configured client +// (the restart-with-unchanged-config case), or refuses with ErrAlreadyExists +// when the existing record is DCR-issued or a different configured client. +// See the ClientRegistry interface doc for the full contract. +// +// The read-check-write sequence for an existing key runs inside a Redis +// WATCH/MULTI transaction so a concurrent writer (e.g. a DCR registration on +// another replica racing this reconciliation) cannot interleave between the +// fingerprint check and the write. Redis aborts the transaction if the +// watched key changed concurrently, and go-redis surfaces that as +// redis.TxFailedErr rather than retrying it — see +// maxConfiguredClientReconcileRetries for why this method retries that error +// itself, up to a bounded count. A configured client is stored with ttl=0 +// (operator-declared clients never expire), clearing any TTL the row might +// have inherited. +func (s *RedisStorage) ReconcileConfiguredClient(ctx context.Context, client fosite.Client) error { if registration.DCRIssued(client) { - created, err := s.client.SetNX(ctx, key, data, ttl).Result() - if err != nil { - return fmt.Errorf("failed to register client: %w", err) + return fmt.Errorf("configured client %q must not carry the DCR-issued marker", client.GetID()) + } + if err := ValidateRegisterableClientID(client.GetID()); err != nil { + return err + } + + key := redisKey(s.keyPrefix, KeyTypeClient, client.GetID()) + stored := buildStoredClient(client) + data, err := json.Marshal(stored) //nolint:gosec // G117 - internal Redis storage serialization, not exposed to users + if err != nil { + return fmt.Errorf("failed to marshal client: %w", err) + } + + setPipelined := func(tx *redis.Tx) error { + _, err := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error { + pipe.Set(ctx, key, data, 0) + return nil + }) + return err + } + + txFn := func(tx *redis.Tx) error { + existingData, getErr := tx.Get(ctx, key).Bytes() + if errors.Is(getErr, redis.Nil) { + return setPipelined(tx) + } + if getErr != nil { + return fmt.Errorf("failed to get existing client: %w", getErr) } - if !created { - return fmt.Errorf("%w: client %q", ErrAlreadyExists, client.GetID()) + + ttl, ttlErr := tx.TTL(ctx, key).Result() + if ttlErr != nil { + return fmt.Errorf("failed to get existing client TTL: %w", ttlErr) } - return nil + + var existingStored storedClient + if unmarshalErr := json.Unmarshal(existingData, &existingStored); unmarshalErr != nil { + return fmt.Errorf("failed to unmarshal existing client: %w", unmarshalErr) + } + + // DCR-issued detection goes through the reconstructed client (not the + // raw existingStored.DCRIssued field) so it inherits clientFromStored's + // legacy-row compensation for rows written before the DCRIssued field + // existed. The fingerprint comparison below deliberately does NOT go + // through the reconstructed client — see storedClientFingerprintsEqual. + if registration.DCRIssued(clientFromStored(existingStored, ttl >= 0)) { + return fmt.Errorf("%w: client %q is DCR-issued, refusing to overwrite with a configured client", + ErrAlreadyExists, client.GetID()) + } + if !storedClientFingerprintsEqual(existingStored, stored) { + return fmt.Errorf("%w: client %q is already registered as a different configured client", + ErrAlreadyExists, client.GetID()) + } + + return setPipelined(tx) } - if err := s.client.Set(ctx, key, data, ttl).Err(); err != nil { - return fmt.Errorf("failed to register client: %w", err) + + var watchErr error + for attempt := 0; attempt < maxConfiguredClientReconcileRetries; attempt++ { + watchErr = s.client.Watch(ctx, txFn, key) + if !errors.Is(watchErr, redis.TxFailedErr) { + return watchErr + } } - return nil + return watchErr } // GetClient loads the client by its ID. diff --git a/pkg/authserver/storage/redis_test.go b/pkg/authserver/storage/redis_test.go index d2811c48df..b542770cc6 100644 --- a/pkg/authserver/storage/redis_test.go +++ b/pkg/authserver/storage/redis_test.go @@ -658,7 +658,7 @@ func TestRedisStorage_DCRClientTTL(t *testing.T) { }) }) - t.Run("static client replaces DCR client without TTL", func(t *testing.T) { + t.Run("ReconcileConfiguredClient refuses to overwrite a DCR client, TTL untouched", func(t *testing.T) { withRedisStorage(t, func(ctx context.Context, s *RedisStorage, mr *miniredis.Miniredis) { dcrClient := newDCRClient(t, "delegate", oauthproto.TokenEndpointAuthMethodClientSecretBasic, "old-secret") require.NoError(t, s.RegisterClient(ctx, dcrClient)) @@ -670,15 +670,76 @@ func TestRedisStorage_DCRClientTTL(t *testing.T) { Scopes: []string{"openid"}, Audience: []string{"https://mcp.example"}, }) require.NoError(t, err) - require.NoError(t, s.RegisterClient(ctx, staticClient)) + err = s.ReconcileConfiguredClient(ctx, staticClient) + require.ErrorIs(t, err, ErrAlreadyExists) + + // The original DCR-issued registration and its TTL must be untouched. retrieved, err := s.GetClient(ctx, "delegate") require.NoError(t, err) + assert.True(t, registration.DCRIssued(retrieved)) + assert.Positive(t, mr.TTL(key)) + }) + }) +} + +// TestRedisStorage_ReconcileConfiguredClient covers the create/idempotent/ +// reject matrix ReconcileConfiguredClient must implement over Redis: create +// when absent (no TTL), no-op when the existing record is itself configured +// and fingerprint-equal, and refuse with ErrAlreadyExists when the existing +// record is DCR-issued or a different configured client. +func TestRedisStorage_ReconcileConfiguredClient(t *testing.T) { + t.Parallel() + + newConfigured := func(id, secret string, scopes []string) fosite.Client { + client, err := registration.NewStaticDelegateClient(registration.Config{ + ID: id, Secret: secret, GrantTypes: []string{oauthproto.GrantTypeTokenExchange}, + Scopes: scopes, Audience: []string{"https://mcp.example"}, + }) + require.NoError(t, err) + return client + } + + t.Run("creates when absent, no TTL", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, mr *miniredis.Miniredis) { + client := newConfigured("configured", "secret", []string{"openid"}) + require.NoError(t, s.ReconcileConfiguredClient(ctx, client)) + + retrieved, err := s.GetClient(ctx, "configured") + require.NoError(t, err) assert.False(t, registration.DCRIssued(retrieved)) + key := redisKey(s.keyPrefix, KeyTypeClient, "configured") assert.Equal(t, time.Duration(0), mr.TTL(key)) + }) + }) + + t.Run("idempotent on matching fingerprint, secret rotation applies", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { + require.NoError(t, s.ReconcileConfiguredClient(ctx, newConfigured("configured", "old-secret", []string{"openid"}))) + require.NoError(t, s.ReconcileConfiguredClient(ctx, newConfigured("configured", "new-secret", []string{"openid"}))) + + retrieved, err := s.GetClient(ctx, "configured") + require.NoError(t, err) assert.NoError(t, registration.SHA256Hasher.Compare(ctx, retrieved.GetHashedSecret(), []byte("new-secret"))) }) }) + + t.Run("refuses a different configured client at the same ID", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { + require.NoError(t, s.ReconcileConfiguredClient(ctx, newConfigured("configured", "secret", []string{"openid"}))) + + err := s.ReconcileConfiguredClient(ctx, newConfigured("configured", "secret", []string{"profile"})) + require.ErrorIs(t, err, ErrAlreadyExists) + }) + }) + + t.Run("rejects a client carrying the DCR-issued marker", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { + err := s.ReconcileConfiguredClient(ctx, newDCRClient(t, "configured", oauthproto.TokenEndpointAuthMethodClientSecretBasic, "secret")) + require.Error(t, err) + assert.Contains(t, err.Error(), "must not carry the DCR-issued marker") + }) + }) } // TestRedisStorage_GetClient_SupportsLoopbackRedirectMatching pins that a diff --git a/pkg/authserver/storage/spiffe_decorator.go b/pkg/authserver/storage/spiffe_decorator.go index 648fbd78e8..0eb97ec24a 100644 --- a/pkg/authserver/storage/spiffe_decorator.go +++ b/pkg/authserver/storage/spiffe_decorator.go @@ -5,9 +5,9 @@ package storage import ( "context" - "errors" "fmt" "maps" + "time" "github.com/ory/fosite" @@ -22,8 +22,9 @@ type SPIFFEStorageDecorator struct { clients map[string]fosite.Client } -// NewSPIFFEStorageDecorator overlays static SPIFFE clients over base. It checks -// the durable backend for client-ID collisions before creating the overlay. +// NewSPIFFEStorageDecorator overlays static SPIFFE clients over base. It +// durably claims each configured client ID in base's durable backend (see +// preflightDurableCollisions) before creating the overlay. func NewSPIFFEStorageDecorator(ctx context.Context, base Storage, clients map[string]fosite.Client) (Storage, error) { if base == nil { return nil, fmt.Errorf("storage is required") @@ -46,8 +47,9 @@ func NewSPIFFEStorageDecorator(ctx context.Context, base Storage, clients map[st return &SPIFFEStorageDecorator{Storage: base, clients: privateClients}, nil } -// PreflightSPIFFEStaticClientCollisions checks configured static client IDs -// against durable storage without creating a persistent reservation. +// PreflightSPIFFEStaticClientCollisions durably claims each configured static +// client ID in base's durable backend before the caller starts serving static +// SPIFFE clients from an in-process overlay. See preflightDurableCollisions. func PreflightSPIFFEStaticClientCollisions(ctx context.Context, base Storage, clients map[string]fosite.Client) error { if base == nil { return fmt.Errorf("storage is required") @@ -55,25 +57,112 @@ func PreflightSPIFFEStaticClientCollisions(ctx context.Context, base Storage, cl return preflightDurableCollisions(ctx, base, clients) } +// preflightDurableCollisions durably claims each configured static SPIFFE +// client ID in base's durable backend using an inert placeholder (see +// staticClientPlaceholder) — never the real SPIFFE client. Reconciliation is +// idempotent: a restart with unchanged association config reconstructs the +// identical placeholder and succeeds silently; a collision with a DCR-issued +// client, or with a durably-claimed placeholder for a *different* association +// at the same ID, fails loudly. +// +// This closes a cross-replica race that a read-only GetClient check cannot: +// with Redis and multiple replicas, an older/still-rolling replica without +// this SPIFFE config could otherwise DCR-register the same client ID after a +// newer replica's read-only check passed, leaving different replicas +// resolving different clients for the same ID. Durably claiming the ID here +// makes the reservation visible to every replica's ReconcileConfiguredClient +// and RegisterClient calls immediately. func preflightDurableCollisions(ctx context.Context, base Storage, clients map[string]fosite.Client) error { - for clientID := range clients { - if client, err := Unwrap(base).GetClient(ctx, clientID); err == nil { - kind := "operator-declared" - if registration.DCRIssued(client) { - kind = "DCR-issued" - } - return fmt.Errorf( - "%w: static SPIFFE client ID %q collides with an existing %s client; "+ - "remove the stale registration from durable storage before configuring this association", - ErrAlreadyExists, clientID, kind, - ) - } else if !errors.Is(err, ErrNotFound) && !errors.Is(err, fosite.ErrNotFound) { - return fmt.Errorf("check durable client collision for SPIFFE client ID %q: %w", clientID, err) + underlying := Unwrap(base) + for clientID, client := range clients { + placeholder := staticClientPlaceholder(client) + if err := underlying.ReconcileConfiguredClient(ctx, placeholder); err != nil { + return fmt.Errorf("claim durable placeholder for static SPIFFE client ID %q: %w", clientID, err) } } return nil } +// inertPlaceholderClient wraps a *fosite.DefaultClient to suppress its +// implicit defaulting: fosite.DefaultClient.GetGrantTypes and +// GetResponseTypes each return a single-element default +// (["authorization_code"] / ["code"]) when the underlying field is nil, +// treating "omitted" as "the interactive default" per the OIDC registration +// metadata convention they follow. staticClientPlaceholder needs the +// opposite: a client that can never be issued a token via any grant. +// Overriding both getters at the type — rather than storing an empty +// (non-nil) slice on the embedded struct — is required, not just clearer: +// fosite defaults on len() == 0 regardless of nil vs. non-nil-empty, so an +// empty slice alone would not suppress the default. +// +// This override is only reliable for the live Go value. A round trip through +// Redis serializes the *fields* (buildStoredClient reads GetGrantTypes() / +// GetResponseTypes() into JSON, which correctly capture the override's empty +// result), but clientFromStored ordinarily reconstructs a bare +// *fosite.DefaultClient on read, which reintroduces the very defaulting this +// type exists to suppress. storedClient.Reserved closes that gap: it marks a +// row as this placeholder so clientFromStored re-wraps the reconstructed +// client in inertPlaceholderClient on every read, independent of backend. +// See buildStoredClient and clientFromStored in redis.go. +type inertPlaceholderClient struct { + registration.BackChannelOnlyMarker + *fosite.DefaultClient +} + +func (inertPlaceholderClient) GetGrantTypes() fosite.Arguments { return nil } +func (inertPlaceholderClient) GetResponseTypes() fosite.Arguments { return nil } + +// isReservedPlaceholder reports whether client is a staticClientPlaceholder +// value, letting buildStoredClient (redis.go) mark the persisted row so +// clientFromStored can restore the same inert wrapper on read-back. Mirrors +// registration.DCRIssued's marker-detection pattern. +func isReservedPlaceholder(client fosite.Client) bool { + _, ok := client.(inertPlaceholderClient) + return ok +} + +// staticClientPlaceholder returns an inert stand-in for a real static SPIFFE +// client, safe to durably persist. It is never returned to a caller of +// GetClient — GetClient always resolves a reserved ID from the in-process +// overlay first (see SPIFFEStorageDecorator.GetClient) — but must still be +// structurally impossible to authenticate as or exchange a token for, in +// case that invariant is ever violated: no grant types and no response types +// mean fosite can never issue it a token via any grant handler this auth +// server registers, and it carries no secret. This guarantee holds on every +// backend, including a bare read-back from Redis by a replica with no SPIFFE +// overlay — see inertPlaceholderClient and storedClient.Reserved. It keeps +// the real client's Scopes and Audience so the fingerprint comparison in +// ReconcileConfiguredClient can distinguish "same association, restarted" +// (idempotent) from "different, colliding association at this ID" (a loud +// failure). +// Scopes and Audience are taken directly from actual.GetScopes() / +// GetAudience() without an extra defensive clone here: the only production +// caller (registration.SPIFFEClient) already returns a fresh slice.Clone +// from both getters (see its doc comments), so there is no live backing +// array to alias. +func staticClientPlaceholder(actual fosite.Client) fosite.Client { + return inertPlaceholderClient{DefaultClient: &fosite.DefaultClient{ + ID: actual.GetID(), + Scopes: actual.GetScopes(), + Audience: actual.GetAudience(), + Public: false, + }} +} + +// ConsumeAssertionJWT delegates assertion replay consumption to the wrapped +// storage, one level down. Storage intentionally does not include this narrow +// capability, so this decorator fails closed when its wrapped storage does +// not provide it, rather than silently skipping past this layer via Unwrap. +func (d *SPIFFEStorageDecorator) ConsumeAssertionJWT( + ctx context.Context, purpose, issuer, jti string, exp time.Time, +) error { + consumer, ok := d.Storage.(AssertionJWTConsumer) + if !ok { + return fmt.Errorf("wrapped storage %T does not support assertion JWT replay consumption", d.Storage) + } + return consumer.ConsumeAssertionJWT(ctx, purpose, issuer, jti, exp) +} + // GetClient returns a configured SPIFFE client before consulting the dynamic backend. func (d *SPIFFEStorageDecorator) GetClient(ctx context.Context, id string) (fosite.Client, error) { if client, ok := d.clients[id]; ok { @@ -93,5 +182,17 @@ func (d *SPIFFEStorageDecorator) RegisterClient(ctx context.Context, client fosi return d.Storage.RegisterClient(ctx, client) } +// ReconcileConfiguredClient rejects reserved static SPIFFE IDs and delegates +// other configured-client reconciliation to the base backend. +func (d *SPIFFEStorageDecorator) ReconcileConfiguredClient(ctx context.Context, client fosite.Client) error { + if client == nil { + return fmt.Errorf("reconcile configured client: client is required") + } + if _, reserved := d.clients[client.GetID()]; reserved { + return fmt.Errorf("%w: client ID %q is reserved for a static SPIFFE client", ErrAlreadyExists, client.GetID()) + } + return d.Storage.ReconcileConfiguredClient(ctx, client) +} + // Unwrap returns the dynamic DCR/CIMD storage backend. func (d *SPIFFEStorageDecorator) Unwrap() Storage { return d.Storage } diff --git a/pkg/authserver/storage/spiffe_decorator_test.go b/pkg/authserver/storage/spiffe_decorator_test.go index 11809a0acb..11149d428d 100644 --- a/pkg/authserver/storage/spiffe_decorator_test.go +++ b/pkg/authserver/storage/spiffe_decorator_test.go @@ -6,12 +6,14 @@ package storage import ( "context" "testing" + "time" "github.com/ory/fosite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stacklok/toolhive/pkg/authserver/server/registration" + "github.com/stacklok/toolhive/pkg/oauthproto" ) func testSPIFFEClients(t *testing.T) map[string]fosite.Client { @@ -39,6 +41,60 @@ func TestSPIFFEStorageDecoratorStaticClients(t *testing.T) { require.ErrorIs(t, decorated.RegisterClient(context.Background(), &fosite.DefaultClient{ID: "spiffe-client"}), ErrAlreadyExists) } +// TestSPIFFEStorageDecoratorReconcileConfiguredClientRejectsReservedID mirrors +// TestSPIFFEStorageDecoratorStaticClients' RegisterClient assertion, but for +// ReconcileConfiguredClient: an operator-declared client must not be able to +// reconcile onto a reserved static SPIFFE client ID either. +func TestSPIFFEStorageDecoratorReconcileConfiguredClientRejectsReservedID(t *testing.T) { + t.Parallel() + base := NewMemoryStorage() + t.Cleanup(func() { _ = base.Close() }) + + decorated, err := NewSPIFFEStorageDecorator(context.Background(), base, testSPIFFEClients(t)) + require.NoError(t, err) + + err = decorated.ReconcileConfiguredClient(context.Background(), &fosite.DefaultClient{ID: "spiffe-client"}) + require.ErrorIs(t, err, ErrAlreadyExists) +} + +// TestSPIFFEStorageDecoratorDurablyClaimsPlaceholder pins the race fix: +// construction durably claims each configured client ID in the base backend +// with an inert placeholder (never the real, unauthenticatable-by-design +// SPIFFE client), so a concurrent DCR registration on another replica cannot +// claim the same ID after this replica's construction completes. +func TestSPIFFEStorageDecoratorDurablyClaimsPlaceholder(t *testing.T) { + t.Parallel() + ctx := context.Background() + base := NewMemoryStorage() + t.Cleanup(func() { _ = base.Close() }) + + _, err := NewSPIFFEStorageDecorator(ctx, base, testSPIFFEClients(t)) + require.NoError(t, err) + + claimed, err := base.GetClient(ctx, "spiffe-client") + require.NoError(t, err, "construction must durably claim the client ID in the base backend") + assert.Empty(t, claimed.GetGrantTypes(), "the durable placeholder must carry no grant types") + assert.Empty(t, claimed.GetResponseTypes(), "the durable placeholder must carry no response types") + assert.Nil(t, claimed.GetHashedSecret(), "the durable placeholder must carry no secret") + assert.False(t, registration.DCRIssued(claimed)) +} + +// TestSPIFFEStorageDecoratorRestartIsIdempotent pins the restart case: +// reconstructing the decorator with the SAME association config reclaims the +// identical placeholder and succeeds, rather than colliding with itself. +func TestSPIFFEStorageDecoratorRestartIsIdempotent(t *testing.T) { + t.Parallel() + ctx := context.Background() + base := NewMemoryStorage() + t.Cleanup(func() { _ = base.Close() }) + + _, err := NewSPIFFEStorageDecorator(ctx, base, testSPIFFEClients(t)) + require.NoError(t, err) + + _, err = NewSPIFFEStorageDecorator(ctx, base, testSPIFFEClients(t)) + require.NoError(t, err, "reconstructing with unchanged association config must be idempotent") +} + func TestSPIFFEStorageDecoratorRejectsInvalidClients(t *testing.T) { t.Parallel() @@ -98,14 +154,43 @@ func TestSPIFFEStorageDecoratorClonesClientMap(t *testing.T) { func TestSPIFFEStorageDecoratorRejectsDurableCollision(t *testing.T) { t.Parallel() + ctx := context.Background() + base := NewMemoryStorage() + t.Cleanup(func() { _ = base.Close() }) + + dcrIssued, err := registration.New(registration.Config{ + ID: "spiffe-client", + TokenEndpointAuthMethod: oauthproto.TokenEndpointAuthMethodNone, + RedirectURIs: []string{"https://app.example/cb"}, + }) + require.NoError(t, err) + require.True(t, registration.DCRIssued(dcrIssued), "precondition: seeded client must be DCR-issued") + require.NoError(t, base.RegisterClient(ctx, dcrIssued)) + + _, err = NewSPIFFEStorageDecorator(ctx, base, testSPIFFEClients(t)) + require.ErrorIs(t, err, ErrAlreadyExists) + assert.Contains(t, err.Error(), "DCR-issued") +} + +// TestSPIFFEStorageDecoratorRejectsDifferentConfiguredClientCollision covers +// the case ReconcileConfiguredClient's fingerprint check adds: a *different* +// configured client (not DCR-issued) already durably claiming a reserved ID +// -- e.g. a stale placeholder from a prior, differently-scoped association -- +// must still fail construction loudly rather than silently diverging. +func TestSPIFFEStorageDecoratorRejectsDifferentConfiguredClientCollision(t *testing.T) { + t.Parallel() + ctx := context.Background() base := NewMemoryStorage() t.Cleanup(func() { _ = base.Close() }) - require.NoError(t, base.RegisterClient(context.Background(), &fosite.DefaultClient{ID: "spiffe-client"})) - _, err := NewSPIFFEStorageDecorator(context.Background(), base, testSPIFFEClients(t)) + // Pre-seed a durably-claimed placeholder with a different fingerprint + // (mismatched scopes) than the one the real association will produce. + mismatched := &fosite.DefaultClient{ID: "spiffe-client", Scopes: []string{"a-different-scope"}} + require.NoError(t, base.ReconcileConfiguredClient(ctx, mismatched)) + + _, err := NewSPIFFEStorageDecorator(ctx, base, testSPIFFEClients(t)) require.ErrorIs(t, err, ErrAlreadyExists) - assert.Contains(t, err.Error(), "operator-declared") - assert.Contains(t, err.Error(), "remove the stale registration") + assert.Contains(t, err.Error(), "different configured client") } func TestSPIFFEStorageDecoratorLeavesEmptyClientsUnchanged(t *testing.T) { @@ -116,3 +201,114 @@ func TestSPIFFEStorageDecoratorLeavesEmptyClientsUnchanged(t *testing.T) { require.NoError(t, err) assert.Same(t, base, decorated) } + +// TestSPIFFEStorageDecoratorPlaceholderInertOnRawRedisReadback is the +// regression test for PR #6474 review finding 1: the durable placeholder +// written by PreflightSPIFFEStaticClientCollisions must remain unusable even +// when read back through the RAW, unwrapped Redis storage — simulating an +// old or mid-rollout replica with no SPIFFE overlay reaching the placeholder +// through the ordinary fosite.ClientManager.GetClient path. +// fosite.DefaultClient.GetGrantTypes/GetResponseTypes silently default to a +// real grant/response type ("authorization_code" / "code") whenever the +// underlying field is empty, and clientFromStored used to reconstruct a bare +// *fosite.DefaultClient on every read, reintroducing exactly that defaulting +// for a row that must never satisfy it. Without the storedClient.Reserved fix +// in clientFromStored, this test fails with non-empty grant/response types. +func TestSPIFFEStorageDecoratorPlaceholderInertOnRawRedisReadback(t *testing.T) { + t.Parallel() + ctx := context.Background() + raw, mr := newTestRedisStorage(t) + t.Cleanup(func() { _ = raw.Close(); mr.Close() }) + + require.NoError(t, PreflightSPIFFEStaticClientCollisions(ctx, raw, testSPIFFEClients(t))) + + client, err := raw.GetClient(ctx, "spiffe-client") + require.NoError(t, err) + + assert.Empty(t, client.GetGrantTypes(), "placeholder must carry no usable grant type on raw Redis readback") + assert.Empty(t, client.GetResponseTypes(), "placeholder must carry no usable response type on raw Redis readback") + assert.Nil(t, client.GetHashedSecret(), "placeholder must carry no usable secret on raw Redis readback") + assert.False(t, client.IsPublic(), "placeholder must not read back as a public client") + assert.True(t, registration.BackChannelOnly(client), + "placeholder must carry the explicit back-channel-only marker on raw Redis readback, "+ + "not rely solely on empty grant/response types") +} + +// TestSPIFFEStorageDecoratorDurableClaimRedisBacked exercises the full +// durable-claim flow (the point of PR #6474) against a real RedisStorage +// backend rather than MemoryStorage: the original cross-replica race (review +// finding 1's precondition) only manifests with a shared backend, since +// MemoryStorage is per-process. +func TestSPIFFEStorageDecoratorDurableClaimRedisBacked(t *testing.T) { + t.Parallel() + ctx := context.Background() + base, mr := newTestRedisStorage(t) + t.Cleanup(func() { _ = base.Close(); mr.Close() }) + + _, err := NewSPIFFEStorageDecorator(ctx, base, testSPIFFEClients(t)) + require.NoError(t, err) + + // Durably written: readable via GetClient on the raw Redis storage. + claimed, err := base.GetClient(ctx, "spiffe-client") + require.NoError(t, err) + assert.Empty(t, claimed.GetGrantTypes()) + + // Reconstructing with the same config twice is idempotent (restart case). + _, err = NewSPIFFEStorageDecorator(ctx, base, testSPIFFEClients(t)) + require.NoError(t, err) + + // Reconstructing with a different/conflicting association at the same ID + // fails loudly. + conflicting, err := registration.NewSPIFFEClient( + "spiffe-client", []string{"a-different-scope"}, []string{"https://api.example.com"}) + require.NoError(t, err) + _, err = NewSPIFFEStorageDecorator(ctx, base, map[string]fosite.Client{"spiffe-client": conflicting}) + require.ErrorIs(t, err, ErrAlreadyExists) +} + +// TestSPIFFEStorageDecorator_ConsumeAssertionJWTDelegatesToWrapped is the +// regression test for PR #6474 review finding 2: SPIFFEStorageDecorator must +// forward JWT-bearer assertion-replay consumption to its immediately-wrapped +// storage (one level down), never by unwrapping past it, so replay protection +// is actually enforced (not just accidentally reachable) when this decorator +// sits in the chain. MemoryStorage's own ConsumeAssertionJWT does the real +// replay bookkeeping here, proving the forward is live rather than a stub. +func TestSPIFFEStorageDecorator_ConsumeAssertionJWTDelegatesToWrapped(t *testing.T) { + t.Parallel() + ctx := context.Background() + base := NewMemoryStorage() + t.Cleanup(func() { _ = base.Close() }) + + decorated, err := NewSPIFFEStorageDecorator(ctx, base, testSPIFFEClients(t)) + require.NoError(t, err) + consumer, ok := decorated.(AssertionJWTConsumer) + require.True(t, ok, "SPIFFEStorageDecorator must implement AssertionJWTConsumer") + + exp := time.Now().Add(time.Hour) + require.NoError(t, consumer.ConsumeAssertionJWT(ctx, "jwt-bearer", "https://issuer.example", "jti", exp)) + require.ErrorIs(t, + consumer.ConsumeAssertionJWT(ctx, "jwt-bearer", "https://issuer.example", "jti", exp), + fosite.ErrJTIKnown) +} + +// TestSPIFFEStorageDecorator_ConsumeAssertionJWTFailsClosedWithoutBackendCapability +// mirrors CIMDStorageDecorator's equivalent test: when the wrapped storage +// does not implement AssertionJWTConsumer, SPIFFEStorageDecorator must fail +// closed with an error naming the concrete wrapped type, not silently succeed +// or panic. storageWithoutAssertionJWTConsumer is defined in +// cimd_decorator_test.go and reused here. +// +// The decorator is built directly (not via NewSPIFFEStorageDecorator) because +// the constructor's preflight durably claims each client ID against the base +// backend, which would call methods on the embedded nil Storage this fake +// deliberately doesn't implement. +func TestSPIFFEStorageDecorator_ConsumeAssertionJWTFailsClosedWithoutBackendCapability(t *testing.T) { + t.Parallel() + ctx := context.Background() + + decorated := &SPIFFEStorageDecorator{Storage: storageWithoutAssertionJWTConsumer{}, clients: testSPIFFEClients(t)} + + err := decorated.ConsumeAssertionJWT(ctx, "jwt-bearer", "https://issuer.example", "jti", time.Now().Add(time.Hour)) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not support assertion JWT replay consumption") +} diff --git a/pkg/authserver/storage/types.go b/pkg/authserver/storage/types.go index 52f72f5aab..a7f9f7571e 100644 --- a/pkg/authserver/storage/types.go +++ b/pkg/authserver/storage/types.go @@ -591,21 +591,61 @@ func ValidateRegisterableClientID(id string) error { return nil } +// sameStringSet reports whether a and b contain the same elements as sets: +// order and duplicate count don't matter, only membership. Canonicalisation +// (sort, then dedup) mirrors ScopesHash's approach so the two stay consistent. +func sameStringSet(a, b []string) bool { + as := slices.Clone(a) + bs := slices.Clone(b) + sort.Strings(as) + sort.Strings(bs) + as = slices.Compact(as) + bs = slices.Compact(bs) + return slices.Equal(as, bs) +} + +// clientFingerprintsEqual reports whether a and b represent the same logical +// client configuration: equal scope set, audience set, grant-type set, +// response-type set, and public/confidential class. Client secrets are +// deliberately excluded from the comparison — an operator rotating a +// configured client's secret must still be able to reconcile; a secret +// rotation is not a different logical client. +func clientFingerprintsEqual(a, b fosite.Client) bool { + return sameStringSet(a.GetScopes(), b.GetScopes()) && + sameStringSet(a.GetAudience(), b.GetAudience()) && + sameStringSet(a.GetGrantTypes(), b.GetGrantTypes()) && + sameStringSet(a.GetResponseTypes(), b.GetResponseTypes()) && + a.IsPublic() == b.IsPublic() +} + // ClientRegistry provides client registration and lookup operations. // It embeds fosite.ClientManager for client lookup (GetClient) and adds -// RegisterClient for dynamic client registration (RFC 7591). +// RegisterClient for dynamic client registration (RFC 7591) and +// ReconcileConfiguredClient for operator-declared clients. type ClientRegistry interface { // ClientManager provides client lookup (GetClient) fosite.ClientManager - // RegisterClient registers a new OAuth client. - // This supports both static configuration and dynamic client registration (RFC 7591). - // A DCR-issued client is create-only: it returns ErrAlreadyExists if a - // client with the same ID already exists. A static/operator-declared - // client is authoritative and replaces any existing client with the same - // ID, including one that was DCR-issued. + // RegisterClient registers a new OAuth client. Always create-only: it + // returns ErrAlreadyExists if a client with the same ID already exists, + // regardless of the new client's origin. Used by unauthenticated DCR + // (RFC 7591) and any other caller that must never silently overwrite an + // existing registration. RegisterClient(ctx context.Context, client fosite.Client) error + // ReconcileConfiguredClient applies an operator-declared (configured) + // client: creates it if no client with that ID exists, or idempotently + // replaces an existing record with the same ID when that record is itself + // operator-declared AND has a matching fingerprint (scopes, audience, + // grant types, response types, public/confidential class) — the + // restart-with-unchanged-config case. It refuses with ErrAlreadyExists if + // the existing record is DCR-issued (registration.DCRIssued), or if it is + // a *different* configured client at that ID (fingerprint mismatch — a + // misconfiguration, e.g. two colliding associations). The passed client + // must not itself carry the registration.DCRIssued marker; + // ReconcileConfiguredClient returns an error if it does. + ReconcileConfiguredClient(ctx context.Context, client fosite.Client) error + // RenewClientTTL extends the registration TTL of a DCR-issued client (public or // confidential, gated on the registration.DCRIssued marker) so an actively-used // client is not evicted mid-lifecycle and forced to re-register. Call it on a From 47a852a019570586628e5fa4a40d9d47c3fd2001 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Wed, 2 Sep 2026 11:47:36 +0200 Subject: [PATCH 08/14] Make outbound DCR cache population race-safe across replicas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reviewer found that two replicas racing on the same outbound DCR (RFC 7591) cache-miss could each independently register a different OAuth client with the upstream IdP — dynamic registration always mints a fresh client_id/secret — then whichever replica's write landed last in the shared Redis cache silently won. The losing replica keeps the client it registered baked into its own config for the rest of its process lifetime (DCR resolution runs once per upstream at startup, never re-resolved), so it no longer agrees with the durable cache about which client it holds credentials for. dcrFlight (a singleflight.Group) only coalesces concurrent callers within one process; it has no cross-replica reach. Change the cache-population contract from upsert to create-if-absent, returning the authoritative durable value either way: the caller's own resolution on a successful claim, or the concurrent winner's otherwise. CredentialStore.Put becomes PutIfAbsent, and DCRCredentialStore.StoreDCRCredentials becomes StoreDCRCredentialsIfAbsent; registerAndCache now returns whichever resolution the store says is authoritative instead of trusting its own local registration, and logs (at Debug, without ever including a secret) when this replica lost the race. Callers MUST use the returned value — RFC 7591 guarantees nothing about the two registrations converging. Redis claims the key with SET...NX (the same reservation-lock shape already used twice in this file for ClientAssertionJWTValid and ConsumeAssertionJWT), not WATCH/MULTI: unlike ReconcileConfiguredClient, this write has no read-then-decide step to protect, so a plain atomic NX claim is sufficient. On a lost claim it reads back the winner through the existing GetDCRCredentials path rather than a second, hand-rolled unmarshal, and retries the whole claim-or-read cycle (bounded) if the winner's row evicts between the failed NX and the read — its TTL can be as short as one second when the caller's ClientSecretExpiresAt was already in the past, so this is a real, reachable window, not a hypothetical one, and the alternative (a hard error) would turn a retryable race into a permanent startup failure. MemoryStorage's implementation treats an existing entry as absent only when its ClientSecretExpiresAt is non-zero and already past — otherwise it returns the existing entry unchanged rather than overwriting it. A single process's dcrFlight already prevents a live race there; this is contract symmetry with Redis, plus the correctness case Redis gets from TTL eviction: without the expiry check, a never-expiring entry can never be reclaimed, but a naive "any existing entry blocks re-registration" check would also permanently pin an already-expired one that should be re-registered. Refs #6200 Signed-off-by: Jakub Hrozek --- pkg/auth/dcr/resolver.go | 26 +- pkg/auth/dcr/resolver_test.go | 16 +- pkg/auth/dcr/store.go | 44 ++- pkg/auth/dcr/store_test.go | 67 +++-- pkg/authserver/storage/memory.go | 43 ++- pkg/authserver/storage/memory_test.go | 87 +++++- pkg/authserver/storage/mocks/mock_storage.go | 17 +- pkg/authserver/storage/redis.go | 184 +++++++++++- .../storage/redis_integration_test.go | 35 ++- pkg/authserver/storage/redis_keys.go | 2 +- pkg/authserver/storage/redis_test.go | 277 ++++++++++++++++-- pkg/authserver/storage/types.go | 22 +- 12 files changed, 686 insertions(+), 134 deletions(-) diff --git a/pkg/auth/dcr/resolver.go b/pkg/auth/dcr/resolver.go index 94992d76e0..a18742ca1c 100644 --- a/pkg/auth/dcr/resolver.go +++ b/pkg/auth/dcr/resolver.go @@ -470,19 +470,39 @@ func registerAndCache( // failure leaves no in-memory state diverging from the cache: the // next call simply re-resolves rather than reading a value the cache // never saw. - if err := cache.Put(ctx, key, resolution); err != nil { + // + // authoritative may differ from resolution: RFC 7591 dynamic + // registration mints a brand-new, unique client_id/client_secret on + // every call, so if another replica raced this one to register against + // the same Key and won the durable claim first, cache.PutIfAbsent + // returns THAT replica's credentials — the only ones the shared cache + // (and hence any other replica or a future restart) will agree this + // caller holds. Returning resolution here instead would leave this + // process using a client_id the durable store does not recognize. + authoritative, err := cache.PutIfAbsent(ctx, key, resolution) + if err != nil { return nil, newDCRStepError(dcrStepCacheWrite, req.Issuer, redirectURI, fmt.Errorf("cache put: %w", err)) } + if authoritative.ClientID != resolution.ClientID { + //nolint:gosec // G706: client_id is public metadata per RFC 7591. + slog.Debug("dcr: registration superseded by concurrent winner", + "local_issuer", req.Issuer, + "upstream_id", key.UpstreamID, + "redirect_uri", redirectURI, + "registered_client_id", resolution.ClientID, + "authoritative_client_id", authoritative.ClientID, + ) + } //nolint:gosec // G706: client_id is public metadata per RFC 7591. slog.Debug("dcr: registered new client", "local_issuer", req.Issuer, "upstream_id", key.UpstreamID, "redirect_uri", redirectURI, - "client_id", resolution.ClientID, + "client_id", authoritative.ClientID, ) - return resolution, nil + return authoritative, nil } // LogStepError emits the single boundary slog.Error record for a DCR diff --git a/pkg/auth/dcr/resolver_test.go b/pkg/auth/dcr/resolver_test.go index 171c48d4f1..f89bd5aa93 100644 --- a/pkg/auth/dcr/resolver_test.go +++ b/pkg/auth/dcr/resolver_test.go @@ -170,7 +170,8 @@ func TestResolveDCRCredentials_CacheHitShortCircuits(t *testing.T) { AuthorizationEndpoint: "https://preloaded/authorize", TokenEndpoint: "https://preloaded/token", } - require.NoError(t, cache.Put(context.Background(), key, preloaded)) + _, err := cache.PutIfAbsent(context.Background(), key, preloaded) + require.NoError(t, err) req := &Request{ Issuer: issuer, @@ -1510,8 +1511,8 @@ func (c *countingStore) Get(ctx context.Context, key Key) (*Resolution, bool, er return res, ok, err } -func (c *countingStore) Put(ctx context.Context, key Key, res *Resolution) error { - return c.inner.Put(ctx, key, res) +func (c *countingStore) PutIfAbsent(ctx context.Context, key Key, res *Resolution) (*Resolution, error) { + return c.inner.PutIfAbsent(ctx, key, res) } // TestResolveDCRCredentials_SingleflightCoalescesConcurrentCallers pins the @@ -1790,8 +1791,11 @@ func (f failingDCRStore) Get(_ context.Context, _ Key) (*Resolution, bool, error return nil, false, nil } -func (f failingDCRStore) Put(_ context.Context, _ Key, _ *Resolution) error { - return f.putErr +func (f failingDCRStore) PutIfAbsent(_ context.Context, _ Key, res *Resolution) (*Resolution, error) { + if f.putErr != nil { + return nil, f.putErr + } + return res, nil } // TestResolveDCRCredentials_CacheGetFailureWrapped covers PR #5042 review @@ -2017,7 +2021,7 @@ func (panickingPutDCRStore) Get(_ context.Context, _ Key) (*Resolution, bool, er return nil, false, nil } -func (s panickingPutDCRStore) Put(_ context.Context, _ Key, _ *Resolution) error { +func (s panickingPutDCRStore) PutIfAbsent(_ context.Context, _ Key, _ *Resolution) (*Resolution, error) { panic(s.panicValue) } diff --git a/pkg/auth/dcr/store.go b/pkg/auth/dcr/store.go index c6b56ce470..d19a7b2842 100644 --- a/pkg/auth/dcr/store.go +++ b/pkg/auth/dcr/store.go @@ -50,11 +50,20 @@ type CredentialStore interface { // key is not present. An error is returned only on backend failure. Get(ctx context.Context, key Key) (*Resolution, bool, error) - // Put stores the resolution for key, overwriting any existing entry. + // PutIfAbsent claims key for resolution. Returns the authoritative + // durable value: the caller's own resolution on a successful claim, the + // concurrent winner's otherwise. Callers MUST use the returned value, + // not their input resolution — RFC 7591 dynamic registration mints a + // unique client_id/client_secret on every call, so a caller that lost + // the race and kept using its own resolution would hold credentials the + // durable store does not agree it owns. + // // Implementations must reject a nil resolution with an error rather // than silently succeeding — a no-op would leave callers with no // debug trail for the subsequent Get miss. - Put(ctx context.Context, key Key, resolution *Resolution) error + // + // The returned *Resolution is always non-nil when err is nil. + PutIfAbsent(ctx context.Context, key Key, resolution *Resolution) (*Resolution, error) } // NewStorageBackedStore returns a CredentialStore that delegates to a @@ -154,15 +163,19 @@ func (s *inMemoryStore) Get(ctx context.Context, key Key) (*Resolution, bool, er return credentialsToResolution(creds), true, nil } -// Put implements CredentialStore by delegating to the embedded +// PutIfAbsent implements CredentialStore by delegating to the embedded // *storage.MemoryStorage. The nil-resolution rejection matches -// storageBackedStore.Put; see that method for the rationale. -func (s *inMemoryStore) Put(ctx context.Context, key Key, resolution *Resolution) error { +// storageBackedStore.PutIfAbsent; see that method for the rationale. +func (s *inMemoryStore) PutIfAbsent(ctx context.Context, key Key, resolution *Resolution) (*Resolution, error) { if resolution == nil { - return fmt.Errorf("dcr: resolution must not be nil") + return nil, fmt.Errorf("dcr: resolution must not be nil") } creds := resolutionToCredentials(key, resolution) - return s.mem.StoreDCRCredentials(ctx, creds) + authoritative, err := s.mem.StoreDCRCredentialsIfAbsent(ctx, creds) + if err != nil { + return nil, err + } + return credentialsToResolution(authoritative), nil } // Close releases the embedded MemoryStorage cleanup goroutine. Safe to @@ -201,18 +214,27 @@ func (s *storageBackedStore) Get(ctx context.Context, key Key) (*Resolution, boo return credentialsToResolution(creds), true, nil } -// Put implements CredentialStore. +// PutIfAbsent implements CredentialStore. // // A nil resolution is rejected rather than silently no-oped: a caller // passing nil would otherwise get a successful return, observe a miss on // the next Get, and have no error trail to debug from. Failing loudly at // the boundary makes such bugs visible at the first call. -func (s *storageBackedStore) Put(ctx context.Context, key Key, resolution *Resolution) error { +// +// The returned *Resolution is the authoritative durable value — +// s.backend.StoreDCRCredentialsIfAbsent's own contract — so a caller whose +// registration lost a concurrent claim on this key gets back the winner's +// credentials, not its own. +func (s *storageBackedStore) PutIfAbsent(ctx context.Context, key Key, resolution *Resolution) (*Resolution, error) { if resolution == nil { - return fmt.Errorf("dcr: resolution must not be nil") + return nil, fmt.Errorf("dcr: resolution must not be nil") } creds := resolutionToCredentials(key, resolution) - return s.backend.StoreDCRCredentials(ctx, creds) + authoritative, err := s.backend.StoreDCRCredentialsIfAbsent(ctx, creds) + if err != nil { + return nil, err + } + return credentialsToResolution(authoritative), nil } // resolutionToCredentials converts a resolver-side *Resolution into the diff --git a/pkg/auth/dcr/store_test.go b/pkg/auth/dcr/store_test.go index 956663dee7..bbcfe65c5b 100644 --- a/pkg/auth/dcr/store_test.go +++ b/pkg/auth/dcr/store_test.go @@ -40,7 +40,10 @@ func TestStorageBackedStore_PutGet_RoundTrip(t *testing.T) { CreatedAt: time.Now(), } - require.NoError(t, store.Put(ctx, key, resolution)) + authoritative, err := store.PutIfAbsent(ctx, key, resolution) + require.NoError(t, err) + assert.Equal(t, resolution.ClientID, authoritative.ClientID, + "an uncontested claim must return the caller's own resolution") got, ok, err := store.Get(ctx, key) require.NoError(t, err) @@ -119,11 +122,15 @@ func TestStorageBackedStore_DistinctKeysDoNotCollide(t *testing.T) { } } - require.NoError(t, store.Put(ctx, keyA, resolution("a"))) - require.NoError(t, store.Put(ctx, keyB, resolution("b"))) - require.NoError(t, store.Put(ctx, keyC, resolution("c"))) - require.NoError(t, store.Put(ctx, keyD, resolution("d"))) - require.NoError(t, store.Put(ctx, keyE, resolution("e"))) + for _, put := range []struct { + key Key + clientID string + }{ + {keyA, "a"}, {keyB, "b"}, {keyC, "c"}, {keyD, "d"}, {keyE, "e"}, + } { + _, err := store.PutIfAbsent(ctx, put.key, resolution(put.clientID)) + require.NoError(t, err) + } for _, tc := range []struct { key Key @@ -142,7 +149,15 @@ func TestStorageBackedStore_DistinctKeysDoNotCollide(t *testing.T) { } } -func TestStorageBackedStore_Put_OverwritesExisting(t *testing.T) { +// TestStorageBackedStore_PutIfAbsent_FirstClaimWins pins the create-if-absent +// contract that replaced unconditional overwrite (see PutIfAbsent doc): a +// second PutIfAbsent for a key that already holds a value must NOT overwrite +// it. Instead it returns the existing (first) resolution as the authoritative +// value, and the store keeps holding the first entry. This is the fix for the +// concurrent-replica bug where two callers independently register distinct +// RFC 7591 clients for the same key — only one registration may ever become +// the durable, agreed-upon value. +func TestStorageBackedStore_PutIfAbsent_FirstClaimWins(t *testing.T) { t.Parallel() store := newMemoryDCRStore(t) @@ -161,21 +176,27 @@ func TestStorageBackedStore_Put_OverwritesExisting(t *testing.T) { Authorization: "https://idp.example.com/authorize", Token: "https://idp.example.com/token", } - require.NoError(t, store.Put(ctx, key, &Resolution{ + first, err := store.PutIfAbsent(ctx, key, &Resolution{ ClientID: "first", AuthorizationEndpoint: endpoints.Authorization, TokenEndpoint: endpoints.Token, - })) - require.NoError(t, store.Put(ctx, key, &Resolution{ + }) + require.NoError(t, err) + assert.Equal(t, "first", first.ClientID, "the uncontested first claim returns its own resolution") + + second, err := store.PutIfAbsent(ctx, key, &Resolution{ ClientID: "second", AuthorizationEndpoint: endpoints.Authorization, TokenEndpoint: endpoints.Token, - })) + }) + require.NoError(t, err) + assert.Equal(t, "first", second.ClientID, + "the loser must get back the winner's resolution, not its own") got, ok, err := store.Get(ctx, key) require.NoError(t, err) require.True(t, ok) - assert.Equal(t, "second", got.ClientID) + assert.Equal(t, "first", got.ClientID, "the store must keep the first-claimed entry, not overwrite it") } // TestStorageBackedStore_Put_RejectsNilResolution pins the @@ -189,7 +210,7 @@ func TestStorageBackedStore_Put_RejectsNilResolution(t *testing.T) { ctx := context.Background() key := Key{Issuer: "https://idp.example.com", RedirectURI: "https://x.example.com/cb"} - err := store.Put(ctx, key, nil) + _, err := store.PutIfAbsent(ctx, key, nil) require.Error(t, err) assert.Contains(t, err.Error(), "must not be nil") @@ -211,11 +232,12 @@ func TestStorageBackedStore_GetReturnsDefensiveCopy(t *testing.T) { RedirectURI: "https://x.example.com/cb", ScopesHash: storage.ScopesHash([]string{"openid"}), } - require.NoError(t, store.Put(ctx, key, &Resolution{ + _, err := store.PutIfAbsent(ctx, key, &Resolution{ ClientID: "orig", AuthorizationEndpoint: "https://idp.example.com/authorize", TokenEndpoint: "https://idp.example.com/token", - })) + }) + require.NoError(t, err) got, ok, err := store.Get(ctx, key) require.NoError(t, err) @@ -289,14 +311,14 @@ func TestStorageBackedStore_ConcurrentAccess(t *testing.T) { CreatedAt: time.Now(), } if i%2 == 0 { - if err := store.Put(ctx, overlappingKey(i), resolution); err != nil { + if _, err := store.PutIfAbsent(ctx, overlappingKey(i), resolution); err != nil { atomic.AddInt32(&errCount, 1) } if _, _, err := store.Get(ctx, overlappingKey(i)); err != nil { atomic.AddInt32(&errCount, 1) } } else { - if err := store.Put(ctx, disjointKey(worker, i), resolution); err != nil { + if _, err := store.PutIfAbsent(ctx, disjointKey(worker, i), resolution); err != nil { atomic.AddInt32(&errCount, 1) } if _, _, err := store.Get(ctx, disjointKey(worker, i)); err != nil { @@ -508,18 +530,19 @@ func TestInMemoryStore_PutGetCloseShareBackend(t *testing.T) { TokenEndpoint: "https://idp.example.com/token", } - require.NoError(t, store.Put(ctx, key, resolution)) + _, err := store.PutIfAbsent(ctx, key, resolution) + require.NoError(t, err) got, ok, err := store.Get(ctx, key) require.NoError(t, err) - require.True(t, ok, "Get must see the value Put just wrote — confirms Put and Get share a backend") + require.True(t, ok, "Get must see the value PutIfAbsent just wrote — confirms Put and Get share a backend") assert.Equal(t, "client-abc", got.ClientID) } // TestInMemoryStore_PutRejectsNilResolution mirrors the contract pinned -// for storageBackedStore.Put: a nil resolution is rejected at the +// for storageBackedStore.PutIfAbsent: a nil resolution is rejected at the // adapter boundary rather than silently no-oped, so the next Get miss -// surfaces with a debug trail. inMemoryStore implements Put directly +// surfaces with a debug trail. inMemoryStore implements PutIfAbsent directly // (not via embedding) — this test guards against a delegation // regression that omitted the nil check. func TestInMemoryStore_PutRejectsNilResolution(t *testing.T) { @@ -528,7 +551,7 @@ func TestInMemoryStore_PutRejectsNilResolution(t *testing.T) { store := NewInMemoryStore() t.Cleanup(func() { _ = store.Close() }) - err := store.Put(context.Background(), Key{Issuer: "https://idp.example.com"}, nil) + _, err := store.PutIfAbsent(context.Background(), Key{Issuer: "https://idp.example.com"}, nil) require.Error(t, err) assert.Contains(t, err.Error(), "resolution must not be nil") } diff --git a/pkg/authserver/storage/memory.go b/pkg/authserver/storage/memory.go index e76b5efbf7..f3985e24d9 100644 --- a/pkg/authserver/storage/memory.go +++ b/pkg/authserver/storage/memory.go @@ -157,7 +157,7 @@ type MemoryStorage struct { // dcrCredentials maps DCRKey -> DCRCredentials for RFC 7591 Dynamic Client // Registration credentials. These entries come from OUTBOUND DCR: ToolHive // acting as a DCR client to register itself against a configured upstream - // IdP (see pkg/auth/dcr/store.go, the only caller of StoreDCRCredentials). + // IdP (see pkg/auth/dcr/store.go, the only caller of StoreDCRCredentialsIfAbsent). // This is not reachable from the inbound, unauthenticated /oauth/register // handler, so unlike clients (bounded by maxClients/clientOrder because // every inbound registration call mints an entry), growth here is bounded @@ -1538,29 +1538,46 @@ func cloneDCRCredentials(c *DCRCredentials) *DCRCredentials { return &cp } -// StoreDCRCredentials persists DCR credentials for the given key. -// The credentials are stored under their own Key field; callers must populate -// it before calling. A defensive copy is made so subsequent caller mutations -// do not affect persisted state. +// StoreDCRCredentialsIfAbsent claims creds.Key for creds. The credentials +// are stored under their own Key field; callers must populate it before +// calling. A defensive copy is made so subsequent caller mutations do not +// affect persisted state. // -// Overwrites any existing entry for the same Key. The in-memory backend -// applies no native TTL — DCR registrations are long-lived and bounded by -// the operator-configured upstream count, and ClientSecretExpiresAt is -// retained verbatim for callers to re-check on read (see the interface -// docstring's "TTL handling" section). +// Create-if-absent, not overwrite: a single process's dcrFlight singleflight +// (see pkg/auth/dcr) already prevents concurrent same-key writers within +// this process, so the check below is not closing a live race here — it is +// contract symmetry with RedisStorage.StoreDCRCredentialsIfAbsent, which +// DOES need it to prevent two replicas from independently registering RFC +// 7591 clients for the same Key and racing on which write wins. +// +// The in-memory backend has no native TTL, so "absent" cannot be a plain +// map-presence check: the Redis backend's rows self-evict via TTL derived +// from ClientSecretExpiresAt, so a claim there naturally succeeds again once +// the old row expires. To keep behaviour symmetric, an existing entry whose +// ClientSecretExpiresAt is non-zero and already in the past is treated as +// absent — the claim proceeds and overwrites it — so a secret's expiry does +// not permanently block re-registration on this backend the way a naive +// presence check would. // // Validation is delegated to validateDCRCredentialsForStore so the rejection // set stays in sync with sibling backends. -func (s *MemoryStorage) StoreDCRCredentials(_ context.Context, creds *DCRCredentials) error { +func (s *MemoryStorage) StoreDCRCredentialsIfAbsent(_ context.Context, creds *DCRCredentials) (*DCRCredentials, error) { if err := validateDCRCredentialsForStore(creds); err != nil { - return err + return nil, err } s.mu.Lock() defer s.mu.Unlock() + if existing, ok := s.dcrCredentials[creds.Key]; ok { + expired := !existing.ClientSecretExpiresAt.IsZero() && time.Now().After(existing.ClientSecretExpiresAt) + if !expired { + return cloneDCRCredentials(existing), nil + } + } + s.dcrCredentials[creds.Key] = cloneDCRCredentials(creds) - return nil + return cloneDCRCredentials(creds), nil } // GetDCRCredentials retrieves DCR credentials by key. diff --git a/pkg/authserver/storage/memory_test.go b/pkg/authserver/storage/memory_test.go index 408cad5ee8..eba30809a9 100644 --- a/pkg/authserver/storage/memory_test.go +++ b/pkg/authserver/storage/memory_test.go @@ -2227,7 +2227,9 @@ func TestMemoryStorage_DCRCredentials_RoundTrip(t *testing.T) { ClientSecretExpiresAt: time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC), } - require.NoError(t, s.StoreDCRCredentials(ctx, creds)) + authoritative, err := s.StoreDCRCredentialsIfAbsent(ctx, creds) + require.NoError(t, err) + assert.Equal(t, *creds, *authoritative) got, err := s.GetDCRCredentials(ctx, key) require.NoError(t, err) @@ -2267,7 +2269,8 @@ func TestMemoryStorage_DCRCredentials_DistinctKeysDoNotCollide(t *testing.T) { mkCreds(mkKey("https://idp-a.example.com", "https://up-b", "https://x/cb", []string{"openid"}), "e"), } for _, e := range entries { - require.NoError(t, s.StoreDCRCredentials(ctx, e)) + _, err := s.StoreDCRCredentialsIfAbsent(ctx, e) + require.NoError(t, err) } for _, want := range entries { @@ -2279,7 +2282,14 @@ func TestMemoryStorage_DCRCredentials_DistinctKeysDoNotCollide(t *testing.T) { }) } -func TestMemoryStorage_DCRCredentials_OverwriteSemantics(t *testing.T) { +// TestMemoryStorage_DCRCredentials_FirstClaimWins pins the create-if-absent +// contract that replaced unconditional overwrite: a second +// StoreDCRCredentialsIfAbsent for a key that already holds a (non-expired) +// value must not replace it. It returns the existing entry as the +// authoritative value instead, mirroring RedisStorage's create-if-absent +// claim so the two backends behave symmetrically for a concurrent- +// registration race. +func TestMemoryStorage_DCRCredentials_FirstClaimWins(t *testing.T) { withStorage(t, func(ctx context.Context, s *MemoryStorage) { key := DCRKey{ Issuer: "https://idp.example.com", @@ -2296,12 +2306,58 @@ func TestMemoryStorage_DCRCredentials_OverwriteSemantics(t *testing.T) { } } - require.NoError(t, s.StoreDCRCredentials(ctx, mkCreds("first"))) - require.NoError(t, s.StoreDCRCredentials(ctx, mkCreds("second"))) + first, err := s.StoreDCRCredentialsIfAbsent(ctx, mkCreds("first")) + require.NoError(t, err) + assert.Equal(t, "first", first.ClientID) + + second, err := s.StoreDCRCredentialsIfAbsent(ctx, mkCreds("second")) + require.NoError(t, err) + assert.Equal(t, "first", second.ClientID, + "the loser must get back the winner's credentials, not its own") + + got, err := s.GetDCRCredentials(ctx, key) + require.NoError(t, err) + assert.Equal(t, "first", got.ClientID, "the store must keep the first-claimed entry") + }) +} + +// TestMemoryStorage_DCRCredentials_ExpiredEntryCanBeReclaimed pins the +// TTL-awareness of the in-memory "absent" check: the in-memory backend has +// no native TTL, so an existing entry whose ClientSecretExpiresAt is already +// in the past must be treated as absent — otherwise an expired secret would +// permanently block re-registration, unlike the Redis backend where the row +// self-evicts. +func TestMemoryStorage_DCRCredentials_ExpiredEntryCanBeReclaimed(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + key := DCRKey{ + Issuer: "https://idp.example.com", + UpstreamID: "https://upstream.example.com", + RedirectURI: "https://x/cb", + ScopesHash: ScopesHash([]string{"openid"}), + } + expired, err := s.StoreDCRCredentialsIfAbsent(ctx, &DCRCredentials{ + Key: key, + ClientID: "expired-client", + AuthorizationEndpoint: "https://idp.example.com/auth", + TokenEndpoint: "https://idp.example.com/token", + ClientSecretExpiresAt: time.Now().Add(-time.Hour), + }) + require.NoError(t, err) + assert.Equal(t, "expired-client", expired.ClientID) + + reclaimed, err := s.StoreDCRCredentialsIfAbsent(ctx, &DCRCredentials{ + Key: key, + ClientID: "fresh-client", + AuthorizationEndpoint: "https://idp.example.com/auth", + TokenEndpoint: "https://idp.example.com/token", + }) + require.NoError(t, err) + assert.Equal(t, "fresh-client", reclaimed.ClientID, + "an expired entry must be treated as absent and reclaimable") got, err := s.GetDCRCredentials(ctx, key) require.NoError(t, err) - assert.Equal(t, "second", got.ClientID) + assert.Equal(t, "fresh-client", got.ClientID) }) } @@ -2401,7 +2457,7 @@ func TestMemoryStorage_DCRCredentials_StoreInvalidInputRejected(t *testing.T) { tc := tc t.Run(tc.name, func(t *testing.T) { withStorage(t, func(ctx context.Context, s *MemoryStorage) { - err := s.StoreDCRCredentials(ctx, tc.mutator(validCreds())) + _, err := s.StoreDCRCredentialsIfAbsent(ctx, tc.mutator(validCreds())) require.Error(t, err) assert.ErrorIs(t, err, fosite.ErrInvalidRequest) // Confirm the rejection did not partially populate the store. @@ -2423,12 +2479,13 @@ func TestMemoryStorage_DCRCredentials_GetReturnsDefensiveCopy(t *testing.T) { RedirectURI: "https://x/cb", ScopesHash: ScopesHash([]string{"openid"}), } - require.NoError(t, s.StoreDCRCredentials(ctx, &DCRCredentials{ + _, err := s.StoreDCRCredentialsIfAbsent(ctx, &DCRCredentials{ Key: key, ClientID: "orig", AuthorizationEndpoint: "https://idp.example.com/auth", TokenEndpoint: "https://idp.example.com/token", - })) + }) + require.NoError(t, err) got, err := s.GetDCRCredentials(ctx, key) require.NoError(t, err) @@ -2457,7 +2514,8 @@ func TestMemoryStorage_DCRCredentials_StoreCopyIsolatesCaller(t *testing.T) { AuthorizationEndpoint: "https://idp.example.com/auth", TokenEndpoint: "https://idp.example.com/token", } - require.NoError(t, s.StoreDCRCredentials(ctx, input)) + _, err := s.StoreDCRCredentialsIfAbsent(ctx, input) + require.NoError(t, err) input.ClientID = "tampered-after-store" @@ -2479,13 +2537,14 @@ func TestMemoryStorage_DCRCredentials_ExcludedFromCleanupExpired(t *testing.T) { RedirectURI: "https://x/cb", ScopesHash: ScopesHash([]string{"openid"}), } - require.NoError(t, s.StoreDCRCredentials(ctx, &DCRCredentials{ + _, err := s.StoreDCRCredentialsIfAbsent(ctx, &DCRCredentials{ Key: key, ClientID: "abc", AuthorizationEndpoint: "https://idp.example.com/auth", TokenEndpoint: "https://idp.example.com/token", CreatedAt: time.Now().Add(-365 * 24 * time.Hour), - })) + }) + require.NoError(t, err) s.cleanupExpired() @@ -2496,7 +2555,7 @@ func TestMemoryStorage_DCRCredentials_ExcludedFromCleanupExpired(t *testing.T) { } // TestMemoryStorage_DCRCredentials_ConcurrentAccess fans out N goroutines -// performing alternating StoreDCRCredentials / GetDCRCredentials against +// performing alternating StoreDCRCredentialsIfAbsent / GetDCRCredentials against // overlapping and disjoint keys, exercising the sync.RWMutex guard // advertised in the DCRCredentialStore contract. With go test -race this // catches a future change that drops the lock or returns an internal @@ -2553,7 +2612,7 @@ func TestMemoryStorage_DCRCredentials_ConcurrentAccess(t *testing.T) { } else { key = disjointKey(worker, i) } - if err := s.StoreDCRCredentials(ctx, mkCreds(key, fmt.Sprintf("worker-%d-op-%d", worker, i))); err != nil { + if _, err := s.StoreDCRCredentialsIfAbsent(ctx, mkCreds(key, fmt.Sprintf("worker-%d-op-%d", worker, i))); err != nil { atomic.AddInt32(&errCount, 1) } // The disjoint Get must always hit (the goroutine that diff --git a/pkg/authserver/storage/mocks/mock_storage.go b/pkg/authserver/storage/mocks/mock_storage.go index a69d4636db..8c98dcdfd6 100644 --- a/pkg/authserver/storage/mocks/mock_storage.go +++ b/pkg/authserver/storage/mocks/mock_storage.go @@ -58,18 +58,19 @@ func (mr *MockDCRCredentialStoreMockRecorder) GetDCRCredentials(ctx, key any) *g return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDCRCredentials", reflect.TypeOf((*MockDCRCredentialStore)(nil).GetDCRCredentials), ctx, key) } -// StoreDCRCredentials mocks base method. -func (m *MockDCRCredentialStore) StoreDCRCredentials(ctx context.Context, creds *storage.DCRCredentials) error { +// StoreDCRCredentialsIfAbsent mocks base method. +func (m *MockDCRCredentialStore) StoreDCRCredentialsIfAbsent(ctx context.Context, creds *storage.DCRCredentials) (*storage.DCRCredentials, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "StoreDCRCredentials", ctx, creds) - ret0, _ := ret[0].(error) - return ret0 + ret := m.ctrl.Call(m, "StoreDCRCredentialsIfAbsent", ctx, creds) + ret0, _ := ret[0].(*storage.DCRCredentials) + ret1, _ := ret[1].(error) + return ret0, ret1 } -// StoreDCRCredentials indicates an expected call of StoreDCRCredentials. -func (mr *MockDCRCredentialStoreMockRecorder) StoreDCRCredentials(ctx, creds any) *gomock.Call { +// StoreDCRCredentialsIfAbsent indicates an expected call of StoreDCRCredentialsIfAbsent. +func (mr *MockDCRCredentialStoreMockRecorder) StoreDCRCredentialsIfAbsent(ctx, creds any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StoreDCRCredentials", reflect.TypeOf((*MockDCRCredentialStore)(nil).StoreDCRCredentials), ctx, creds) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StoreDCRCredentialsIfAbsent", reflect.TypeOf((*MockDCRCredentialStore)(nil).StoreDCRCredentialsIfAbsent), ctx, creds) } // MockPendingAuthorizationStorage is a mock of PendingAuthorizationStorage interface. diff --git a/pkg/authserver/storage/redis.go b/pkg/authserver/storage/redis.go index cf6a166659..d7a8824ebf 100644 --- a/pkg/authserver/storage/redis.go +++ b/pkg/authserver/storage/redis.go @@ -39,6 +39,16 @@ const nullMarker = "null" // confuse this row with a healthy long-lived registration. const pastExpiryDCRTTL = time.Second +// maxDCRClaimRetries bounds StoreDCRCredentialsIfAbsent's WATCH/MULTI retry +// loop. go-redis does not retry Watch internally: a concurrent write to the +// watched key (another replica claiming, refreshing, or evicting the same +// row) aborts the pipelined EXEC with redis.TxFailedErr, which Watch returns +// to the caller unwrapped. Retrying a small, fixed number of times lets a +// real concurrent write during the exact race this method exists to close +// resolve on its own rather than failing the caller with a spurious error. +// Mirrors maxConfiguredClientReconcileRetries above for the same reason. +const maxDCRClaimRetries = 3 + // warnOnCleanupErr logs a warning when a best-effort cleanup operation fails. // // Secondary index cleanup in Redis (SRem from reverse-lookup sets, Del of orphaned @@ -1628,7 +1638,7 @@ func unmarshalUpstreamTokens(data []byte) (*UpstreamTokens, error) { // Time fields use int64 Unix epoch; 0 is the sentinel meaning "not set", matching // the storedUpstreamTokens convention. ClientSecretExpiresAt == 0 specifically // encodes the RFC 7591 §3.2.1 "client_secret does not expire" semantics, in -// which case StoreDCRCredentials persists the entry without a Redis TTL. +// which case StoreDCRCredentialsIfAbsent persists the entry without a Redis TTL. type storedDCRCredentials struct { // Embed the canonical key so a row recovered without its lookup key // (e.g. via SCAN during diagnostics) still self-identifies. @@ -1687,17 +1697,58 @@ func (s *storedDCRCredentials) toDCRCredentials() *DCRCredentials { } } -// StoreDCRCredentials persists DCR credentials, overwriting any existing entry -// for the same Key. Defensive copy is provided implicitly by JSON serialisation — +// StoreDCRCredentialsIfAbsent claims creds.Key for creds via a Redis +// WATCH/MULTI compare-and-set (the same pattern ReconcileConfiguredClient +// uses above), returning the authoritative durable value: creds itself on a +// successful claim, or the existing winner's credentials when the key +// already holds a live (non-expired) entry. Callers MUST use the returned +// value — RFC 7591 dynamic registration mints a unique client_id/client_secret +// on every call, so two replicas racing a cache miss for the same Key +// register genuinely different OAuth clients with the upstream, and only the +// value the durable store agrees to is a credential either replica actually +// holds. Defensive copy is provided implicitly by JSON serialisation — // caller mutations after the call cannot reach the persisted bytes. // +// # Expired existing rows are claimable +// +// An existing row whose ClientSecretExpiresAt is non-zero and already in the +// past is treated as absent, mirroring MemoryStorage.StoreDCRCredentialsIfAbsent: +// it is overwritten with creds rather than handed back as if it were still +// the authoritative winner — but only when creds itself is fresher (creds' +// own ClientSecretExpiresAt is zero or still in the future). When both the +// existing row and the incoming creds are already expired — the startup +// case where many replicas independently re-resolve a dead upstream +// registration and each mints equally-stale credentials — the existing row +// is returned as-is instead. Overwriting would gain nothing there, and +// without this guard every concurrent claimant would keep re-observing an +// expired row and re-entering the write path, risking exhausting +// maxDCRClaimRetries under contention instead of converging the way the +// genuinely-absent case does. WATCH still guards the overwrite path: if +// another replica claims or refreshes the same key between our GET and our +// SET, EXEC aborts with redis.TxFailedErr and the loop below retries, so +// this does not reopen the double-registration race the NX-based +// predecessor of this method was built to close — it only changes what +// happens when the existing row is provably expired. +// +// This means a caller CAN receive an already-expired credential back from +// this call — deliberately, per the above, rather than as an oversight. The +// only thing that later re-checks an expiry against ResolveCredentials' +// cache is lookupCachedResolution, which only runs on a *subsequent* +// ResolveCredentials call for the same key; nothing currently re-resolves +// after the one-time boot-time call in embeddedauthserver.go. So a replica +// that receives an expired credential here keeps it for the rest of its +// process lifetime, failing cleanly with an upstream invalid_client/ +// invalid_grant on every use until restart. Closing that gap needs +// revisiting credentials after boot, not another guard in this function — +// tracked in #6496. +// // # TTL // // When creds.ClientSecretExpiresAt is non-zero (the upstream advertised an // RFC 7591 §3.2.1 client_secret_expires_at), the entry is stored with a Redis // TTL derived from time.Until(ClientSecretExpiresAt) so the row evicts before // the upstream rejects the secret at the token endpoint. When zero (RFC 7591 -// "never"), Set with TTL=0 is used and the entry is long-lived. +// "never"), TTL=0 is used and the entry is long-lived. // // If ClientSecretExpiresAt is already in the past at call time, the entry is // written with the bounded TTL pastExpiryDCRTTL (1 second) rather than rejected @@ -1708,13 +1759,57 @@ func (s *storedDCRCredentials) toDCRCredentials() *DCRCredentials { // // Validation is delegated to validateDCRCredentialsForStore so the rejection // set stays in sync with MemoryStorage and any future backend. -func (s *RedisStorage) StoreDCRCredentials(ctx context.Context, creds *DCRCredentials) error { +func (s *RedisStorage) StoreDCRCredentialsIfAbsent(ctx context.Context, creds *DCRCredentials) (*DCRCredentials, error) { if err := validateDCRCredentialsForStore(creds); err != nil { - return err + return nil, err } key := redisDCRKey(s.keyPrefix, creds.Key) + data, ttl, err := marshalDCRCredentialsForStore(creds) + if err != nil { + return nil, err + } + + // result is set by txFn on every non-error path: either to creds (a + // successful claim) or to the decoded existing row (a live winner + // already present). It must not be read until s.client.Watch returns nil. + var result *DCRCredentials + txFn := func(tx *redis.Tx) error { + var txErr error + result, txErr = dcrClaimOrReturnWinner(ctx, tx, key, data, ttl, creds) + return txErr + } + + // Retry bound mirrors ReconcileConfiguredClient's maxConfiguredClientReconcileRetries: + // go-redis does not retry Watch internally, so a concurrent writer to the + // watched key aborts EXEC with redis.TxFailedErr, which must be retried + // here for the race this method exists to close to resolve on its own. + var watchErr error + for attempt := 0; attempt < maxDCRClaimRetries; attempt++ { + watchErr = s.client.Watch(ctx, txFn, key) + if watchErr == nil { + return result, nil + } + if !errors.Is(watchErr, redis.TxFailedErr) { + return nil, fmt.Errorf("failed to store dcr credentials: %w", watchErr) + } + } + return nil, fmt.Errorf("failed to claim dcr credentials after %d attempts: %w", maxDCRClaimRetries, watchErr) +} + +// marshalDCRCredentialsForStore serializes creds to its Redis wire form and +// derives the TTL to store it with. Split out of StoreDCRCredentialsIfAbsent +// purely to keep that method's cyclomatic complexity down; it has no +// transaction-retry concerns of its own. +// +// Derive Redis TTL from ClientSecretExpiresAt: +// - Zero (unset) -> TTL=0 (no expiration) per RFC 7591 §3.2.1 "never". +// - Future expiry -> TTL = time.Until(expiry). +// - Past expiry -> TTL = pastExpiryDCRTTL (bounded eviction window). +// +// See StoreDCRCredentialsIfAbsent's docstring for the past-expiry rationale. +func marshalDCRCredentialsForStore(creds *DCRCredentials) ([]byte, time.Duration, error) { stored := storedDCRCredentials{ KeyIssuer: creds.Key.Issuer, KeyUpstreamID: creds.Key.UpstreamID, @@ -1738,14 +1833,9 @@ func (s *RedisStorage) StoreDCRCredentials(ctx context.Context, creds *DCRCreden data, err := json.Marshal(stored) //nolint:gosec // G117 - internal Redis storage serialization, not exposed to users if err != nil { - return fmt.Errorf("failed to marshal dcr credentials: %w", err) + return nil, 0, fmt.Errorf("failed to marshal dcr credentials: %w", err) } - // Derive Redis TTL from ClientSecretExpiresAt: - // * Zero (unset) -> TTL=0 (no expiration) per RFC 7591 §3.2.1 "never". - // * Future expiry -> TTL = time.Until(expiry). - // * Past expiry -> TTL = pastExpiryDCRTTL (bounded eviction window). - // See the function docstring for the past-expiry rationale. ttl := time.Duration(0) if !creds.ClientSecretExpiresAt.IsZero() { if until := time.Until(creds.ClientSecretExpiresAt); until > 0 { @@ -1754,11 +1844,73 @@ func (s *RedisStorage) StoreDCRCredentials(ctx context.Context, creds *DCRCreden ttl = pastExpiryDCRTTL } } + return data, ttl, nil +} - if err := s.client.Set(ctx, key, data, ttl).Err(); err != nil { - return fmt.Errorf("failed to store dcr credentials: %w", err) +// dcrClaimOrReturnWinner runs the read-check-write body of +// StoreDCRCredentialsIfAbsent's WATCH transaction: an absent row, or an +// existing row that is expired while creds is fresher, is claimed with +// (data, ttl) inside MULTI; a live existing row — or one that is expired but +// no fresher than creds — is decoded and returned as-is, with no write. See +// StoreDCRCredentialsIfAbsent's "Expired existing rows" doc section for why. +// Split out purely to keep StoreDCRCredentialsIfAbsent's cyclomatic +// complexity down. +func dcrClaimOrReturnWinner( + ctx context.Context, tx *redis.Tx, key string, data []byte, ttl time.Duration, creds *DCRCredentials, +) (*DCRCredentials, error) { + claim := func() error { + _, err := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error { + pipe.Set(ctx, key, data, ttl) + return nil + }) + return err } - return nil + + existingData, getErr := tx.Get(ctx, key).Bytes() + if errors.Is(getErr, redis.Nil) { + if err := claim(); err != nil { + return nil, err + } + return creds, nil + } + if getErr != nil { + return nil, fmt.Errorf("failed to get existing dcr credentials: %w", getErr) + } + + var existingStored storedDCRCredentials + if unmarshalErr := json.Unmarshal(existingData, &existingStored); unmarshalErr != nil { + return nil, fmt.Errorf("failed to unmarshal existing dcr credentials: %w", unmarshalErr) + } + existing := existingStored.toDCRCredentials() + + expired := !existing.ClientSecretExpiresAt.IsZero() && time.Now().After(existing.ClientSecretExpiresAt) + if !expired { + return existing, nil + } + + // The existing row is expired. Only overwrite it when the incoming + // credentials are actually fresher — a genuine new registration + // reclaiming a dead slot. If the incoming credentials are ALSO already + // expired (the startup-reconciliation case: many replicas independently + // re-resolving a dead upstream registration all mint equally-stale + // credentials), overwriting gains nothing and, under contention, every + // concurrent claimant would keep re-observing an expired row and + // re-entering this write path, burning through maxDCRClaimRetries + // instead of converging. Returning the stable existing row lets every + // claimant converge without a write, matching the "genuinely absent" + // case's self-limiting behaviour. + incomingExpired := !creds.ClientSecretExpiresAt.IsZero() && time.Now().After(creds.ClientSecretExpiresAt) + if incomingExpired { + return existing, nil + } + + // The existing row is provably expired: treat it as absent and overwrite + // it, matching MemoryStorage's semantics. WATCH still protects this — see + // StoreDCRCredentialsIfAbsent's "Expired existing rows" doc section. + if err := claim(); err != nil { + return nil, err + } + return creds, nil } // GetDCRCredentials retrieves the credentials previously persisted under key. @@ -1766,7 +1918,7 @@ func (s *RedisStorage) StoreDCRCredentials(ctx context.Context, creds *DCRCreden // fresh struct decoded from JSON, which acts as a defensive copy. // // An unpopulated key (empty Issuer, UpstreamID, RedirectURI, or ScopesHash) cannot match -// any stored row because StoreDCRCredentials rejects such keys, so a Get +// any stored row because StoreDCRCredentialsIfAbsent rejects such keys, so a Get // against one is a normal miss — ErrNotFound — matching // MemoryStorage.GetDCRCredentials and the DCRCredentialStore interface contract. func (s *RedisStorage) GetDCRCredentials(ctx context.Context, key DCRKey) (*DCRCredentials, error) { diff --git a/pkg/authserver/storage/redis_integration_test.go b/pkg/authserver/storage/redis_integration_test.go index ab43f00a58..74bb6e851a 100644 --- a/pkg/authserver/storage/redis_integration_test.go +++ b/pkg/authserver/storage/redis_integration_test.go @@ -1443,7 +1443,9 @@ func TestIntegration_DCRCredentials_RoundTrip(t *testing.T) { ClientSecretExpiresAt: expiresAt, } - require.NoError(t, s.StoreDCRCredentials(ctx, creds)) + authoritative, err := s.StoreDCRCredentialsIfAbsent(ctx, creds) + require.NoError(t, err) + assert.Equal(t, *creds, *authoritative) got, err := s.GetDCRCredentials(ctx, creds.Key) require.NoError(t, err) @@ -1483,7 +1485,8 @@ func TestIntegration_DCRCredentials_DistinctKeysCoexist(t *testing.T) { mk(mkKey("https://idp-a.example.com", "https://up-b", "https://x/cb", []string{"openid"}), "e"), } for _, e := range entries { - require.NoError(t, s.StoreDCRCredentials(ctx, e)) + _, err := s.StoreDCRCredentialsIfAbsent(ctx, e) + require.NoError(t, err) } for _, want := range entries { @@ -1495,7 +1498,11 @@ func TestIntegration_DCRCredentials_DistinctKeysCoexist(t *testing.T) { }) } -func TestIntegration_DCRCredentials_OverwriteSemantics(t *testing.T) { +// TestIntegration_DCRCredentials_FirstClaimWins pins the create-if-absent +// WATCH/MULTI claim against a real Redis Sentinel cluster: a second +// StoreDCRCredentialsIfAbsent for a key that already holds a value must not +// overwrite it, and the loser must get back the winner's credentials. +func TestIntegration_DCRCredentials_FirstClaimWins(t *testing.T) { withIntegrationStorage(t, func(ctx context.Context, s *RedisStorage) { key := dcrFixtureKey() mk := func(clientID string) *DCRCredentials { @@ -1507,12 +1514,18 @@ func TestIntegration_DCRCredentials_OverwriteSemantics(t *testing.T) { } } - require.NoError(t, s.StoreDCRCredentials(ctx, mk("first"))) - require.NoError(t, s.StoreDCRCredentials(ctx, mk("second"))) + first, err := s.StoreDCRCredentialsIfAbsent(ctx, mk("first")) + require.NoError(t, err) + assert.Equal(t, "first", first.ClientID) + + second, err := s.StoreDCRCredentialsIfAbsent(ctx, mk("second")) + require.NoError(t, err) + assert.Equal(t, "first", second.ClientID, + "the loser must get back the winner's credentials, not its own") got, err := s.GetDCRCredentials(ctx, key) require.NoError(t, err) - assert.Equal(t, "second", got.ClientID) + assert.Equal(t, "first", got.ClientID) }) } @@ -1529,13 +1542,14 @@ func TestIntegration_DCRCredentials_TTL(t *testing.T) { withIntegrationStorage(t, func(ctx context.Context, s *RedisStorage) { key := dcrFixtureKey() expires := time.Now().Add(24 * time.Hour).Truncate(time.Second) - require.NoError(t, s.StoreDCRCredentials(ctx, &DCRCredentials{ + _, err := s.StoreDCRCredentialsIfAbsent(ctx, &DCRCredentials{ Key: key, ClientID: "client-with-expiry", AuthorizationEndpoint: "https://idp.example.com/auth", TokenEndpoint: "https://idp.example.com/token", ClientSecretExpiresAt: expires, - })) + }) + require.NoError(t, err) ttl, err := s.client.TTL(ctx, redisDCRKey(s.keyPrefix, key)).Result() require.NoError(t, err) @@ -1551,13 +1565,14 @@ func TestIntegration_DCRCredentials_TTL(t *testing.T) { t.Run("zero expiry means no TTL", func(t *testing.T) { withIntegrationStorage(t, func(ctx context.Context, s *RedisStorage) { key := dcrFixtureKey() - require.NoError(t, s.StoreDCRCredentials(ctx, &DCRCredentials{ + _, err := s.StoreDCRCredentialsIfAbsent(ctx, &DCRCredentials{ Key: key, ClientID: "client-no-expiry", AuthorizationEndpoint: "https://idp.example.com/auth", TokenEndpoint: "https://idp.example.com/token", // ClientSecretExpiresAt deliberately zero. - })) + }) + require.NoError(t, err) ttl, err := s.client.TTL(ctx, redisDCRKey(s.keyPrefix, key)).Result() require.NoError(t, err) diff --git a/pkg/authserver/storage/redis_keys.go b/pkg/authserver/storage/redis_keys.go index 288e71a4cc..da545649e9 100644 --- a/pkg/authserver/storage/redis_keys.go +++ b/pkg/authserver/storage/redis_keys.go @@ -131,7 +131,7 @@ func redisProviderKey(prefix, providerID, providerSubject string) string { // format, so entries written by an older binary (without the segment) will // miss on lookup and be harmlessly re-registered under the new key. Orphaned // old rows self-evict only when the upstream asserted an RFC 7591 §3.2.1 -// client_secret_expires_at (StoreDCRCredentials sets a matching Redis TTL for +// client_secret_expires_at (StoreDCRCredentialsIfAbsent sets a matching Redis TTL for // those); entries for non-expiring secrets — the common §3.2.1 "never" case — // carry no TTL and persist indefinitely, so they require manual cleanup. // There is no automated one-shot migration for this key-format change today. diff --git a/pkg/authserver/storage/redis_test.go b/pkg/authserver/storage/redis_test.go index b542770cc6..2e9d3ad259 100644 --- a/pkg/authserver/storage/redis_test.go +++ b/pkg/authserver/storage/redis_test.go @@ -3159,7 +3159,9 @@ func TestRedisStorage_DCRCredentials_RoundTrip(t *testing.T) { ClientSecretExpiresAt: expiresAt, } - require.NoError(t, s.StoreDCRCredentials(ctx, creds)) + authoritative, err := s.StoreDCRCredentialsIfAbsent(ctx, creds) + require.NoError(t, err) + assert.Equal(t, *creds, *authoritative) got, err := s.GetDCRCredentials(ctx, creds.Key) require.NoError(t, err) @@ -3169,7 +3171,14 @@ func TestRedisStorage_DCRCredentials_RoundTrip(t *testing.T) { }) } -func TestRedisStorage_DCRCredentials_OverwriteSemantics(t *testing.T) { +// TestRedisStorage_DCRCredentials_FirstClaimWins pins the create-if-absent +// contract enforced by the Redis WATCH/MULTI claim: a second +// StoreDCRCredentialsIfAbsent for a key that already holds a value must NOT +// overwrite it. The loser gets back the winner's credentials as the +// authoritative value, and the stored row is unchanged. This is the fix for +// the concurrent-replica bug where two replicas racing a cache miss each +// independently register a distinct RFC 7591 client for the same key. +func TestRedisStorage_DCRCredentials_FirstClaimWins(t *testing.T) { withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { key := dcrFixtureKey() mk := func(clientID string) *DCRCredentials { @@ -3181,12 +3190,231 @@ func TestRedisStorage_DCRCredentials_OverwriteSemantics(t *testing.T) { } } - require.NoError(t, s.StoreDCRCredentials(ctx, mk("first"))) - require.NoError(t, s.StoreDCRCredentials(ctx, mk("second"))) + first, err := s.StoreDCRCredentialsIfAbsent(ctx, mk("first")) + require.NoError(t, err) + assert.Equal(t, "first", first.ClientID) + + second, err := s.StoreDCRCredentialsIfAbsent(ctx, mk("second")) + require.NoError(t, err) + assert.Equal(t, "first", second.ClientID, + "the loser must get back the winner's credentials, not its own") + + got, err := s.GetDCRCredentials(ctx, key) + require.NoError(t, err) + assert.Equal(t, "first", got.ClientID, "the stored row must be the first claim, not overwritten") + }) +} + +// TestRedisStorage_DCRCredentials_ExpiredWinnerIsClaimable is the regression +// test for the fix to StoreDCRCredentialsIfAbsent's WATCH/MULTI claim: an +// existing row whose ClientSecretExpiresAt is already in the past must be +// treated as absent and overwritten, not handed back as if it were still the +// authoritative winner. This mirrors MemoryStorage's equivalent behaviour +// (see TestMemoryStorage_DCRCredentials_FirstClaimWins's expired-entry +// subtest) which the pre-fix Redis implementation diverged from: a SET NX +// claim against a live-but-expired key fails, and the pre-fix read-back path +// returned that stale row verbatim. +func TestRedisStorage_DCRCredentials_ExpiredWinnerIsClaimable(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, mr *miniredis.Miniredis) { + key := dcrFixtureKey() + past := time.Now().Add(-time.Hour).Truncate(time.Second) + + expired, err := s.StoreDCRCredentialsIfAbsent(ctx, &DCRCredentials{ + Key: key, + ClientID: "expired-client", + AuthorizationEndpoint: "https://idp.example.com/auth", + TokenEndpoint: "https://idp.example.com/token", + ClientSecretExpiresAt: past, + }) + require.NoError(t, err) + assert.Equal(t, "expired-client", expired.ClientID) + + // The row was written with the bounded pastExpiryDCRTTL and would + // self-evict almost immediately. Force a long TTL directly so the key + // stays PRESENT in Redis while ClientSecretExpiresAt is still in the + // past — otherwise the row would simply expire out of miniredis (which + // deletes a key once its TTL reaches zero) and the claim below would + // hit the "genuinely absent" branch instead of the "present but + // expired" branch this test exists to exercise. + mr.SetTTL(redisDCRKey(s.keyPrefix, key), time.Hour) + + fresh, err := s.StoreDCRCredentialsIfAbsent(ctx, &DCRCredentials{ + Key: key, + ClientID: "fresh-client", + AuthorizationEndpoint: "https://idp.example.com/auth", + TokenEndpoint: "https://idp.example.com/token", + }) + require.NoError(t, err) + assert.Equal(t, "fresh-client", fresh.ClientID, + "an expired existing row must be claimable, not returned as the authoritative winner") + + got, err := s.GetDCRCredentials(ctx, key) + require.NoError(t, err) + assert.Equal(t, "fresh-client", got.ClientID, "the stored row must reflect the overwrite") + }) +} + +// TestRedisStorage_DCRCredentials_LiveWinnerNotOverwritten pins the "present, +// not expired" branch of the WATCH/MULTI claim: a genuine, non-expired +// winner is returned as-is and the stored row is left untouched. +func TestRedisStorage_DCRCredentials_LiveWinnerNotOverwritten(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { + key := dcrFixtureKey() + future := time.Now().Add(24 * time.Hour).Truncate(time.Second) + + winner, err := s.StoreDCRCredentialsIfAbsent(ctx, &DCRCredentials{ + Key: key, + ClientID: "live-client", + AuthorizationEndpoint: "https://idp.example.com/auth", + TokenEndpoint: "https://idp.example.com/token", + ClientSecretExpiresAt: future, + }) + require.NoError(t, err) + assert.Equal(t, "live-client", winner.ClientID) + + loser, err := s.StoreDCRCredentialsIfAbsent(ctx, &DCRCredentials{ + Key: key, + ClientID: "challenger-client", + AuthorizationEndpoint: "https://idp.example.com/auth", + TokenEndpoint: "https://idp.example.com/token", + ClientSecretExpiresAt: future, + }) + require.NoError(t, err) + assert.Equal(t, "live-client", loser.ClientID, + "a non-expired existing row must be returned as the winner, not overwritten") + + got, err := s.GetDCRCredentials(ctx, key) + require.NoError(t, err) + assert.Equal(t, "live-client", got.ClientID, "the stored row must be unchanged") + }) +} + +// TestRedisStorage_DCRCredentials_FirstClaimWins_Concurrent proves the +// create-if-absent race-safety property holds under genuine concurrency, not +// just the sequential shape of TestRedisStorage_DCRCredentials_FirstClaimWins: +// goroutines racing to claim the same, genuinely-absent key via the +// WATCH/MULTI transaction must produce exactly one winner, with every caller +// (including the losers) getting back that same winner's credentials. +func TestRedisStorage_DCRCredentials_FirstClaimWins_Concurrent(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { + const goroutines = 8 + key := dcrFixtureKey() + + start := make(chan struct{}) + results := make([]*DCRCredentials, goroutines) + errs := make([]error, goroutines) + + var wg sync.WaitGroup + wg.Add(goroutines) + for g := 0; g < goroutines; g++ { + gid := g + go func() { + defer wg.Done() + <-start + results[gid], errs[gid] = s.StoreDCRCredentialsIfAbsent(ctx, &DCRCredentials{ + Key: key, + ClientID: fmt.Sprintf("client-%d", gid), + AuthorizationEndpoint: "https://idp.example.com/auth", + TokenEndpoint: "https://idp.example.com/token", + }) + }() + } + close(start) + + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("timeout waiting for concurrent claim goroutines") + } + + winner := "" + for i, err := range errs { + require.NoError(t, err) + require.NotNil(t, results[i]) + if winner == "" { + winner = results[i].ClientID + } + assert.Equal(t, winner, results[i].ClientID, + "every caller must observe the same winner's credentials") + } + + got, err := s.GetDCRCredentials(ctx, key) + require.NoError(t, err) + assert.Equal(t, winner, got.ClientID, "the stored row must be the agreed-upon winner") + }) +} + +// TestRedisStorage_DCRCredentials_ExpiredVsExpiredConverges pins the +// contention-avoidance guard in dcrClaimOrReturnWinner: when the existing row +// is expired AND the incoming credentials being claimed are ALSO already +// expired (the startup-reconciliation case where many replicas independently +// re-resolve a dead upstream registration), concurrent claimants must not +// churn the write and exhaust maxDCRClaimRetries. They should all converge on +// returning the stable, still-expired existing row without error. +func TestRedisStorage_DCRCredentials_ExpiredVsExpiredConverges(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, mr *miniredis.Miniredis) { + const goroutines = 8 + key := dcrFixtureKey() + past := time.Now().Add(-time.Hour).Truncate(time.Second) + + seeded, err := s.StoreDCRCredentialsIfAbsent(ctx, &DCRCredentials{ + Key: key, + ClientID: "seed-client", + AuthorizationEndpoint: "https://idp.example.com/auth", + TokenEndpoint: "https://idp.example.com/token", + ClientSecretExpiresAt: past, + }) + require.NoError(t, err) + assert.Equal(t, "seed-client", seeded.ClientID) + + // Keep the seeded row PRESENT in Redis (see + // TestRedisStorage_DCRCredentials_ExpiredWinnerIsClaimable for why + // FastForward is the wrong tool here) while its ClientSecretExpiresAt + // stays in the past. + mr.SetTTL(redisDCRKey(s.keyPrefix, key), time.Hour) + + start := make(chan struct{}) + results := make([]*DCRCredentials, goroutines) + errs := make([]error, goroutines) + + var wg sync.WaitGroup + wg.Add(goroutines) + for g := 0; g < goroutines; g++ { + gid := g + go func() { + defer wg.Done() + <-start + results[gid], errs[gid] = s.StoreDCRCredentialsIfAbsent(ctx, &DCRCredentials{ + Key: key, + ClientID: fmt.Sprintf("stale-client-%d", gid), + AuthorizationEndpoint: "https://idp.example.com/auth", + TokenEndpoint: "https://idp.example.com/token", + ClientSecretExpiresAt: past, + }) + }() + } + close(start) + + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("timeout waiting for concurrent expired-vs-expired claim goroutines") + } + + for i, err := range errs { + require.NoError(t, err, "an expired-vs-expired claim must not exhaust maxDCRClaimRetries") + require.NotNil(t, results[i]) + assert.Equal(t, "seed-client", results[i].ClientID, + "every caller must converge on the stable existing row, not churn a write") + } got, err := s.GetDCRCredentials(ctx, key) require.NoError(t, err) - assert.Equal(t, "second", got.ClientID) + assert.Equal(t, "seed-client", got.ClientID, "the stored row must be unchanged") }) } @@ -3254,7 +3482,8 @@ func TestRedisStorage_DCRCredentials_DistinctKeysCoexist(t *testing.T) { mk(mkKey("https://idp-a.example.com", "https://up-b", "https://x/cb", []string{"openid"}), "e"), } for _, e := range entries { - require.NoError(t, s.StoreDCRCredentials(ctx, e)) + _, err := s.StoreDCRCredentialsIfAbsent(ctx, e) + require.NoError(t, err) } for _, want := range entries { @@ -3351,7 +3580,7 @@ func TestRedisStorage_DCRCredentials_StoreInvalidInputRejected(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { withRedisStorage(t, func(ctx context.Context, s *RedisStorage, mr *miniredis.Miniredis) { - err := s.StoreDCRCredentials(ctx, tc.mutator(validCreds())) + _, err := s.StoreDCRCredentialsIfAbsent(ctx, tc.mutator(validCreds())) assert.ErrorIs(t, err, fosite.ErrInvalidRequest) // Pin the fail-loud contract: a rejected Store must not leave // any row behind, even under a partially-populated key. This @@ -3371,12 +3600,13 @@ func TestRedisStorage_DCRCredentials_StoreInvalidInputRejected(t *testing.T) { func TestRedisStorage_DCRCredentials_GetReturnsDefensiveCopy(t *testing.T) { withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { key := dcrFixtureKey() - require.NoError(t, s.StoreDCRCredentials(ctx, &DCRCredentials{ + _, err := s.StoreDCRCredentialsIfAbsent(ctx, &DCRCredentials{ Key: key, ClientID: "orig", AuthorizationEndpoint: "https://idp.example.com/auth", TokenEndpoint: "https://idp.example.com/token", - })) + }) + require.NoError(t, err) got, err := s.GetDCRCredentials(ctx, key) require.NoError(t, err) @@ -3397,7 +3627,7 @@ func TestRedisStorage_DCRCredentials_GetReturnsDefensiveCopy(t *testing.T) { // - When ClientSecretExpiresAt is in the past at write time, the row // is written with the bounded `pastExpiryDCRTTL` (1 second) so an // already-expired secret self-evicts almost immediately rather than -// persisting forever (see StoreDCRCredentials docstring). +// persisting forever (see StoreDCRCredentialsIfAbsent docstring). func TestRedisStorage_DCRCredentials_TTL(t *testing.T) { t.Parallel() @@ -3405,13 +3635,14 @@ func TestRedisStorage_DCRCredentials_TTL(t *testing.T) { withRedisStorage(t, func(ctx context.Context, s *RedisStorage, mr *miniredis.Miniredis) { key := dcrFixtureKey() expires := time.Now().Add(24 * time.Hour).Truncate(time.Second) - require.NoError(t, s.StoreDCRCredentials(ctx, &DCRCredentials{ + _, err := s.StoreDCRCredentialsIfAbsent(ctx, &DCRCredentials{ Key: key, ClientID: "client-with-expiry", AuthorizationEndpoint: "https://idp.example.com/auth", TokenEndpoint: "https://idp.example.com/token", ClientSecretExpiresAt: expires, - })) + }) + require.NoError(t, err) ttl := mr.TTL(redisDCRKey("test:auth:", key)) assert.Greater(t, ttl, time.Duration(0), "TTL should be positive when ClientSecretExpiresAt is in the future") @@ -3423,13 +3654,14 @@ func TestRedisStorage_DCRCredentials_TTL(t *testing.T) { t.Run("zero expiry means no TTL", func(t *testing.T) { withRedisStorage(t, func(ctx context.Context, s *RedisStorage, mr *miniredis.Miniredis) { key := dcrFixtureKey() - require.NoError(t, s.StoreDCRCredentials(ctx, &DCRCredentials{ + _, err := s.StoreDCRCredentialsIfAbsent(ctx, &DCRCredentials{ Key: key, ClientID: "client-no-expiry", AuthorizationEndpoint: "https://idp.example.com/auth", TokenEndpoint: "https://idp.example.com/token", // ClientSecretExpiresAt deliberately zero. - })) + }) + require.NoError(t, err) // miniredis returns 0 (not -1) for "no TTL"; the integration test // asserts the real Redis -1 behaviour separately. @@ -3442,13 +3674,14 @@ func TestRedisStorage_DCRCredentials_TTL(t *testing.T) { withRedisStorage(t, func(ctx context.Context, s *RedisStorage, mr *miniredis.Miniredis) { key := dcrFixtureKey() past := time.Now().Add(-time.Hour).Truncate(time.Second) - require.NoError(t, s.StoreDCRCredentials(ctx, &DCRCredentials{ + _, err := s.StoreDCRCredentialsIfAbsent(ctx, &DCRCredentials{ Key: key, ClientID: "client-past-expiry", AuthorizationEndpoint: "https://idp.example.com/auth", TokenEndpoint: "https://idp.example.com/token", ClientSecretExpiresAt: past, - })) + }) + require.NoError(t, err) // Pin the bounded-TTL contract for past-expiry writes: // the row exists immediately after the write (so a resolver that @@ -3506,7 +3739,7 @@ const ( ) // runDCRConcurrentAccess fans out goroutines doing alternating -// StoreDCRCredentials / GetDCRCredentials and asserts no Store errored and, +// StoreDCRCredentialsIfAbsent / GetDCRCredentials and asserts no Store errored and, // when the keyspace is disjoint, that every Get hit. Shared between the // unit-test (miniredis) and integration-test (real Redis) suites — the // integration suite passes a longer deadline. @@ -3561,17 +3794,17 @@ func runDCRConcurrentAccess( defer wg.Done() for i := 0; i < iterations; i++ { key := keyFor(gid, i) - if err := s.StoreDCRCredentials(ctx, mkCreds(key, gid, i)); err != nil { + if _, err := s.StoreDCRCredentialsIfAbsent(ctx, mkCreds(key, gid, i)); err != nil { atomic.AddInt32(&storeErrCount, 1) continue } if _, err := s.GetDCRCredentials(ctx, key); err != nil { // In the disjoint keyspace, every goroutine just wrote its own // key; a miss is a real error. In the overlapping keyspace, - // the immediate Get can race with another goroutine's - // rewrite-then-evict only if a TTL expires mid-test, which - // none of these credentials use, so a miss there is also an - // error to track. + // every write after the first is a no-op under first-claim-wins + // semantics (StoreDCRCredentialsIfAbsent), so a miss there could + // only mean the key evicted via a TTL — none of these credentials + // use one — so a miss there is also an error to track. atomic.AddInt32(&getErrCount, 1) } } diff --git a/pkg/authserver/storage/types.go b/pkg/authserver/storage/types.go index a7f9f7571e..7606d2b333 100644 --- a/pkg/authserver/storage/types.go +++ b/pkg/authserver/storage/types.go @@ -289,7 +289,7 @@ func validateDCRCredentialsForStore(creds *DCRCredentials) error { // // Callers receive a defensive copy from the store. Mutations on the returned // value do not affect persisted state, and mutations on a value passed to -// StoreDCRCredentials are not observed by subsequent reads. This matches the +// StoreDCRCredentialsIfAbsent are not observed by subsequent reads. This matches the // UpstreamTokens contract. // // # Lifetime @@ -384,8 +384,8 @@ type DCRCredentials struct { // // # Why the key is embedded in DCRCredentials // -// StoreDCRCredentials takes a single (ctx, creds) argument rather than the -// (ctx, key, value) shape used by sibling Store* methods on Storage. The +// StoreDCRCredentialsIfAbsent takes a single (ctx, creds) argument rather +// than the (ctx, key, value) shape used by sibling Store* methods on Storage. The // DCRKey is embedded as DCRCredentials.Key so the persisted blob is // self-describing: a Redis SCAN, an admin-tool dump, or a cross-replica // reconciliation path can identify a record's logical cache slot @@ -400,10 +400,16 @@ type DCRCredentialStore interface { // The returned value is a defensive copy. GetDCRCredentials(ctx context.Context, key DCRKey) (*DCRCredentials, error) - // StoreDCRCredentials persists the credentials, overwriting any existing - // entry for the same Key. See the interface-level "TTL handling" section - // for the contract on ClientSecretExpiresAt. - StoreDCRCredentials(ctx context.Context, creds *DCRCredentials) error + // StoreDCRCredentialsIfAbsent claims creds.Key for creds. Returns the + // authoritative durable value: the caller's own creds on a successful + // claim, the concurrent winner's value otherwise. Callers MUST use the + // returned value, not their input creds — RFC 7591 dynamic registration + // mints a unique client_id/client_secret on every call, so a caller that + // lost the race and kept using its own creds would hold credentials the + // durable store does not agree it owns. See the interface-level "TTL + // handling" section for the contract on ClientSecretExpiresAt. The + // returned *DCRCredentials is always non-nil when err is nil. + StoreDCRCredentialsIfAbsent(ctx context.Context, creds *DCRCredentials) (*DCRCredentials, error) } // User represents a user account in the authorization server. @@ -867,7 +873,7 @@ type Storage interface { // and user management for multi-IDP support. // // DCRCredentialStore is intentionally NOT embedded here: doing so would - // promote GetDCRCredentials / StoreDCRCredentials onto every consumer of + // promote GetDCRCredentials / StoreDCRCredentialsIfAbsent onto every consumer of // storage.Storage (handlers, server, registration, etc.), broadening the // surface that can read raw client_secret / registration_access_token even // when those consumers have no DCR responsibility. Code that legitimately From 571f0213ed8c9e6e2988e24b72fc2ac1b9e50d2d Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Thu, 3 Sep 2026 15:00:18 +0200 Subject: [PATCH 09/14] Fix CIMD client renewal broken by create-only registration Rebasing this stack onto main picked up CIMD's write-through client persistence (main), which relies on RegisterClient acting as an upsert to renew a resolved client's row on every document re-fetch. This stack's own registration hardening made RegisterClient create-only for DCR-issued clients, so every renewal after the first fetch for a given CIMD client_id would silently fail, leaving stale client data (and, absent the token-exchange-triggered RenewClientTTL path, a stale TTL) in storage. Add UpsertDCRIssuedClient, a narrow fourth ClientRegistry operation distinct from both RegisterClient (unauthenticated DCR, stays create-only) and ReconcileConfiguredClient (fingerprint-locked, the wrong shape since a CIMD document can legitimately change between fetches). It creates the row if absent, replaces and renews it only when the existing row is itself DCR-issued, and refuses with ErrAlreadyExists otherwise -- protecting a configured or SPIFFE client from being clobbered. Wire CIMDStorageDecorator.fetch to call it instead of RegisterClient, and give SPIFFEStorageDecorator the same reserved-ID guard its other overrides already enforce. Signed-off-by: Jakub Hrozek --- pkg/authserver/storage/cimd_decorator.go | 12 ++- pkg/authserver/storage/cimd_decorator_test.go | 54 ++++++++++- pkg/authserver/storage/memory.go | 32 +++++++ pkg/authserver/storage/memory_test.go | 93 +++++++++++++++++++ pkg/authserver/storage/mocks/mock_storage.go | 28 ++++++ pkg/authserver/storage/redis.go | 75 +++++++++++++++ pkg/authserver/storage/redis_test.go | 83 +++++++++++++++++ pkg/authserver/storage/spiffe_decorator.go | 16 ++++ .../storage/spiffe_decorator_test.go | 26 ++++++ pkg/authserver/storage/types.go | 13 +++ 10 files changed, 425 insertions(+), 7 deletions(-) diff --git a/pkg/authserver/storage/cimd_decorator.go b/pkg/authserver/storage/cimd_decorator.go index 86848e59c2..9b6545ec5c 100644 --- a/pkg/authserver/storage/cimd_decorator.go +++ b/pkg/authserver/storage/cimd_decorator.go @@ -292,10 +292,14 @@ func (d *CIMDStorageDecorator) fetch(ctx context.Context, id string) (fosite.Cli // issue #6187). The client carries the DCR-issued marker (above) so the // row gets the same anti-bloat TTL as DCR registrations: unauthenticated // /oauth/authorize traffic can mint these rows, so they must never be - // permanent. Re-persisting on every fresh fetch keeps the snapshot - // current with the document. A persistence failure only degrades - // token-path rehydration, so it must not fail the resolution itself. - if err := d.RegisterClient(ctx, client); err != nil { + // permanent. UpsertDCRIssuedClient (not RegisterClient) is used because + // RegisterClient is strictly create-only and would fail every fetch after + // the first for the same client_id -- this method re-persists on every + // fresh fetch, keeping the stored snapshot current with the document, + // while still refusing to clobber a configured/SPIFFE-reconciled client + // at the same ID. A persistence failure only degrades token-path + // rehydration, so it must not fail the resolution itself. + if err := d.UpsertDCRIssuedClient(ctx, client); err != nil { slog.WarnContext(ctx, "failed to persist resolved CIMD client", "client_id", id, "error", err) } diff --git a/pkg/authserver/storage/cimd_decorator_test.go b/pkg/authserver/storage/cimd_decorator_test.go index 94baa94446..6742eea0cf 100644 --- a/pkg/authserver/storage/cimd_decorator_test.go +++ b/pkg/authserver/storage/cimd_decorator_test.go @@ -958,14 +958,15 @@ func TestCIMDStorageDecorator_PersistsLoopbackResolvedClient(t *testing.T) { require.NoError(t, err) } -// registerFailingStorage wraps a Storage and fails every RegisterClient call, -// for testing that a write-through persistence failure does not fail the +// registerFailingStorage wraps a Storage and fails every UpsertDCRIssuedClient +// call -- the write-through persistence path fetch() actually calls -- for +// testing that a write-through persistence failure does not fail the // resolution itself. type registerFailingStorage struct { Storage } -func (*registerFailingStorage) RegisterClient(context.Context, fosite.Client) error { +func (*registerFailingStorage) UpsertDCRIssuedClient(context.Context, fosite.Client) error { return errors.New("register failed") } @@ -1132,3 +1133,50 @@ func TestCIMDStorageDecorator_RefreshGrantAudienceMatch(t *testing.T) { require.NoError(t, err, "a CIMD client must pass fosite's refresh-grant audience check against its own previously granted audience") } + +// TestCIMDStorageDecorator_RepeatFetchRenewsPersistedClient closes the gap +// that let the RegisterClient/UpsertDCRIssuedClient bug go undetected: +// nothing previously asserted the persisted row's state after a repeat +// fetch of the same client_id. Two fetches of the same CIMD document, whose +// served content changes between them, must both succeed, and the second +// fetch's write-through must actually replace the stored row's data rather +// than silently failing with ErrAlreadyExists (as it would have with the old +// RegisterClient-only call, whose failure fetch() only logs). +func TestCIMDStorageDecorator_RepeatFetchRenewsPersistedClient(t *testing.T) { + t.Parallel() + + var callCount atomic.Int32 + srv := serveCIMDDocWithFields(t, func(doc *cimd.ClientMetadataDocument) { + // Change the served document between the first and second fetch so a + // real re-persist is distinguishable from a no-op. + if callCount.Add(1) == 1 { + doc.RedirectURIs = []string{"https://example.com/callback-v1"} + } else { + doc.RedirectURIs = []string{"https://example.com/callback-v2"} + } + }) + base := newTestBase(t) + dec := newEnabledDecorator(t, base, 10, time.Minute) + id := srv.URL + "/meta.json" + ctx := context.Background() + + // Call fetch() directly (bypassing the in-process LRU cache) to force two + // real write-through persistence attempts, exactly as two independent + // process replicas resolving the same client_id would. + first, err := dec.fetch(ctx, id) + require.NoError(t, err) + assert.Equal(t, []string{"https://example.com/callback-v1"}, first.GetRedirectURIs()) + + stored, err := base.GetClient(ctx, id) + require.NoError(t, err, "first fetch must persist the row") + assert.Equal(t, []string{"https://example.com/callback-v1"}, stored.GetRedirectURIs()) + + second, err := dec.fetch(ctx, id) + require.NoError(t, err, "second fetch for the same client_id must not fail") + assert.Equal(t, []string{"https://example.com/callback-v2"}, second.GetRedirectURIs()) + + stored, err = base.GetClient(ctx, id) + require.NoError(t, err) + assert.Equal(t, []string{"https://example.com/callback-v2"}, stored.GetRedirectURIs(), + "the persisted row must be renewed with the newly-fetched document, not left stale") +} diff --git a/pkg/authserver/storage/memory.go b/pkg/authserver/storage/memory.go index f3985e24d9..c543a5da16 100644 --- a/pkg/authserver/storage/memory.go +++ b/pkg/authserver/storage/memory.go @@ -476,6 +476,38 @@ func (s *MemoryStorage) RegisterClient(_ context.Context, client fosite.Client) return s.insertClientLocked(id, client) } +// UpsertDCRIssuedClient creates or replaces a DCR-issued client at +// client.GetID(). Unlike RegisterClient, an existing row is replaced (and its +// eviction position refreshed) rather than rejected -- but only when the +// existing row is itself DCR-issued; a configured/SPIFFE-reconciled row at +// the same ID is protected and refuses with ErrAlreadyExists. See the +// ClientRegistry interface doc for the full contract. +func (s *MemoryStorage) UpsertDCRIssuedClient(_ context.Context, client fosite.Client) error { + if !registration.DCRIssued(client) { + return fmt.Errorf("client %q must carry the DCR-issued marker to use UpsertDCRIssuedClient", client.GetID()) + } + id := client.GetID() + if err := ValidateRegisterableClientID(id); err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + + existing, exists := s.clients[id] + if !exists { + return s.insertClientLocked(id, client) + } + if !registration.DCRIssued(existing) { + return fmt.Errorf("%w: client %q", ErrAlreadyExists, id) + } + + s.clientOrder = slices.DeleteFunc(s.clientOrder, func(e clientOrderEntry) bool { return e.id == id }) + s.clientOrder = append(s.clientOrder, clientOrderEntry{id: id, touchedAt: time.Now()}) + s.clients[id] = client + return nil +} + // ReconcileConfiguredClient applies an operator-declared client: creates it // if absent, idempotently replaces a matching-fingerprint configured client // (the restart-with-unchanged-config case), or refuses with ErrAlreadyExists diff --git a/pkg/authserver/storage/memory_test.go b/pkg/authserver/storage/memory_test.go index eba30809a9..25eab0325a 100644 --- a/pkg/authserver/storage/memory_test.go +++ b/pkg/authserver/storage/memory_test.go @@ -380,6 +380,99 @@ func TestMemoryStorage_ReconcileConfiguredClient(t *testing.T) { }) } +// TestMemoryStorage_UpsertDCRIssuedClient covers the create/replace/reject +// matrix UpsertDCRIssuedClient must implement: create when absent, replace +// (and renew the eviction position) when the existing record is itself +// DCR-issued, refuse with ErrAlreadyExists when the existing record is NOT +// DCR-issued (the critical protection: a configured/SPIFFE client must never +// be clobbered by this path), and refuse when the incoming client itself does +// not carry the DCR-issued marker (misuse guard). +func TestMemoryStorage_UpsertDCRIssuedClient(t *testing.T) { + t.Parallel() + + t.Run("creates when absent", func(t *testing.T) { + t.Parallel() + ctx := t.Context() + s := NewMemoryStorage() + defer s.Close() + + client := dcrClient(t, "cimd-client") + require.NoError(t, s.UpsertDCRIssuedClient(ctx, client)) + + retrieved, err := s.GetClient(ctx, "cimd-client") + require.NoError(t, err) + assert.Equal(t, client, retrieved) + }) + + t.Run("replaces and renews when existing row is DCR-issued", func(t *testing.T) { + t.Parallel() + ctx := t.Context() + // MinClientAge(0) isolates this test to the replace/renew behaviour; + // the age-floor grace window has its own tests above. + s := NewMemoryStorage(WithMaxClients(2), WithMinClientAge(0)) + defer s.Close() + + first := dcrClient(t, "cimd-client") + require.NoError(t, s.UpsertDCRIssuedClient(ctx, first)) + require.NoError(t, s.RegisterClient(ctx, dcrClient(t, "client-b"))) + + // A distinguishing field on the replacement proves the replace branch + // actually overwrote the stored data rather than being a silent no-op. + second, err := registration.New(registration.Config{ + ID: "cimd-client", + TokenEndpointAuthMethod: oauthproto.TokenEndpointAuthMethodNone, + RedirectURIs: []string{"https://app.example/cb-v2"}, + }) + require.NoError(t, err) + require.NoError(t, s.UpsertDCRIssuedClient(ctx, second)) + + retrieved, err := s.GetClient(ctx, "cimd-client") + require.NoError(t, err) + assert.Equal(t, second, retrieved, "second call must replace the stored row") + + // Renewed eviction position: cimd-client was registered first (oldest) + // but the upsert must have moved it to the back, so overflow evicts + // client-b instead. + require.NoError(t, s.RegisterClient(ctx, dcrClient(t, "client-c"))) + _, err = s.GetClient(ctx, "cimd-client") + require.NoError(t, err, "renewed client must survive eviction") + _, err = s.GetClient(ctx, "client-b") + requireNotFoundError(t, err) + }) + + t.Run("refuses to overwrite a non-DCR-issued client", func(t *testing.T) { + t.Parallel() + ctx := t.Context() + s := NewMemoryStorage() + defer s.Close() + + configured := &mockClient{id: "configured", public: false} + require.NoError(t, s.ReconcileConfiguredClient(ctx, configured)) + + err := s.UpsertDCRIssuedClient(ctx, dcrClient(t, "configured")) + require.ErrorIs(t, err, ErrAlreadyExists) + + // The original registration must be untouched. + retrieved, err := s.GetClient(ctx, "configured") + require.NoError(t, err) + assert.Equal(t, configured, retrieved) + }) + + t.Run("rejects a client not carrying the DCR-issued marker", func(t *testing.T) { + t.Parallel() + ctx := t.Context() + s := NewMemoryStorage() + defer s.Close() + + err := s.UpsertDCRIssuedClient(ctx, &mockClient{id: "not-dcr"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "must carry the DCR-issued marker") + + _, err = s.GetClient(ctx, "not-dcr") + require.ErrorIs(t, err, ErrNotFound) + }) +} + // TestMemoryStorage_RegisterClient_Bounded pins the anti-DoS cap: the client // map is bounded by maxClients with oldest-first eviction among DCR-issued // clients only, and duplicate registrations fail without changing stored state. diff --git a/pkg/authserver/storage/mocks/mock_storage.go b/pkg/authserver/storage/mocks/mock_storage.go index 8c98dcdfd6..8ee47fdf71 100644 --- a/pkg/authserver/storage/mocks/mock_storage.go +++ b/pkg/authserver/storage/mocks/mock_storage.go @@ -287,6 +287,20 @@ func (mr *MockClientRegistryMockRecorder) SetClientAssertionJWT(ctx, jti, exp an return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetClientAssertionJWT", reflect.TypeOf((*MockClientRegistry)(nil).SetClientAssertionJWT), ctx, jti, exp) } +// UpsertDCRIssuedClient mocks base method. +func (m *MockClientRegistry) UpsertDCRIssuedClient(ctx context.Context, client fosite.Client) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertDCRIssuedClient", ctx, client) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpsertDCRIssuedClient indicates an expected call of UpsertDCRIssuedClient. +func (mr *MockClientRegistryMockRecorder) UpsertDCRIssuedClient(ctx, client any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertDCRIssuedClient", reflect.TypeOf((*MockClientRegistry)(nil).UpsertDCRIssuedClient), ctx, client) +} + // MockUpstreamTokenStorage is a mock of UpstreamTokenStorage interface. type MockUpstreamTokenStorage struct { ctrl *gomock.Controller @@ -1173,3 +1187,17 @@ func (mr *MockStorageMockRecorder) UpdateProviderIdentityLastUsed(ctx, providerI mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateProviderIdentityLastUsed", reflect.TypeOf((*MockStorage)(nil).UpdateProviderIdentityLastUsed), ctx, providerID, providerSubject, lastUsedAt) } + +// UpsertDCRIssuedClient mocks base method. +func (m *MockStorage) UpsertDCRIssuedClient(ctx context.Context, client fosite.Client) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertDCRIssuedClient", ctx, client) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpsertDCRIssuedClient indicates an expected call of UpsertDCRIssuedClient. +func (mr *MockStorageMockRecorder) UpsertDCRIssuedClient(ctx, client any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertDCRIssuedClient", reflect.TypeOf((*MockStorage)(nil).UpsertDCRIssuedClient), ctx, client) +} diff --git a/pkg/authserver/storage/redis.go b/pkg/authserver/storage/redis.go index d7a8824ebf..795cd15a54 100644 --- a/pkg/authserver/storage/redis.go +++ b/pkg/authserver/storage/redis.go @@ -421,6 +421,81 @@ func (s *RedisStorage) RegisterClient(ctx context.Context, client fosite.Client) return nil } +// UpsertDCRIssuedClient creates or replaces a DCR-issued client at +// client.GetID(). Unlike RegisterClient (create-only via SetNX), an existing +// row is replaced -- and its TTL refreshed to DefaultDCRClientTTL -- when the +// existing row is itself DCR-issued; it refuses with ErrAlreadyExists when the +// existing row is NOT DCR-issued, protecting a configured/SPIFFE-reconciled +// client from being clobbered. See the ClientRegistry interface doc for the +// full contract. +// +// The read-check-write sequence for an existing key runs inside a Redis +// WATCH/MULTI transaction, mirroring ReconcileConfiguredClient, so a +// concurrent writer cannot interleave between the DCR-issued check and the +// write; see maxConfiguredClientReconcileRetries for why this method retries +// redis.TxFailedErr itself, up to the same bounded count. Unlike +// ReconcileConfiguredClient (ttl=0, permanent), the write here always uses +// DefaultDCRClientTTL: this row is TTL-bounded like any other DCR-issued +// client. +func (s *RedisStorage) UpsertDCRIssuedClient(ctx context.Context, client fosite.Client) error { + if !registration.DCRIssued(client) { + return fmt.Errorf("client %q must carry the DCR-issued marker to use UpsertDCRIssuedClient", client.GetID()) + } + if err := ValidateRegisterableClientID(client.GetID()); err != nil { + return err + } + + key := redisKey(s.keyPrefix, KeyTypeClient, client.GetID()) + stored := buildStoredClient(client) + data, err := json.Marshal(stored) //nolint:gosec // G117 - internal Redis storage serialization, not exposed to users + if err != nil { + return fmt.Errorf("failed to marshal client: %w", err) + } + + setPipelined := func(tx *redis.Tx) error { + _, err := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error { + pipe.Set(ctx, key, data, DefaultDCRClientTTL) + return nil + }) + return err + } + + txFn := func(tx *redis.Tx) error { + existingData, getErr := tx.Get(ctx, key).Bytes() + if errors.Is(getErr, redis.Nil) { + return setPipelined(tx) + } + if getErr != nil { + return fmt.Errorf("failed to get existing client: %w", getErr) + } + + ttl, ttlErr := tx.TTL(ctx, key).Result() + if ttlErr != nil { + return fmt.Errorf("failed to get existing client TTL: %w", ttlErr) + } + + var existingStored storedClient + if unmarshalErr := json.Unmarshal(existingData, &existingStored); unmarshalErr != nil { + return fmt.Errorf("failed to unmarshal existing client: %w", unmarshalErr) + } + + if !registration.DCRIssued(clientFromStored(existingStored, ttl >= 0)) { + return fmt.Errorf("%w: client %q", ErrAlreadyExists, client.GetID()) + } + + return setPipelined(tx) + } + + var watchErr error + for attempt := 0; attempt < maxConfiguredClientReconcileRetries; attempt++ { + watchErr = s.client.Watch(ctx, txFn, key) + if !errors.Is(watchErr, redis.TxFailedErr) { + return watchErr + } + } + return watchErr +} + // storedClientFingerprintsEqual mirrors clientFingerprintsEqual but compares // the raw serialized fields directly rather than through the fosite.Client // interface returned by clientFromStored. This matters because diff --git a/pkg/authserver/storage/redis_test.go b/pkg/authserver/storage/redis_test.go index 2e9d3ad259..a4bca614c4 100644 --- a/pkg/authserver/storage/redis_test.go +++ b/pkg/authserver/storage/redis_test.go @@ -742,6 +742,89 @@ func TestRedisStorage_ReconcileConfiguredClient(t *testing.T) { }) } +// TestRedisStorage_UpsertDCRIssuedClient covers the create/replace/reject +// matrix UpsertDCRIssuedClient must implement: create when absent (with the +// DCR TTL, not permanent), replace and renew the TTL when the existing row is +// itself DCR-issued, refuse with ErrAlreadyExists when the existing row is NOT +// DCR-issued (the critical protection: a configured/SPIFFE client must never +// be clobbered by this path), and refuse when the incoming client itself does +// not carry the DCR-issued marker (misuse guard). +func TestRedisStorage_UpsertDCRIssuedClient(t *testing.T) { + t.Parallel() + + newConfigured := func(id, secret string) fosite.Client { + client, err := registration.NewStaticDelegateClient(registration.Config{ + ID: id, Secret: secret, GrantTypes: []string{oauthproto.GrantTypeTokenExchange}, + Scopes: []string{"openid"}, Audience: []string{"https://mcp.example"}, + }) + require.NoError(t, err) + return client + } + + t.Run("creates when absent, with the DCR TTL", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, mr *miniredis.Miniredis) { + client := newDCRClient(t, "cimd-client", oauthproto.TokenEndpointAuthMethodNone, "") + require.NoError(t, s.UpsertDCRIssuedClient(ctx, client)) + + retrieved, err := s.GetClient(ctx, "cimd-client") + require.NoError(t, err) + assert.True(t, registration.DCRIssued(retrieved)) + key := redisKey(s.keyPrefix, KeyTypeClient, "cimd-client") + assert.InDelta(t, DefaultDCRClientTTL.Seconds(), mr.TTL(key).Seconds(), 60) + }) + }) + + t.Run("replaces and renews when existing row is DCR-issued", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, mr *miniredis.Miniredis) { + first := newDCRClient(t, "cimd-client", oauthproto.TokenEndpointAuthMethodNone, "") + require.NoError(t, s.UpsertDCRIssuedClient(ctx, first)) + + key := redisKey(s.keyPrefix, KeyTypeClient, "cimd-client") + mr.FastForward(DefaultDCRClientTTL - time.Hour) + + second, err := registration.New(registration.Config{ + ID: "cimd-client", + TokenEndpointAuthMethod: oauthproto.TokenEndpointAuthMethodNone, + RedirectURIs: []string{"https://app.example/cb-v2"}, + }) + require.NoError(t, err) + require.NoError(t, s.UpsertDCRIssuedClient(ctx, second)) + + retrieved, err := s.GetClient(ctx, "cimd-client") + require.NoError(t, err) + assert.Equal(t, []string{"https://app.example/cb-v2"}, retrieved.GetRedirectURIs(), + "second call must replace the stored row's data") + assert.InDelta(t, DefaultDCRClientTTL.Seconds(), mr.TTL(key).Seconds(), 60, + "second call must renew the TTL") + }) + }) + + t.Run("refuses to overwrite a non-DCR-issued client", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { + configured := newConfigured("configured", "secret") + require.NoError(t, s.ReconcileConfiguredClient(ctx, configured)) + + err := s.UpsertDCRIssuedClient(ctx, newDCRClient(t, "configured", oauthproto.TokenEndpointAuthMethodNone, "")) + require.ErrorIs(t, err, ErrAlreadyExists) + + retrieved, err := s.GetClient(ctx, "configured") + require.NoError(t, err) + assert.False(t, registration.DCRIssued(retrieved), "the configured client must be untouched") + }) + }) + + t.Run("rejects a client not carrying the DCR-issued marker", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { + err := s.UpsertDCRIssuedClient(ctx, newConfigured("not-dcr", "secret")) + require.Error(t, err) + assert.Contains(t, err.Error(), "must carry the DCR-issued marker") + + _, err = s.GetClient(ctx, "not-dcr") + require.ErrorIs(t, err, ErrNotFound) + }) + }) +} + // TestRedisStorage_GetClient_SupportsLoopbackRedirectMatching pins that a // public client round-tripped through Redis still supports RFC 8252 §7.3 // loopback dynamic-port redirect_uri matching. diff --git a/pkg/authserver/storage/spiffe_decorator.go b/pkg/authserver/storage/spiffe_decorator.go index 0eb97ec24a..a064b3aae1 100644 --- a/pkg/authserver/storage/spiffe_decorator.go +++ b/pkg/authserver/storage/spiffe_decorator.go @@ -194,5 +194,21 @@ func (d *SPIFFEStorageDecorator) ReconcileConfiguredClient(ctx context.Context, return d.Storage.ReconcileConfiguredClient(ctx, client) } +// UpsertDCRIssuedClient rejects reserved static SPIFFE IDs and delegates +// other DCR-issued upsert-on-fetch calls (e.g. CIMDStorageDecorator) to the +// base backend. Without this override the guard would be skipped whenever +// the durable SPIFFE placeholder row is absent (reconciliation hasn't run +// yet, or persistence failed), letting a DCR row get created at a reserved +// SPIFFE ID. +func (d *SPIFFEStorageDecorator) UpsertDCRIssuedClient(ctx context.Context, client fosite.Client) error { + if client == nil { + return fmt.Errorf("upsert DCR-issued client: client is required") + } + if _, reserved := d.clients[client.GetID()]; reserved { + return fmt.Errorf("%w: client ID %q is reserved for a static SPIFFE client", ErrAlreadyExists, client.GetID()) + } + return d.Storage.UpsertDCRIssuedClient(ctx, client) +} + // Unwrap returns the dynamic DCR/CIMD storage backend. func (d *SPIFFEStorageDecorator) Unwrap() Storage { return d.Storage } diff --git a/pkg/authserver/storage/spiffe_decorator_test.go b/pkg/authserver/storage/spiffe_decorator_test.go index 11149d428d..29a907b942 100644 --- a/pkg/authserver/storage/spiffe_decorator_test.go +++ b/pkg/authserver/storage/spiffe_decorator_test.go @@ -57,6 +57,32 @@ func TestSPIFFEStorageDecoratorReconcileConfiguredClientRejectsReservedID(t *tes require.ErrorIs(t, err, ErrAlreadyExists) } +// TestSPIFFEStorageDecoratorUpsertDCRIssuedClientRejectsReservedID mirrors +// TestSPIFFEStorageDecoratorStaticClients' RegisterClient assertion, but for +// UpsertDCRIssuedClient: a DCR-issued client (e.g. from CIMDStorageDecorator's +// write-through) must not be able to clobber a reserved static SPIFFE client +// ID either, even though UpsertDCRIssuedClient's own DCR-issued guard alone +// would otherwise let a create-when-absent slip through if the durable +// placeholder row were ever missing. +func TestSPIFFEStorageDecoratorUpsertDCRIssuedClientRejectsReservedID(t *testing.T) { + t.Parallel() + base := NewMemoryStorage() + t.Cleanup(func() { _ = base.Close() }) + + decorated, err := NewSPIFFEStorageDecorator(context.Background(), base, testSPIFFEClients(t)) + require.NoError(t, err) + + dcrClient, err := registration.New(registration.Config{ + ID: "spiffe-client", + TokenEndpointAuthMethod: oauthproto.TokenEndpointAuthMethodNone, + RedirectURIs: []string{"https://app.example/cb"}, + }) + require.NoError(t, err) + + err = decorated.UpsertDCRIssuedClient(context.Background(), dcrClient) + require.ErrorIs(t, err, ErrAlreadyExists) +} + // TestSPIFFEStorageDecoratorDurablyClaimsPlaceholder pins the race fix: // construction durably claims each configured client ID in the base backend // with an inert placeholder (never the real, unauthenticatable-by-design diff --git a/pkg/authserver/storage/types.go b/pkg/authserver/storage/types.go index 7606d2b333..f65cccd176 100644 --- a/pkg/authserver/storage/types.go +++ b/pkg/authserver/storage/types.go @@ -639,6 +639,19 @@ type ClientRegistry interface { // existing registration. RegisterClient(ctx context.Context, client fosite.Client) error + // UpsertDCRIssuedClient creates or replaces a DCR-issued client at + // client.GetID(). Unlike RegisterClient (create-only, for the unauthenticated + // /oauth/register endpoint), this is for callers that independently + // re-validate the client's authoritative source on every call -- today only + // CIMDStorageDecorator, which re-fetches and re-validates the document at + // client.GetID() before calling this. Creates the row if absent. If a row + // exists, replaces its data and refreshes its TTL only when the existing row + // is itself DCR-issued; refuses with ErrAlreadyExists if the existing row is + // NOT DCR-issued (protects a configured/SPIFFE-reconciled client from being + // clobbered). client MUST carry registration.DCRIssued -- refuses otherwise + // (mirrors ReconcileConfiguredClient's inverse check). + UpsertDCRIssuedClient(ctx context.Context, client fosite.Client) error + // ReconcileConfiguredClient applies an operator-declared (configured) // client: creates it if no client with that ID exists, or idempotently // replaces an existing record with the same ID when that record is itself From 319de92a14c711a5c2f6209a23409662c905272d Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Thu, 3 Sep 2026 16:11:21 +0200 Subject: [PATCH 10/14] Reject an already-expired authoritative DCR credential StoreDCRCredentialsIfAbsent deliberately returns a stable-but-expired existing row without error when both it and a fresh registration attempt are already expired, to avoid every concurrent claimant re-entering the write path and exhausting retries. That's the right call for the storage layer, but registerAndCache was treating whatever it got back as a successful resolution regardless -- handing callers a client_secret the upstream has already invalidated. Reject an already-expired authoritative credential in registerAndCache instead, where "expired means unusable" is actually DCR policy, not storage policy. This also covers a replica's own fresh registration turning out already-expired (upstream clock skew, or an upstream that issues a past client_secret_expires_at) -- the same guard applies either way, since a fresh-but-dead secret is exactly as unusable as a stale winner's. Signed-off-by: Jakub Hrozek --- pkg/auth/dcr/resolver.go | 18 ++++++++ pkg/auth/dcr/resolver_test.go | 86 +++++++++++++++++++++++++++++------ 2 files changed, 89 insertions(+), 15 deletions(-) diff --git a/pkg/auth/dcr/resolver.go b/pkg/auth/dcr/resolver.go index a18742ca1c..66d2306f0d 100644 --- a/pkg/auth/dcr/resolver.go +++ b/pkg/auth/dcr/resolver.go @@ -262,6 +262,8 @@ const ( dcrStepSelectAuthMethod = "select_auth_method" dcrStepRegister = "dcr_call" dcrStepCacheWrite = "cache_write" + //nolint:gosec // G101: this is a log/error "step" tag, not a credential value. + dcrStepExpiredCredential = "expired_credential" ) // dcrStepError annotates a resolver error with the phase it was produced @@ -484,6 +486,22 @@ func registerAndCache( return nil, newDCRStepError(dcrStepCacheWrite, req.Issuer, redirectURI, fmt.Errorf("cache put: %w", err)) } + + // The authoritative row can be a concurrent claimant's stable-but-expired + // registration: when both the existing stored row and this replica's + // fresh registration are already expired, dcrClaimOrReturnWinner + // (pkg/authserver/storage/redis.go) deliberately returns the existing + // row without error rather than retrying forever. That's the right call + // at the storage layer, but "expired means unusable" is DCR policy, not + // storage policy, so it belongs here: treat an already-expired + // authoritative credential as a resolution failure instead of handing + // callers a dead client_secret disguised as a success. + if !authoritative.ClientSecretExpiresAt.IsZero() && time.Now().After(authoritative.ClientSecretExpiresAt) { + return nil, newDCRStepError(dcrStepExpiredCredential, req.Issuer, redirectURI, + fmt.Errorf("authoritative credential is already expired (client_secret_expires_at=%s)", + authoritative.ClientSecretExpiresAt.UTC().Format(time.RFC3339))) + } + if authoritative.ClientID != resolution.ClientID { //nolint:gosec // G706: client_id is public metadata per RFC 7591. slog.Debug("dcr: registration superseded by concurrent winner", diff --git a/pkg/auth/dcr/resolver_test.go b/pkg/auth/dcr/resolver_test.go index f89bd5aa93..4ae71f48bd 100644 --- a/pkg/auth/dcr/resolver_test.go +++ b/pkg/auth/dcr/resolver_test.go @@ -1851,6 +1851,59 @@ func TestResolveDCRCredentials_CachePutFailureWrapped(t *testing.T) { "the wrap message is part of the operator-debugging contract") } +// expiredWinnerDCRStore simulates a concurrent claimant that already won the +// durable claim with a credential that is already expired. This mirrors +// dcrClaimOrReturnWinner (pkg/authserver/storage/redis.go) when both the +// existing stored row and the incoming registration are already expired: it +// deliberately returns the existing row without error rather than retrying +// forever. PutIfAbsent here returns winner regardless of the resolution the +// caller tried to store, exactly as a real "someone else already claimed +// this key" response would. +type expiredWinnerDCRStore struct { + winner *Resolution +} + +func (expiredWinnerDCRStore) Get(_ context.Context, _ Key) (*Resolution, bool, error) { + return nil, false, nil +} + +func (s expiredWinnerDCRStore) PutIfAbsent(_ context.Context, _ Key, _ *Resolution) (*Resolution, error) { + return s.winner, nil +} + +// TestResolveDCRCredentials_ExpiredAuthoritativeCredentialErrors covers the +// case a human reviewer flagged on PR #6474: cache.PutIfAbsent can return an +// authoritative credential that is already expired (the storage layer +// deliberately returns the stable expired row rather than erroring when both +// claimants are expired). Treating that as a successful resolution would +// hand the caller a client_secret the upstream will already reject. +// registerAndCache must instead surface it as a failure. +func TestResolveDCRCredentials_ExpiredAuthoritativeCredentialErrors(t *testing.T) { + t.Parallel() + + server := newDCRTestServer(t, dcrTestHandlerConfig{}) + + store := expiredWinnerDCRStore{ + winner: &Resolution{ + ClientID: "winner-client-id", + ClientSecret: "winner-client-secret", + ClientSecretExpiresAt: time.Now().Add(-time.Hour), + }, + } + + req := &Request{ + Issuer: server.URL, + Scopes: []string{"openid"}, + DiscoveryURL: server.URL + "/.well-known/oauth-authorization-server", + } + + res, err := ResolveCredentials(context.Background(), req, store) + require.Error(t, err, "an already-expired authoritative credential must not be returned as a success") + assert.Nil(t, res) + assert.Contains(t, err.Error(), dcrStepExpiredCredential, + "step identifier is part of the operator-debugging contract") +} + // TestBuildResolution_PopulatesRFC7591ExpiryFields covers the conversion of // the int64 epoch fields client_id_issued_at and client_secret_expires_at // into time.Time on Resolution. The wire convention "0 means absent / @@ -1918,10 +1971,12 @@ func TestBuildResolution_PopulatesRFC7591ExpiryFields(t *testing.T) { // TestResolveDCRCredentials_RefetchesOnExpiredCachedSecret pins the fix for // the cache-serves-expired-secrets bug: when an entry's // ClientSecretExpiresAt has passed, lookupCachedResolution treats it as a -// miss so registerAndCache re-runs and overwrites the stale entry. Without -// this, the cached secret would be served indefinitely past the upstream- -// asserted expiry and every token-endpoint call would 401 with no signal -// back to the resolver. +// miss so registerAndCache re-runs rather than serving the stale entry +// indefinitely. Since the upstream in this test always issues an +// already-expired secret, every call re-registers AND — per the +// expired-authoritative-credential check in registerAndCache — every call +// also fails, rather than handing the caller a client_secret the upstream +// has already invalidated. func TestResolveDCRCredentials_RefetchesOnExpiredCachedSecret(t *testing.T) { t.Parallel() @@ -1929,7 +1984,8 @@ func TestResolveDCRCredentials_RefetchesOnExpiredCachedSecret(t *testing.T) { server := newDCRTestServer(t, dcrTestHandlerConfig{ // Issue a secret that expired one minute ago. Every fresh // registration call will produce an already-expired entry; the - // resolver will refetch on every Resolve as a result. + // resolver will refetch (and, per the expired-credential check, + // fail) on every Resolve as a result. clientSecretExpiresAt: time.Now().Add(-time.Minute).Unix(), observeRegistration: func(_ *http.Request, _ []byte) { atomic.AddInt32(®istrationCalls, 1) @@ -1944,20 +2000,20 @@ func TestResolveDCRCredentials_RefetchesOnExpiredCachedSecret(t *testing.T) { DiscoveryURL: issuer + "/.well-known/oauth-authorization-server", } - // First call: registers, populates cache with already-expired entry. + // First call: registers, but the issued secret is already expired, so + // the resolver must not report success. res1, err := ResolveCredentials(context.Background(), req, cache) - require.NoError(t, err) - require.NotNil(t, res1) - require.False(t, res1.ClientSecretExpiresAt.IsZero(), - "upstream advertised an expiry — the resolution must echo it") - require.True(t, time.Now().After(res1.ClientSecretExpiresAt), - "test setup should have produced an already-expired secret") + require.Error(t, err) + assert.Nil(t, res1) + assert.Contains(t, err.Error(), dcrStepExpiredCredential) require.EqualValues(t, 1, atomic.LoadInt32(®istrationCalls)) - // Second call: the cached entry is expired, so the resolver must refetch. + // Second call: the cached entry is expired, so the resolver must + // refetch — and fail again, since the upstream keeps issuing + // already-expired secrets. res2, err := ResolveCredentials(context.Background(), req, cache) - require.NoError(t, err) - require.NotNil(t, res2) + require.Error(t, err) + assert.Nil(t, res2) assert.EqualValues(t, 2, atomic.LoadInt32(®istrationCalls), "expired cache entry must trigger a re-registration; got %d total calls", atomic.LoadInt32(®istrationCalls)) From 7ff760b2c9c831c5172ae1779c58f6fde3911ac2 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Fri, 4 Sep 2026 10:03:34 +0200 Subject: [PATCH 11/14] Enforce SPIFFE resources independently of audiences RFC 8707 resources and RFC 8693 audiences are independent request dimensions for SPIFFE-authenticated token exchange, but the resource allowlist was validated at config time and then silently discarded when building the runtime client: staticClients() only passed scopes and audiences to NewSPIFFEClient, and grantResourceAudience checked every "resource" request parameter against GetAudience() regardless. A client configured with disjoint audiences and resources could get a token for a resource that was never in its resources allowlist (as long as it happened to match an audience), while a legitimately configured resource was wrongly denied. Give SPIFFEClient its own Resources() accessor alongside Audiences(), thread policy.Resources() through the association registry, and have grantResourceAudience check a resourceScopedClient's Resources() instead of GetAudience() when the client implements it. Other client types (DelegateClient, DCR clients) are unaffected since they don't implement the new interface and keep using GetAudience() exactly as before. An earlier iteration of this client (before the current three-arg constructor) modeled resources correctly and had a test asserting GetAudience() stayed empty absent an explicit audience -- fail closed. That assertion was removed rather than adapted when the field was dropped, and its replacement asserted the new fail-open behavior as intended. Restore disjoint-resources/audiences coverage at the client, registry, and handler layers so the same regression can't hide again. Signed-off-by: Jakub Hrozek --- .../server/handlers/authorize_test.go | 1 + .../server/registration/spiffe_client.go | 21 ++++++--- .../server/registration/spiffe_client_test.go | 34 ++++++++++++-- .../server/tokenexchange/handler.go | 30 +++++++++---- .../server/tokenexchange/handler_test.go | 45 +++++++++++++++++++ .../tokenexchange/jwt_bearer_handler_test.go | 2 +- pkg/authserver/spiffe_association_registry.go | 2 +- .../spiffe_association_registry_test.go | 34 ++++++++++++-- .../storage/spiffe_decorator_test.go | 3 +- 9 files changed, 147 insertions(+), 25 deletions(-) diff --git a/pkg/authserver/server/handlers/authorize_test.go b/pkg/authserver/server/handlers/authorize_test.go index 8432a4145f..f971c038c0 100644 --- a/pkg/authserver/server/handlers/authorize_test.go +++ b/pkg/authserver/server/handlers/authorize_test.go @@ -92,6 +92,7 @@ func TestAuthorizeHandler_BackChannelOnlyClientsMatchMissingClient(t *testing.T) "spiffe-client", []string{"openid"}, []string{"https://mcp.example.com"}, + nil, ) require.NoError(t, err) return client diff --git a/pkg/authserver/server/registration/spiffe_client.go b/pkg/authserver/server/registration/spiffe_client.go index e0a238e113..91e2339ebf 100644 --- a/pkg/authserver/server/registration/spiffe_client.go +++ b/pkg/authserver/server/registration/spiffe_client.go @@ -23,12 +23,14 @@ type SPIFFEClient struct { grantTypes fosite.Arguments scopes fosite.Arguments audiences []string + resources []string } // NewSPIFFEClient creates an immutable, secretless, non-public client for a -// configured SPIFFE association. GetAudience returns the configured allowlist; -// token exchange checks both audience and resource request parameters against it. -func NewSPIFFEClient(id string, scopes, audiences []string) (*SPIFFEClient, error) { +// configured SPIFFE association. GetAudience returns the configured audience +// allowlist. resources is the independent RFC 8707 resource allowlist, +// available via Resources(); it may be empty. +func NewSPIFFEClient(id string, scopes, audiences, resources []string) (*SPIFFEClient, error) { if id == "" { return nil, fmt.Errorf("SPIFFE client ID is required") } @@ -41,6 +43,7 @@ func NewSPIFFEClient(id string, scopes, audiences []string) (*SPIFFEClient, erro grantTypes: fosite.Arguments{oauthproto.GrantTypeTokenExchange}, scopes: slices.Clone(scopes), audiences: slices.Clone(audiences), + resources: slices.Clone(resources), }, nil } @@ -63,12 +66,16 @@ func (*SPIFFEClient) GetResponseTypes() fosite.Arguments { return nil } // GetScopes returns a copy of the configured scopes. func (c *SPIFFEClient) GetScopes() fosite.Arguments { return slices.Clone(c.scopes) } -// Audiences returns a copy of the allowed token-exchange target identifiers. +// Audiences returns a copy of the allowed RFC 8693 audience request values. func (c *SPIFFEClient) Audiences() []string { return slices.Clone(c.audiences) } -// GetAudience returns the allowed token-exchange target identifiers. The token -// exchange handler checks both the RFC 8693 audience and RFC 8707 resource -// request parameters against this Fosite client field. +// Resources returns a copy of the allowed RFC 8707 resource request values. +// Resources and audiences are independent request dimensions; the token +// exchange handler checks the "resource" request parameter against this, +// not against GetAudience. +func (c *SPIFFEClient) Resources() []string { return slices.Clone(c.resources) } + +// GetAudience returns the allowed RFC 8693 audience request values. func (c *SPIFFEClient) GetAudience() fosite.Arguments { return slices.Clone(c.audiences) } // IsPublic returns false so Fosite does not treat unauthenticated requests as diff --git a/pkg/authserver/server/registration/spiffe_client_test.go b/pkg/authserver/server/registration/spiffe_client_test.go index 232acd9eb4..18e27f4cd9 100644 --- a/pkg/authserver/server/registration/spiffe_client_test.go +++ b/pkg/authserver/server/registration/spiffe_client_test.go @@ -15,11 +15,13 @@ func TestNewSPIFFEClient(t *testing.T) { scopes := []string{"openid"} audiences := []string{"https://api.example.com"} - client, err := NewSPIFFEClient("spiffe-client", scopes, audiences) + resources := []string{"https://resource.example.com"} + client, err := NewSPIFFEClient("spiffe-client", scopes, audiences, resources) require.NoError(t, err) scopes[0] = "changed" audiences[0] = "changed" + resources[0] = "changed" assert.Equal(t, "spiffe-client", client.GetID()) assert.Nil(t, client.GetHashedSecret()) @@ -30,22 +32,47 @@ func TestNewSPIFFEClient(t *testing.T) { assert.Equal(t, "openid", client.GetScopes()[0]) assert.Equal(t, []string{"https://api.example.com"}, client.Audiences()) assert.Equal(t, []string{"https://api.example.com"}, []string(client.GetAudience())) + assert.Equal(t, []string{"https://resource.example.com"}, client.Resources()) client.GetScopes()[0] = "mutated" client.Audiences()[0] = "mutated" client.GetAudience()[0] = "mutated" + client.Resources()[0] = "mutated" assert.Equal(t, "openid", client.GetScopes()[0]) assert.Equal(t, []string{"https://api.example.com"}, client.Audiences()) assert.Equal(t, []string{"https://api.example.com"}, []string(client.GetAudience())) + assert.Equal(t, []string{"https://resource.example.com"}, client.Resources()) - _, err = NewSPIFFEClient("disabled-exchange", nil, nil) + _, err = NewSPIFFEClient("disabled-exchange", nil, nil, nil) require.Error(t, err) } +// TestNewSPIFFEClient_DisjointAudiencesAndResources proves audiences and +// resources are independently tracked, not aliases of the same underlying +// field: a client configured with disjoint allowlists must return each list +// unmodified by the other, and GetAudience (the RFC 8693 audience allowlist) +// must never leak the RFC 8707 resource allowlist or vice versa. +func TestNewSPIFFEClient_DisjointAudiencesAndResources(t *testing.T) { + t.Parallel() + + client, err := NewSPIFFEClient( + "spiffe-client", + []string{"openid"}, + []string{"https://audience.example.com"}, + []string{"https://resource.example.com"}, + ) + require.NoError(t, err) + + assert.Equal(t, []string{"https://audience.example.com"}, []string(client.GetAudience())) + assert.Equal(t, []string{"https://resource.example.com"}, client.Resources()) + assert.NotContains(t, client.GetAudience(), "https://resource.example.com") + assert.NotContains(t, client.Resources(), "https://audience.example.com") +} + func TestNewSPIFFEClient_RequiresID(t *testing.T) { t.Parallel() - _, err := NewSPIFFEClient("", nil, nil) + _, err := NewSPIFFEClient("", nil, nil, nil) require.Error(t, err) } @@ -56,6 +83,7 @@ func TestNewSPIFFEClient_RequiresAudiences(t *testing.T) { "spiffe-client", []string{"openid"}, nil, + nil, ) require.EqualError(t, err, "SPIFFE client scopes and audiences are required") } diff --git a/pkg/authserver/server/tokenexchange/handler.go b/pkg/authserver/server/tokenexchange/handler.go index b1344b445a..643c472e6e 100644 --- a/pkg/authserver/server/tokenexchange/handler.go +++ b/pkg/authserver/server/tokenexchange/handler.go @@ -817,9 +817,20 @@ func (h *Handler) grantAudiences(ctx context.Context, requester fosite.AccessReq return nil } +// resourceScopedClient is implemented by fosite.Client types (e.g. +// SPIFFEClient) that maintain an RFC 8707 resource allowlist independent of +// GetAudience's RFC 8693 audience allowlist. Resources and audiences are +// independent request dimensions: permission in one must never imply +// permission in the other. A client that doesn't implement this interface +// falls back to GetAudience for the resource check, preserving prior +// behavior for clients (e.g. DelegateClient) that only ever had one list. +type resourceScopedClient interface { + Resources() []string +} + // grantResourceAudience validates the RFC 8707 resource parameter against -// both the server's allowedAudiences and the client's own registered -// audiences, and grants it as an additional audience claim, binding the +// the server's allowedAudiences and the client's own registered resource +// allowlist, and grants it as an additional audience claim, binding the // issued token to a specific resource server (e.g., an MCP server). // // Per RFC 8707 §2, a request MAY carry multiple resource parameters; this @@ -839,12 +850,15 @@ func (h *Handler) grantResourceAudience(ctx context.Context, requester fosite.Ac if err := server.ValidateAudienceAllowed(resource, h.allowedAudiences); err != nil { return errorsx.WithStack(err) } - // The resource parameter is RFC 8707's mechanism for requesting an - // audience, so it must be subject to the same per-client audience - // registration as the "audience" parameter (grantAudiences) — otherwise - // a client could bypass its registered audiences simply by using - // "resource" instead of "audience". - if err := h.config.GetAudienceStrategy(ctx)(client.GetAudience(), []string{resource}); err != nil { + // The resource parameter is RFC 8707's mechanism for requesting a + // resource-bound token, so it must be subject to the client's own + // registered allowlist — otherwise a client could bypass its + // registration simply by using "resource" instead of "audience". + allowedResources := client.GetAudience() + if rc, ok := client.(resourceScopedClient); ok { + allowedResources = rc.Resources() + } + if err := h.config.GetAudienceStrategy(ctx)(allowedResources, []string{resource}); err != nil { return errorsx.WithStack(server.ErrInvalidTarget.WithHintf( "The client is not permitted to request a token for resource %q.", resource)) } diff --git a/pkg/authserver/server/tokenexchange/handler_test.go b/pkg/authserver/server/tokenexchange/handler_test.go index 413892b789..5b23e43f6f 100644 --- a/pkg/authserver/server/tokenexchange/handler_test.go +++ b/pkg/authserver/server/tokenexchange/handler_test.go @@ -20,6 +20,7 @@ import ( coreaudit "github.com/stacklok/toolhive-core/audit" "github.com/stacklok/toolhive/pkg/authserver/server" + "github.com/stacklok/toolhive/pkg/authserver/server/registration" "github.com/stacklok/toolhive/pkg/authserver/server/session" "github.com/stacklok/toolhive/pkg/oauthproto" ) @@ -2451,3 +2452,47 @@ func TestTokenExchangeHandler_PopulateTokenEndpointResponse(t *testing.T) { assert.Nil(t, responder.GetExtra("issued_token_type")) }) } + +// TestGrantResourceAudience_SPIFFEClientEnforcesResourcesIndependentlyOfAudiences +// is a regression test for a bug where the RFC 8707 "resource" parameter was +// checked against a SPIFFE client's audience allowlist instead of its +// independently configured resource allowlist: a resource the client was +// registered for was rejected, and a resource the client was NOT registered +// for -- but which happened to be one of its audiences -- was granted. +func TestGrantResourceAudience_SPIFFEClientEnforcesResourcesIndependentlyOfAudiences(t *testing.T) { + t.Parallel() + + tj := newTestJWKS(t) + h := newTestHandler(t, tj, time.Hour) + // Both values must be server-wide allowed so the test isolates the + // per-client check (grantResourceAudience) from the server-wide one. + h.allowedAudiences = []string{testIssuer, "https://other.example.com"} + + const registeredResource = testIssuer + const registeredAudience = "https://other.example.com" + client, err := registration.NewSPIFFEClient( + "spiffe-client", + []string{"openid"}, + []string{registeredAudience}, + []string{registeredResource}, + ) + require.NoError(t, err) + + t.Run("resource in the client's resources is granted", func(t *testing.T) { + t.Parallel() + + req := newAccessRequest(t, client, url.Values{"resource": {registeredResource}}) + require.NoError(t, h.grantResourceAudience(context.Background(), req, client)) + assert.Contains(t, req.GetGrantedAudience(), registeredResource) + }) + + t.Run("resource that is only an audience, not a resource, is rejected", func(t *testing.T) { + t.Parallel() + + req := newAccessRequest(t, client, url.Values{"resource": {registeredAudience}}) + err := h.grantResourceAudience(context.Background(), req, client) + require.Error(t, err) + assert.True(t, errors.Is(err, server.ErrInvalidTarget)) + assert.Empty(t, req.GetGrantedAudience()) + }) +} diff --git a/pkg/authserver/server/tokenexchange/jwt_bearer_handler_test.go b/pkg/authserver/server/tokenexchange/jwt_bearer_handler_test.go index 1fb6a8e54c..7792d717ce 100644 --- a/pkg/authserver/server/tokenexchange/jwt_bearer_handler_test.go +++ b/pkg/authserver/server/tokenexchange/jwt_bearer_handler_test.go @@ -458,7 +458,7 @@ func TestAssertionJWTConsumer_ForwardsThroughFullSPIFFEDecoratorChain(t *testing require.NoError(t, err) spiffeClient, err := registration.NewSPIFFEClient( - "spiffe-client", []string{"openid"}, []string{"https://mcp.example.com"}) + "spiffe-client", []string{"openid"}, []string{"https://mcp.example.com"}, nil) require.NoError(t, err) chain, err := storage.NewSPIFFEStorageDecorator( ctx, cimdDecorated, map[string]fosite.Client{spiffeClient.GetID(): spiffeClient}) diff --git a/pkg/authserver/spiffe_association_registry.go b/pkg/authserver/spiffe_association_registry.go index aea39ac26c..8c33236609 100644 --- a/pkg/authserver/spiffe_association_registry.go +++ b/pkg/authserver/spiffe_association_registry.go @@ -54,7 +54,7 @@ func (r *SPIFFEAssociationRegistry) staticClients() (map[string]fosite.Client, e for clientID, association := range r.byClientID { policy := association.AuthorizationPolicy() client, err := registration.NewSPIFFEClient( - association.ClientID(), policy.Scopes(), policy.Audiences(), + association.ClientID(), policy.Scopes(), policy.Audiences(), policy.Resources(), ) if err != nil { return nil, fmt.Errorf("SPIFFE client %q: %w", clientID, err) diff --git a/pkg/authserver/spiffe_association_registry_test.go b/pkg/authserver/spiffe_association_registry_test.go index 4b03d0e74d..a19baf5d1a 100644 --- a/pkg/authserver/spiffe_association_registry_test.go +++ b/pkg/authserver/spiffe_association_registry_test.go @@ -42,17 +42,43 @@ func TestSPIFFEAssociationRegistryKeepsClientAudiencesIsolated(t *testing.T) { t.Parallel() first := testSPIFFEAssociation("first-client", "openid") - first.Audiences = []string{"https://resource.example.com"} + first.Audiences = []string{"https://first-audience.example.com"} second := testSPIFFEAssociation("second-client", "profile") second.PrincipalPattern = "spiffe://example.org/ns/default/other-agent" - second.Audiences = []string{"https://audience.example.com"} + second.Audiences = []string{"https://second-audience.example.com"} registry := newTestSPIFFEAssociationRegistry(t, []SPIFFEClientAuthRunConfig{first, second}) clients, err := registry.staticClients() require.NoError(t, err) - assert.Equal(t, []string{"https://resource.example.com"}, []string(clients["first-client"].GetAudience())) - assert.Equal(t, []string{"https://audience.example.com"}, []string(clients["second-client"].GetAudience())) + assert.Equal(t, []string{"https://first-audience.example.com"}, []string(clients["first-client"].GetAudience())) + assert.Equal(t, []string{"https://second-audience.example.com"}, []string(clients["second-client"].GetAudience())) +} + +// TestSPIFFEAssociationRegistryKeepsResourcesIndependentOfAudiences proves the +// association registry wires an association's RFC 8707 resources through to +// the runtime client as a dimension independent of RFC 8693 audiences: a +// client configured with disjoint audiences and resources must expose each +// list separately, not have one silently discarded or aliased to the other. +func TestSPIFFEAssociationRegistryKeepsResourcesIndependentOfAudiences(t *testing.T) { + t.Parallel() + + association := testSPIFFEAssociation("client", "openid") + association.Audiences = []string{"https://audience.example.com"} + association.Resources = []string{"https://resource.example.com"} + + registry := newTestSPIFFEAssociationRegistry(t, []SPIFFEClientAuthRunConfig{association}) + clients, err := registry.staticClients() + require.NoError(t, err) + + client, found := clients["client"] + require.True(t, found) + + resourceScoped, ok := client.(interface{ Resources() []string }) + require.True(t, ok, "SPIFFE client must expose a Resources() accessor") + + assert.Equal(t, []string{"https://audience.example.com"}, []string(client.GetAudience())) + assert.Equal(t, []string{"https://resource.example.com"}, resourceScoped.Resources()) } func newTestSPIFFEAssociationRegistry(t *testing.T, associations []SPIFFEClientAuthRunConfig) *SPIFFEAssociationRegistry { diff --git a/pkg/authserver/storage/spiffe_decorator_test.go b/pkg/authserver/storage/spiffe_decorator_test.go index 29a907b942..cd99705dae 100644 --- a/pkg/authserver/storage/spiffe_decorator_test.go +++ b/pkg/authserver/storage/spiffe_decorator_test.go @@ -22,6 +22,7 @@ func testSPIFFEClients(t *testing.T) map[string]fosite.Client { "spiffe-client", []string{"openid"}, []string{"https://api.example.com"}, + nil, ) require.NoError(t, err) return map[string]fosite.Client{client.GetID(): client} @@ -286,7 +287,7 @@ func TestSPIFFEStorageDecoratorDurableClaimRedisBacked(t *testing.T) { // Reconstructing with a different/conflicting association at the same ID // fails loudly. conflicting, err := registration.NewSPIFFEClient( - "spiffe-client", []string{"a-different-scope"}, []string{"https://api.example.com"}) + "spiffe-client", []string{"a-different-scope"}, []string{"https://api.example.com"}, nil) require.NoError(t, err) _, err = NewSPIFFEStorageDecorator(ctx, base, map[string]fosite.Client{"spiffe-client": conflicting}) require.ErrorIs(t, err, ErrAlreadyExists) From bc524edd13b6704eb89319cf987cfe73aa0c1f46 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Fri, 4 Sep 2026 13:31:13 +0200 Subject: [PATCH 12/14] Merge client fingerprint comparison into one type memory.go and redis.go each hand-rolled their own fingerprint comparison to decide whether a reconciling client is the same logical client or a colliding one, kept in sync only by a doc comment. Replace both with one clientFingerprint value type and two adapters so the comparison exists once and cannot drift between backends. --- .../runner/embeddedauthserver_test.go | 2 +- pkg/authserver/storage/memory.go | 2 +- pkg/authserver/storage/redis.go | 38 ++++----- pkg/authserver/storage/redis_test.go | 27 +++++++ pkg/authserver/storage/types.go | 58 +++++++++++--- pkg/authserver/storage/types_test.go | 78 +++++++++++++++++++ 6 files changed, 172 insertions(+), 33 deletions(-) diff --git a/pkg/authserver/runner/embeddedauthserver_test.go b/pkg/authserver/runner/embeddedauthserver_test.go index 33f9b597b9..429dc482f6 100644 --- a/pkg/authserver/runner/embeddedauthserver_test.go +++ b/pkg/authserver/runner/embeddedauthserver_test.go @@ -2256,7 +2256,7 @@ func TestEmbeddedAuthServer_SPIFFEAssociationDoesNotAuthenticateClient(t *testin // secret and not a public client, so it can never pass client // authentication for any grant. (fosite.DefaultClient.GetGrantTypes // applies its own single-grant default when the underlying field reads - // back empty from Redis — see storedClientFingerprintsEqual in redis.go + // back empty from Redis — see clientFromStored in redis.go // — so the "no grant types" property is asserted directly against // MemoryStorage in storage/spiffe_decorator_test.go instead of here.) claimed, err := redisStorage.GetClient(context.Background(), "spiffe-client") diff --git a/pkg/authserver/storage/memory.go b/pkg/authserver/storage/memory.go index c543a5da16..bb7e8965b5 100644 --- a/pkg/authserver/storage/memory.go +++ b/pkg/authserver/storage/memory.go @@ -533,7 +533,7 @@ func (s *MemoryStorage) ReconcileConfiguredClient(_ context.Context, client fosi return fmt.Errorf("%w: client %q is DCR-issued, refusing to overwrite with a configured client", ErrAlreadyExists, id) } - if !clientFingerprintsEqual(existing, client) { + if !fingerprintOfClient(existing).equal(fingerprintOfClient(client)) { return fmt.Errorf("%w: client %q is already registered as a different configured client", ErrAlreadyExists, id) } diff --git a/pkg/authserver/storage/redis.go b/pkg/authserver/storage/redis.go index 795cd15a54..4ebb44099f 100644 --- a/pkg/authserver/storage/redis.go +++ b/pkg/authserver/storage/redis.go @@ -496,23 +496,23 @@ func (s *RedisStorage) UpsertDCRIssuedClient(ctx context.Context, client fosite. return watchErr } -// storedClientFingerprintsEqual mirrors clientFingerprintsEqual but compares -// the raw serialized fields directly rather than through the fosite.Client -// interface returned by clientFromStored. This matters because -// fosite.DefaultClient.GetGrantTypes and GetResponseTypes each silently -// substitute a single-element default (["authorization_code"] / ["code"]) -// whenever the underlying field is empty (see clientFromStored) — comparing -// through the reconstructed client would report a false mismatch between -// two rows that both deliberately carry no grant/response types, such as -// the SPIFFE static-client placeholder in spiffe_decorator.go reconciling -// against itself. Comparing the stored bytes directly sidesteps that -// defaulting entirely. -func storedClientFingerprintsEqual(a, b storedClient) bool { - return sameStringSet(a.Scopes, b.Scopes) && - sameStringSet(a.Audience, b.Audience) && - sameStringSet(a.GrantTypes, b.GrantTypes) && - sameStringSet(a.ResponseTypes, b.ResponseTypes) && - a.Public == b.Public +// fingerprint reads the persisted fields directly rather than going through +// clientFromStored. This matters because fosite.DefaultClient.GetGrantTypes +// and GetResponseTypes each silently substitute a single-element default +// (["authorization_code"] / ["code"]) whenever the underlying field is empty +// (see clientFromStored) — comparing through the reconstructed client would +// report a false mismatch between two rows that both deliberately carry no +// grant/response types, such as the SPIFFE static-client placeholder in +// spiffe_decorator.go reconciling against itself. Reading the stored fields +// directly sidesteps that defaulting entirely. +func (s storedClient) fingerprint() clientFingerprint { + return clientFingerprint{ + scopes: s.Scopes, + audience: s.Audience, + grantTypes: s.GrantTypes, + responseTypes: s.ResponseTypes, + public: s.Public, + } } // maxConfiguredClientReconcileRetries bounds the retry loop @@ -588,12 +588,12 @@ func (s *RedisStorage) ReconcileConfiguredClient(ctx context.Context, client fos // raw existingStored.DCRIssued field) so it inherits clientFromStored's // legacy-row compensation for rows written before the DCRIssued field // existed. The fingerprint comparison below deliberately does NOT go - // through the reconstructed client — see storedClientFingerprintsEqual. + // through the reconstructed client — see storedClient.fingerprint. if registration.DCRIssued(clientFromStored(existingStored, ttl >= 0)) { return fmt.Errorf("%w: client %q is DCR-issued, refusing to overwrite with a configured client", ErrAlreadyExists, client.GetID()) } - if !storedClientFingerprintsEqual(existingStored, stored) { + if !existingStored.fingerprint().equal(stored.fingerprint()) { return fmt.Errorf("%w: client %q is already registered as a different configured client", ErrAlreadyExists, client.GetID()) } diff --git a/pkg/authserver/storage/redis_test.go b/pkg/authserver/storage/redis_test.go index a4bca614c4..cee64a03c5 100644 --- a/pkg/authserver/storage/redis_test.go +++ b/pkg/authserver/storage/redis_test.go @@ -683,6 +683,33 @@ func TestRedisStorage_DCRClientTTL(t *testing.T) { }) } +// TestClientFingerprint_StoredAndReconstructedAgree builds a storedClient by +// hand and the fosite.Client it round-trips to via clientFromStored, then +// asserts their fingerprints agree. This is the stronger companion to +// TestClientFingerprintFieldsAreJustified (types_test.go): the field-name +// canary only catches a field left out of clientFingerprint entirely, not one +// wired to the wrong source. If, say, fingerprintOfClient read GetAudience() +// into the responseTypes slot, the canary would still pass but this test +// would fail, because a fully-populated stored row's live-client fingerprint +// would then have a mismatched responseTypes value. +func TestClientFingerprint_StoredAndReconstructedAgree(t *testing.T) { + t.Parallel() + + stored := storedClient{ + ID: "configured", + Scopes: []string{"openid", "profile"}, + Audience: []string{"https://mcp.example"}, + GrantTypes: []string{oauthproto.GrantTypeTokenExchange}, + ResponseTypes: []string{"token"}, + Public: false, + } + + rebuilt := clientFromStored(stored, false) + + assert.True(t, fingerprintOfClient(rebuilt).equal(stored.fingerprint()), + "a storedClient's fingerprint must agree with the fingerprint of the fosite.Client it reconstructs to") +} + // TestRedisStorage_ReconcileConfiguredClient covers the create/idempotent/ // reject matrix ReconcileConfiguredClient must implement over Redis: create // when absent (no TTL), no-op when the existing record is itself configured diff --git a/pkg/authserver/storage/types.go b/pkg/authserver/storage/types.go index f65cccd176..1d682dc3e0 100644 --- a/pkg/authserver/storage/types.go +++ b/pkg/authserver/storage/types.go @@ -610,18 +610,52 @@ func sameStringSet(a, b []string) bool { return slices.Equal(as, bs) } -// clientFingerprintsEqual reports whether a and b represent the same logical -// client configuration: equal scope set, audience set, grant-type set, -// response-type set, and public/confidential class. Client secrets are -// deliberately excluded from the comparison — an operator rotating a -// configured client's secret must still be able to reconcile; a secret -// rotation is not a different logical client. -func clientFingerprintsEqual(a, b fosite.Client) bool { - return sameStringSet(a.GetScopes(), b.GetScopes()) && - sameStringSet(a.GetAudience(), b.GetAudience()) && - sameStringSet(a.GetGrantTypes(), b.GetGrantTypes()) && - sameStringSet(a.GetResponseTypes(), b.GetResponseTypes()) && - a.IsPublic() == b.IsPublic() +// clientFingerprint is the identity of a configured client registration: the +// fields that decide whether two records at the same client ID are the same +// logical client (idempotent restart) or two different colliding ones (a loud +// failure). Client secrets are deliberately excluded -- an operator rotating a +// secret must still be able to reconcile. TokenEndpointAuthMethod is excluded +// for the same reason -- both memory.go and redis.go fully overwrite the +// stored record on a fingerprint match rather than merging, so a match never +// "keeps stale data": reconfiguring either field is always applied on the +// next reconcile regardless of whether the fingerprint matched. +// +// Deliberately NOT fosite.Client: fosite.DefaultClient's GetGrantTypes/ +// GetResponseTypes substitute an interactive default when the underlying list +// is empty, so a deliberately-empty SPIFFE placeholder compared through that +// interface can mismatch itself. Each backend converts its own representation +// into this type; the comparison exists once, so memory- and Redis-backed +// deployments cannot disagree about what "same client" means. +type clientFingerprint struct { + scopes []string + audience []string + grantTypes []string + responseTypes []string + public bool +} + +// equal reports whether f and o represent the same logical client +// configuration: equal scope set, audience set, grant-type set, +// response-type set, and public/confidential class. +func (f clientFingerprint) equal(o clientFingerprint) bool { + return sameStringSet(f.scopes, o.scopes) && + sameStringSet(f.audience, o.audience) && + sameStringSet(f.grantTypes, o.grantTypes) && + sameStringSet(f.responseTypes, o.responseTypes) && + f.public == o.public +} + +// fingerprintOfClient reads a live fosite.Client. Safe here because a +// deliberately-empty client is the live inertPlaceholderClient, whose +// overridden getters correctly return nil on both sides of the comparison. +func fingerprintOfClient(c fosite.Client) clientFingerprint { + return clientFingerprint{ + scopes: c.GetScopes(), + audience: c.GetAudience(), + grantTypes: c.GetGrantTypes(), + responseTypes: c.GetResponseTypes(), + public: c.IsPublic(), + } } // ClientRegistry provides client registration and lookup operations. diff --git a/pkg/authserver/storage/types_test.go b/pkg/authserver/storage/types_test.go index 57f0c50baf..a3cb51e40e 100644 --- a/pkg/authserver/storage/types_test.go +++ b/pkg/authserver/storage/types_test.go @@ -17,11 +17,13 @@ package storage import ( "context" "errors" + "reflect" "strings" "testing" "time" "github.com/ory/fosite" + "github.com/stretchr/testify/assert" ) func TestUpstreamTokens_IsExpired(t *testing.T) { @@ -176,3 +178,79 @@ func TestDefaultConfig(t *testing.T) { t.Errorf("DefaultConfig().Type = %q, want %q", cfg.Type, TypeMemory) } } + +// TestClientFingerprintFieldsAreJustified is a drift guard over +// clientFingerprint's field set: every field must carry a one-sentence +// justification here, so a field added to clientFingerprint without a +// matching entry fails loudly instead of silently compiling green. Unlike a +// bare []string of names, an unjustified addition can't be satisfied by +// just appending the name — the reviewer has to say why the field belongs in +// the identity comparison. +// +// Listing a field here is not enough on its own: a field added to the struct +// and to this map, but never wired into equal(), previously still passed +// this test (only a memory-backend collision test happened to catch it). +// The second half of this test closes that gap by constructing, for every +// justified field, two fingerprints that differ ONLY in that field and +// asserting equal() reports them as different -- proving each field actually +// participates in the comparison, not just that it exists on the struct. +func TestClientFingerprintFieldsAreJustified(t *testing.T) { + t.Parallel() + + justifications := map[string]string{ + "scopes": "a different OAuth scope set is a different logical client authorization", + "audience": "a different RFC 8707/8693 audience allowlist is a different logical client authorization", + "grantTypes": "a different grant-type set is a different logical client capability", + "responseTypes": "a different response-type set is a different logical client capability", + "public": "public vs. confidential is a different logical client class", + } + + var gotFields []string + for _, f := range reflect.VisibleFields(reflect.TypeOf(clientFingerprint{})) { + gotFields = append(gotFields, f.Name) + } + + var wantFields []string + for name, justification := range justifications { + assert.NotEmptyf(t, justification, "field %q must carry a non-empty justification", name) + wantFields = append(wantFields, name) + } + + assert.ElementsMatchf(t, wantFields, gotFields, + "clientFingerprint's field set changed (got %v); add or remove a justification entry in this test "+ + "and confirm the new field is actually wired into fingerprintOfClient and storedClient.fingerprint", + gotFields) + + base := clientFingerprint{ + scopes: []string{"scope-a"}, + audience: []string{"audience-a"}, + grantTypes: []string{"grant-a"}, + responseTypes: []string{"response-a"}, + public: false, + } + + for name := range justifications { + t.Run(name, func(t *testing.T) { + t.Parallel() + + other := base + switch name { + case "scopes": + other.scopes = []string{"scope-b"} + case "audience": + other.audience = []string{"audience-b"} + case "grantTypes": + other.grantTypes = []string{"grant-b"} + case "responseTypes": + other.responseTypes = []string{"response-b"} + case "public": + other.public = !base.public + default: + t.Fatalf("field %q has a justification but no wiring check in this test -- add one", name) + } + + assert.False(t, base.equal(other), + "clientFingerprint.equal() must report a mismatch when only %q differs", name) + }) + } +} From bcb58e0ba9a304412b4a80c79b1b3c1599d00aba Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Fri, 4 Sep 2026 13:40:00 +0200 Subject: [PATCH 13/14] Include resources in the SPIFFE placeholder fingerprint staticClientPlaceholder durably fingerprinted a SPIFFE association on scopes and audiences only, so two associations at the same client ID differing solely in their RFC 8707 resource allowlist would reconcile as the same client instead of failing loudly. Thread resources through inertPlaceholderClient, clientFingerprint, and storedClient so the durable identity matches what the client actually authorizes. --- pkg/authserver/storage/redis.go | 36 +++++-- pkg/authserver/storage/spiffe_decorator.go | 49 +++++++--- .../storage/spiffe_decorator_test.go | 93 +++++++++++++++++++ pkg/authserver/storage/types.go | 19 +++- pkg/authserver/storage/types_test.go | 4 + 5 files changed, 180 insertions(+), 21 deletions(-) diff --git a/pkg/authserver/storage/redis.go b/pkg/authserver/storage/redis.go index 4ebb44099f..518f73da64 100644 --- a/pkg/authserver/storage/redis.go +++ b/pkg/authserver/storage/redis.go @@ -188,7 +188,14 @@ type storedClient struct { ResponseTypes []string `json:"response_types"` Scopes []string `json:"scopes"` Audience []string `json:"audience"` - Public bool `json:"public"` + // Resources is the RFC 8707 resource allowlist, populated only for a + // Reserved client that implements resourceScopedClient (today, always the + // SPIFFE static-client placeholder). Gated on Reserved rather than the + // interface check alone so buildStoredClient's write and clientFromStored's + // read stay symmetric by construction. Nil for every other client shape, + // and omitted from the JSON encoding in that case. + Resources []string `json:"resources,omitempty"` + Public bool `json:"public"` // TokenEndpointAuthMethod is the auth method registered for the client // ("none", "client_secret_basic", "client_secret_post"). Empty means the // row predates confidential-client support; see GetClient for how legacy @@ -303,12 +310,15 @@ func clientFromStored(stored storedClient, hasTTL bool) fosite.Client { // below: a reserved row never carries a TokenEndpointAuthMethod, but even // if it somehow did, Reserved must still win. if stored.Reserved { - return inertPlaceholderClient{DefaultClient: &fosite.DefaultClient{ - ID: stored.ID, - Scopes: stored.Scopes, - Audience: stored.Audience, - Public: false, - }} + return inertPlaceholderClient{ + DefaultClient: &fosite.DefaultClient{ + ID: stored.ID, + Scopes: stored.Scopes, + Audience: stored.Audience, + Public: false, + }, + resources: stored.Resources, + } } method := stored.TokenEndpointAuthMethod @@ -368,13 +378,22 @@ func buildStoredClient(client fosite.Client) storedClient { Audience: client.GetAudience(), Public: client.IsPublic(), } + stored.Reserved = isReservedPlaceholder(client) + // Resources is only ever restored on read for a Reserved row (see + // clientFromStored); gating the write the same way keeps the two + // symmetric by construction instead of relying on every + // resourceScopedClient implementation always being Reserved. + if stored.Reserved { + if rc, ok := client.(resourceScopedClient); ok { + stored.Resources = rc.Resources() + } + } if oidcClient, ok := client.(fosite.OpenIDConnectClient); ok { stored.TokenEndpointAuthMethod = oidcClient.GetTokenEndpointAuthMethod() stored.JSONWebKeys = publicJSONWebKeySet(oidcClient.GetJSONWebKeys()) stored.TokenEndpointAuthSigningAlgorithm = oidcClient.GetTokenEndpointAuthSigningAlgorithm() } stored.DCRIssued = registration.DCRIssued(client) - stored.Reserved = isReservedPlaceholder(client) return stored } @@ -511,6 +530,7 @@ func (s storedClient) fingerprint() clientFingerprint { audience: s.Audience, grantTypes: s.GrantTypes, responseTypes: s.ResponseTypes, + resources: s.Resources, public: s.Public, } } diff --git a/pkg/authserver/storage/spiffe_decorator.go b/pkg/authserver/storage/spiffe_decorator.go index a064b3aae1..14e488da14 100644 --- a/pkg/authserver/storage/spiffe_decorator.go +++ b/pkg/authserver/storage/spiffe_decorator.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "maps" + "slices" "time" "github.com/ory/fosite" @@ -83,6 +84,16 @@ func preflightDurableCollisions(ctx context.Context, base Storage, clients map[s return nil } +// resourceScopedClient is implemented by fosite.Client types (e.g. +// registration.SPIFFEClient) that maintain an RFC 8707 resource allowlist +// independent of GetAudience's audience allowlist. This mirrors the +// identically-shaped interface in pkg/authserver/server/tokenexchange -- both +// packages match it structurally against the same concrete client types +// without either importing the other's unexported interface. +type resourceScopedClient interface { + Resources() []string +} + // inertPlaceholderClient wraps a *fosite.DefaultClient to suppress its // implicit defaulting: fosite.DefaultClient.GetGrantTypes and // GetResponseTypes each return a single-element default @@ -107,11 +118,19 @@ func preflightDurableCollisions(ctx context.Context, base Storage, clients map[s type inertPlaceholderClient struct { registration.BackChannelOnlyMarker *fosite.DefaultClient + resources []string } func (inertPlaceholderClient) GetGrantTypes() fosite.Arguments { return nil } func (inertPlaceholderClient) GetResponseTypes() fosite.Arguments { return nil } +// Resources returns the placeholder's RFC 8707 resource allowlist, satisfying +// resourceScopedClient so the fingerprint comparison (see clientFingerprint) +// distinguishes associations that differ only in their resource allowlist. +// Returns a defensive copy, matching SPIFFEClient.Resources and +// SPIFFEAuthorizationPolicy.Resources. +func (c inertPlaceholderClient) Resources() []string { return slices.Clone(c.resources) } + // isReservedPlaceholder reports whether client is a staticClientPlaceholder // value, letting buildStoredClient (redis.go) mark the persisted row so // clientFromStored can restore the same inert wrapper on read-back. Mirrors @@ -131,22 +150,30 @@ func isReservedPlaceholder(client fosite.Client) bool { // server registers, and it carries no secret. This guarantee holds on every // backend, including a bare read-back from Redis by a replica with no SPIFFE // overlay — see inertPlaceholderClient and storedClient.Reserved. It keeps -// the real client's Scopes and Audience so the fingerprint comparison in -// ReconcileConfiguredClient can distinguish "same association, restarted" -// (idempotent) from "different, colliding association at this ID" (a loud -// failure). +// the real client's Scopes, Audience, and (RFC 8707) Resources so the +// fingerprint comparison in ReconcileConfiguredClient can distinguish "same +// association, restarted" (idempotent) from "different, colliding +// association at this ID" (a loud failure). // Scopes and Audience are taken directly from actual.GetScopes() / // GetAudience() without an extra defensive clone here: the only production // caller (registration.SPIFFEClient) already returns a fresh slice.Clone // from both getters (see its doc comments), so there is no live backing -// array to alias. +// array to alias. The same applies to Resources() when actual implements +// resourceScopedClient; a client that doesn't (falls back to nil). func staticClientPlaceholder(actual fosite.Client) fosite.Client { - return inertPlaceholderClient{DefaultClient: &fosite.DefaultClient{ - ID: actual.GetID(), - Scopes: actual.GetScopes(), - Audience: actual.GetAudience(), - Public: false, - }} + var resources []string + if rc, ok := actual.(resourceScopedClient); ok { + resources = rc.Resources() + } + return inertPlaceholderClient{ + DefaultClient: &fosite.DefaultClient{ + ID: actual.GetID(), + Scopes: actual.GetScopes(), + Audience: actual.GetAudience(), + Public: false, + }, + resources: resources, + } } // ConsumeAssertionJWT delegates assertion replay consumption to the wrapped diff --git a/pkg/authserver/storage/spiffe_decorator_test.go b/pkg/authserver/storage/spiffe_decorator_test.go index cd99705dae..3145b17ee6 100644 --- a/pkg/authserver/storage/spiffe_decorator_test.go +++ b/pkg/authserver/storage/spiffe_decorator_test.go @@ -220,6 +220,99 @@ func TestSPIFFEStorageDecoratorRejectsDifferentConfiguredClientCollision(t *test assert.Contains(t, err.Error(), "different configured client") } +// TestSPIFFEStorageDecoratorResourcesOnlyDifferenceIsACollision is the +// regression test for the review finding on PR #6473: staticClientPlaceholder +// used to durably fingerprint a SPIFFE association on scopes and audiences +// only, so two associations at the same client ID differing solely in their +// RFC 8707 resource allowlist would reconcile as "the same client, restarted" +// instead of failing loudly as a misconfiguration. +func TestSPIFFEStorageDecoratorResourcesOnlyDifferenceIsACollision(t *testing.T) { + t.Parallel() + ctx := context.Background() + base := NewMemoryStorage() + t.Cleanup(func() { _ = base.Close() }) + + first, err := registration.NewSPIFFEClient( + "spiffe-client", []string{"openid"}, []string{"https://api.example.com"}, []string{"https://mcp-a.example.com"}) + require.NoError(t, err) + _, err = NewSPIFFEStorageDecorator(ctx, base, map[string]fosite.Client{first.GetID(): first}) + require.NoError(t, err) + + second, err := registration.NewSPIFFEClient( + "spiffe-client", []string{"openid"}, []string{"https://api.example.com"}, []string{"https://mcp-b.example.com"}) + require.NoError(t, err) + _, err = NewSPIFFEStorageDecorator(ctx, base, map[string]fosite.Client{second.GetID(): second}) + require.ErrorIs(t, err, ErrAlreadyExists, + "two associations differing only in resources must collide, not reconcile idempotently") + assert.Contains(t, err.Error(), "different configured client") +} + +// TestSPIFFEStorageDecoratorResourcesOnlyDifferenceIsACollisionRedis is the +// Redis-backed equivalent of +// TestSPIFFEStorageDecoratorResourcesOnlyDifferenceIsACollision. The memory +// test alone left storedClient.fingerprint's resources wiring (redis.go) +// completely uncovered: deleting `resources: s.Resources,` from that method +// left the full storage test suite green. +func TestSPIFFEStorageDecoratorResourcesOnlyDifferenceIsACollisionRedis(t *testing.T) { + t.Parallel() + ctx := context.Background() + base, mr := newTestRedisStorage(t) + t.Cleanup(func() { _ = base.Close(); mr.Close() }) + + first, err := registration.NewSPIFFEClient( + "spiffe-client", []string{"openid"}, []string{"https://api.example.com"}, []string{"https://mcp-a.example.com"}) + require.NoError(t, err) + require.NoError(t, PreflightSPIFFEStaticClientCollisions(ctx, base, map[string]fosite.Client{first.GetID(): first})) + + second, err := registration.NewSPIFFEClient( + "spiffe-client", []string{"openid"}, []string{"https://api.example.com"}, []string{"https://mcp-b.example.com"}) + require.NoError(t, err) + err = PreflightSPIFFEStaticClientCollisions(ctx, base, map[string]fosite.Client{second.GetID(): second}) + require.ErrorIs(t, err, ErrAlreadyExists, + "two associations differing only in resources must collide, not reconcile idempotently, on Redis-backed storage") + assert.Contains(t, err.Error(), "different configured client") +} + +// TestSPIFFEStorageDecoratorPlaceholderResourcesRoundTripThroughRedis proves +// staticClientPlaceholder's resource allowlist survives a real Redis +// round-trip (buildStoredClient -> storedClient.Resources -> +// clientFromStored), not just the in-process placeholder value. +func TestSPIFFEStorageDecoratorPlaceholderResourcesRoundTripThroughRedis(t *testing.T) { + t.Parallel() + ctx := context.Background() + raw, mr := newTestRedisStorage(t) + t.Cleanup(func() { _ = raw.Close(); mr.Close() }) + + client, err := registration.NewSPIFFEClient( + "spiffe-client", []string{"openid"}, []string{"https://api.example.com"}, []string{"https://mcp.example.com"}) + require.NoError(t, err) + + require.NoError(t, PreflightSPIFFEStaticClientCollisions(ctx, raw, map[string]fosite.Client{client.GetID(): client})) + + readBack, err := raw.GetClient(ctx, "spiffe-client") + require.NoError(t, err) + rc, ok := readBack.(resourceScopedClient) + require.True(t, ok, "placeholder read back from raw Redis storage must still expose Resources()") + assert.Equal(t, []string{"https://mcp.example.com"}, rc.Resources()) +} + +// TestSPIFFEStorageDecoratorPlaceholderNoResourcesStillIdempotent pins the +// zero-value case: an association with no RFC 8707 resources configured must +// still reconcile against itself on restart, not be treated as a collision +// merely because both sides carry a nil/empty resource allowlist. +func TestSPIFFEStorageDecoratorPlaceholderNoResourcesStillIdempotent(t *testing.T) { + t.Parallel() + ctx := context.Background() + base := NewMemoryStorage() + t.Cleanup(func() { _ = base.Close() }) + + _, err := NewSPIFFEStorageDecorator(ctx, base, testSPIFFEClients(t)) + require.NoError(t, err) + + _, err = NewSPIFFEStorageDecorator(ctx, base, testSPIFFEClients(t)) + require.NoError(t, err, "restarting with an unchanged, resource-less association must be idempotent") +} + func TestSPIFFEStorageDecoratorLeavesEmptyClientsUnchanged(t *testing.T) { t.Parallel() base := NewMemoryStorage() diff --git a/pkg/authserver/storage/types.go b/pkg/authserver/storage/types.go index 1d682dc3e0..cd0aae5b16 100644 --- a/pkg/authserver/storage/types.go +++ b/pkg/authserver/storage/types.go @@ -631,29 +631,44 @@ type clientFingerprint struct { audience []string grantTypes []string responseTypes []string - public bool + // resources is the RFC 8707 resource allowlist, distinct from audience + // (see resourceScopedClient in spiffe_decorator.go). Two clients sharing + // every other field but differing in their resource allowlist are + // different logical clients -- this is the field #6473's review found + // missing from the SPIFFE static-client placeholder's durable identity. + resources []string + public bool } // equal reports whether f and o represent the same logical client // configuration: equal scope set, audience set, grant-type set, -// response-type set, and public/confidential class. +// response-type set, resource set, and public/confidential class. func (f clientFingerprint) equal(o clientFingerprint) bool { return sameStringSet(f.scopes, o.scopes) && sameStringSet(f.audience, o.audience) && sameStringSet(f.grantTypes, o.grantTypes) && sameStringSet(f.responseTypes, o.responseTypes) && + sameStringSet(f.resources, o.resources) && f.public == o.public } // fingerprintOfClient reads a live fosite.Client. Safe here because a // deliberately-empty client is the live inertPlaceholderClient, whose // overridden getters correctly return nil on both sides of the comparison. +// resources is read through resourceScopedClient (nil when a client type +// doesn't implement it), the same narrow interface staticClientPlaceholder +// and buildStoredClient use. func fingerprintOfClient(c fosite.Client) clientFingerprint { + var resources []string + if rc, ok := c.(resourceScopedClient); ok { + resources = rc.Resources() + } return clientFingerprint{ scopes: c.GetScopes(), audience: c.GetAudience(), grantTypes: c.GetGrantTypes(), responseTypes: c.GetResponseTypes(), + resources: resources, public: c.IsPublic(), } } diff --git a/pkg/authserver/storage/types_test.go b/pkg/authserver/storage/types_test.go index a3cb51e40e..22af90d53c 100644 --- a/pkg/authserver/storage/types_test.go +++ b/pkg/authserver/storage/types_test.go @@ -202,6 +202,7 @@ func TestClientFingerprintFieldsAreJustified(t *testing.T) { "audience": "a different RFC 8707/8693 audience allowlist is a different logical client authorization", "grantTypes": "a different grant-type set is a different logical client capability", "responseTypes": "a different response-type set is a different logical client capability", + "resources": "a different RFC 8707 resource allowlist is a different logical client authorization", "public": "public vs. confidential is a different logical client class", } @@ -226,6 +227,7 @@ func TestClientFingerprintFieldsAreJustified(t *testing.T) { audience: []string{"audience-a"}, grantTypes: []string{"grant-a"}, responseTypes: []string{"response-a"}, + resources: []string{"resource-a"}, public: false, } @@ -243,6 +245,8 @@ func TestClientFingerprintFieldsAreJustified(t *testing.T) { other.grantTypes = []string{"grant-b"} case "responseTypes": other.responseTypes = []string{"response-b"} + case "resources": + other.resources = []string{"resource-b"} case "public": other.public = !base.public default: From d2d311d302471f373dc3fead4f8d8cea3c43af0a Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Fri, 4 Sep 2026 21:08:14 +0200 Subject: [PATCH 14/14] Fingerprint SPIFFE association identity Signed-off-by: Jakub Hrozek --- pkg/authserver/spiffe_association_registry.go | 54 ++++++++- .../spiffe_association_registry_test.go | 103 ++++++++++++++++++ pkg/authserver/storage/redis.go | 33 +++--- pkg/authserver/storage/spiffe_decorator.go | 29 ++++- .../storage/spiffe_decorator_test.go | 90 +++++++++++++++ pkg/authserver/storage/types.go | 23 ++-- pkg/authserver/storage/types_test.go | 19 ++-- 7 files changed, 315 insertions(+), 36 deletions(-) diff --git a/pkg/authserver/spiffe_association_registry.go b/pkg/authserver/spiffe_association_registry.go index 8c33236609..04f965e13a 100644 --- a/pkg/authserver/spiffe_association_registry.go +++ b/pkg/authserver/spiffe_association_registry.go @@ -4,7 +4,10 @@ package authserver import ( + "crypto/sha256" + "encoding/hex" "fmt" + "slices" "github.com/ory/fosite" @@ -45,7 +48,19 @@ func NewSPIFFEAssociationRegistry(trust *SPIFFETrustConfig) (*SPIFFEAssociationR return registry, nil } -// staticClients builds immutable OAuth clients for all configured associations. +// spiffeStaticClient embeds the concrete *registration.SPIFFEClient (not the +// fosite.Client interface) so that embedding still promotes Resources() and +// the BackChannelOnlyMarker -- an interface embedding would silently drop +// both, since neither is part of the fosite.Client method set. +type spiffeStaticClient struct { + *registration.SPIFFEClient + identityFingerprint string +} + +func (c spiffeStaticClient) IdentityFingerprint() string { return c.identityFingerprint } + +// staticClients builds immutable OAuth clients and their association identity for +// durable placeholder reconciliation. func (r *SPIFFEAssociationRegistry) staticClients() (map[string]fosite.Client, error) { if r == nil { return nil, nil @@ -59,7 +74,42 @@ func (r *SPIFFEAssociationRegistry) staticClients() (map[string]fosite.Client, e if err != nil { return nil, fmt.Errorf("SPIFFE client %q: %w", clientID, err) } - clients[clientID] = client + clients[clientID] = spiffeStaticClient{ + SPIFFEClient: client, + identityFingerprint: fingerprintSPIFFEAssociation(association), + } } return clients, nil } + +// fingerprintSPIFFEAssociation hashes the SPIFFE association identity (trust +// domain reference, principal pattern, and accepted methods) so two +// associations with a differing identity durably reconcile as different +// clients rather than being silently accepted as the same one. Each value is +// length-prefixed with an unambiguous decimal-and-colon marker so +// concatenation can't alias two different value sequences to the same hash, +// and methods are sorted first so their original order never affects the +// result. +// +// Note: TrustDomainRef() hashes the trust-domain *reference name* (e.g. +// "production"), not the trust domain value itself. In practice this doesn't +// weaken the fingerprint because a principal pattern always embeds its trust +// domain, so a genuine trust-domain difference still changes the hash via the +// principal -- but repointing a trust domain ref's bundle source to a +// different CA while keeping the same ref name and trust domain would not be +// detected by this fingerprint alone. +func fingerprintSPIFFEAssociation(association SPIFFEClientAuthConfig) string { + hash := sha256.New() + write := func(value string) { + _, _ = fmt.Fprintf(hash, "%d:", len(value)) + _, _ = hash.Write([]byte(value)) + } + write(association.TrustDomainRef()) + write(association.Principal()) + methods := association.Methods() + slices.Sort(methods) + for _, method := range methods { + write(string(method)) + } + return hex.EncodeToString(hash.Sum(nil)) +} diff --git a/pkg/authserver/spiffe_association_registry_test.go b/pkg/authserver/spiffe_association_registry_test.go index a19baf5d1a..f587b1c914 100644 --- a/pkg/authserver/spiffe_association_registry_test.go +++ b/pkg/authserver/spiffe_association_registry_test.go @@ -8,6 +8,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/authserver/server/registration" ) func TestSPIFFEAssociationRegistryRebuildsChangedAndRemovedAuthority(t *testing.T) { @@ -81,6 +83,107 @@ func TestSPIFFEAssociationRegistryKeepsResourcesIndependentOfAudiences(t *testin assert.Equal(t, []string{"https://resource.example.com"}, resourceScoped.Resources()) } +// TestSPIFFEAssociationRegistryStaticClientPreservesConcreteCapabilities is the +// regression test for the review finding on PR #6473: wrapping the registry's +// runtime client to carry an identity fingerprint must not lose capabilities +// the concrete *registration.SPIFFEClient exposes beyond fosite.Client's +// method set -- Resources() (independent RFC 8707 resource enforcement) and +// the BackChannelOnlyMarker (relied on by the authorize handler). Embedding +// the fosite.Client interface instead of the concrete type would silently +// drop both while still compiling. +func TestSPIFFEAssociationRegistryStaticClientPreservesConcreteCapabilities(t *testing.T) { + t.Parallel() + + association := testSPIFFEAssociation("client", "openid") + association.Resources = []string{"https://resource.example.com"} + + registry := newTestSPIFFEAssociationRegistry(t, []SPIFFEClientAuthRunConfig{association}) + clients, err := registry.staticClients() + require.NoError(t, err) + + client, found := clients["client"] + require.True(t, found) + + resourceScoped, ok := client.(interface{ Resources() []string }) + require.True(t, ok, "wrapped SPIFFE client must still expose a Resources() accessor") + assert.Equal(t, []string{"https://resource.example.com"}, resourceScoped.Resources()) + + assert.True(t, registration.BackChannelOnly(client), + "wrapped SPIFFE client must still carry the explicit back-channel-only marker") +} + +// TestSPIFFEAssociationFingerprintOrderStable proves two associations that +// are identical except for the order their configured methods are listed in +// produce the SAME identity fingerprint. fingerprintSPIFFEAssociation sorts +// methods before hashing specifically so config-file reordering (which +// carries no semantic meaning) can never manifest as a false collision on +// reconciliation. +func TestSPIFFEAssociationFingerprintOrderStable(t *testing.T) { + t.Parallel() + + forward := testSPIFFEAssociation("client", "openid") + forward.Methods = []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodX509, SPIFFEAuthenticationMethodJWT} + reversed := testSPIFFEAssociation("client", "openid") + reversed.Methods = []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodJWT, SPIFFEAuthenticationMethodX509} + + forwardClients, err := newTestSPIFFEAssociationRegistry(t, []SPIFFEClientAuthRunConfig{forward}).staticClients() + require.NoError(t, err) + reversedClients, err := newTestSPIFFEAssociationRegistry(t, []SPIFFEClientAuthRunConfig{reversed}).staticClients() + require.NoError(t, err) + + forwardIdentity, ok := forwardClients["client"].(interface{ IdentityFingerprint() string }) + require.True(t, ok) + reversedIdentity, ok := reversedClients["client"].(interface{ IdentityFingerprint() string }) + require.True(t, ok) + + assert.Equal(t, forwardIdentity.IdentityFingerprint(), reversedIdentity.IdentityFingerprint(), + "method order must not affect the identity fingerprint") +} + +// TestSPIFFEAssociationFingerprintDiffersOnMethodsOnly proves two associations +// identical in every other respect but differing in their accepted methods +// set produce DIFFERENT identity fingerprints -- the review finding on PR +// #6473 was precisely that the fingerprint ignored methods (and the rest of +// the association identity) entirely. +func TestSPIFFEAssociationFingerprintDiffersOnMethodsOnly(t *testing.T) { + t.Parallel() + + x509Only := testSPIFFEAssociation("client", "openid") + x509Only.Methods = []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodX509} + jwtOnly := testSPIFFEAssociation("client", "openid") + jwtOnly.Methods = []SPIFFEAuthenticationMethod{SPIFFEAuthenticationMethodJWT} + + x509Clients, err := newTestSPIFFEAssociationRegistry(t, []SPIFFEClientAuthRunConfig{x509Only}).staticClients() + require.NoError(t, err) + jwtClients, err := newTestSPIFFEAssociationRegistry(t, []SPIFFEClientAuthRunConfig{jwtOnly}).staticClients() + require.NoError(t, err) + + x509Identity, ok := x509Clients["client"].(interface{ IdentityFingerprint() string }) + require.True(t, ok) + jwtIdentity, ok := jwtClients["client"].(interface{ IdentityFingerprint() string }) + require.True(t, ok) + + assert.NotEqual(t, x509Identity.IdentityFingerprint(), jwtIdentity.IdentityFingerprint(), + "a genuinely different methods set must produce a different identity fingerprint") +} + +// TestSPIFFEAssociationFingerprintFieldsDoNotConcatenateAmbiguously pins the +// length-prefixing invariant fingerprintSPIFFEAssociation's doc comment +// asserts but nothing otherwise enforces: without it, two different +// (trustDomainRef, principal) pairs whose concatenation is byte-identical +// -- e.g. ("12", "3") and ("1", "23") -- would hash to the same value, +// silently reconciling two different associations as one. Calls +// fingerprintSPIFFEAssociation directly (unexported, same-package test) since +// the collision is a property of that function's own encoding, not of +// anything reachable through the public registry API. +func TestSPIFFEAssociationFingerprintFieldsDoNotConcatenateAmbiguously(t *testing.T) { + t.Parallel() + + a := fingerprintSPIFFEAssociation(SPIFFEClientAuthConfig{trustDomainRef: "12", principal: "3"}) + b := fingerprintSPIFFEAssociation(SPIFFEClientAuthConfig{trustDomainRef: "1", principal: "23"}) + assert.NotEqual(t, a, b, "length prefixing must keep field boundaries unambiguous") +} + func newTestSPIFFEAssociationRegistry(t *testing.T, associations []SPIFFEClientAuthRunConfig) *SPIFFEAssociationRegistry { t.Helper() diff --git a/pkg/authserver/storage/redis.go b/pkg/authserver/storage/redis.go index 518f73da64..bc8c1dc86f 100644 --- a/pkg/authserver/storage/redis.go +++ b/pkg/authserver/storage/redis.go @@ -194,8 +194,9 @@ type storedClient struct { // interface check alone so buildStoredClient's write and clientFromStored's // read stay symmetric by construction. Nil for every other client shape, // and omitted from the JSON encoding in that case. - Resources []string `json:"resources,omitempty"` - Public bool `json:"public"` + Resources []string `json:"resources,omitempty"` + IdentityFingerprint string `json:"identity_fingerprint,omitempty"` + Public bool `json:"public"` // TokenEndpointAuthMethod is the auth method registered for the client // ("none", "client_secret_basic", "client_secret_post"). Empty means the // row predates confidential-client support; see GetClient for how legacy @@ -317,7 +318,8 @@ func clientFromStored(stored storedClient, hasTTL bool) fosite.Client { Audience: stored.Audience, Public: false, }, - resources: stored.Resources, + resources: stored.Resources, + identityFingerprint: stored.IdentityFingerprint, } } @@ -379,14 +381,18 @@ func buildStoredClient(client fosite.Client) storedClient { Public: client.IsPublic(), } stored.Reserved = isReservedPlaceholder(client) - // Resources is only ever restored on read for a Reserved row (see - // clientFromStored); gating the write the same way keeps the two - // symmetric by construction instead of relying on every - // resourceScopedClient implementation always being Reserved. + // Resources and IdentityFingerprint are only ever restored on read for a + // Reserved row (see clientFromStored); gating the write the same way + // keeps the two symmetric by construction instead of relying on every + // resourceScopedClient/spiffeIdentityClient implementation always being + // Reserved. if stored.Reserved { if rc, ok := client.(resourceScopedClient); ok { stored.Resources = rc.Resources() } + if identity, ok := client.(spiffeIdentityClient); ok { + stored.IdentityFingerprint = identity.IdentityFingerprint() + } } if oidcClient, ok := client.(fosite.OpenIDConnectClient); ok { stored.TokenEndpointAuthMethod = oidcClient.GetTokenEndpointAuthMethod() @@ -526,12 +532,13 @@ func (s *RedisStorage) UpsertDCRIssuedClient(ctx context.Context, client fosite. // directly sidesteps that defaulting entirely. func (s storedClient) fingerprint() clientFingerprint { return clientFingerprint{ - scopes: s.Scopes, - audience: s.Audience, - grantTypes: s.GrantTypes, - responseTypes: s.ResponseTypes, - resources: s.Resources, - public: s.Public, + scopes: s.Scopes, + audience: s.Audience, + grantTypes: s.GrantTypes, + responseTypes: s.ResponseTypes, + resources: s.Resources, + identityFingerprint: s.IdentityFingerprint, + public: s.Public, } } diff --git a/pkg/authserver/storage/spiffe_decorator.go b/pkg/authserver/storage/spiffe_decorator.go index 14e488da14..d9ef1d741a 100644 --- a/pkg/authserver/storage/spiffe_decorator.go +++ b/pkg/authserver/storage/spiffe_decorator.go @@ -94,6 +94,14 @@ type resourceScopedClient interface { Resources() []string } +// spiffeIdentityClient is implemented by fosite.Client types (e.g. +// spiffeStaticClient) that carry a durable SPIFFE association identity +// fingerprint independent of OAuth policy. Mirrors resourceScopedClient's +// structural-matching pattern above. +type spiffeIdentityClient interface { + IdentityFingerprint() string +} + // inertPlaceholderClient wraps a *fosite.DefaultClient to suppress its // implicit defaulting: fosite.DefaultClient.GetGrantTypes and // GetResponseTypes each return a single-element default @@ -118,7 +126,8 @@ type resourceScopedClient interface { type inertPlaceholderClient struct { registration.BackChannelOnlyMarker *fosite.DefaultClient - resources []string + resources []string + identityFingerprint string } func (inertPlaceholderClient) GetGrantTypes() fosite.Arguments { return nil } @@ -131,6 +140,8 @@ func (inertPlaceholderClient) GetResponseTypes() fosite.Arguments { return nil } // SPIFFEAuthorizationPolicy.Resources. func (c inertPlaceholderClient) Resources() []string { return slices.Clone(c.resources) } +func (c inertPlaceholderClient) IdentityFingerprint() string { return c.identityFingerprint } + // isReservedPlaceholder reports whether client is a staticClientPlaceholder // value, letting buildStoredClient (redis.go) mark the persisted row so // clientFromStored can restore the same inert wrapper on read-back. Mirrors @@ -150,10 +161,11 @@ func isReservedPlaceholder(client fosite.Client) bool { // server registers, and it carries no secret. This guarantee holds on every // backend, including a bare read-back from Redis by a replica with no SPIFFE // overlay — see inertPlaceholderClient and storedClient.Reserved. It keeps -// the real client's Scopes, Audience, and (RFC 8707) Resources so the -// fingerprint comparison in ReconcileConfiguredClient can distinguish "same -// association, restarted" (idempotent) from "different, colliding -// association at this ID" (a loud failure). +// the real client's Scopes, Audience, (RFC 8707) Resources, and SPIFFE +// association identity fingerprint so the fingerprint comparison in +// ReconcileConfiguredClient can distinguish "same association, restarted" +// (idempotent) from "different, colliding association at this ID" (a loud +// failure). // Scopes and Audience are taken directly from actual.GetScopes() / // GetAudience() without an extra defensive clone here: the only production // caller (registration.SPIFFEClient) already returns a fresh slice.Clone @@ -162,9 +174,13 @@ func isReservedPlaceholder(client fosite.Client) bool { // resourceScopedClient; a client that doesn't (falls back to nil). func staticClientPlaceholder(actual fosite.Client) fosite.Client { var resources []string + var identityFingerprint string if rc, ok := actual.(resourceScopedClient); ok { resources = rc.Resources() } + if identity, ok := actual.(spiffeIdentityClient); ok { + identityFingerprint = identity.IdentityFingerprint() + } return inertPlaceholderClient{ DefaultClient: &fosite.DefaultClient{ ID: actual.GetID(), @@ -172,7 +188,8 @@ func staticClientPlaceholder(actual fosite.Client) fosite.Client { Audience: actual.GetAudience(), Public: false, }, - resources: resources, + resources: resources, + identityFingerprint: identityFingerprint, } } diff --git a/pkg/authserver/storage/spiffe_decorator_test.go b/pkg/authserver/storage/spiffe_decorator_test.go index 3145b17ee6..87dcafdccb 100644 --- a/pkg/authserver/storage/spiffe_decorator_test.go +++ b/pkg/authserver/storage/spiffe_decorator_test.go @@ -28,6 +28,27 @@ func testSPIFFEClients(t *testing.T) map[string]fosite.Client { return map[string]fosite.Client{client.GetID(): client} } +// testIdentityClient wraps a *registration.SPIFFEClient the same way the +// production spiffeStaticClient wrapper (pkg/authserver) does, so tests in +// this package can construct a client carrying an identity fingerprint +// without depending on the authserver package (which would be an import +// cycle: authserver already imports storage). +type testIdentityClient struct { + *registration.SPIFFEClient + identityFingerprint string +} + +func (c testIdentityClient) IdentityFingerprint() string { return c.identityFingerprint } + +func testSPIFFEClientWithIdentity(t *testing.T, identityFingerprint string) fosite.Client { + t.Helper() + client, err := registration.NewSPIFFEClient( + "spiffe-client", []string{"openid"}, []string{"https://api.example.com"}, nil, + ) + require.NoError(t, err) + return testIdentityClient{SPIFFEClient: client, identityFingerprint: identityFingerprint} +} + func TestSPIFFEStorageDecoratorStaticClients(t *testing.T) { t.Parallel() base := NewMemoryStorage() @@ -296,6 +317,75 @@ func TestSPIFFEStorageDecoratorPlaceholderResourcesRoundTripThroughRedis(t *test assert.Equal(t, []string{"https://mcp.example.com"}, rc.Resources()) } +// TestSPIFFEStorageDecoratorIdentityOnlyDifferenceIsACollision is the +// identity-fingerprint equivalent of +// TestSPIFFEStorageDecoratorResourcesOnlyDifferenceIsACollision: two +// associations at the same client ID, same scopes/audience/resources, but a +// genuinely different SPIFFE association identity (trust domain, principal, +// or methods -- summarized here as an opaque identityFingerprint, mirroring +// how fingerprintSPIFFEAssociation in pkg/authserver produces it) must +// collide loudly on reconciliation, not be accepted as "the same client, +// restarted". +func TestSPIFFEStorageDecoratorIdentityOnlyDifferenceIsACollision(t *testing.T) { + t.Parallel() + ctx := context.Background() + base := NewMemoryStorage() + t.Cleanup(func() { _ = base.Close() }) + + first := testSPIFFEClientWithIdentity(t, "identity-a") + _, err := NewSPIFFEStorageDecorator(ctx, base, map[string]fosite.Client{first.GetID(): first}) + require.NoError(t, err) + + second := testSPIFFEClientWithIdentity(t, "identity-b") + _, err = NewSPIFFEStorageDecorator(ctx, base, map[string]fosite.Client{second.GetID(): second}) + require.ErrorIs(t, err, ErrAlreadyExists, + "two associations differing only in identity must collide, not reconcile idempotently") + assert.Contains(t, err.Error(), "different configured client") +} + +// TestSPIFFEStorageDecoratorIdentityOnlyDifferenceIsACollisionRedis is the +// Redis-backed equivalent of +// TestSPIFFEStorageDecoratorIdentityOnlyDifferenceIsACollision. The memory +// test alone would leave storedClient.fingerprint's identityFingerprint +// wiring (redis.go) completely uncovered, mirroring the exact gap the +// resources fix's Redis test closed. +func TestSPIFFEStorageDecoratorIdentityOnlyDifferenceIsACollisionRedis(t *testing.T) { + t.Parallel() + ctx := context.Background() + base, mr := newTestRedisStorage(t) + t.Cleanup(func() { _ = base.Close(); mr.Close() }) + + first := testSPIFFEClientWithIdentity(t, "identity-a") + require.NoError(t, PreflightSPIFFEStaticClientCollisions(ctx, base, map[string]fosite.Client{first.GetID(): first})) + + second := testSPIFFEClientWithIdentity(t, "identity-b") + err := PreflightSPIFFEStaticClientCollisions(ctx, base, map[string]fosite.Client{second.GetID(): second}) + require.ErrorIs(t, err, ErrAlreadyExists, + "two associations differing only in identity must collide, not reconcile idempotently, on Redis-backed storage") + assert.Contains(t, err.Error(), "different configured client") +} + +// TestSPIFFEStorageDecoratorPlaceholderIdentityRoundTripThroughRedis proves +// staticClientPlaceholder's identity fingerprint survives a real Redis +// round-trip (buildStoredClient -> storedClient.IdentityFingerprint -> +// clientFromStored), not just the in-process placeholder value, mirroring +// TestSPIFFEStorageDecoratorPlaceholderResourcesRoundTripThroughRedis. +func TestSPIFFEStorageDecoratorPlaceholderIdentityRoundTripThroughRedis(t *testing.T) { + t.Parallel() + ctx := context.Background() + raw, mr := newTestRedisStorage(t) + t.Cleanup(func() { _ = raw.Close(); mr.Close() }) + + client := testSPIFFEClientWithIdentity(t, "identity-a") + require.NoError(t, PreflightSPIFFEStaticClientCollisions(ctx, raw, map[string]fosite.Client{client.GetID(): client})) + + readBack, err := raw.GetClient(ctx, "spiffe-client") + require.NoError(t, err) + identity, ok := readBack.(spiffeIdentityClient) + require.True(t, ok, "placeholder read back from raw Redis storage must still expose IdentityFingerprint()") + assert.Equal(t, "identity-a", identity.IdentityFingerprint()) +} + // TestSPIFFEStorageDecoratorPlaceholderNoResourcesStillIdempotent pins the // zero-value case: an association with no RFC 8707 resources configured must // still reconcile against itself on restart, not be treated as a collision diff --git a/pkg/authserver/storage/types.go b/pkg/authserver/storage/types.go index cd0aae5b16..61c3d15d3b 100644 --- a/pkg/authserver/storage/types.go +++ b/pkg/authserver/storage/types.go @@ -636,8 +636,9 @@ type clientFingerprint struct { // every other field but differing in their resource allowlist are // different logical clients -- this is the field #6473's review found // missing from the SPIFFE static-client placeholder's durable identity. - resources []string - public bool + resources []string + identityFingerprint string + public bool } // equal reports whether f and o represent the same logical client @@ -649,6 +650,7 @@ func (f clientFingerprint) equal(o clientFingerprint) bool { sameStringSet(f.grantTypes, o.grantTypes) && sameStringSet(f.responseTypes, o.responseTypes) && sameStringSet(f.resources, o.resources) && + f.identityFingerprint == o.identityFingerprint && f.public == o.public } @@ -660,16 +662,21 @@ func (f clientFingerprint) equal(o clientFingerprint) bool { // and buildStoredClient use. func fingerprintOfClient(c fosite.Client) clientFingerprint { var resources []string + var identityFingerprint string if rc, ok := c.(resourceScopedClient); ok { resources = rc.Resources() } + if identity, ok := c.(spiffeIdentityClient); ok { + identityFingerprint = identity.IdentityFingerprint() + } return clientFingerprint{ - scopes: c.GetScopes(), - audience: c.GetAudience(), - grantTypes: c.GetGrantTypes(), - responseTypes: c.GetResponseTypes(), - resources: resources, - public: c.IsPublic(), + scopes: c.GetScopes(), + audience: c.GetAudience(), + grantTypes: c.GetGrantTypes(), + responseTypes: c.GetResponseTypes(), + resources: resources, + identityFingerprint: identityFingerprint, + public: c.IsPublic(), } } diff --git a/pkg/authserver/storage/types_test.go b/pkg/authserver/storage/types_test.go index 22af90d53c..13150f7fb8 100644 --- a/pkg/authserver/storage/types_test.go +++ b/pkg/authserver/storage/types_test.go @@ -203,7 +203,9 @@ func TestClientFingerprintFieldsAreJustified(t *testing.T) { "grantTypes": "a different grant-type set is a different logical client capability", "responseTypes": "a different response-type set is a different logical client capability", "resources": "a different RFC 8707 resource allowlist is a different logical client authorization", - "public": "public vs. confidential is a different logical client class", + "identityFingerprint": "a different SPIFFE association identity (trust domain, principal, methods) " + + "behind the same client ID is a different logical client authorization", + "public": "public vs. confidential is a different logical client class", } var gotFields []string @@ -223,12 +225,13 @@ func TestClientFingerprintFieldsAreJustified(t *testing.T) { gotFields) base := clientFingerprint{ - scopes: []string{"scope-a"}, - audience: []string{"audience-a"}, - grantTypes: []string{"grant-a"}, - responseTypes: []string{"response-a"}, - resources: []string{"resource-a"}, - public: false, + scopes: []string{"scope-a"}, + audience: []string{"audience-a"}, + grantTypes: []string{"grant-a"}, + responseTypes: []string{"response-a"}, + resources: []string{"resource-a"}, + identityFingerprint: "identity-a", + public: false, } for name := range justifications { @@ -247,6 +250,8 @@ func TestClientFingerprintFieldsAreJustified(t *testing.T) { other.responseTypes = []string{"response-b"} case "resources": other.resources = []string{"resource-b"} + case "identityFingerprint": + other.identityFingerprint = "identity-b" case "public": other.public = !base.public default: