diff --git a/docs/arch/17-token-exchange-delegation.md b/docs/arch/17-token-exchange-delegation.md index ed21bf45c3..26678150c9 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 @@ -559,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 @@ -636,7 +716,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/auth/dcr/resolver.go b/pkg/auth/dcr/resolver.go index 94992d76e0..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 @@ -470,19 +472,55 @@ 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)) } + // 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", + "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..4ae71f48bd 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 @@ -1847,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 / @@ -1914,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() @@ -1925,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) @@ -1940,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)) @@ -2017,7 +2077,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/config.go b/pkg/authserver/config.go index 525f5d67c1..f0d48da816 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 @@ -1144,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) @@ -1251,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"}}}, 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/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/runner/embeddedauthserver.go b/pkg/authserver/runner/embeddedauthserver.go index fa7ce6cf57..3c677a4cd4 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,12 +136,57 @@ 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) + } + // 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 @@ -176,12 +216,18 @@ 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 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 } + 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 { @@ -221,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. @@ -240,18 +283,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, @@ -279,7 +315,11 @@ func newEmbeddedAuthServerWithStorage( // authorization-critical data is protected without a deep copy here. TrustedIssuers: trustedIssuers, DelegateClients: delegateClients, - SPIFFETrust: spiffeTrust, + // 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, } // 8. Create the auth server. authserver.New also asserts the DCR @@ -392,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) { @@ -455,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) { @@ -732,24 +795,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_integration_test.go b/pkg/authserver/runner/embeddedauthserver_integration_test.go new file mode 100644 index 0000000000..cb16f60023 --- /dev/null +++ b/pkg/authserver/runner/embeddedauthserver_integration_test.go @@ -0,0 +1,142 @@ +// 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:") + } + 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, + 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) + // 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()) + + // 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) + require.ErrorIs(t, err, storage.ErrAlreadyExists) +} diff --git a/pkg/authserver/runner/embeddedauthserver_test.go b/pkg/authserver/runner/embeddedauthserver_test.go index 6ad4286862..429dc482f6 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,575 @@ 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) + // 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 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") + 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. 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) +} + // 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 @@ -2344,6 +2918,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/authorize.go b/pkg/authserver/server/handlers/authorize.go index dda866b4a5..a8f9f0dae1 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,47 @@ 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 +} + +// 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 registration.BackChannelOnly(client) || + 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..f971c038c0 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,114 @@ 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 + explicitMarker bool + }{ + { + 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"}, + nil, + ) + require.NoError(t, err) + return client + }, + explicitMarker: true, + }, + { + 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 + }, + // 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, + }, + } + + 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 + + // 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)) + + 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) 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/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 new file mode 100644 index 0000000000..91e2339ebf --- /dev/null +++ b/pkg/authserver/server/registration/spiffe_client.go @@ -0,0 +1,85 @@ +// 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 { + BackChannelOnlyMarker + id string + 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 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") + } + 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), + resources: slices.Clone(resources), + }, 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 RFC 8693 audience request values. +func (c *SPIFFEClient) Audiences() []string { return slices.Clone(c.audiences) } + +// 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 +// 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..18e27f4cd9 --- /dev/null +++ b/pkg/authserver/server/registration/spiffe_client_test.go @@ -0,0 +1,89 @@ +// 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"} + 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()) + 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())) + 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, 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, nil) + require.Error(t, err) +} + +func TestNewSPIFFEClient_RequiresAudiences(t *testing.T) { + t.Parallel() + + _, err := NewSPIFFEClient( + "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 303b8658f9..643c472e6e 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 @@ -786,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 @@ -808,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 bdc300c609..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" ) @@ -1772,6 +1773,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 +1793,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 +1830,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 +1849,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,6 +1905,224 @@ 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_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 + wantNestedActor bool + }{ + { + name: "allowlist path nests the allowlisted external actor", + claims: &ValidatedClaims{ + Subject: "ext-user-456", + ExternalIssuer: testExternalIssuer, + ExternalActor: "ext-agent", + }, + wantNestedActor: true, + }, + { + name: "may_act path with no ExternalActor nests nothing under act", + claims: &ValidatedClaims{ + Subject: "ext-user-456", + ExternalIssuer: testExternalIssuer, + ExternalActor: "", + }, + 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, externalIssuer, err := buildActClaim(tt.claims, testIssuer, testAgentClientID) + require.NoError(t, err) + + // 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 + } + 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) + }) + } } func TestChainToAct_EmptyIdentityHop(t *testing.T) { @@ -2229,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.go b/pkg/authserver/server/tokenexchange/jwt_bearer_handler.go index ac7fa19f22..33f175df10 100644 --- a/pkg/authserver/server/tokenexchange/jwt_bearer_handler.go +++ b/pkg/authserver/server/tokenexchange/jwt_bearer_handler.go @@ -446,14 +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 - if decorated, ok := rawStorage.(*storage.CIMDStorageDecorator); ok { - baseStorage = decorated.Unwrap() - } - 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..7792d717ce 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"}, nil) + 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/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..e8f3d99355 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 } @@ -203,6 +208,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) @@ -244,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 { @@ -301,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: "+ @@ -312,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). @@ -357,12 +382,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 +401,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 +434,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 } @@ -574,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 f0a70b5765..d54cac7bdf 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. @@ -421,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() @@ -473,12 +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() }) cfg := Config{ Issuer: "https://example.com", KeyProvider: keys.NewGeneratingProvider(keys.DefaultAlgorithm), @@ -492,28 +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 - _, err := newServer(ctx, cfg, stor) - require.ErrorIs(t, err, assert.AnError) + factoryCalled := false + factory := func(context.Context, *UpstreamConfig) (upstream.OAuth2Provider, error) { + factoryCalled = true + return nil, assert.AnError + } + subCfg := cfg + subCfg.UpstreamFactory = factory - // 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) - 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") + + 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/spiffe_association_registry.go b/pkg/authserver/spiffe_association_registry.go new file mode 100644 index 0000000000..04f965e13a --- /dev/null +++ b/pkg/authserver/spiffe_association_registry.go @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package authserver + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "slices" + + "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 +} + +// 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 + } + 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(), policy.Resources(), + ) + if err != nil { + return nil, fmt.Errorf("SPIFFE client %q: %w", clientID, err) + } + 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 new file mode 100644 index 0000000000..f587b1c914 --- /dev/null +++ b/pkg/authserver/spiffe_association_registry_test.go @@ -0,0 +1,216 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package authserver + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/authserver/server/registration" +) + +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://first-audience.example.com"} + second := testSPIFFEAssociation("second-client", "profile") + second.PrincipalPattern = "spiffe://example.org/ns/default/other-agent" + 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://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()) +} + +// 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() + + 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/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/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 07967f316b..bb7e8965b5 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 @@ -150,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 @@ -449,15 +456,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 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 { @@ -467,12 +470,88 @@ 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 { - // Refresh the eviction position: an actively re-registering client is - // not the oldest. - 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) +} + +// 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 +// 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 !fingerprintOfClient(existing).equal(fingerprintOfClient(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:]...) @@ -1491,29 +1570,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 c40f134001..25eab0325a 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" @@ -252,30 +251,231 @@ 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"}}, + } + + 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) + assert.Equal(t, client, retrieved) + }) + + 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.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") + }) +} + +// 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() - 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"}, + 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.RegisterClient(ctx, staticClient)) + require.NoError(t, s.UpsertDCRIssuedClient(ctx, second)) - retrieved, err := s.GetClient(ctx, "delegate") + retrieved, err := s.GetClient(ctx, "cimd-client") 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, 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, 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 +490,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: @@ -2131,7 +2320,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) @@ -2171,7 +2362,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 { @@ -2183,7 +2375,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", @@ -2200,12 +2399,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, "second", got.ClientID) + 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, "fresh-client", got.ClientID) }) } @@ -2305,7 +2550,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. @@ -2327,12 +2572,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) @@ -2361,7 +2607,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" @@ -2383,13 +2630,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() @@ -2400,7 +2648,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 @@ -2457,7 +2705,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 24c81b0817..8ee47fdf71 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. @@ -230,6 +231,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() @@ -272,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 @@ -1004,6 +1033,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() @@ -1144,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 4ec0d41146..bc8c1dc86f 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 @@ -178,7 +188,15 @@ 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"` + 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 @@ -201,6 +219,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 +301,28 @@ 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, + }, + resources: stored.Resources, + identityFingerprint: stored.IdentityFingerprint, + } + } + method := stored.TokenEndpointAuthMethod if method == "" { client := &fosite.DefaultClient{ @@ -312,14 +361,15 @@ func clientFromStored(stored storedClient, hasTTL bool) fosite.Client { return oidcClient } -// RegisterClient adds or updates a client in the storage. -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(), @@ -330,19 +380,42 @@ 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. + stored.Reserved = isReservedPlaceholder(client) + // 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() stored.JSONWebKeys = publicJSONWebKeySet(oidcClient.GetJSONWebKeys()) stored.TokenEndpointAuthSigningAlgorithm = oidcClient.GetTokenEndpointAuthSigningAlgorithm() } stored.DCRIssued = registration.DCRIssued(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 { @@ -363,7 +436,206 @@ func (s *RedisStorage) RegisterClient(ctx context.Context, client fosite.Client) ttl = DefaultDCRClientTTL } - return s.client.Set(ctx, key, data, ttl).Err() + 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 +} + +// 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 +} + +// 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, + resources: s.Resources, + identityFingerprint: s.IdentityFingerprint, + public: s.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) { + 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) + } + + 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) + } + + // 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 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 !existingStored.fingerprint().equal(stored.fingerprint()) { + return fmt.Errorf("%w: client %q is already registered as a different configured client", + 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 } // GetClient loads the client by its ID. @@ -1468,7 +1740,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. @@ -1527,17 +1799,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 @@ -1548,13 +1861,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, @@ -1578,14 +1935,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 { @@ -1594,11 +1946,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. @@ -1606,7 +2020,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 d2811c48df..cee64a03c5 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,186 @@ 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)) + }) + }) +} + +// 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 +// 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_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 @@ -3098,7 +3269,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) @@ -3108,7 +3281,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 { @@ -3120,12 +3300,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") }) } @@ -3193,7 +3592,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 { @@ -3290,7 +3690,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 @@ -3310,12 +3710,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) @@ -3336,7 +3737,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() @@ -3344,13 +3745,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") @@ -3362,13 +3764,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. @@ -3381,13 +3784,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 @@ -3445,7 +3849,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. @@ -3500,17 +3904,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/spiffe_decorator.go b/pkg/authserver/storage/spiffe_decorator.go new file mode 100644 index 0000000000..d9ef1d741a --- /dev/null +++ b/pkg/authserver/storage/spiffe_decorator.go @@ -0,0 +1,258 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package storage + +import ( + "context" + "fmt" + "maps" + "slices" + "time" + + "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 +// 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") + } + 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 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") + } + 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 { + 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 +} + +// 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 +} + +// 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 +// (["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 + resources []string + identityFingerprint 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) } + +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 +// 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, 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 +// from both getters (see its doc comments), so there is no live backing +// 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 { + 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(), + Scopes: actual.GetScopes(), + Audience: actual.GetAudience(), + Public: false, + }, + resources: resources, + identityFingerprint: identityFingerprint, + } +} + +// 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 { + 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) +} + +// 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) +} + +// 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 new file mode 100644 index 0000000000..87dcafdccb --- /dev/null +++ b/pkg/authserver/storage/spiffe_decorator_test.go @@ -0,0 +1,524 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +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 { + t.Helper() + client, err := registration.NewSPIFFEClient( + "spiffe-client", + []string{"openid"}, + []string{"https://api.example.com"}, + nil, + ) + require.NoError(t, err) + 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() + 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) +} + +// 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) +} + +// 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 +// 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() + + 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() + 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() }) + + // 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(), "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()) +} + +// 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 +// 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() + t.Cleanup(func() { _ = base.Close() }) + decorated, err := NewSPIFFEStorageDecorator(context.Background(), base, nil) + 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"}, nil) + 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 a2dbf90392..61c3d15d3b 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. @@ -591,27 +597,130 @@ 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) +} + +// 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 + // 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 + identityFingerprint 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, 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.identityFingerprint == o.identityFingerprint && + 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 + 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, + identityFingerprint: identityFingerprint, + public: c.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). - // - // 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. 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 + // 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 + // 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 @@ -833,7 +942,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 diff --git a/pkg/authserver/storage/types_test.go b/pkg/authserver/storage/types_test.go index 57f0c50baf..13150f7fb8 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,88 @@ 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", + "resources": "a different RFC 8707 resource allowlist is a different logical client authorization", + "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 + 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"}, + resources: []string{"resource-a"}, + identityFingerprint: "identity-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 "resources": + other.resources = []string{"resource-b"} + case "identityFingerprint": + other.identityFingerprint = "identity-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) + }) + } +} 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() + } +} 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) {