From 41d2b4a5c8b32233ace7fe31c87900124e9e389c Mon Sep 17 00:00:00 2001 From: strehle Date: Fri, 6 Nov 2026 16:11:03 +0100 Subject: [PATCH] Feature proposal - issue #4125 Support named allowed-resource policies for mTLS clients --- docs/UAA-Client-Authentication.md | 44 +++++- docs/UAA-Configuration-Reference.md | 50 +++++++ docs/UAA-mTLS-Getting-Started.md | 24 ++- .../client/TlsClientAuthConfiguration.java | 15 +- .../identity/uaa/client/UaaClientDetails.java | 3 + .../identity/uaa/zone/MtlsConfig.java | 5 + .../identity/uaa/zone/MtlsResourcePolicy.java | 16 ++ .../TlsClientAuthConfigurationTest.java | 14 ++ .../uaa/client/UaaClientDetailsTest.java | 12 +- .../SpringServletXmlBeansConfiguration.java | 4 + .../client/ClientAdminEndpointsValidator.java | 88 ++++++----- .../IdentityZoneConfigurationBootstrap.java | 3 + .../uaa/oauth/tls/MtlsClaimsEnhancer.java | 20 ++- .../uaa/oauth/tls/MtlsResourcePolicies.java | 138 ++++++++++++++++++ .../tls/MtlsTrustBundleStartupValidator.java | 9 +- .../tls/TlsClientAuthConfigurationReader.java | 2 + .../uaa/oauth/token/UaaTokenEndpoint.java | 49 ++++--- ...ralIdentityZoneConfigurationValidator.java | 2 + .../uaa/client/ClientAdminBootstrapTests.java | 35 +++++ .../ClientAdminEndpointsValidatorTests.java | 74 +++++++++- ...entityZoneConfigurationBootstrapTests.java | 16 ++ .../IdentityZoneConfigurationTests.java | 17 +++ .../uaa/oauth/tls/MtlsClaimsEnhancerTest.java | 57 +++++++- .../oauth/tls/MtlsResourcePoliciesTest.java | 88 +++++++++++ .../MtlsTrustBundleStartupValidatorTest.java | 17 ++- .../TlsClientAuthConfigurationReaderTest.java | 2 + .../oauth/token/UaaTokenEndpointTests.java | 8 +- ...entityZoneConfigurationValidatorTests.java | 17 +++ .../identity/uaa/login/TokenEndpointDocs.java | 3 +- .../mock/clients/ClientAdminEndpointDocs.java | 6 +- ...tlsTokenEndpointHardeningMockMvcTests.java | 123 +++++++++++++++- ...MtlsTokenEndpointMockMvcZonePathTests.java | 54 +++++++ .../MtlsTrustBundleConfigurationTests.java | 7 + .../mock/zones/IdentityZoneEndpointDocs.java | 10 ++ .../uaa/oauth/UaaTokenServicesTests.java | 20 ++- .../resources/mtls-trust-bundles.properties | 3 + 36 files changed, 956 insertions(+), 99 deletions(-) create mode 100644 model/src/main/java/org/cloudfoundry/identity/uaa/zone/MtlsResourcePolicy.java create mode 100644 server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsResourcePolicies.java create mode 100644 server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsResourcePoliciesTest.java diff --git a/docs/UAA-Client-Authentication.md b/docs/UAA-Client-Authentication.md index 1266cac7445..249122b2d6f 100644 --- a/docs/UAA-Client-Authentication.md +++ b/docs/UAA-Client-Authentication.md @@ -213,8 +213,9 @@ present a certificate whose chain validates to the configured CA; no separate | `tls-client-auth-required-claims` | no | Map of `claimName -> requiredValue`, checked against the values produced by `tls-client-auth-claim-mappings`. A UAA extension applied in addition to the subject binding, not instead of it -- authentication fails unless every entry matches exactly. | | `tls-client-auth-claim-mappings` | no | List of `{field, pattern, claim}` mappings from certificate subject fields (`subject_cn`, `subject_ou`, `subject_o`) to JWT claim names. `subject_cn` and `subject_o` map their values directly; `pattern` is rejected for either, since it is applied only to `subject_ou`, where it must contain at least one capturing group -- a pattern that only matches without capturing (e.g. `space:.+` instead of `space:(.+)`) is rejected too, since it would never produce a value. Patterns use RE2/J syntax and a linear-time matcher. OU values and pattern source are limited to 256 characters, and compiled patterns to 4096 instructions. Backreferences, lookaround, Java comments mode, and counted repetitions of groups are unsupported and rejected at registration; counts on literals and character classes are allowed. Invalid or unsupported patterns in stored configuration yield no claim, so a dependent required-claim check refuses authentication. Prefer simple anchored patterns such as `^space:(.+)$`. | | `tls-client-auth-sub-template` | no | Template string rendered (using the mapped claim values) to produce the JWT `sub` claim. At most 256 characters; must contain at least one `{claim}` placeholder; every placeholder must name a claim declared in `tls-client-auth-claim-mappings`. | -| `tls-client-auth-aud-templates` | no | List of template strings rendered to produce the JWT `aud` claim. Each entry is subject to the same constraints as `tls-client-auth-sub-template`: at most 256 characters, at least one `{claim}` placeholder, and every placeholder must name a declared claim. | -| `tls-client-auth-allowed-resources` | no | List of exact [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707) resource values (absolute URIs, no fragment) this client may request at token time via the `resource` request parameter -- see "RFC 8707 resource indicators" below. Mutually exclusive with `tls-client-auth-aud-templates`: two mechanisms for setting `aud` on one client is a footgun. Must be non-empty if present. | +| `tls-client-auth-aud-templates` | no | List of template strings rendered to produce the JWT `aud` claim. Each entry is subject to the same constraints as `tls-client-auth-sub-template`: at most 256 characters, at least one `{claim}` placeholder, and every placeholder must name a declared claim. Mutually exclusive with both allowed-resource properties. | +| `tls-client-auth-allowed-resources` | no | List of exact [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707) resource values (absolute URIs, no fragment) this client may request at token time via the `resource` request parameter -- see "RFC 8707 resource indicators" below. Mutually exclusive with `tls-client-auth-allowed-resources-ref` and `tls-client-auth-aud-templates`. Must be non-empty if present. | +| `tls-client-auth-allowed-resources-ref` | no | Name of an operator-managed allowed-resource policy in this client's identity zone. UAA resolves it for every token request, so policy changes apply without rewriting clients. Unknown or blank references are rejected during create, update, and bootstrap. Mutually exclusive with the inline list and audience templates. | Example (Gorouter-fronted; a Cloud Foundry app instance identity certificate mapped to `cf_instance_guid`/`app_guid`/`space_guid`/`org_guid` claims): @@ -332,8 +333,8 @@ returns `404`. This endpoint never returns the bundle contents. #### RFC 8707 resource indicators -A client configured with `tls-client-auth-allowed-resources` may pass a `resource` parameter to -`/oauth/mtls/token`: +A client configured with an inline `tls-client-auth-allowed-resources` list or a named policy +reference may pass a `resource` parameter to `/oauth/mtls/token`: ```yaml tls-client-auth-allowed-resources: @@ -353,6 +354,39 @@ an unrestricted `aud`. Requesting a value outside the list, requesting more than the same call, or a value that is not an absolute URI without a fragment (per the RFC) is refused as `invalid_target`, and no token is issued. +#### Named allowed-resource policies + +Operators can share one allowed-resource policy across many default-zone clients: + +```yaml +uaa: + mtls: + resource_policies: + cf-workload-federation: + allowed_resources: + - https://sts.amazonaws.com + - api://AzureADTokenExchange +``` + +Other zones configure the equivalent `config.mtls.resourcePolicies` map through the identity-zone +API, using `allowedResources` inside each policy. Names follow the trust-bundle name rules and are +case-sensitive. Policy lists must be non-empty; every entry must be an absolute URI without a +fragment. + +```json +{ + "client_id": "cf:service-account:payments-worker", + "tls-client-auth-allowed-resources-ref": "cf-workload-federation" +} +``` + +Policy definitions and references resolve only in the client's current identity zone. An unknown +reference is rejected when a client is created, updated, or bootstrapped. UAA resolves the policy +again during token issuance: adding a resource permits subsequent requests, removing one rejects +subsequent requests, and removing the policy fails explicit resource requests as `invalid_target`. +Existing tokens keep their original lifetime and audience. The inline property remains supported +for existing clients. + #### Registering a `tls_client_auth` client A client opts in by setting `tls-client-auth-ca` or `tls-client-auth-ca-ref` together with exactly one subject parameter @@ -450,7 +484,7 @@ typed mTLS mirror does not change equality, hash codes, or set membership after `uaa.mtls.enabled` defaults to `false`. With it off the feature does nothing observable. OIDC discovery includes `tls_client_certificate_bound_access_tokens` only when mTLS is enabled for the deployment and the zone; RFC 8705 section 3.3 treats the omitted value as `false`. Identity-zone configuration includes `config.mtls` only once it -differs from the default (disabled, no bundles), and a `null` value reads as that default. +differs from the default (disabled, no bundles or resource policies), and a `null` value reads as that default. **Token enhancers.** `MtlsClaimsEnhancer` is always registered alongside other `UaaTokenEnhancer` beans. It is active only when deployment and zone mTLS are both enabled. It never activates refresh-claim copying, so refreshed access diff --git a/docs/UAA-Configuration-Reference.md b/docs/UAA-Configuration-Reference.md index 6d0abce0c9c..77a9ef5d61a 100644 --- a/docs/UAA-Configuration-Reference.md +++ b/docs/UAA-Configuration-Reference.md @@ -129,6 +129,7 @@ or `$CLOUDFOUNDRY_CONFIG_PATH/uaa.yml`. | [↪](#clientmaxcount) `clientMaxCount` | `500`| Max clients returned by list endpoint| | [↪](#uaamtlsenabled) `uaa.mtls.enabled` | `false`| Enables RFC 8705 mutual-TLS client authentication| | [↪](#uaamtlstrust_bundles) `uaa.mtls.trust_bundles` | `{}` | Named mTLS CA bundles for the default identity zone | +| [↪](#uaamtlsresource_policies) `uaa.mtls.resource_policies` | `{}` | Named mTLS allowed-resource policies for the default identity zone | ### Password Policy @@ -1387,6 +1388,55 @@ not returned. See [Named CA trust bundles](UAA-Client-Authentication.md#named-ca --- +### `uaa.mtls.resource_policies` + +**Default:** `{}` +**Type:** `Map }>` +**Source:** `Binder` in +[configuration wiring](../server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlBeansConfiguration.java) + +Defines named RFC 8707 allowed-resource policies for mTLS clients in the default UAA identity +zone. Policy names are case-sensitive and follow the trust-bundle name rules: 1 to 128 letters, +digits, dots, underscores, or hyphens, starting with a letter or digit. Each +`allowed_resources` list must be non-empty. Every entry must be an absolute URI without a +fragment. +Use quoted bracket notation for YAML policy names containing dots or underscores, for example +`"[prod.federation]"`, to preserve the literal name during Spring Boot map binding. + +```yaml +uaa: + mtls: + resource_policies: + cf-workload-federation: + allowed_resources: + - https://sts.amazonaws.com + - api://AzureADTokenExchange + "[prod.federation]": + allowed_resources: + - https://sts.amazonaws.com +``` + +Clients select a policy with `tls-client-auth-allowed-resources-ref`. The reference is mutually +exclusive with both `tls-client-auth-allowed-resources` and `tls-client-auth-aud-templates`. +Unknown or blank references are rejected during client create, update, and `oauth.clients` +bootstrap. + +UAA resolves the policy from the client's current identity zone on every token request. Updating +the list therefore affects subsequent requests without rewriting client registrations. Removing +a resource denies new requests for it; removing the referenced policy fails explicit resource +requests closed as `invalid_target`. Existing tokens are not changed. No lookup falls back to +another zone. + +Other zones configure the equivalent `config.mtls.resourcePolicies` map through the +identity-zone API, with `allowedResources` as the nested list property. Default-zone YAML is +reapplied at startup. + +**Related Properties:** `uaa.mtls.enabled`, `uaa.mtls.trust_bundles` + +[Back to table](#oauth-clients--users) + +--- + ### `password.policy.global.minLength` **Default:** `0` diff --git a/docs/UAA-mTLS-Getting-Started.md b/docs/UAA-mTLS-Getting-Started.md index 72573e7b673..04579f3deb5 100644 --- a/docs/UAA-mTLS-Getting-Started.md +++ b/docs/UAA-mTLS-Getting-Started.md @@ -406,6 +406,25 @@ An absent allow-list authorizes nothing — it does not silently fall back to an This is mutually exclusive with `tls-client-auth-aud-templates` (Step 9): pick one mechanism per client. +When many clients share the same audiences, an operator can define a named policy in the default +zone's `uaa.yml`: + +```yaml +uaa: + mtls: + resource_policies: + workload-federation: + allowed_resources: + - https://billing.apps.internal + - https://reporting.apps.internal +``` + +The client then uses `"tls-client-auth-allowed-resources-ref": "workload-federation"` instead of +the inline list. Other zones use `config.mtls.resourcePolicies` in the identity-zone API. UAA +resolves the policy on each request, so adding or removing an audience affects new token requests +without updating clients. References never fall back to another zone; a removed policy fails +explicit resource requests as `invalid_target`. + Requesting a value that isn't on the list fails closed, without saying which value was tried: ```bash @@ -569,8 +588,9 @@ whatever value was rejected — a caller cannot use the error message to probe w | `tls-client-auth-required-claims` entry doesn't match | same message as above | Same deliberate non-disclosure | Re-derive the claim values via `tls-client-auth-claim-mappings` and compare by hand | | Sent a `client_secret` (even an empty one), an `Authorization` header, or a `client_assertion` for a client that has `tls-client-auth-ca` | `401 invalid_client`: `tls_client_auth: configured clients must authenticate at /oauth/mtls/token without client credentials` | A certificate-bound client cannot also obtain unbound tokens | Send only `client_id` and `grant_type`; authenticate with the certificate only | | Requested any grant other than `client_credentials` | `400 invalid_grant`: `the mTLS token endpoint only issues client_credentials tokens` | This endpoint exists for workload identity, not user grants | Use `client_credentials` | -| `resource=` not on the client's `tls-client-auth-allowed-resources`, or the client has no list | `400 invalid_target`: `client_id= is not authorized to request the given resource` | An absent allow-list authorizes nothing | Add the value to the list (Step 8), or omit `resource` entirely | -| `tls-client-auth-allowed-resources` and `tls-client-auth-aud-templates` both set | `400 invalid_client`, registration refused: `...are mutually exclusive for client_id=...` | Two mechanisms for setting `aud` on one client | Pick one | +| `resource=` is not in the client's inline list or referenced policy, or the referenced policy was removed | `400 invalid_target`: `client_id= is not authorized to request the given resource` | An absent or removed allow-list authorizes nothing | Add the value to the inline list or referenced policy (Step 8), or omit `resource` entirely | +| `tls-client-auth-allowed-resources-ref` is blank or names an unknown policy | `400 invalid_client`, registration refused | A reference must resolve in the client's identity zone | Configure the named policy first, then register the client with its exact case-sensitive name | +| More than one of `tls-client-auth-allowed-resources`, `tls-client-auth-allowed-resources-ref`, and `tls-client-auth-aud-templates` is set | `400 invalid_client`, registration refused: `...are mutually exclusive for client_id=...` | Only one mechanism may set `aud` on a client | Pick one | | A `tls-client-auth-claim-mappings` `pattern` targets `subject_cn`/`subject_o` | `400 invalid_client`, registration refused | `pattern` only applies to `subject_ou` | Drop `pattern` for `subject_cn`/`subject_o`, or move the mapping to `subject_ou` | | `PATCH /oauth/clients/{id}` | `405` (or an unrelated-looking `403`/CSRF error, depending on what else is mapped at that path) | The client-admin API has no `PATCH` | Use `PUT` with at least `client_id`; omitted fields are preserved | | `curl: (60) SSL certificate problem` on *every* request, including plain `/oauth/token` | — | `--cacert` is pointing at the demo mTLS CA from Step 2 instead of whatever CA signed UAA's own HTTPS certificate | Set `$UAA_CACERT` correctly — see Prerequisites; the two files are unrelated | diff --git a/model/src/main/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfiguration.java b/model/src/main/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfiguration.java index 69b8b7fa849..e1933c73078 100644 --- a/model/src/main/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfiguration.java +++ b/model/src/main/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfiguration.java @@ -30,6 +30,7 @@ public class TlsClientAuthConfiguration { * what the server is willing to believe it may ask for. */ public static final String TLS_CLIENT_AUTH_ALLOWED_RESOURCES = "tls-client-auth-allowed-resources"; + public static final String TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF = "tls-client-auth-allowed-resources-ref"; /** * RFC 8705 section 3.4 client registration metadata, named verbatim like the subject parameters @@ -82,7 +83,8 @@ public class TlsClientAuthConfiguration { TLS_CLIENT_AUTH_SUB_TEMPLATE, TLS_CLIENT_AUTH_AUD_TEMPLATES, TLS_CLIENT_AUTH_REQUIRED_CLAIMS, - TLS_CLIENT_AUTH_ALLOWED_RESOURCES); + TLS_CLIENT_AUTH_ALLOWED_RESOURCES, + TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF); /** Keys that configure the proxy allowed to forward a client certificate. Each requires workload trust. */ public static final List PROXY_TRUST_KEYS = List.of( @@ -219,6 +221,9 @@ public static byte[] parseIpLiteral(String value) { @JsonProperty(TLS_CLIENT_AUTH_ALLOWED_RESOURCES) private List allowedResources; + @JsonProperty(TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF) + private String allowedResourcesRef; + @JsonProperty(TLS_CLIENT_AUTH_SUBJECT_DN) private String subjectDn; @@ -267,6 +272,8 @@ public TlsClientAuthConfiguration(String trustedCaPem, List claimM public List getAllowedResources() { return allowedResources; } public void setAllowedResources(List allowedResources) { this.allowedResources = allowedResources; } + public String getAllowedResourcesRef() { return allowedResourcesRef; } + public void setAllowedResourcesRef(String allowedResourcesRef) { this.allowedResourcesRef = allowedResourcesRef; } public String getSubjectDn() { return subjectDn; } public void setSubjectDn(String subjectDn) { this.subjectDn = subjectDn; } @@ -322,13 +329,15 @@ public boolean equals(Object o) { Objects.equals(sanUri, that.sanUri) && Objects.equals(sanIp, that.sanIp) && Objects.equals(sanEmail, that.sanEmail) && - Objects.equals(allowedResources, that.allowedResources); + Objects.equals(allowedResources, that.allowedResources) && + Objects.equals(allowedResourcesRef, that.allowedResourcesRef); } @Override public int hashCode() { return Objects.hash(trustedCaPem, trustedCaRef, trustedProxyCaRef, claimMappings, subTemplate, audTemplates, trustedProxyCaPem, - trustedProxySubjectDn, requiredClaims, subjectDn, sanDns, sanUri, sanIp, sanEmail, allowedResources); + trustedProxySubjectDn, requiredClaims, subjectDn, sanDns, sanUri, sanIp, sanEmail, allowedResources, + allowedResourcesRef); } public static boolean isConfigured(TlsClientAuthConfiguration config) { diff --git a/model/src/main/java/org/cloudfoundry/identity/uaa/client/UaaClientDetails.java b/model/src/main/java/org/cloudfoundry/identity/uaa/client/UaaClientDetails.java index ee891554bc7..2cd1c135055 100644 --- a/model/src/main/java/org/cloudfoundry/identity/uaa/client/UaaClientDetails.java +++ b/model/src/main/java/org/cloudfoundry/identity/uaa/client/UaaClientDetails.java @@ -334,6 +334,8 @@ public void setTlsClientAuthConfiguration(TlsClientAuthConfiguration tlsClientAu tlsClientAuthConfiguration.getRequiredClaims()); putOrRemove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES, tlsClientAuthConfiguration.getAllowedResources()); + putOrRemove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF, + tlsClientAuthConfiguration.getAllowedResourcesRef()); putOrRemove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUBJECT_DN, tlsClientAuthConfiguration.getSubjectDn()); putOrRemove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SAN_DNS, tlsClientAuthConfiguration.getSanDns()); putOrRemove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SAN_URI, tlsClientAuthConfiguration.getSanUri()); @@ -350,6 +352,7 @@ public void setTlsClientAuthConfiguration(TlsClientAuthConfiguration tlsClientAu this.additionalInformation.remove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_SUBJECT_DN); this.additionalInformation.remove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS); this.additionalInformation.remove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES); + this.additionalInformation.remove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF); TlsClientAuthConfiguration.SUBJECT_BINDING_PARAMETERS.forEach(this.additionalInformation::remove); // Not part of the typed configuration, but meaningless without TLS trust. this.additionalInformation.remove(TlsClientAuthConfiguration.TLS_CLIENT_CERTIFICATE_BOUND_ACCESS_TOKENS); diff --git a/model/src/main/java/org/cloudfoundry/identity/uaa/zone/MtlsConfig.java b/model/src/main/java/org/cloudfoundry/identity/uaa/zone/MtlsConfig.java index 8e291826e4b..d9a7508e3f7 100644 --- a/model/src/main/java/org/cloudfoundry/identity/uaa/zone/MtlsConfig.java +++ b/model/src/main/java/org/cloudfoundry/identity/uaa/zone/MtlsConfig.java @@ -1,6 +1,7 @@ package org.cloudfoundry.identity.uaa.zone; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonSetter; import com.fasterxml.jackson.annotation.Nulls; import lombok.Data; @@ -17,4 +18,8 @@ public class MtlsConfig { @JsonSetter(nulls = Nulls.AS_EMPTY) private Map trustBundles = new LinkedHashMap<>(); + + @JsonSetter(nulls = Nulls.AS_EMPTY) + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private Map resourcePolicies = new LinkedHashMap<>(); } diff --git a/model/src/main/java/org/cloudfoundry/identity/uaa/zone/MtlsResourcePolicy.java b/model/src/main/java/org/cloudfoundry/identity/uaa/zone/MtlsResourcePolicy.java new file mode 100644 index 00000000000..bc3c97acc4f --- /dev/null +++ b/model/src/main/java/org/cloudfoundry/identity/uaa/zone/MtlsResourcePolicy.java @@ -0,0 +1,16 @@ +package org.cloudfoundry.identity.uaa.zone; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class MtlsResourcePolicy { + @JsonSetter(nulls = Nulls.AS_EMPTY) + private List allowedResources = new ArrayList<>(); +} diff --git a/model/src/test/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfigurationTest.java b/model/src/test/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfigurationTest.java index a265fb83dff..4885de50219 100644 --- a/model/src/test/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfigurationTest.java +++ b/model/src/test/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfigurationTest.java @@ -220,6 +220,20 @@ void requiredClaimsRoundTripsViaJson() throws Exception { assertThat(json).contains("tls-client-auth-required-claims"); } + @Test + void allowedResourcesReferenceRoundTripsViaJson() throws Exception { + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(EXAMPLE_CA, null); + config.setAllowedResourcesRef("federation"); + + JsonMapper mapper = new JsonMapper(); + String json = mapper.writeValueAsString(config); + TlsClientAuthConfiguration deserialized = + mapper.readValue(json, TlsClientAuthConfiguration.class); + + assertThat(deserialized.getAllowedResourcesRef()).isEqualTo("federation"); + assertThat(json).contains(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF); + } + @Test void unequalWhenRequiredClaimsDiffer() { TlsClientAuthConfiguration config1 = new TlsClientAuthConfiguration(EXAMPLE_CA, null); diff --git a/model/src/test/java/org/cloudfoundry/identity/uaa/client/UaaClientDetailsTest.java b/model/src/test/java/org/cloudfoundry/identity/uaa/client/UaaClientDetailsTest.java index bd54de44781..317c2dc8a66 100644 --- a/model/src/test/java/org/cloudfoundry/identity/uaa/client/UaaClientDetailsTest.java +++ b/model/src/test/java/org/cloudfoundry/identity/uaa/client/UaaClientDetailsTest.java @@ -455,19 +455,25 @@ class TlsClientAuthConfigurationMirroring { private static final String CA = "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n"; @Test - void namedTrustReferencesSurviveTypedConfigurationAndClearing() { + void namedMtlsReferencesSurviveTypedConfigurationAndClearing() { TlsClientAuthConfiguration config = org.cloudfoundry.identity.uaa.util.JsonUtils.convertValue(Map.of( - "tls-client-auth-ca-ref", "workload", "tls-client-auth-trusted-proxy-ca-ref", "proxy"), + "tls-client-auth-ca-ref", "workload", + "tls-client-auth-trusted-proxy-ca-ref", "proxy", + "tls-client-auth-allowed-resources-ref", "federation"), TlsClientAuthConfiguration.class); UaaClientDetails client = new UaaClientDetails(); client.setTlsClientAuthConfiguration(config); assertThat(client.getAdditionalInformation()) .containsEntry("tls-client-auth-ca-ref", "workload") .containsEntry("tls-client-auth-trusted-proxy-ca-ref", "proxy") + .containsEntry("tls-client-auth-allowed-resources-ref", "federation") .doesNotContainKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); client.setTlsClientAuthConfiguration(null); assertThat(client.getAdditionalInformation()) - .doesNotContainKeys("tls-client-auth-ca-ref", "tls-client-auth-trusted-proxy-ca-ref"); + .doesNotContainKeys( + "tls-client-auth-ca-ref", + "tls-client-auth-trusted-proxy-ca-ref", + "tls-client-auth-allowed-resources-ref"); } private TlsClientAuthConfiguration fullConfiguration() { diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlBeansConfiguration.java b/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlBeansConfiguration.java index cf28ab5bf8c..96efcb7a5b5 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlBeansConfiguration.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlBeansConfiguration.java @@ -40,6 +40,7 @@ import org.cloudfoundry.identity.uaa.zone.IdentityZoneProvisioning; import org.cloudfoundry.identity.uaa.zone.IdentityZoneValidator; import org.cloudfoundry.identity.uaa.zone.Links; +import org.cloudfoundry.identity.uaa.zone.MtlsResourcePolicy; import org.cloudfoundry.identity.uaa.zone.MultitenantClientServices; import org.cloudfoundry.identity.uaa.zone.TokenPolicy; import org.cloudfoundry.identity.uaa.zone.UserConfig; @@ -425,6 +426,9 @@ IdentityZoneConfigurationBootstrap identityZoneConfigurationBootstrap( bean.setMtlsTrustBundles(Binder.get(environment) .bind("uaa.mtls.trust-bundles", Bindable.mapOf(String.class, String.class)) .orElse(Map.of())); + bean.setMtlsResourcePolicies(Binder.get(environment) + .bind("uaa.mtls.resource-policies", Bindable.mapOf(String.class, MtlsResourcePolicy.class)) + .orElse(Map.of())); bean.setValidator(identityZoneValidator); bean.setClientSecretPolicy(defaultUaaClientSecretPolicy); bean.setTokenPolicy(uaaTokenPolicy); diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java index 4b950639e9d..cba13da6dcc 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java @@ -14,6 +14,7 @@ package org.cloudfoundry.identity.uaa.client; import org.cloudfoundry.identity.uaa.zone.IdentityZone; +import org.cloudfoundry.identity.uaa.oauth.tls.MtlsResourcePolicies; import org.cloudfoundry.identity.uaa.oauth.tls.MtlsTrustBundles; import org.cloudfoundry.identity.uaa.oauth.tls.TlsClientAuthSubjectMatcher; import org.cloudfoundry.identity.uaa.constants.OriginKeys; @@ -24,6 +25,7 @@ import org.cloudfoundry.identity.uaa.util.PemCertificateParser; import org.cloudfoundry.identity.uaa.util.UaaUrlUtils; import org.cloudfoundry.identity.uaa.zone.ClientSecretValidator; +import org.cloudfoundry.identity.uaa.zone.MtlsResourcePolicy; import org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -35,8 +37,6 @@ import tools.jackson.core.type.TypeReference; -import java.net.URI; -import java.net.URISyntaxException; import java.util.Arrays; import java.util.Collection; import java.util.Collections; @@ -380,11 +380,23 @@ public static void checkRequestedGrantTypes(Set requestedGrantTypes) { * is used is decided at request time. */ public static void validateMtlsClientConfiguration(Map info, String clientId, IdentityZone zone) { - validateMtlsClientConfiguration(info, clientId, zone.getConfig().getMtls().getTrustBundles()); + validateMtlsClientConfiguration( + info, + clientId, + zone.getConfig().getMtls().getTrustBundles(), + zone.getConfig().getMtls().getResourcePolicies()); } public static void validateMtlsClientConfiguration(Map info, String clientId, Map bundles) { + validateMtlsClientConfiguration(info, clientId, bundles, Map.of()); + } + + public static void validateMtlsClientConfiguration( + Map info, + String clientId, + Map bundles, + Map resourcePolicies) { boolean hasCa = info.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA) || info.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA_REF); boolean hasProxy = info.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA) @@ -422,6 +434,14 @@ public static void validateMtlsClientConfiguration(Map info, Str if (hasCa) { requireExactlyOneSubjectBinding(info, clientId); } + try { + MtlsResourcePolicies.resolve(info, resourcePolicies); + } catch (RuntimeException e) { + throw new InvalidClientDetailsException( + "Invalid mTLS allowed-resource configuration for client_id=" + clientId + + ": " + e.getMessage(), + e); + } } private static void validateProxyIdentity(Map info, boolean hasProxy, @@ -694,14 +714,28 @@ public static void validateTlsClientAuthClaimConfig(Map addition } } - if (additionalInfo.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES)) { + boolean hasAllowedResources = + additionalInfo.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES); + boolean hasAllowedResourcesRef = + additionalInfo.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF); + if (hasAllowedResources && hasAllowedResourcesRef) { + throw new InvalidClientDetailsException( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES + " and " + + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF + + " are mutually exclusive for client_id=" + clientId); + } + if (hasAllowedResources || hasAllowedResourcesRef) { if (additionalInfo.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES)) { throw new InvalidClientDetailsException( - TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES + " and " + (hasAllowedResources + ? TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES + : TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF) + " and " + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES + " are mutually exclusive for client_id=" + clientId + ": two mechanisms for setting aud on one client is a footgun."); } + } + if (hasAllowedResources) { List allowedResources; try { Object rawAllowedResources = @@ -718,15 +752,10 @@ public static void validateTlsClientAuthClaimConfig(Map addition "Invalid " + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES + " for client_id=" + clientId + ": " + e.getMessage(), e); } - if (allowedResources == null || allowedResources.isEmpty()) { - throw new InvalidClientDetailsException( - TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES - + " for client_id=" + clientId + " must not be empty."); - } - for (String resource : allowedResources) { - requireValidResourceIndicator( - resource, TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES, clientId); - } + MtlsResourcePolicies.validateAllowedResources( + allowedResources, + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES + + " for client_id=" + clientId); } if (additionalInfo.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS)) { @@ -764,37 +793,6 @@ public static void validateTlsClientAuthClaimConfig(Map addition } } - /** - * RFC 8707 section 2: "the resource parameter... MUST be an absolute URI... MUST NOT include a - * fragment component." Applied to every {@code tls-client-auth-allowed-resources} entry at - * registration time, since a value that could never be sent as a valid {@code resource} request - * parameter would otherwise sit in the allow-list looking configured while matching nothing. - */ - private static void requireValidResourceIndicator(String resource, String propertyName, String clientId) { - if (resource == null || resource.isBlank()) { - throw new InvalidClientDetailsException( - propertyName + " entry cannot be blank for client_id=" + clientId); - } - URI uri; - try { - uri = new URI(resource); - } catch (URISyntaxException e) { - throw new InvalidClientDetailsException( - propertyName + " entry '" + resource + "' for client_id=" + clientId - + " is not a valid URI (RFC 8707 section 2): " + e.getMessage(), e); - } - if (!uri.isAbsolute()) { - throw new InvalidClientDetailsException( - propertyName + " entry '" + resource + "' for client_id=" + clientId - + " must be an absolute URI (RFC 8707 section 2)."); - } - if (uri.getFragment() != null) { - throw new InvalidClientDetailsException( - propertyName + " entry '" + resource + "' for client_id=" + clientId - + " must not include a fragment component (RFC 8707 section 2)."); - } - } - /** * Rejects a template that contains no {@code {claim}} placeholder at all. * diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/impl/config/IdentityZoneConfigurationBootstrap.java b/server/src/main/java/org/cloudfoundry/identity/uaa/impl/config/IdentityZoneConfigurationBootstrap.java index 1b2ee4c5d72..4233d6e6937 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/impl/config/IdentityZoneConfigurationBootstrap.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/impl/config/IdentityZoneConfigurationBootstrap.java @@ -24,6 +24,7 @@ import org.cloudfoundry.identity.uaa.zone.IdentityZoneProvisioning; import org.cloudfoundry.identity.uaa.zone.IdentityZoneValidator; import org.cloudfoundry.identity.uaa.zone.InvalidIdentityZoneDetailsException; +import org.cloudfoundry.identity.uaa.zone.MtlsResourcePolicy; import org.cloudfoundry.identity.uaa.zone.TokenPolicy; import org.cloudfoundry.identity.uaa.zone.UserConfig; import org.springframework.beans.factory.InitializingBean; @@ -41,6 +42,7 @@ public class IdentityZoneConfigurationBootstrap implements InitializingBean { private ClientSecretPolicy clientSecretPolicy; private Map mtlsTrustBundles = Map.of(); + private Map mtlsResourcePolicies = Map.of(); private boolean mtlsEnabled = false; private TokenPolicy tokenPolicy; @@ -91,6 +93,7 @@ public void afterPropertiesSet() throws InvalidIdentityZoneDetailsException { IdentityZone identityZone = provisioning.retrieve(IdentityZone.getUaaZoneId()); IdentityZoneConfiguration definition = new IdentityZoneConfiguration(tokenPolicy); definition.getMtls().setTrustBundles(mtlsTrustBundles); + definition.getMtls().setResourcePolicies(mtlsResourcePolicies); definition.getMtls().setEnabled(mtlsEnabled); definition.setClientSecretPolicy(clientSecretPolicy); definition.getLinks().getSelfService().setSelfServiceLinksEnabled(selfServiceLinksEnabled); diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java index e14e6dd7775..f1b39f983c5 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java @@ -9,6 +9,8 @@ import org.cloudfoundry.identity.uaa.oauth.provider.ClientDetailsService; import org.cloudfoundry.identity.uaa.oauth.provider.OAuth2Authentication; import org.cloudfoundry.identity.uaa.util.UaaSecurityContextUtils; +import org.cloudfoundry.identity.uaa.zone.MtlsResourcePolicy; +import org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManager; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import java.security.MessageDigest; @@ -61,12 +63,16 @@ public class MtlsClaimsEnhancer implements UaaTokenEnhancer { private final TlsClientAuthentication tlsClientAuthentication; private final ClientDetailsService clientDetailsService; + private final IdentityZoneManager identityZoneManager; @Autowired - public MtlsClaimsEnhancer(TlsClientAuthentication tlsClientAuthentication, - ClientDetailsService clientDetailsService) { + public MtlsClaimsEnhancer( + TlsClientAuthentication tlsClientAuthentication, + ClientDetailsService clientDetailsService, + IdentityZoneManager identityZoneManager) { this.tlsClientAuthentication = tlsClientAuthentication; this.clientDetailsService = clientDetailsService; + this.identityZoneManager = identityZoneManager; } /** @@ -220,7 +226,15 @@ public Map enhance(Map claims, OAuth2Authenticat String resource = authentication.getOAuth2Request().getRequestParameters() .get(TlsClientAuthConfiguration.RESOURCE_PARAMETER); if (resource != null && !resource.isBlank()) { - List allowedResources = config.getAllowedResources(); + List allowedResources; + try { + Map policies = identityZoneManager + .getCurrentIdentityZone().getConfig().getMtls().getResourcePolicies(); + allowedResources = MtlsResourcePolicies.resolve(config, policies); + } catch (RuntimeException e) { + throw new InvalidTargetException( + "client_id=" + clientId + " is not authorized to request the given resource"); + } boolean workloadGrant = GRANT_TYPE_CLIENT_CREDENTIALS.equals( authentication.getOAuth2Request().getGrantType()); if (!workloadGrant || allowedResources == null || !allowedResources.contains(resource)) { diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsResourcePolicies.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsResourcePolicies.java new file mode 100644 index 00000000000..a3601b87e75 --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsResourcePolicies.java @@ -0,0 +1,138 @@ +package org.cloudfoundry.identity.uaa.oauth.tls; + +import org.cloudfoundry.identity.uaa.client.InvalidClientDetailsException; +import org.cloudfoundry.identity.uaa.client.TlsClientAuthConfiguration; +import org.cloudfoundry.identity.uaa.zone.MtlsResourcePolicy; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.List; +import java.util.Map; + +/** Resolves allowed resources only from the supplied zone configuration. */ +public final class MtlsResourcePolicies { + + private static final String NAME_PATTERN = "[A-Za-z0-9][A-Za-z0-9._-]{0,127}"; + + private MtlsResourcePolicies() { + } + + public static void validate(Map policies) { + if (policies == null) { + return; + } + policies.forEach((name, policy) -> { + if (name == null || !name.matches(NAME_PATTERN)) { + throw new InvalidClientDetailsException("Invalid mTLS resource policy name: " + name); + } + if (policy == null) { + throw new InvalidClientDetailsException( + "Invalid mTLS resource policy '" + name + "': policy must not be null"); + } + validateAllowedResources( + policy.getAllowedResources(), "mTLS resource policy '" + name + "'"); + }); + } + + public static List resolve( + Map info, Map policies) { + if (info == null) { + return null; + } + String inlineKey = TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES; + String referenceKey = TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF; + if (info.containsKey(inlineKey) && info.containsKey(referenceKey)) { + throw new InvalidClientDetailsException( + inlineKey + " and " + referenceKey + " are mutually exclusive"); + } + if (info.containsKey(referenceKey)) { + if (!(info.get(referenceKey) instanceof String name) || name.isBlank()) { + throw new InvalidClientDetailsException( + referenceKey + " must name a nonblank mTLS resource policy"); + } + return resolve(name, policies); + } + List allowedResources = TlsClientAuthConfigurationReader.readAllowedResources(info); + if (allowedResources != null) { + validateAllowedResources(allowedResources, inlineKey); + } + return allowedResources; + } + + public static List resolve( + TlsClientAuthConfiguration config, Map policies) { + if (config == null) { + return null; + } + if (config.getAllowedResources() != null && config.getAllowedResourcesRef() != null) { + throw new InvalidClientDetailsException( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES + " and " + + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF + + " are mutually exclusive"); + } + if (config.getAllowedResourcesRef() != null) { + if (config.getAllowedResourcesRef().isBlank()) { + throw new InvalidClientDetailsException( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF + + " must name a nonblank mTLS resource policy"); + } + return resolve(config.getAllowedResourcesRef(), policies); + } + if (config.getAllowedResources() != null) { + validateAllowedResources( + config.getAllowedResources(), + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES); + } + return config.getAllowedResources(); + } + + public static void validateAllowedResources(List allowedResources, String propertyName) { + if (allowedResources == null || allowedResources.isEmpty()) { + throw new InvalidClientDetailsException(propertyName + " must not be empty"); + } + for (String resource : allowedResources) { + requireValidResourceIndicator(resource, propertyName); + } + } + + private static List resolve( + String name, Map policies) { + MtlsResourcePolicy policy = policies == null ? null : policies.get(name); + if (policy == null) { + throw new InvalidClientDetailsException( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF + + ": unknown mTLS resource policy '" + name + "'"); + } + validateAllowedResources( + policy.getAllowedResources(), "mTLS resource policy '" + name + "'"); + return List.copyOf(policy.getAllowedResources()); + } + + /** + * RFC 8707 section 2: resource indicators are absolute URIs without a fragment. + */ + private static void requireValidResourceIndicator(String resource, String propertyName) { + if (resource == null || resource.isBlank()) { + throw new InvalidClientDetailsException(propertyName + " entry cannot be blank"); + } + URI uri; + try { + uri = new URI(resource); + } catch (URISyntaxException e) { + throw new InvalidClientDetailsException( + propertyName + " entry '" + resource + + "' is not a valid URI (RFC 8707 section 2): " + e.getMessage(), + e); + } + if (!uri.isAbsolute()) { + throw new InvalidClientDetailsException( + propertyName + " entry '" + resource + + "' must be an absolute URI (RFC 8707 section 2)"); + } + if (uri.getFragment() != null) { + throw new InvalidClientDetailsException( + propertyName + " entry '" + resource + + "' must not include a fragment component (RFC 8707 section 2)"); + } + } +} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsTrustBundleStartupValidator.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsTrustBundleStartupValidator.java index 1934e4306bf..0a6a2ac8042 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsTrustBundleStartupValidator.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsTrustBundleStartupValidator.java @@ -7,13 +7,13 @@ import org.springframework.stereotype.Component; /** - * Checks the default zone's mTLS trust-bundle definitions after the zone bootstrap has finished. + * Checks the default zone's named mTLS configuration after the zone bootstrap has finished. * *

Only the default zone is inspected, and only when mTLS is enabled for both the deployment and * that zone: installations can have tens of thousands of zones, so startup must not scale with the - * tenant count. Tenant bundle definitions are validated on every zone update. Client references to - * bundles are not checked here, because that would require reading every client of the zone; a - * reference that does not resolve is denied at token time. + * tenant count. Tenant definitions are validated on every zone update. Client references are not + * checked here, because that would require reading every client of the zone; a reference that does + * not resolve is denied at token time. */ @Component public class MtlsTrustBundleStartupValidator implements SmartInitializingSingleton { @@ -34,6 +34,7 @@ public void afterSingletonsInstantiated() { IdentityZone defaultZone = zones.retrieve(IdentityZone.getUaaZoneId()); if (MtlsFeaturePolicy.isEnabled(true, defaultZone)) { MtlsTrustBundles.validate(defaultZone.getConfig().getMtls().getTrustBundles()); + MtlsResourcePolicies.validate(defaultZone.getConfig().getMtls().getResourcePolicies()); } } } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthConfigurationReader.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthConfigurationReader.java index c2679082ed6..cdbe12ce325 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthConfigurationReader.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthConfigurationReader.java @@ -49,6 +49,8 @@ public static TlsClientAuthConfiguration read(Map info) { cfg.setAudTemplates(readJson(info.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES), STRING_LIST)); cfg.setRequiredClaims(readJson(info.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS), STRING_MAP)); cfg.setAllowedResources(readAllowedResources(info)); + cfg.setAllowedResourcesRef(flatString( + info, TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF)); cfg.setSubjectDn(flatString(info, TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUBJECT_DN)); cfg.setSanDns(flatString(info, TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SAN_DNS)); cfg.setSanUri(flatString(info, TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SAN_URI)); diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/token/UaaTokenEndpoint.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/token/UaaTokenEndpoint.java index 6ac9c5ffbb5..48e92b886c0 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/token/UaaTokenEndpoint.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/token/UaaTokenEndpoint.java @@ -11,9 +11,11 @@ import org.cloudfoundry.identity.uaa.oauth.common.exceptions.InvalidGrantException; import org.cloudfoundry.identity.uaa.oauth.common.exceptions.InvalidTargetException; import org.cloudfoundry.identity.uaa.oauth.provider.endpoint.TokenEndpoint; +import org.cloudfoundry.identity.uaa.oauth.tls.MtlsResourcePolicies; import org.cloudfoundry.identity.uaa.oauth.tls.RawPeerCertificateCaptureFilter; -import org.cloudfoundry.identity.uaa.oauth.tls.TlsClientAuthConfigurationReader; import org.cloudfoundry.identity.uaa.zone.MultitenantClientServices; +import org.cloudfoundry.identity.uaa.zone.MtlsResourcePolicy; +import org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManager; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.HttpMethod; import org.springframework.http.ResponseEntity; @@ -49,18 +51,21 @@ public class UaaTokenEndpoint extends TokenEndpoint { private final boolean allowQueryString; + private final IdentityZoneManager identityZoneManager; public UaaTokenEndpoint( final @Qualifier("authorizationRequestManager") OAuth2RequestFactory oAuth2RequestFactory, final @Qualifier("jdbcClientDetailsService") MultitenantClientServices clientDetailsService, final @Qualifier("oauth2RequestValidator") OAuth2RequestValidator oAuth2RequestValidator, final @Qualifier("oauth2TokenGranter") TokenGranter tokenGranter, - final @Qualifier("allowQueryStringForTokens") Boolean allowQueryStringForTokens + final @Qualifier("allowQueryStringForTokens") Boolean allowQueryStringForTokens, + final @Qualifier("identityZoneManager") IdentityZoneManager identityZoneManager ) { this.setOAuth2RequestFactory(oAuth2RequestFactory); this.setClientDetailsService(clientDetailsService); this.setOAuth2RequestValidator(oAuth2RequestValidator); this.setTokenGranter(tokenGranter); + this.identityZoneManager = identityZoneManager; this.allowQueryString = Boolean.TRUE.equals(Optional .ofNullable(allowQueryStringForTokens) @@ -128,10 +133,10 @@ private static void rejectNonWorkloadGrantAtMtlsEndpoint( /** * RFC 8707 section 2: a client may request a specific {@code resource} (audience) at the mTLS - * token endpoint, but only one drawn from a per-client allow-list -- {@code - * tls-client-auth-allowed-resources}, validated for shape at registration by {@code - * ClientAdminEndpointsValidator} -- since without curation any client could name an arbitrary - * audience for itself, the same forgery shape already closed for {@code + * token endpoint, but only one drawn from a per-client inline or named allow-list -- {@code + * tls-client-auth-allowed-resources} or {@code tls-client-auth-allowed-resources-ref}, validated + * at registration by {@code ClientAdminEndpointsValidator} -- since without curation any client + * could name an arbitrary audience for itself, the same forgery shape already closed for {@code * tls-client-auth-sub-template}/{@code -aud-templates}. Runs before the grant, so an * unauthorized or malformed resource never reaches token issuance. * @@ -187,11 +192,12 @@ private static void requireValidResourceSyntax(String resource) { } /** - * The client's {@code tls-client-auth-allowed-resources}: the typed field if one is set, otherwise - * {@code additionalInformation} -- the same two-step lookup {@code MtlsClaimsEnhancer} uses for the - * rest of this configuration. Clients loaded from the database carry their configuration in - * {@code additionalInformation}, so that is the path that matters in practice. Returns an empty list (authorizing nothing) rather than throwing when the - * client cannot be loaded or the value cannot be parsed, so a lookup failure fails closed. + * The client's inline allowed resources or its current zone's referenced named policy. The typed + * field is used when set, otherwise {@code additionalInformation} is read -- the same two-step + * lookup {@code MtlsClaimsEnhancer} uses for the rest of this configuration. Clients loaded from + * the database carry their configuration in {@code additionalInformation}, so that is the path + * that matters in practice. Returns an empty list (authorizing nothing) rather than throwing when + * the client or policy cannot be loaded or parsed, so a lookup failure fails closed. */ private List allowedResourcesFor(String clientId) { ClientDetails client; @@ -200,16 +206,21 @@ private List allowedResourcesFor(String clientId) { } catch (Exception e) { return List.of(); } - if (client instanceof UaaClientDetails uaaClient) { - TlsClientAuthConfiguration typed = uaaClient.getTlsClientAuthConfiguration(); - if (typed != null && typed.getAllowedResources() != null) { - return typed.getAllowedResources(); - } - } try { - List allowed = TlsClientAuthConfigurationReader.readAllowedResources(client.getAdditionalInformation()); + Map policies = identityZoneManager == null + ? Map.of() + : identityZoneManager.getCurrentIdentityZone().getConfig().getMtls().getResourcePolicies(); + if (client instanceof UaaClientDetails uaaClient) { + TlsClientAuthConfiguration typed = uaaClient.getTlsClientAuthConfiguration(); + if (typed != null + && (typed.getAllowedResources() != null || typed.getAllowedResourcesRef() != null)) { + List allowed = MtlsResourcePolicies.resolve(typed, policies); + return allowed == null ? List.of() : allowed; + } + } + List allowed = MtlsResourcePolicies.resolve(client.getAdditionalInformation(), policies); return allowed == null ? List.of() : allowed; - } catch (IllegalArgumentException e) { + } catch (RuntimeException e) { return List.of(); } } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/GeneralIdentityZoneConfigurationValidator.java b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/GeneralIdentityZoneConfigurationValidator.java index fb6c4ebce78..ca38ccf43b5 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/GeneralIdentityZoneConfigurationValidator.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/GeneralIdentityZoneConfigurationValidator.java @@ -1,6 +1,7 @@ package org.cloudfoundry.identity.uaa.zone; import org.cloudfoundry.identity.uaa.oauth.token.TokenConstants; +import org.cloudfoundry.identity.uaa.oauth.tls.MtlsResourcePolicies; import org.cloudfoundry.identity.uaa.oauth.tls.MtlsTrustBundles; import org.cloudfoundry.identity.uaa.client.InvalidClientDetailsException; import org.cloudfoundry.identity.uaa.saml.SamlKey; @@ -31,6 +32,7 @@ public IdentityZoneConfiguration validate(IdentityZone zone, IdentityZoneValidat if (mode == IdentityZoneValidator.Mode.CREATE || mode == IdentityZoneValidator.Mode.MODIFY) { try { MtlsTrustBundles.validate(config.getMtls().getTrustBundles()); + MtlsResourcePolicies.validate(config.getMtls().getResourcePolicies()); } catch (InvalidClientDetailsException e) { throw new InvalidIdentityZoneConfigurationException(e.getMessage(), e); } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapTests.java index caa23bec41f..68345fb3394 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapTests.java @@ -12,6 +12,7 @@ import org.cloudfoundry.identity.uaa.provider.NoSuchClientException; import org.cloudfoundry.identity.uaa.zone.IdentityZone; import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; +import org.cloudfoundry.identity.uaa.zone.MtlsResourcePolicy; import org.cloudfoundry.identity.uaa.zone.MultitenantJdbcClientDetailsService; import org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManager; import org.junit.jupiter.api.BeforeEach; @@ -739,6 +740,40 @@ void bootstrapClientBundleReferenceIsValidatedAgainstTheSavedDefaultZone() { } } + @Test + void bootstrapClientResourcePolicyReferenceIsValidatedAgainstTheSavedDefaultZone() { + org.cloudfoundry.identity.uaa.zone.JdbcIdentityZoneProvisioning zones = + new org.cloudfoundry.identity.uaa.zone.JdbcIdentityZoneProvisioning(jdbcTemplate); + IdentityZone saved = zones.retrieve(IdentityZone.getUaaZoneId()); + MtlsResourcePolicy policy = new MtlsResourcePolicy(); + policy.setAllowedResources(List.of("https://sts.amazonaws.com")); + saved.getConfig().getMtls().setResourcePolicies(Map.of("federation", policy)); + zones.update(saved); + IdentityZone cached = IdentityZone.getUaa(); + cached.getConfig().getMtls().setEnabled(true); + IdentityZoneHolder.set(cached); + IdentityZoneHolder.setProvisioning(zones); + try { + Map map = createClientMap("foo"); + map.put("authorized-grant-types", GRANT_TYPE_CLIENT_CREDENTIALS); + map.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT); + map.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUBJECT_DN, "CN=app"); + map.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF, "federation"); + clients.put((String) map.get("id"), map); + + assertThatCode(() -> clientAdminBootstrap.afterPropertiesSet()).doesNotThrowAnyException(); + assertThat(multitenantJdbcClientDetailsService.loadClientByClientId( + (String) map.get("id"), IdentityZone.getUaaZoneId()).getAdditionalInformation()) + .containsEntry( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF, + "federation"); + } finally { + IdentityZoneHolder.setProvisioning(null); + saved.getConfig().getMtls().setResourcePolicies(Map.of()); + zones.update(saved); + } + } + @Test void proxyTrustWithoutWorkloadTrustIsRejectedDuringBootstrap() { Map map = createClientMap("foo"); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java index e9898c484c0..ccb6f5d61af 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidatorTests.java @@ -23,6 +23,7 @@ import org.cloudfoundry.identity.uaa.zone.ClientSecretPolicy; import org.cloudfoundry.identity.uaa.zone.ClientSecretValidator; import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; +import org.cloudfoundry.identity.uaa.zone.MtlsResourcePolicy; import org.cloudfoundry.identity.uaa.zone.ZoneAwareClientSecretPolicyValidator; import org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManagerImpl; import org.junit.jupiter.api.BeforeEach; @@ -428,6 +429,68 @@ void acceptsProxyTrustTogetherWithWorkloadTrust() { assertThatCode(() -> mtlsEnabledValidator().validate(client, false, false)).doesNotThrowAnyException(); } + @Test + void acceptsKnownAllowedResourcesPolicyReference() { + MtlsResourcePolicy policy = new MtlsResourcePolicy(); + policy.setAllowedResources(List.of("https://sts.amazonaws.com")); + IdentityZoneHolder.get().getConfig().getMtls() + .setResourcePolicies(Map.of("federation", policy)); + client.setAuthorizedGrantTypes(Set.of("client_credentials")); + client.setAdditionalInformation(new java.util.HashMap<>(Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT, + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUBJECT_DN, "CN=app", + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF, "federation"))); + + UaaClientDetails validated = + (UaaClientDetails) mtlsEnabledValidator().validate(client, false, false); + + assertThat(validated.getAdditionalInformation()).containsEntry( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF, "federation"); + } + + @ParameterizedTest + @ValueSource(strings = {"", " ", "missing"}) + void rejectsBlankOrUnknownAllowedResourcesPolicyReference(String reference) { + client.setAuthorizedGrantTypes(Set.of("client_credentials")); + client.setAdditionalInformation(new java.util.HashMap<>(Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT, + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUBJECT_DN, "CN=app", + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF, reference))); + + assertThatThrownBy(() -> mtlsEnabledValidator().validate(client, false, false)) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF); + } + + @Test + void rejectsAllowedResourcesReferenceWithInlineListOrAudienceTemplates() { + MtlsResourcePolicy policy = new MtlsResourcePolicy(); + policy.setAllowedResources(List.of("https://sts.amazonaws.com")); + IdentityZoneHolder.get().getConfig().getMtls() + .setResourcePolicies(Map.of("federation", policy)); + Map info = new java.util.HashMap<>(Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT, + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUBJECT_DN, "CN=app", + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF, "federation", + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES, + List.of("https://sts.amazonaws.com"))); + client.setAuthorizedGrantTypes(Set.of("client_credentials")); + client.setAdditionalInformation(info); + + assertThatThrownBy(() -> mtlsEnabledValidator().validate(client, false, false)) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("mutually exclusive"); + + info.remove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(Map.of("field", "subject_cn", "claim", "app_id"))); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, List.of("{app_id}")); + client.setAdditionalInformation(info); + assertThatThrownBy(() -> mtlsEnabledValidator().validate(client, false, false)) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("mutually exclusive"); + } + /** /oauth/mtls/token issues only client_credentials, and an mTLS client is refused on every other path. */ @ParameterizedTest(name = "{0}") @ValueSource(strings = {"authorization_code", "password", "refresh_token"}) @@ -483,6 +546,12 @@ void implicitStaysValidForAClientWithoutMtls() { @ParameterizedTest(name = "{0}") @MethodSource("mtlsOnlySettingsWithoutWorkloadTrust") void rejectsMtlsOnlySettingsWithoutWorkloadTrust(String key, Map settings) { + if (settings.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF)) { + MtlsResourcePolicy policy = new MtlsResourcePolicy(); + policy.setAllowedResources(List.of("https://sts.amazonaws.com")); + IdentityZoneHolder.get().getConfig().getMtls() + .setResourcePolicies(Map.of("federation", policy)); + } client.setAuthorizedGrantTypes(java.util.Set.of("client_credentials")); client.setAdditionalInformation(new java.util.HashMap<>(settings)); @@ -519,7 +588,10 @@ static java.util.stream.Stream mtls TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, List.of("aud:{app_id}"))), org.junit.jupiter.params.provider.Arguments.of("allowed_resources", Map.of(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES, - List.of("https://billing.example.com")))); + List.of("https://billing.example.com"))), + org.junit.jupiter.params.provider.Arguments.of("allowed_resources_ref", + Map.of(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF, + "federation"))); } private ClientAdminEndpointsValidator mtlsEnabledValidator() { diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/config/IdentityZoneConfigurationBootstrapTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/config/IdentityZoneConfigurationBootstrapTests.java index 057ef2fdd35..373b2257b07 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/config/IdentityZoneConfigurationBootstrapTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/config/IdentityZoneConfigurationBootstrapTests.java @@ -14,6 +14,7 @@ import org.cloudfoundry.identity.uaa.zone.IdentityZoneProvisioning; import org.cloudfoundry.identity.uaa.zone.InvalidIdentityZoneDetailsException; import org.cloudfoundry.identity.uaa.zone.JdbcIdentityZoneProvisioning; +import org.cloudfoundry.identity.uaa.zone.MtlsResourcePolicy; import org.cloudfoundry.identity.uaa.zone.SamlConfig; import org.cloudfoundry.identity.uaa.zone.TokenPolicy; import org.cloudfoundry.identity.uaa.zone.UserConfig; @@ -105,6 +106,21 @@ void bootstrapCopiesNamedTrustBundlesToDefaultZone() throws Exception { assertThat(config).containsEntry("mtls", Map.of("enabled", true, "trustBundles", Map.of("configured-ca", certificate1()))); } + @Test + void bootstrapCopiesNamedResourcePoliciesToDefaultZone() throws Exception { + MtlsResourcePolicy policy = new MtlsResourcePolicy(); + policy.setAllowedResources(List.of("https://sts.amazonaws.com")); + org.springframework.beans.BeanWrapper properties = new org.springframework.beans.BeanWrapperImpl(bootstrap); + assertThat(properties.isWritableProperty("mtlsResourcePolicies")).isTrue(); + properties.setPropertyValue("mtlsResourcePolicies", Map.of("federation", policy)); + + bootstrap.afterPropertiesSet(); + + assertThat(provisioning.retrieve(IdentityZone.getUaaZoneId()).getConfig().getMtls() + .getResourcePolicies().get("federation").getAllowedResources()) + .containsExactly("https://sts.amazonaws.com"); + } + @Test void defaultZoneMtlsIsDisabledUnlessTheDeploymentEnablesIt() throws Exception { bootstrap.afterPropertiesSet(); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/config/IdentityZoneConfigurationTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/config/IdentityZoneConfigurationTests.java index 0ef30881a1a..b419e502f56 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/config/IdentityZoneConfigurationTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/config/IdentityZoneConfigurationTests.java @@ -23,6 +23,7 @@ import org.cloudfoundry.identity.uaa.zone.IdentityZone; import org.cloudfoundry.identity.uaa.zone.IdentityZoneConfiguration; import org.cloudfoundry.identity.uaa.zone.Links; +import org.cloudfoundry.identity.uaa.zone.MtlsResourcePolicy; import org.cloudfoundry.identity.uaa.zone.SamlConfig; import org.cloudfoundry.identity.uaa.zone.TokenPolicy; import org.cloudfoundry.identity.uaa.zone.UserConfig; @@ -53,6 +54,7 @@ void configure() { void mtlsConfigurationDefaultsToDisabledWithNoBundles() { assertThat(definition.getMtls().isEnabled()).isFalse(); assertThat(definition.getMtls().getTrustBundles()).isEmpty(); + assertThat(definition.getMtls().getResourcePolicies()).isEmpty(); } @Test @@ -85,6 +87,20 @@ void nestedMtlsConfigurationSurvivesJsonRoundTrip() { .doesNotContainKey("mtlsTrustBundles"); } + @Test + void namedMtlsResourcePoliciesSurviveJsonRoundTrip() { + MtlsResourcePolicy policy = new MtlsResourcePolicy(); + policy.setAllowedResources(java.util.List.of( + "https://sts.amazonaws.com", "api://AzureADTokenExchange")); + definition.getMtls().setResourcePolicies(java.util.Map.of("federation", policy)); + + IdentityZoneConfiguration roundTripped = JsonUtils.readValue( + JsonUtils.writeValueAsString(definition), IdentityZoneConfiguration.class); + + assertThat(roundTripped.getMtls().getResourcePolicies().get("federation").getAllowedResources()) + .containsExactly("https://sts.amazonaws.com", "api://AzureADTokenExchange"); + } + @org.junit.jupiter.params.ParameterizedTest @org.junit.jupiter.params.provider.ValueSource(strings = {"{\"mtls\":null}", "{\"mtls\":{\"enabled\":null}}"}) void nullMtlsPolicyMeansTheDisabledDefault(String json) { @@ -92,6 +108,7 @@ void nullMtlsPolicyMeansTheDisabledDefault(String json) { IdentityZoneConfiguration configuration = JsonUtils.readValue(json, IdentityZoneConfiguration.class); assertThat(configuration.getMtls().isEnabled()).isFalse(); assertThat(configuration.getMtls().getTrustBundles()).isEmpty(); + assertThat(configuration.getMtls().getResourcePolicies()).isEmpty(); } @Test diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java index 8987c64cd7a..9bd0e3d1cf1 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java @@ -8,6 +8,7 @@ import org.cloudfoundry.identity.uaa.oauth.provider.OAuth2Authentication; import org.cloudfoundry.identity.uaa.oauth.provider.OAuth2Request; import org.cloudfoundry.identity.uaa.provider.ClientRegistrationException; +import org.cloudfoundry.identity.uaa.zone.MtlsResourcePolicy; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -73,7 +74,10 @@ void setUp() { // per-test exactly as before. tlsClientAuthentication = spy(new TlsClientAuthentication()); clientDetailsService = mock(ClientDetailsService.class); - enhancer = new MtlsClaimsEnhancer(tlsClientAuthentication, clientDetailsService); + enhancer = new MtlsClaimsEnhancer( + tlsClientAuthentication, + clientDetailsService, + new org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManagerImpl()); } @Test @@ -776,6 +780,57 @@ void resourceOnTheAllowListBecomesAud() throws Exception { assertThat(result).containsEntry("aud", List.of("https://billing.apps.internal")); } + @Test + void namedResourcePolicyIsResolvedForEveryTokenRequest() throws Exception { + X509Certificate cert = mockCfCert(); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); + TlsClientAuthConfiguration config = cfMappingsConfig(); + config.setAllowedResourcesRef("federation"); + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration(config); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + setResourcePolicy("federation", "https://billing.apps.internal"); + assertThat(enhancer.enhance(new HashMap<>(), + mockAuthenticationRequesting( + "instance-identity", + "https://billing.apps.internal", + GRANT_TYPE_CLIENT_CREDENTIALS))) + .containsEntry("aud", List.of("https://billing.apps.internal")); + + setResourcePolicy("federation", "https://reporting.apps.internal"); + assertThatThrownBy(() -> enhancer.enhance(new HashMap<>(), + mockAuthenticationRequesting( + "instance-identity", + "https://billing.apps.internal", + GRANT_TYPE_CLIENT_CREDENTIALS))) + .isInstanceOf(InvalidTargetException.class); + assertThat(enhancer.enhance(new HashMap<>(), + mockAuthenticationRequesting( + "instance-identity", + "https://reporting.apps.internal", + GRANT_TYPE_CLIENT_CREDENTIALS))) + .containsEntry("aud", List.of("https://reporting.apps.internal")); + + org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder.get().getConfig().getMtls() + .setResourcePolicies(Map.of()); + assertThatThrownBy(() -> enhancer.enhance(new HashMap<>(), + mockAuthenticationRequesting( + "instance-identity", + "https://reporting.apps.internal", + GRANT_TYPE_CLIENT_CREDENTIALS))) + .isInstanceOf(InvalidTargetException.class); + } + + private static void setResourcePolicy(String name, String... resources) { + MtlsResourcePolicy policy = new MtlsResourcePolicy(); + policy.setAllowedResources(List.of(resources)); + org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder.get().getConfig().getMtls() + .setResourcePolicies(Map.of(name, policy)); + } + @Test void resourceOffTheAllowListIsRefusedEvenWithoutTheEndpointCheck() throws Exception { X509Certificate cert = mockCfCert(); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsResourcePoliciesTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsResourcePoliciesTest.java new file mode 100644 index 00000000000..14318684dcd --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsResourcePoliciesTest.java @@ -0,0 +1,88 @@ +package org.cloudfoundry.identity.uaa.oauth.tls; + +import org.cloudfoundry.identity.uaa.client.InvalidClientDetailsException; +import org.cloudfoundry.identity.uaa.client.TlsClientAuthConfiguration; +import org.cloudfoundry.identity.uaa.zone.MtlsResourcePolicy; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class MtlsResourcePoliciesTest { + + private static final String RESOURCE = "https://sts.example.com"; + + @Test + void resolvesOnlyTheNamedPolicyFromTheSuppliedZone() { + Map client = Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF, "federation"); + + assertThat(MtlsResourcePolicies.resolve( + client, Map.of("federation", policy(RESOURCE)))) + .containsExactly(RESOURCE); + assertThatThrownBy(() -> MtlsResourcePolicies.resolve( + client, Map.of("other-zone-policy", policy(RESOURCE)))) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("unknown mTLS resource policy"); + } + + @Test + void resolvesCurrentPolicyContentsWithoutChangingTheClientReference() { + Map client = Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF, "federation"); + Map policies = + new HashMap<>(Map.of("federation", policy(RESOURCE))); + + assertThat(MtlsResourcePolicies.resolve(client, policies)).containsExactly(RESOURCE); + + policies.put("federation", policy("api://AzureADTokenExchange")); + + assertThat(MtlsResourcePolicies.resolve(client, policies)) + .containsExactly("api://AzureADTokenExchange"); + assertThat(client).containsEntry( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF, "federation"); + } + + @ParameterizedTest + @ValueSource(strings = {"not-a-uri", "https://api.example.com#fragment", " "}) + void rejectsInvalidPolicyResources(String resource) { + assertThatThrownBy(() -> MtlsResourcePolicies.validate( + Map.of("federation", policy(resource)))) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("federation"); + } + + @Test + void rejectsEmptyPolicyAndInvalidName() { + assertThatThrownBy(() -> MtlsResourcePolicies.validate( + Map.of("federation", policy()))) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("must not be empty"); + assertThatThrownBy(() -> MtlsResourcePolicies.validate( + Map.of("invalid name", policy(RESOURCE)))) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("name"); + } + + @Test + void inlineListAndReferenceAreMutuallyExclusive() { + assertThatThrownBy(() -> MtlsResourcePolicies.resolve(Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES, List.of(RESOURCE), + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF, "federation"), + Map.of("federation", policy(RESOURCE)))) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("mutually exclusive"); + } + + private static MtlsResourcePolicy policy(String... resources) { + MtlsResourcePolicy policy = new MtlsResourcePolicy(); + policy.setAllowedResources(List.of(resources)); + return policy; + } +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsTrustBundleStartupValidatorTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsTrustBundleStartupValidatorTest.java index c65479da44b..7f2634e94aa 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsTrustBundleStartupValidatorTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsTrustBundleStartupValidatorTest.java @@ -3,6 +3,7 @@ import org.cloudfoundry.identity.uaa.client.InvalidClientDetailsException; import org.cloudfoundry.identity.uaa.zone.IdentityZone; import org.cloudfoundry.identity.uaa.zone.IdentityZoneProvisioning; +import org.cloudfoundry.identity.uaa.zone.MtlsResourcePolicy; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -38,6 +39,18 @@ void invalidDefaultZoneBundleDefinitionPreventsStartup() { .hasMessageContaining("broken"); } + @Test + void invalidDefaultZoneResourcePolicyPreventsStartup() { + IdentityZone zone = givenDefaultZone(true, Map.of()); + MtlsResourcePolicy policy = new MtlsResourcePolicy(); + policy.setAllowedResources(java.util.List.of("not-a-uri")); + zone.getConfig().getMtls().setResourcePolicies(Map.of("broken-policy", policy)); + + assertThatThrownBy(() -> validator(true).afterSingletonsInstantiated()) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("broken-policy"); + } + @Test void neverLooksUpOtherZones() { givenDefaultZone(true, Map.of()); @@ -63,11 +76,13 @@ void defaultZoneDisabledSkipsValidation() { assertThatCode(() -> validator(true).afterSingletonsInstantiated()).doesNotThrowAnyException(); } - private void givenDefaultZone(boolean mtlsEnabled, Map bundles) { + private IdentityZone givenDefaultZone(boolean mtlsEnabled, Map bundles) { IdentityZone zone = IdentityZone.getUaa(); zone.getConfig().getMtls().setEnabled(mtlsEnabled); zone.getConfig().getMtls().setTrustBundles(bundles); + zone.getConfig().getMtls().setResourcePolicies(Map.of()); when(zones.retrieve(IdentityZone.getUaaZoneId())).thenReturn(zone); + return zone; } private MtlsTrustBundleStartupValidator validator(boolean deploymentEnabled) { diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthConfigurationReaderTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthConfigurationReaderTest.java index d4a23b7955a..e4c384bddb4 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthConfigurationReaderTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthConfigurationReaderTest.java @@ -29,6 +29,7 @@ void read_flatConfiguration_populatesEverySetting() { info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA, "proxy-pem"); info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_SUBJECT_DN, "CN=router"); info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, "{app_id}"); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF, "federation"); info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SAN_URI, "spiffe://cf/app"); info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUBJECT_DN, " "); @@ -40,6 +41,7 @@ void read_flatConfiguration_populatesEverySetting() { assertThat(config.getTrustedProxyCaPem()).isEqualTo("proxy-pem"); assertThat(config.getTrustedProxySubjectDn()).isEqualTo("CN=router"); assertThat(config.getSubTemplate()).isEqualTo("{app_id}"); + assertThat(config.getAllowedResourcesRef()).isEqualTo("federation"); assertThat(config.getSanUri()).isEqualTo("spiffe://cf/app"); assertThat(config.getSubjectDn()).as("blank values count as absent").isNull(); } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/token/UaaTokenEndpointTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/token/UaaTokenEndpointTests.java index e6e83c8f27b..0b19b66f29c 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/token/UaaTokenEndpointTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/token/UaaTokenEndpointTests.java @@ -40,7 +40,7 @@ class UaaTokenEndpointTests { @BeforeEach void setup() { - endpoint = spy(new UaaTokenEndpoint(null, null, null, null, null)); + endpoint = spy(new UaaTokenEndpoint(null, null, null, null, null, null)); } @Test @@ -52,7 +52,7 @@ void allowsGetByDefault() throws Exception { @Test void getIsDisabled() { - endpoint = spy(new UaaTokenEndpoint(null, null, null, null, false)); + endpoint = spy(new UaaTokenEndpoint(null, null, null, null, false, null)); assertThatThrownBy(() -> endpoint.doDelegateGet(mock(Principal.class), emptyMap())).asInstanceOf(InstanceOfAssertFactories.throwable(HttpRequestMethodNotSupportedException.class)); } @@ -88,7 +88,7 @@ void mapsMtlsTokenEndpointAndItsDescendants() throws NoSuchMethodException { @Test void callToGetAlwaysThrowsSuperMethod() { - endpoint = new UaaTokenEndpoint(null, null, null, null, false); + endpoint = new UaaTokenEndpoint(null, null, null, null, false, null); assertThatThrownBy(() -> endpoint.getAccessToken(mock(Principal.class), emptyMap())) .isInstanceOf(HttpRequestMethodNotSupportedException.class) @@ -97,7 +97,7 @@ void callToGetAlwaysThrowsSuperMethod() { @Test void callToGetAlwaysThrowsOverrideMethod() { - endpoint = new UaaTokenEndpoint(null, null, null, null, false); + endpoint = new UaaTokenEndpoint(null, null, null, null, false, null); assertThatThrownBy(() -> endpoint.doDelegateGet(mock(Principal.class), emptyMap())) .isInstanceOf(HttpRequestMethodNotSupportedException.class) diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/zone/GeneralIdentityZoneConfigurationValidatorTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/zone/GeneralIdentityZoneConfigurationValidatorTests.java index 0ebef677c6c..2079f894f1e 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/zone/GeneralIdentityZoneConfigurationValidatorTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/zone/GeneralIdentityZoneConfigurationValidatorTests.java @@ -213,6 +213,23 @@ void malformedNamedTrustBundleIsRejected() { .hasMessageContaining("broken"); } + @Test + void malformedNamedResourcePolicyIsRejected() { + Map config = new HashMap<>(org.cloudfoundry.identity.uaa.util.JsonUtils.convertValue( + zone.getConfig(), Map.class)); + config.put("mtls", Map.of( + "enabled", true, + "resourcePolicies", Map.of( + "federation", Map.of("allowedResources", List.of("not-a-uri"))))); + zone.setConfig(org.cloudfoundry.identity.uaa.util.JsonUtils.convertValue( + config, IdentityZoneConfiguration.class)); + + assertThatThrownBy(() -> validator.validate(zone, IdentityZoneValidator.Mode.MODIFY)) + .isInstanceOf(InvalidIdentityZoneConfigurationException.class) + .hasMessageContaining("federation") + .hasMessageContaining("absolute URI"); + } + @BeforeEach void setUp() { IdentityZoneHolder.clear(); diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java index 162b10beef1..f7f10ff3b75 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/TokenEndpointDocs.java @@ -596,7 +596,8 @@ TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, toPem(caCert), .description("Optional. An [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707) resource indicator " + "naming the target this token is for; it becomes the token's `aud` claim. Must be an " + "absolute URI without a fragment, and must be one of the values registered in the " - + "client's `tls-client-auth-allowed-resources`. A value outside that list, more than one " + + "client's `tls-client-auth-allowed-resources` or its referenced named policy. A value " + + "outside that allow-list, more than one " + "`resource` parameter, or a client with no allow-list configured is refused with " + "`invalid_target`. Omit it to receive the client's default audience.") ); diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/clients/ClientAdminEndpointDocs.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/clients/ClientAdminEndpointDocs.java index 1206e8d2f2c..d6431549bab 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/clients/ClientAdminEndpointDocs.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/clients/ClientAdminEndpointDocs.java @@ -101,11 +101,13 @@ class ClientAdminEndpointDocs extends AdminClientCreator { fieldWithPath(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE).optional(null).type(STRING) .description("Template for the token's `sub` claim, with `{claim}` placeholders that must name claims declared in `tls-client-auth-claim-mappings`. At least one placeholder is required; at most 256 characters."), fieldWithPath(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES).optional(null).type(Arrays.asList(ARRAY, STRING)) - .description("Templates for the token's `aud` claim, with the same placeholder rules as `tls-client-auth-sub-template`. Mutually exclusive with `tls-client-auth-allowed-resources`."), + .description("Templates for the token's `aud` claim, with the same placeholder rules as `tls-client-auth-sub-template`. Mutually exclusive with `tls-client-auth-allowed-resources` and `tls-client-auth-allowed-resources-ref`."), fieldWithPath(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS).optional(null).type(Arrays.asList(OBJECT, STRING)) .description("Map of claim name to the exact value that claim must have, extracted from the certificate via `tls-client-auth-claim-mappings`; authentication is refused otherwise. Scopes a client to, for example, one org, space or app. A UAA extension, not a substitute for a subject parameter."), fieldWithPath(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES).optional(null).type(Arrays.asList(ARRAY, STRING)) - .description("List of exact [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707) resource values (absolute URIs, no fragment) this client may request with the `resource` parameter at `/oauth/mtls/token`; the requested value becomes the token's `aud`. An absent list authorizes nothing. Mutually exclusive with `tls-client-auth-aud-templates`."), + .description("List of exact [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707) resource values (absolute URIs, no fragment) this client may request with the `resource` parameter at `/oauth/mtls/token`; the requested value becomes the token's `aud`. An absent list authorizes nothing. Mutually exclusive with `tls-client-auth-allowed-resources-ref` and `tls-client-auth-aud-templates`."), + fieldWithPath(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF).optional(null).type(STRING) + .description("Name of an allowed-resource policy in this client's identity zone. UAA resolves the policy on every token request, so operator updates apply without rewriting the client. Unknown or blank references are rejected on create/update/bootstrap; a removed reference fails explicit resource requests closed as `invalid_target`. Mutually exclusive with `tls-client-auth-allowed-resources` and `tls-client-auth-aud-templates`."), fieldWithPath(TlsClientAuthConfiguration.TLS_CLIENT_CERTIFICATE_BOUND_ACCESS_TOKENS).optional(null).type(Arrays.asList(BOOLEAN, STRING)) .description("[RFC 8705 section 3.4](https://www.rfc-editor.org/rfc/rfc8705#section-3.4): `true` makes access tokens issued to this client at `/oauth/mtls/token` certificate-bound (`cnf.x5t#S256`) and requires a nonblank `tls-client-auth-ca` or a valid `tls-client-auth-ca-ref`, and exactly one subject binding. Registration is rejected without these prerequisites, whether or not mTLS is enabled; token issuance refuses a missing certificate confirmation. `false` or absent (the default) issues an ordinary bearer JWT after the same certificate, chain and subject validation. Must be `true` or `false`. Set at registration only; a token request cannot change it."), fieldWithPath("client_jwt_config").optional(null).type(STRING) diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/MtlsTokenEndpointHardeningMockMvcTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/MtlsTokenEndpointHardeningMockMvcTests.java index 7e111902385..b1c2813ea76 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/MtlsTokenEndpointHardeningMockMvcTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/MtlsTokenEndpointHardeningMockMvcTests.java @@ -26,6 +26,7 @@ import org.cloudfoundry.identity.uaa.zone.IdentityZone; import org.cloudfoundry.identity.uaa.zone.IdentityZoneConfiguration; import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; +import org.cloudfoundry.identity.uaa.zone.MtlsResourcePolicy; import org.cloudfoundry.identity.uaa.zone.ZoneContextPathSessionFilter; import org.cloudfoundry.identity.uaa.zone.ZonePathContextRewritingFilter; import org.junit.jupiter.api.BeforeAll; @@ -1934,6 +1935,123 @@ void discoveryRequiresAuthentication() throws Exception { } } + @Nested + @DisplayName("N. named allowed-resource policies") + class NamedAllowedResourcePolicies { + private IdentityZoneConfiguration previous; + private final List createdIds = new java.util.ArrayList<>(); + + @BeforeEach + void configurePolicies() { + previous = JsonUtils.convertValue(JsonUtils.convertValue( + identityZoneProvisioning.retrieve(IdentityZone.getUaaZoneId()).getConfig(), Map.class), + IdentityZoneConfiguration.class); + setPolicies(Map.of("federation", resourcePolicy("https://billing.apps.internal"))); + } + + @AfterEach + void restorePolicies() { + for (String id : createdIds) { + try { + clientDetailsService.removeClientDetails(id, IdentityZone.getUaaZoneId()); + } catch (org.cloudfoundry.identity.uaa.provider.NoSuchClientException ignored) { + // A rejected registration did not persist the client. + } + } + IdentityZone zone = identityZoneProvisioning.retrieve(IdentityZone.getUaaZoneId()); + zone.setConfig(previous); + identityZoneProvisioning.update(zone); + IdentityZoneHolder.clear(); + } + + @Test + void policyChangesApplyWithoutUpdatingTheClientAndRemovalFailsClosed() throws Exception { + String clientId = "policy" + generator.generate(); + createdIds.add(clientId); + MvcResult created = createClientViaAdminApi(clientId, Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, toPem(caCert), + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUBJECT_DN, "CN=policy-app", + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF, "federation")); + assertThat(created.getResponse().getStatus()).as("Actual: %s", outcome(created)).isEqualTo(201); + assertThat(clientDetailsService.loadClientByClientId(clientId).getAdditionalInformation()) + .containsEntry( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF, + "federation") + .doesNotContainKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES); + X509Certificate certificate = leafSignedByCa("CN=policy-app"); + + MvcResult allowed = perform(mtlsPost(clientId, GRANT_TYPE_CLIENT_CREDENTIALS, certificate) + .param(TlsClientAuthConfiguration.RESOURCE_PARAMETER, "https://billing.apps.internal")); + assertThat(allowed.getResponse().getStatus()).as("Actual: %s", outcome(allowed)).isEqualTo(200); + assertThat(claimsOf(allowed)).containsEntry("aud", "https://billing.apps.internal"); + + setPolicies(Map.of("federation", resourcePolicy("https://reporting.apps.internal"))); + MvcResult removed = perform(mtlsPost(clientId, GRANT_TYPE_CLIENT_CREDENTIALS, certificate) + .param(TlsClientAuthConfiguration.RESOURCE_PARAMETER, "https://billing.apps.internal")); + assertThat(denial(removed).error()).isEqualTo("invalid_target"); + MvcResult added = perform(mtlsPost(clientId, GRANT_TYPE_CLIENT_CREDENTIALS, certificate) + .param(TlsClientAuthConfiguration.RESOURCE_PARAMETER, "https://reporting.apps.internal")); + assertThat(added.getResponse().getStatus()).as("Actual: %s", outcome(added)).isEqualTo(200); + + setPolicies(Map.of()); + MvcResult missing = perform(mtlsPost(clientId, GRANT_TYPE_CLIENT_CREDENTIALS, certificate) + .param(TlsClientAuthConfiguration.RESOURCE_PARAMETER, "https://reporting.apps.internal")); + assertThat(denial(missing).error()).isEqualTo("invalid_target"); + } + + @ParameterizedTest + @ValueSource(strings = {"", " ", "missing"}) + void blankOrUnknownPolicyReferenceIsRejectedAtRegistration(String reference) throws Exception { + String clientId = "badpolicy" + generator.generate(); + createdIds.add(clientId); + MvcResult created = createClientViaAdminApi(clientId, Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, toPem(caCert), + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUBJECT_DN, "CN=policy-app", + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF, reference)); + + assertThat(created.getResponse().getStatus()).as("Actual: %s", outcome(created)).isEqualTo(400); + assertThat(denial(created).description()) + .contains(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF); + } + + @Test + void policyReferenceIsMutuallyExclusiveWithInlineResourcesAndAudienceTemplates() throws Exception { + Map config = new HashMap<>(Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, toPem(caCert), + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUBJECT_DN, "CN=policy-app", + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF, "federation", + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES, + List.of("https://billing.apps.internal"))); + MvcResult inlineConflict = createClientViaAdminApi( + "policyconflict" + generator.generate(), config); + assertThat(inlineConflict.getResponse().getStatus()) + .as("Actual: %s", outcome(inlineConflict)).isEqualTo(400); + + config.remove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES); + config.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(new TlsClientAuthConfiguration.ClaimMapping( + "subject_cn", null, "app_id"))); + config.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, + List.of("{app_id}.apps.internal")); + MvcResult templateConflict = createClientViaAdminApi( + "policytemplate" + generator.generate(), config); + assertThat(templateConflict.getResponse().getStatus()) + .as("Actual: %s", outcome(templateConflict)).isEqualTo(400); + } + + private void setPolicies(Map policies) { + IdentityZone zone = identityZoneProvisioning.retrieve(IdentityZone.getUaaZoneId()); + zone.getConfig().getMtls().setResourcePolicies(policies); + IdentityZoneHolder.set(identityZoneProvisioning.update(zone)); + } + + private MtlsResourcePolicy resourcePolicy(String... resources) { + MtlsResourcePolicy policy = new MtlsResourcePolicy(); + policy.setAllowedResources(List.of(resources)); + return policy; + } + } + /** * Clients written directly to the store, bypassing registration validation, must fail the same way a request * with a bad credential does: 401 invalid_client, never a secret fallback and never a 500. @@ -1970,7 +2088,10 @@ static java.util.stream.Stream mtls TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS, Map.of("app_id", "x"))), org.junit.jupiter.params.provider.Arguments.of("allowed_resources", Map.of(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES, - List.of("https://billing.example.com")))); + List.of("https://billing.example.com"))), + org.junit.jupiter.params.provider.Arguments.of("allowed_resources_ref", + Map.of(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF, + "federation"))); } @Test diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/MtlsTokenEndpointMockMvcZonePathTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/MtlsTokenEndpointMockMvcZonePathTests.java index 6f496b7fb12..a888047b6b1 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/MtlsTokenEndpointMockMvcZonePathTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/MtlsTokenEndpointMockMvcZonePathTests.java @@ -21,6 +21,7 @@ import org.cloudfoundry.identity.uaa.test.TestClient; import org.cloudfoundry.identity.uaa.util.JsonUtils; import org.cloudfoundry.identity.uaa.zone.IdentityZone; +import org.cloudfoundry.identity.uaa.zone.MtlsResourcePolicy; import org.cloudfoundry.identity.uaa.zone.ZoneContextPathSessionFilter; import org.cloudfoundry.identity.uaa.zone.ZonePathContextRewritingFilter; import org.junit.jupiter.api.BeforeAll; @@ -646,6 +647,39 @@ void namedTrustBundlesAreIsolatedBetweenIdentityZones(ZoneResolutionMode mode) t assertThat(perform(mtlsPost(mode, zoneB, id, certA)).getResponse().getStatus()).isEqualTo(401); } + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void namedAllowedResourcePoliciesAreIsolatedBetweenIdentityZones(ZoneResolutionMode mode) + throws Exception { + IdentityZone zoneA = newZone(); + IdentityZone zoneB = newZone(); + configureResourcePolicy(zoneA, "https://zone-a.example.com"); + configureResourcePolicy(zoneB, "https://zone-b.example.com"); + String id = "same-policy-client" + generator.generate(); + registerResourcePolicyClient(zoneA, id, zoneACaCert, "CN=policy-app"); + registerResourcePolicyClient(zoneB, id, zoneBCaCert, "CN=policy-app"); + X509Certificate certA = leafSignedBy(zoneACaSubject, zoneACaKeyPair, "CN=policy-app"); + X509Certificate certB = leafSignedBy(zoneBCaSubject, zoneBCaKeyPair, "CN=policy-app"); + + MvcResult ownA = perform(mtlsPost(mode, zoneA, id, certA) + .param(TlsClientAuthConfiguration.RESOURCE_PARAMETER, "https://zone-a.example.com")); + assertThat(ownA.getResponse().getStatus()).as("Actual: %s", outcome(ownA)).isEqualTo(200); + assertThat(claimsOf(ownA)).containsEntry("aud", "https://zone-a.example.com"); + MvcResult ownB = perform(mtlsPost(mode, zoneB, id, certB) + .param(TlsClientAuthConfiguration.RESOURCE_PARAMETER, "https://zone-b.example.com")); + assertThat(ownB.getResponse().getStatus()).as("Actual: %s", outcome(ownB)).isEqualTo(200); + assertThat(claimsOf(ownB)).containsEntry("aud", "https://zone-b.example.com"); + + MvcResult crossA = perform(mtlsPost(mode, zoneA, id, certA) + .param(TlsClientAuthConfiguration.RESOURCE_PARAMETER, "https://zone-b.example.com")); + assertThat(crossA.getResponse().getStatus()).as("Actual: %s", outcome(crossA)).isEqualTo(400); + assertThat(crossA.getResponse().getContentAsString()).contains("\"error\":\"invalid_target\""); + MvcResult crossB = perform(mtlsPost(mode, zoneB, id, certB) + .param(TlsClientAuthConfiguration.RESOURCE_PARAMETER, "https://zone-a.example.com")); + assertThat(crossB.getResponse().getStatus()).as("Actual: %s", outcome(crossB)).isEqualTo(400); + assertThat(crossB.getResponse().getContentAsString()).contains("\"error\":\"invalid_target\""); + } + private void configureTrustBundle(IdentityZone zone, String caPem) { Map config = new java.util.HashMap<>(JsonUtils.convertValue(zone.getConfig(), Map.class)); config.put("mtls", Map.of("enabled", true, "trustBundles", Map.of("shared-name", caPem))); @@ -653,6 +687,26 @@ private void configureTrustBundle(IdentityZone zone, String caPem) { identityZoneProvisioning.update(zone); } + private void configureResourcePolicy(IdentityZone zone, String resource) { + IdentityZone current = identityZoneProvisioning.retrieve(zone.getId()); + MtlsResourcePolicy policy = new MtlsResourcePolicy(); + policy.setAllowedResources(List.of(resource)); + current.getConfig().getMtls().setResourcePolicies(Map.of("shared-policy", policy)); + identityZoneProvisioning.update(current); + } + + private void registerResourcePolicyClient( + IdentityZone zone, + String clientId, + X509Certificate ca, + String subjectDn) throws Exception { + Map config = new java.util.HashMap<>(tlsConfig(ca, subjectDn)); + config.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_ALLOWED_RESOURCES_REF, "shared-policy"); + setUpClients(clientId, "uaa.resource", "uaa.none", GRANT_TYPE_CLIENT_CREDENTIALS, + false, null, null, -1, zone, config); + clientDetailsService.updateClientSecret(clientId, null, zone.getId()); + } + // ------------------------------------------------------------------------------------ // helpers // ------------------------------------------------------------------------------------ diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/MtlsTrustBundleConfigurationTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/MtlsTrustBundleConfigurationTests.java index 444e5714c0e..8954a21b621 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/MtlsTrustBundleConfigurationTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/MtlsTrustBundleConfigurationTests.java @@ -27,6 +27,13 @@ void operatorConfigurationPopulatesOnlyTheDefaultZonesTrustBundles() { Map mtls = (Map) config.get("mtls"); assertThat(mtls).containsEntry("enabled", true); assertThat((Map) mtls.get("trustBundles")).containsKey("configured-ca"); + assertThat((Map) ((Map) mtls.get("resourcePolicies")) + .get("cf-workload-federation")) + .containsEntry("allowedResources", java.util.List.of( + "https://sts.amazonaws.com", "api://AzureADTokenExchange")); + assertThat((Map) ((Map) mtls.get("resourcePolicies")) + .get("prod.federation")) + .containsEntry("allowedResources", java.util.List.of("https://sts.amazonaws.com")); IdentityZone other = new IdentityZone(); assertThat(JsonUtils.convertValue(other.getConfig(), Map.class)) .as("zones opt in to mTLS, and an unconfigured zone serializes as before the feature") diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/zones/IdentityZoneEndpointDocs.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/zones/IdentityZoneEndpointDocs.java index 9b9b5565b39..138094b9845 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/zones/IdentityZoneEndpointDocs.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/zones/IdentityZoneEndpointDocs.java @@ -59,6 +59,12 @@ class IdentityZoneEndpointDocs extends EndpointDocs { private static final String DESCRIPTION_DESC = "Description of the zone"; private static final String VERSION_DESC = "Reserved for future use of E-Tag versioning"; private static final String ACTIVE_DESC = "Indicates whether the identity zone is active. Defaults to true."; + private static final String MTLS_RESOURCE_POLICIES_DESC = "Zone-scoped map of named mTLS allowed-resource " + + "policies. Each policy contains a non-empty allowedResources list of absolute URIs without fragments. " + + "Names use the same syntax as trust-bundle names. Clients reference a policy with " + + "tls-client-auth-allowed-resources-ref. Updates apply to subsequent token requests without changing " + + "clients; removed references fail closed. The default zone is configured through " + + "uaa.mtls.resource_policies."; private static final String TOKEN_POLICY_DESC = "Various fields pertaining to the JWT access and refresh tokens."; private static final String ACTIVE_KEY_ID_DESC = "The ID for the key that is being used to sign tokens"; private static final String KEYS_UPDATE_DESC = "Keys which will be used to sign the token. If null value is specified for keys, then existing value will be retained."; @@ -227,6 +233,7 @@ void createIdentityZone() throws Exception { fieldWithPath("config.mtls.enabled").optional().type(BOOLEAN).description("Zone mTLS switch, false when omitted. Both this flag and deployment-wide uaa.mtls.enabled must be enabled. Changes apply without restarting; a zone cannot override the deployment off-switch. Disabling does not revoke issued tokens.").attributes(key("constraints").value("Optional")), subsectionWithPath("config.mtls.trustBundles").optional().type(OBJECT).description("Zone-scoped map of named PEM CA bundles for mTLS clients. Names contain 1 to 128 letters, digits, dots, underscores or hyphens and start with a letter or digit. Clients reference these using tls-client-auth-ca-ref or tls-client-auth-trusted-proxy-ca-ref. Updating a bundle rotates trust without client changes; removed references fail authentication at token time. The default zone is configured through uaa.mtls.trust_bundles.").attributes(key("constraints").value("Optional")), + subsectionWithPath("config.mtls.resourcePolicies").optional().type(OBJECT).description(MTLS_RESOURCE_POLICIES_DESC).attributes(key("constraints").value("Optional")), fieldWithPath("config.clientSecretPolicy.minLength").type(NUMBER).description(SECRET_POLICY_MIN_LENGTH).attributes(key("constraints").value("Required when `clientSecretPolicy` in the config is not null")), fieldWithPath("config.clientSecretPolicy.maxLength").type(NUMBER).description(SECRET_POLICY_MAX_LENGTH).attributes(key("constraints").value("Required when `clientSecretPolicy` in the config is not null")), @@ -398,6 +405,7 @@ void getAllIdentityZones() throws Exception { fieldWithPath("[].config.mtls.enabled").optional().type(BOOLEAN).description("Zone mTLS switch, false when omitted. Both this flag and deployment-wide uaa.mtls.enabled must be enabled. Changes apply without restarting; a zone cannot override the deployment off-switch. Disabling does not revoke issued tokens.").attributes(key("constraints").value("Optional")), subsectionWithPath("[].config.mtls.trustBundles").optional().type(OBJECT).description("Zone-scoped map of named PEM CA bundles for mTLS clients. Names contain 1 to 128 letters, digits, dots, underscores or hyphens and start with a letter or digit. Clients reference these using tls-client-auth-ca-ref or tls-client-auth-trusted-proxy-ca-ref. Updating a bundle rotates trust without client changes; removed references fail authentication at token time. The default zone is configured through uaa.mtls.trust_bundles.").attributes(key("constraints").value("Optional")), + subsectionWithPath("[].config.mtls.resourcePolicies").optional().type(OBJECT).description(MTLS_RESOURCE_POLICIES_DESC).attributes(key("constraints").value("Optional")), fieldWithPath("[].config.clientSecretPolicy.minLength").type(NUMBER).description(SECRET_POLICY_MIN_LENGTH).attributes(key("constraints").value("Required when `clientSecretPolicy` in the config is not null")), fieldWithPath("[].config.clientSecretPolicy.maxLength").type(NUMBER).description(SECRET_POLICY_MAX_LENGTH).attributes(key("constraints").value("Required when `clientSecretPolicy` in the config is not null")), @@ -549,6 +557,7 @@ void updateIdentityZone() throws Exception { fieldWithPath("config.mtls.enabled").optional().type(BOOLEAN).description("Zone mTLS switch, false when omitted. Both this flag and deployment-wide uaa.mtls.enabled must be enabled. Changes apply without restarting; a zone cannot override the deployment off-switch. Disabling does not revoke issued tokens.").attributes(key("constraints").value("Optional")), subsectionWithPath("config.mtls.trustBundles").optional().type(OBJECT).description("Zone-scoped map of named PEM CA bundles for mTLS clients. Names contain 1 to 128 letters, digits, dots, underscores or hyphens and start with a letter or digit. Clients reference these using tls-client-auth-ca-ref or tls-client-auth-trusted-proxy-ca-ref. Updating a bundle rotates trust without client changes; removed references fail authentication at token time. The default zone is configured through uaa.mtls.trust_bundles.").attributes(key("constraints").value("Optional")), + subsectionWithPath("config.mtls.resourcePolicies").optional().type(OBJECT).description(MTLS_RESOURCE_POLICIES_DESC).attributes(key("constraints").value("Optional")), fieldWithPath("config.clientSecretPolicy.minLength").type(NUMBER).description(SECRET_POLICY_MIN_LENGTH).attributes(key("constraints").value("Required when `clientSecretPolicy` in the config is not null")), fieldWithPath("config.clientSecretPolicy.maxLength").type(NUMBER).description(SECRET_POLICY_MAX_LENGTH).attributes(key("constraints").value("Required when `clientSecretPolicy` in the config is not null")), @@ -736,6 +745,7 @@ private Snippet getResponseFields() { fieldWithPath("config.mtls.enabled").optional().type(BOOLEAN).description("Zone mTLS switch, false when omitted. Both this flag and deployment-wide uaa.mtls.enabled must be enabled. Changes apply without restarting; a zone cannot override the deployment off-switch. Disabling does not revoke issued tokens.").attributes(key("constraints").value("Optional")), subsectionWithPath("config.mtls.trustBundles").optional().type(OBJECT).description("Zone-scoped map of named PEM CA bundles for mTLS clients. Names contain 1 to 128 letters, digits, dots, underscores or hyphens and start with a letter or digit. Clients reference these using tls-client-auth-ca-ref or tls-client-auth-trusted-proxy-ca-ref. Updating a bundle rotates trust without client changes; removed references fail authentication at token time. The default zone is configured through uaa.mtls.trust_bundles.").attributes(key("constraints").value("Optional")), + subsectionWithPath("config.mtls.resourcePolicies").optional().type(OBJECT).description(MTLS_RESOURCE_POLICIES_DESC).attributes(key("constraints").value("Optional")), fieldWithPath("config.clientSecretPolicy.minLength").type(NUMBER).description(SECRET_POLICY_MIN_LENGTH).attributes(key("constraints").value("Required when `clientSecretPolicy` in the config is not null")), fieldWithPath("config.clientSecretPolicy.maxLength").type(NUMBER).description(SECRET_POLICY_MAX_LENGTH).attributes(key("constraints").value("Required when `clientSecretPolicy` in the config is not null")), diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/oauth/UaaTokenServicesTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/oauth/UaaTokenServicesTests.java index dcc02f3c8fb..93d8d0f182b 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/oauth/UaaTokenServicesTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/oauth/UaaTokenServicesTests.java @@ -476,7 +476,10 @@ void cleanup() { void inactiveMtlsEnhancerDoesNotCopyRefreshOnlyClaimsIntoAccessTokens() { assertThat(waitForClient("jku_test", 5)).as("refresh client fixture must exist").isTrue(); TlsClientAuthentication noCertificate = mock(TlsClientAuthentication.class); - tokenServices.setUaaTokenEnhancers(List.of(new MtlsClaimsEnhancer(noCertificate, jdbcClientDetailsService))); + tokenServices.setUaaTokenEnhancers(List.of(new MtlsClaimsEnhancer( + noCertificate, + jdbcClientDetailsService, + new org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManagerImpl()))); try { RefreshTokenRequestData data = new RefreshTokenRequestData( GRANT_TYPE_AUTHORIZATION_CODE, Sets.newHashSet("openid", "user_attributes"), null, "", @@ -541,7 +544,10 @@ void anActiveMtlsEnhancerDoesNotChangeRefreshedAccessTokens() { assumeTrue(waitForClient("jku_test", 5), "Test client needs to be setup for this test"); TlsClientAuthentication mtlsOn = mock(TlsClientAuthentication.class); when(mtlsOn.isMtlsEnabled()).thenReturn(true); - MtlsClaimsEnhancer mtlsEnhancer = new MtlsClaimsEnhancer(mtlsOn, mock(ClientDetailsService.class)); + MtlsClaimsEnhancer mtlsEnhancer = new MtlsClaimsEnhancer( + mtlsOn, + mock(ClientDetailsService.class), + new org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManagerImpl()); assertThat(mtlsEnhancer.isEnabled()).isTrue(); try { Map withoutEnhancer = refreshedClaimsWith(List.of()); @@ -1329,7 +1335,10 @@ private Map clientCredentialsTokenClaims(UaaTokenEnhancer... enh void mtlsEnhancerIsInertForARequestThatIsNotTlsClientAuth() { TlsClientAuthentication noCertificate = mock(TlsClientAuthentication.class); when(noCertificate.hasCertificateFromRequest()).thenReturn(false); - UaaTokenEnhancer mtls = new MtlsClaimsEnhancer(noCertificate, jdbcClientDetailsService); + UaaTokenEnhancer mtls = new MtlsClaimsEnhancer( + noCertificate, + jdbcClientDetailsService, + new org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManagerImpl()); UaaTokenEnhancer other = enhancerReturning(Map.of("tenant", "acme", "plan", Map.of("tier", "gold"))); Map claims = clientCredentialsTokenClaims(other, mtls); @@ -1421,7 +1430,10 @@ void subAndAudFromAnotherEnhancerDoNotWin() { @Test @DisplayName("the real MtlsClaimsEnhancer opts in to sub and aud and to nothing else") void mtlsEnhancerOptsInToSubAndAudOnly() { - UaaTokenEnhancer mtls = new MtlsClaimsEnhancer(mock(TlsClientAuthentication.class), jdbcClientDetailsService); + UaaTokenEnhancer mtls = new MtlsClaimsEnhancer( + mock(TlsClientAuthentication.class), + jdbcClientDetailsService, + new org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManagerImpl()); assertThat(mtls.getLateOverrideClaims()).containsExactlyInAnyOrder("sub", "aud"); } diff --git a/uaa/src/test/resources/mtls-trust-bundles.properties b/uaa/src/test/resources/mtls-trust-bundles.properties index f517c1ab664..ebd8be92b7f 100644 --- a/uaa/src/test/resources/mtls-trust-bundles.properties +++ b/uaa/src/test/resources/mtls-trust-bundles.properties @@ -1,2 +1,5 @@ uaa.mtls.enabled=true +uaa.mtls.resource_policies.cf-workload-federation.allowed_resources[0]=https://sts.amazonaws.com +uaa.mtls.resource_policies.cf-workload-federation.allowed_resources[1]=api://AzureADTokenExchange +uaa.mtls.resource_policies.[prod.federation].allowed_resources[0]=https://sts.amazonaws.com uaa.mtls.trust_bundles.configured-ca=-----BEGIN CERTIFICATE-----\nMIIDXTCCAkWgAwIBAgIJAOpOBuLToBXJMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV\nBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX\naWRnaXRzIFB0eSBMdGQwHhcNMTcwNzE0MTcxNDE4WhcNMTcwODEzMTcxNDE4WjBF\nMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50\nZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\nCgKCAQEA3+07F4S5Fz3wv/UFm/OWsJXm6s3pKI2mp4fSAY8rx9+0cyLAHsedWzeq\n5uKcDeRW858DOdnClaTOZC73FcvOmv1bw2eYcmfsbqHEhyR0dp+rDHt/7pr6kajC\nyUvAW+hoRRSMpooiZckxrjJ7LOa5iqRyZRwshfGN+mFSygfVguMDKrsE2rvpK6/K\ntkG/lcToLHiw4OnMnZ9ocrNRDAoCkzKGZTLJkUEr3MgOKmr2EO0P6KOAmNnOEmCf\n05ohcrUXeFZVnS5MMUzoGAOzBstZhA0dd7l297IDnWH9uIhCANCvZ9sovZWz/o3J\npc2LyXsaI1cV7O1cGV4aEEn8zzWWGwIDAQABo1AwTjAdBgNVHQ4EFgQUXBO1+qo7\nw6iiiv1pnm+zdrQ3CzkwHwYDVR0jBBgwFoAUXBO1+qo7w6iiiv1pnm+zdrQ3Czkw\nDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAT78lT5VEIetWPGk3szPz\nCT9zNpR1F+7o3rvRTI6Psyjz4tGlyX5iU0Z99Xa9yimIEhWme2UVsgQ9uOzk2IgH\nwMbB2TTP/RRK5+eO4BUu4zWWIXsIcfC6Rqw9Y3Hki+mRpuWMv+5pcOz/H+aYeSfy\nWvVYfRZJOhcztysII4HWIxw8qqwBrf5kX8IRKZXay+A2W04A6kjjX3zfN2OzljTA\njZbtHedUGxSHvK8x6tHEwS0lZ9eZh+V4DWyRvrunwDCtA7zJQmrJd1qbM84H/1C8\ncAC6dglvc82n1BTAZbZwWHYt+Ro3Vp0GMPsZLOXJ0g03LbkhXg4krwXjJPD42nus\n3A==\n-----END CERTIFICATE-----\n