diff --git a/docs/UAA-Client-Authentication.md b/docs/UAA-Client-Authentication.md index 38bf74e9e57..073d2479b8f 100644 --- a/docs/UAA-Client-Authentication.md +++ b/docs/UAA-Client-Authentication.md @@ -6,7 +6,7 @@ In [RFC 6749](https://www.rfc-editor.org/rfc/rfc6749#section-2.3.1) the password or better the process of checking its possession means the authentication process. The secrets can be passed to a server in different ways. It can happen through the HTTP header and/or the body. In the case that an Authorization header is used, -the encoding of the secret needs to be done according to the RFC 6749. UAA fixed this behavior with https://github.com/cloudfoundry/uaa/issues/778. +the encoding of the secret needs to be done according to the RFC 6749. UAA fixed this behavior with . The OIDC standard defines additional authentication mechanisms, see [section 9](https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication). The usage of secrets via client_secret_basic and client_secret_post is straightforward to set up and to use, however, if system-to-system communication is in use, this can be a security problem because it will be hard to change secrets in running systems. The use of many secrets is not @@ -17,10 +17,12 @@ standards define token-based authentication mechanisms for OAuth2 clients. They * tls_client_auth [RFC 8705](https://www.rfc-editor.org/rfc/rfc8705) ## New methods + The new methods are based on asymmetric trust relation, so that the keys are divided into a private and a public one. The private key should never leave the original system, but only the public key should be exchanged. ### private_key_jwt (Partly finished) + The standard private_key_jwt is similar to the existing JWT bearer flow, but JWT bearer is for user principle propagation, whereas private_key_jwt is used for client authentication only. The used technics are similar and therefore the trust model is similar. Both usages are specified in the same [RFC 7523](https://www.rfc-editor.org/rfc/rfc7523.txt). The JWT bearer trust is based on parameters tokenKey and/or tokenKeyUrl parameter, part of the @@ -29,7 +31,7 @@ of public keys, and this set can contain many keys because each key has its own a dynamic token key URI. OIDC has defined the parameter jwks_uri for this already. The structure of the keys is defined with [RFC 7517](https://datatracker.ietf.org/doc/html/rfc7517). UAA provides its own jwks_uri with endpoint /token_keys. The content of this endpoint is [JWKS](https://datatracker.ietf.org/doc/html/rfc7517#section-5). -The content of the JWT (parameter client_assertion) can be different. The standards define the difference. The [OIDC core standard](https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication) +The content of the JWT (parameter client_assertion) can be different. The standards define the difference. The [OIDC core standard](https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication) simplifies the structure so that issuer and subject are the client_id of the authenticated OAuth2 client. The key rotation is supported with jwks_uri, which retrieves the JWK. You can only have one JWKS_URI by the client. For the [RFC 7523 from OAuth2 standard](https://www.rfc-editor.org/info/rfc7523) the structure is more complex, but with seperated issuer and subject there can be more than one entry of federated credential. @@ -45,11 +47,131 @@ The new parameter for federated Credentials in UAA clients is (Work in progress * jwt_creds -### tls_client_auth (Planned Feature) -Not yet defined a release date. +### tls_client_auth ([RFC 8705](https://www.rfc-editor.org/rfc/rfc8705)) + +Mutual-TLS client authentication: a client presents an X.509 certificate at the TLS layer +instead of a `client_secret` or a signed JWT assertion. UAA validates the certificate against +a per-client trusted CA and, optionally, derives JWT claims from the certificate's subject +fields (e.g. mapping a Cloud Foundry app instance identity certificate to `app_guid`/ +`space_guid`/`org_guid` claims). + +The client is authenticated on the fixed dedicated endpoint, `/oauth/mtls/token`, rather than +the regular `/oauth/token`. A nonblank `tls-client-auth-ca` is the sole inbound mTLS selector +for a client. This dedicated endpoint routing is what's scoped: only requests to +`/oauth/mtls/token` attempt to authenticate the caller via a presented client certificate -- +requests to `/oauth/token` are never affected by this. + +The underlying TLS-layer change, however, is **connector-wide, not per-endpoint**: enabling +this feature (`uaa.mtls-enabled`) reconfigures the whole embedded Tomcat connector to request a +client certificate on *every* TLS handshake to this UAA instance (`certificateVerification= +optionalNoCA`; see `MtlsClientAuthTomcatCustomizer`), regardless of which path the request is +ultimately routed to. Any TLS client connecting to any UAA endpoint will therefore be prompted +for a certificate during the handshake -- well-behaved clients (including Go's `crypto/tls`) +simply respond with an empty `Certificate` message if they have no certificate matching the +connector's advertised acceptable-issuer list, so this doesn't outright break other endpoints, +but it is a deployment-wide TLS-layer change, not one isolated to `/oauth/mtls/token`. + +#### Deployment topology + +UAA itself only ever sees the certificate presented by its *immediate* TLS peer -- whatever +that happens to be depends on how UAA is deployed: + +* **Behind a Gorouter** with `forwarded_client_cert: sanitize_set` (the typical Cloud + Foundry deployment): the Gorouter terminates the client's TLS connection, validates it, and + forwards the client's certificate to UAA in the `X-Forwarded-Client-Cert` header over its own + backend mTLS connection. Here, UAA's immediate TLS peer is the Gorouter itself, not the + original client. +* **Direct connections**, e.g. an app connecting straight to UAA over BOSH DNS + (`uaa.service.cf.internal`) where Application Security Groups permit it, bypassing the + Gorouter entirely: UAA's immediate TLS peer *is* the original client. + +`tls-client-auth-trusted-proxy-ca` determines which of the two topologies a *given client* uses -- +the two are mutually exclusive per client, not two ways of satisfying the same requirement: + +* **Not configured:** the client is direct-connection-only. UAA always authenticates it using the + certificate its immediate TLS peer actually presented during the handshake, and never consults + the `X-Forwarded-Client-Cert` header at all (even if one happens to be present -- e.g. noise + from an unrelated proxy in the network path). +* **Configured:** the client is proxy-only. UAA requires the `X-Forwarded-Client-Cert` header to + actually be present, and the genuine TLS peer that presented it to validate against this CA, + before trusting the header-derived certificate. A direct connection (no header) is always + rejected for this client, even if its own certificate happens to validate against the configured + CA. + +An operator who needs both a Gorouter-fronted access pattern and a direct-connection access +pattern for what is conceptually "the same" workload registers **two separate UAA clients** -- one +with `tls-client-auth-trusted-proxy-ca` set (proxy path) and one without it (direct path) -- rather +than expecting one client to accept either. + +#### Scoping a client to a specific org/space/app + +Because Cloud Foundry's Diego instance-identity CA is shared across every app instance in a +foundation, any two clients configured with the same `tls-client-auth-ca` can otherwise +authenticate each other's certificates -- PKIX chain validation alone only proves a certificate +was issued by the configured CA, not that it belongs to *this* client specifically. Configure +`tls-client-auth-required-claims` to close this gap for a client that should only be reachable by +a specific subset of apps: + +```yaml +tls-client-auth-claim-mappings: + - field: subject_ou + pattern: "space:(.+)" + claim: space_guid +tls-client-auth-required-claims: + space_guid: +``` + +An operator who needs both a broadly-scoped client (e.g. the generic `instance-identity` client, +accepting any app in the foundation) and a narrowly-scoped one (e.g. limited to a single space) +registers them as two separate UAA clients, only the latter configuring +`tls-client-auth-required-claims`. + +#### Configuration + +Per-client properties (set via the client-admin API, `oauth.clients` bootstrap, or the client +admin UI, alongside the client's other properties such as `authorized-grant-types`): + +The mTLS token endpoint is fixed at `/oauth/mtls/token`; it is not configurable. A client opts +into mTLS by configuring a nonblank `tls-client-auth-ca`. The client must use that endpoint and +present a certificate whose chain validates to the configured CA; no separate +`token-endpoint-auth-method` property is used or supported. + +| Property | Required | Description | +|----------|----------|--------------| +| `tls-client-auth-ca` | yes | PEM-encoded CA certificate. This is the per-client mTLS selector: requests to the fixed `/oauth/mtls/token` endpoint authenticate with a presented leaf certificate only when it chains to this CA. | +| `tls-client-auth-trusted-proxy-ca` | conditional | PEM-encoded CA certificate the Gorouter's own backend mTLS certificate must chain to. Configuring this switches the client to the Gorouter/XFCC-forwarding-only topology (requiring the `X-Forwarded-Client-Cert` header) -- see "Deployment topology" above. Leave unset for a direct-connection-only client. | +| `tls-client-auth-required-claims` | no | Map of `claimName -> requiredValue`, checked against the values already produced by `tls-client-auth-claim-mappings`. When configured, authentication fails unless every entry matches exactly -- e.g. `{space_guid: ""}` scopes this client to a single CF space, even if other clients share the same `tls-client-auth-ca`. | +| `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 supported only for `subject_ou`, where it extracts a capture group. Patterns are UAA administrator-controlled configuration and are evaluated on every mTLS authentication request; use efficient Java regular expressions and avoid patterns with catastrophic backtracking. | +| `tls-client-auth-sub-template` | no | Template string rendered (using the mapped claim values) to produce the JWT `sub` claim. | +| `tls-client-auth-aud-templates` | no | List of template strings rendered to produce the JWT `aud` claim. | + +Example (Gorouter-fronted; a Cloud Foundry app instance identity certificate mapped to +`cf_instance_guid`/`app_guid`/`space_guid`/`org_guid` claims): + +```yaml +tls-client-auth-ca: +tls-client-auth-trusted-proxy-ca: +tls-client-auth-claim-mappings: + - field: subject_cn + claim: cf_instance_guid + - field: subject_ou + pattern: "app:(.+)" + claim: app_guid + - field: subject_ou + pattern: "space:(.+)" + claim: space_guid + - field: subject_ou + pattern: "organization:(.+)" + claim: org_guid +``` + +For the direct-connection topology described above, omit `tls-client-auth-trusted-proxy-ca` +entirely rather than setting it -- configuring it at all switches this client to proxy-only. ## Configs + Here is a brief example of the `clients` section: + ```yaml oauth: clients: @@ -78,9 +200,11 @@ oauth: ] } ``` + The example configuration above with jwks_uri enables continuous trust to a running UAA. Here is a brief example of the oauth providers section, where UAA is acting as a client. + ```yaml login: oauth: @@ -99,12 +223,13 @@ login: The option jwtClientAuthentication creates during the proxy flow a client assertion which is based on OIDC private_key_jwt. ### Developer implementation + As a developer, you should use the [UAA documentation](https://docs.cloudfoundry.org/api/uaa/version/77.18.0/index.html#token). There is a description -about the new parameters client_assertion and client_assertion_type. In addition, you can check in the retrieved access_token tokens for the existence -of claim client_auth_method with value private_key_jwt, (client_auth_method=private_key). This claim should guarantee the used method of client -authentication. Tokens without this claim are authenticated with secrets. There might be use-cases where a stronger authentication mechanism is +about the new parameters client_assertion and client_assertion_type. In addition, you can check in the retrieved access_token tokens for the existence +of claim client_auth_method with value private_key_jwt, (client_auth_method=private_key). This claim should guarantee the used method of client +authentication. Tokens without this claim are authenticated with secrets. There might be use-cases where a stronger authentication mechanism is required. ### Production use -The support of private_key_jwt (according to OIDC) for a production system is given with the end of Q4/2024. +The support of private_key_jwt (according to OIDC) for a production system is given with the end of Q4/2024. diff --git a/docs/UAA-Configuration-Reference.md b/docs/UAA-Configuration-Reference.md index 65aca8f4e6b..e5451ae4eae 100644 --- a/docs/UAA-Configuration-Reference.md +++ b/docs/UAA-Configuration-Reference.md @@ -126,6 +126,7 @@ or `$CLOUDFOUNDRY_CONFIG_PATH/uaa.yml`. | `oauth.client.autoapprove` | `[]`| Clients auto-approved for all scopes| | `oauth.user.authorities` | (see details)| Default authorities for new users| | `clientMaxCount` | `500`| Max clients returned by list endpoint| +| `uaa.mtls-enabled` | `false`| Enables RFC 8705 mutual-TLS client authentication| ### Password Policy @@ -1221,6 +1222,39 @@ client admin API (`/oauth/clients`). --- +### `uaa.mtls-enabled` + +**Default:** `false` +**Source:** `@Value("${uaa.mtls-enabled:false}")` in [`SpringServletXmlBeansConfiguration`](../server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlBeansConfiguration.java), [`ClientAdminBootstrap`](../server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrap.java), [`ZoneEndpointsClientDetailsValidator`](../server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java), [`MtlsClientAuthTomcatCustomizer`](../server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java) +**Type:** `boolean` + +Master switch enabling RFC 8705 mutual-TLS client authentication (`tls_client_auth`) +deployment-wide. This is **connector-wide**: it affects every TLS connection to this UAA +instance, not just requests to the mTLS token endpoint (`/oauth/mtls/token`). +[`SpringServletXmlBeansConfiguration`](../server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlBeansConfiguration.java) +also uses this value to wire +[`ClientAdminEndpointsValidator`](../server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java)'s +`mtlsEnabled` constructor argument. + +When `true`, the embedded Tomcat connector is reconfigured to request a client certificate +during every TLS handshake (`certificateVerification=optionalNoCA`), without validating it +against any CA at the transport layer -- the trust decision is deferred entirely to per-client +application logic (see [`docs/UAA-Client-Authentication.md`](UAA-Client-Authentication.md) for +the per-client `tls-client-auth-*` properties). Enabling this also switches the connector to the +FIPS BouncyCastle JSSE provider, required for TLS 1.3 client-certificate support (OpenJDK's JSSE +does not implement server-side TLS 1.3 post-handshake client-certificate requests). + +When `false` (the default), no client certificate is requested at the TLS layer at all, and any +client configured with a `tls-client-auth-ca` property fails validation at creation/update time. + +```yaml +uaa.mtls-enabled: true +``` + +[Back to table](#oauth-clients--users) + +--- + ### `password.policy.global.minLength` **Default:** `0` diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4ff4d69b9f8..4a16adbd181 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,7 @@ jacoco = "4.0.2" jackson = "3.1.5" jackson2 = "2.21.5" jacocoAgent = "0.8.15" +javaBuildpackClientCertificateMapper = "2.0.1" nimbusJwt = "10.9.1" opensaml = "5.2.3" orgJson = "20260814" @@ -70,6 +71,9 @@ bouncyCastlePkixFips = { module = "org.bouncycastle:bcpkix-fips", version.ref = bouncyCastleTlsFips = { module = "org.bouncycastle:bctls-fips", version.ref = "bouncyCastleTls" } bouncyCastleUtilFips = { module = "org.bouncycastle:bcutil-fips", version.ref = "bouncyCastleUtil" } +# CloudFoundry +javaBuildpackClientCertificateMapper = { module = "org.cloudfoundry:java-buildpack-client-certificate-mapper-jakarta", version.ref = "javaBuildpackClientCertificateMapper" } + # Eclipse JGit eclipseJgit = { module = "org.eclipse.jgit:org.eclipse.jgit", version.ref = "eclipseJgit" } @@ -164,6 +168,7 @@ springBootStarterMail = { module = "org.springframework.boot:spring-boot-starter springBootStarterTest = { module = "org.springframework.boot:spring-boot-starter-test" } springBootStarterTomcatRuntime = { module = "org.springframework.boot:spring-boot-starter-tomcat-runtime" } springBootStarterWeb = { module = "org.springframework.boot:spring-boot-starter-web" } +springBootTomcat = { module = "org.springframework.boot:spring-boot-tomcat" } springBootTransaction = { module = "org.springframework.boot:spring-boot-transaction" } # Spring Data diff --git a/model/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConfiguration.java b/model/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConfiguration.java index 591525495bc..01d236c4a61 100644 --- a/model/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConfiguration.java +++ b/model/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConfiguration.java @@ -1,10 +1,14 @@ package org.cloudfoundry.identity.uaa.account; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; import lombok.NoArgsConstructor; import org.cloudfoundry.identity.uaa.constants.ClientAuthentication; +import java.util.Arrays; +import java.util.Map; + @Data @NoArgsConstructor public class OpenIdConfiguration { @@ -19,7 +23,7 @@ public class OpenIdConfiguration { private String tokenUrl; @JsonProperty("token_endpoint_auth_methods_supported") - private String[] tokenAMR = new String[]{ClientAuthentication.CLIENT_SECRET_BASIC, ClientAuthentication.CLIENT_SECRET_POST, ClientAuthentication.PRIVATE_KEY_JWT}; + private String[] tokenAMR = new String[]{ClientAuthentication.CLIENT_SECRET_BASIC, ClientAuthentication.CLIENT_SECRET_POST, ClientAuthentication.PRIVATE_KEY_JWT, ClientAuthentication.TLS_CLIENT_AUTH}; @JsonProperty("token_endpoint_auth_signing_alg_values_supported") private String[] tokenEndpointAuthSigningValues = new String[]{"RS256", "HS256"}; @@ -67,12 +71,25 @@ public class OpenIdConfiguration { @JsonProperty("code_challenge_methods_supported") private String[] codeChallengeMethodsSupported = new String[]{"S256", "plain"}; + @JsonProperty("mtls_endpoint_aliases") + @JsonInclude(JsonInclude.Include.NON_NULL) + private Map mtlsEndpointAliases; + public OpenIdConfiguration(final String contextPath, final String issuer) { + this(contextPath, issuer, true); + } + + public OpenIdConfiguration(final String contextPath, final String issuer, final boolean mtlsEnabled) { this.issuer = issuer; this.authUrl = contextPath + "/oauth/authorize"; this.tokenUrl = contextPath + "/oauth/token"; this.userInfoUrl = contextPath + "/userinfo"; this.jwksUri = contextPath + "/token_keys"; this.logoutEndpoint = contextPath + "/logout.do"; + if (!mtlsEnabled) { + this.tokenAMR = Arrays.stream(this.tokenAMR) + .filter(method -> !ClientAuthentication.TLS_CLIENT_AUTH.equals(method)) + .toArray(String[]::new); + } } } 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 new file mode 100644 index 00000000000..3535384bdf6 --- /dev/null +++ b/model/src/main/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfiguration.java @@ -0,0 +1,129 @@ +package org.cloudfoundry.identity.uaa.client; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public class TlsClientAuthConfiguration { + + public static final String TLS_CLIENT_AUTH_CA = "tls-client-auth-ca"; + public static final String TLS_CLIENT_AUTH_CLAIM_MAPPINGS = "tls-client-auth-claim-mappings"; + public static final String TLS_CLIENT_AUTH_SUB_TEMPLATE = "tls-client-auth-sub-template"; + public static final String TLS_CLIENT_AUTH_AUD_TEMPLATES = "tls-client-auth-aud-templates"; + public static final String TLS_CLIENT_AUTH_TRUSTED_PROXY_CA = "tls-client-auth-trusted-proxy-ca"; + public static final String TLS_CLIENT_AUTH_REQUIRED_CLAIMS = "tls-client-auth-required-claims"; + + @JsonProperty(TLS_CLIENT_AUTH_CA) + private String trustedCaPem; + + @JsonProperty(TLS_CLIENT_AUTH_CLAIM_MAPPINGS) + private List claimMappings; + + @JsonProperty(TLS_CLIENT_AUTH_SUB_TEMPLATE) + private String subTemplate; + + @JsonProperty(TLS_CLIENT_AUTH_AUD_TEMPLATES) + private List audTemplates; + + @JsonProperty(TLS_CLIENT_AUTH_TRUSTED_PROXY_CA) + private String trustedProxyCaPem; + + @JsonProperty(TLS_CLIENT_AUTH_REQUIRED_CLAIMS) + private Map requiredClaims; + + public TlsClientAuthConfiguration() {} + + public TlsClientAuthConfiguration(String trustedCaPem, List claimMappings) { + this.trustedCaPem = trustedCaPem; + this.claimMappings = claimMappings; + } + + public String getTrustedCaPem() { return trustedCaPem; } + public void setTrustedCaPem(String trustedCaPem) { this.trustedCaPem = trustedCaPem; } + + public List getClaimMappings() { return claimMappings; } + public void setClaimMappings(List claimMappings) { this.claimMappings = claimMappings; } + + public String getSubTemplate() { return subTemplate; } + public void setSubTemplate(String subTemplate) { this.subTemplate = subTemplate; } + + public List getAudTemplates() { return audTemplates; } + public void setAudTemplates(List audTemplates) { this.audTemplates = audTemplates; } + + public String getTrustedProxyCaPem() { return trustedProxyCaPem; } + public void setTrustedProxyCaPem(String trustedProxyCaPem) { this.trustedProxyCaPem = trustedProxyCaPem; } + + public Map getRequiredClaims() { return requiredClaims; } + public void setRequiredClaims(Map requiredClaims) { this.requiredClaims = requiredClaims; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof TlsClientAuthConfiguration that)) return false; + return Objects.equals(trustedCaPem, that.trustedCaPem) && + Objects.equals(claimMappings, that.claimMappings) && + Objects.equals(subTemplate, that.subTemplate) && + Objects.equals(audTemplates, that.audTemplates) && + Objects.equals(trustedProxyCaPem, that.trustedProxyCaPem) && + Objects.equals(requiredClaims, that.requiredClaims); + } + + @Override + public int hashCode() { + return Objects.hash(trustedCaPem, claimMappings, subTemplate, audTemplates, trustedProxyCaPem, requiredClaims); + } + + public static boolean isConfigured(TlsClientAuthConfiguration config) { + return config != null && config.getTrustedCaPem() != null && !config.getTrustedCaPem().isBlank(); + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ClaimMapping { + + @JsonProperty("field") + private String field; + + @JsonProperty("pattern") + private String pattern; + + @JsonProperty("claim") + private String claim; + + public ClaimMapping() {} + + public ClaimMapping(String field, String pattern, String claim) { + this.field = field; + this.pattern = pattern; + this.claim = claim; + } + + public String getField() { return field; } + public String getPattern() { return pattern; } + public String getClaim() { return claim; } + + public void setField(String field) { this.field = field; } + public void setPattern(String pattern) { this.pattern = pattern; } + public void setClaim(String claim) { this.claim = claim; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof ClaimMapping that)) return false; + return Objects.equals(field, that.field) && + Objects.equals(pattern, that.pattern) && + Objects.equals(claim, that.claim); + } + + @Override + public int hashCode() { + return Objects.hash(field, pattern, claim); + } + } +} 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 92418c12927..a7e25674c43 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 @@ -39,6 +39,7 @@ * * Extended this class with fields * - client_jwt_config (supporting private_key_jwt) + * - tls-client-auth-ca (supporting RFC 8705 mTLS client authentication) */ @JsonInclude(JsonInclude.Include.NON_DEFAULT) @JsonIgnoreProperties(ignoreUnknown = true) @@ -86,6 +87,9 @@ public class UaaClientDetails implements ClientDetails { @JsonProperty("client_jwt_config") private String clientJwtConfig; + @JsonIgnore + private TlsClientAuthConfiguration tlsClientAuthConfiguration; + public UaaClientDetails() { } @@ -103,6 +107,9 @@ public UaaClientDetails(ClientDetails prototype) { this.setAdditionalInformation(prototype.getAdditionalInformation()); if (prototype instanceof UaaClientDetails uaa) { this.setClientJwtConfig(uaa.getClientJwtConfig()); + if (uaa.getTlsClientAuthConfiguration() != null) { + this.setTlsClientAuthConfiguration(uaa.getTlsClientAuthConfiguration()); + } } } @@ -302,6 +309,43 @@ public void setClientJwtConfig(String clientJwtConfig) { this.clientJwtConfig = clientJwtConfig; } + public TlsClientAuthConfiguration getTlsClientAuthConfiguration() { + return tlsClientAuthConfiguration; + } + + public void setTlsClientAuthConfiguration(TlsClientAuthConfiguration tlsClientAuthConfiguration) { + this.tlsClientAuthConfiguration = tlsClientAuthConfiguration; + if (tlsClientAuthConfiguration != null) { + this.additionalInformation.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, + tlsClientAuthConfiguration.getTrustedCaPem()); + putOrRemove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + tlsClientAuthConfiguration.getClaimMappings()); + putOrRemove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, + tlsClientAuthConfiguration.getSubTemplate()); + putOrRemove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, + tlsClientAuthConfiguration.getAudTemplates()); + putOrRemove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA, + tlsClientAuthConfiguration.getTrustedProxyCaPem()); + putOrRemove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS, + tlsClientAuthConfiguration.getRequiredClaims()); + } else { + this.additionalInformation.remove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); + this.additionalInformation.remove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS); + this.additionalInformation.remove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE); + this.additionalInformation.remove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES); + this.additionalInformation.remove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA); + this.additionalInformation.remove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS); + } + } + + private void putOrRemove(String key, Object value) { + if (value == null) { + this.additionalInformation.remove(key); + } else { + this.additionalInformation.put(key, value); + } + } + @Override public boolean equals(Object obj) { if (this == obj) { @@ -344,7 +388,10 @@ public boolean equals(Object obj) { if (!Objects.equals(additionalInformation, other.additionalInformation)) { return false; } - return Objects.equals(clientJwtConfig, other.clientJwtConfig); + if (!Objects.equals(clientJwtConfig, other.clientJwtConfig)) { + return false; + } + return Objects.equals(tlsClientAuthConfiguration, other.tlsClientAuthConfiguration); } @Override @@ -378,6 +425,7 @@ public int hashCode() { result = prime * result + (scope == null ? 0 : scope.hashCode()); result = prime * result + (additionalInformation == null ? 0 : additionalInformation.hashCode()); result = prime * result + (clientJwtConfig == null ? 0 : clientJwtConfig.hashCode()); + result = prime * result + (tlsClientAuthConfiguration == null ? 0 : tlsClientAuthConfiguration.hashCode()); return result; } } diff --git a/model/src/main/java/org/cloudfoundry/identity/uaa/constants/ClientAuthentication.java b/model/src/main/java/org/cloudfoundry/identity/uaa/constants/ClientAuthentication.java index c1fe791bc4f..f6d51c5da5c 100644 --- a/model/src/main/java/org/cloudfoundry/identity/uaa/constants/ClientAuthentication.java +++ b/model/src/main/java/org/cloudfoundry/identity/uaa/constants/ClientAuthentication.java @@ -7,9 +7,8 @@ /** * ClientAuthentication constants are defined in OIDC core and discovery standard, e.g. https://openid.net/specs/openid-connect-registration-1_0.html * OIDC possible values are: client_secret_post, client_secret_basic, client_secret_jwt, private_key_jwt, and none - * UAA knows only: client_secret_post, client_secret_basic, private_key_jwt, and none + * UAA knows only: client_secret_post, client_secret_basic, private_key_jwt, none, and tls_client_auth * - * Planned: tls_client_auth */ public final class ClientAuthentication { @@ -20,8 +19,17 @@ private ClientAuthentication() { public static final String CLIENT_SECRET_POST = "client_secret_post"; public static final String PRIVATE_KEY_JWT = "private_key_jwt"; public static final String NONE = "none"; + public static final String TLS_CLIENT_AUTH = "tls_client_auth"; - public static final List UAA_SUPPORTED_METHODS = List.of(CLIENT_SECRET_BASIC, CLIENT_SECRET_POST, NONE, PRIVATE_KEY_JWT); + public static final List UAA_SUPPORTED_METHODS = + List.of(CLIENT_SECRET_BASIC, CLIENT_SECRET_POST, NONE, PRIVATE_KEY_JWT, TLS_CLIENT_AUTH); + + public static final List EXTERNAL_OAUTH_SUPPORTED_METHODS = + List.of(CLIENT_SECRET_BASIC, CLIENT_SECRET_POST, NONE, PRIVATE_KEY_JWT); + + public static boolean isExternalOAuthMethodSupported(String method) { + return Optional.ofNullable(method).map(EXTERNAL_OAUTH_SUPPORTED_METHODS::contains).orElse(true); + } public static boolean secretNeeded(String method) { return method == null || CLIENT_SECRET_POST.equals(method) || CLIENT_SECRET_BASIC.equals(method); @@ -31,17 +39,25 @@ public static boolean isMethodSupported(String method) { return Optional.ofNullable(method).map(UAA_SUPPORTED_METHODS::contains).orElse(true); } + public static boolean isValidMethod(String method, boolean hasSecret, + boolean hasKeyConfiguration, boolean hasCaConfig) { + return isMethodSupported(method) && secretNeeded(method) && hasSecret && !hasKeyConfiguration && !hasCaConfig + || isMethodSupported(method) && PRIVATE_KEY_JWT.equals(method) && !hasSecret && hasKeyConfiguration && !hasCaConfig + || isMethodSupported(method) && (TLS_CLIENT_AUTH.equals(method) || method == null) && !hasSecret && !hasKeyConfiguration && hasCaConfig + || isMethodSupported(method) && (NONE.equals(method) || method == null) && !hasSecret && !hasKeyConfiguration && !hasCaConfig + || (method == null && (!hasSecret || !hasKeyConfiguration) && !hasCaConfig); + } + public static boolean isValidMethod(String method, boolean hasSecret, boolean hasKeyConfiguration) { - return isMethodSupported(method) && secretNeeded(method) && hasSecret && !hasKeyConfiguration || - isMethodSupported(method) && !secretNeeded(method) && !hasSecret || - (method == null && (!hasSecret || !hasKeyConfiguration)); + return isValidMethod(method, hasSecret, hasKeyConfiguration, false); } public static boolean isAuthMethodEqual(String method1, String method2) { return secretNeeded(method1) && secretNeeded(method2) || Objects.equals(method1, method2); } - public static String getCalculatedMethod(String method, boolean hasSecret, boolean hasKeyConfiguration) { + public static String getCalculatedMethod(String method, boolean hasSecret, + boolean hasKeyConfiguration, boolean hasCaConfig) { if (method != null && isMethodSupported(method)) { return method; } else { @@ -49,9 +65,15 @@ public static String getCalculatedMethod(String method, boolean hasSecret, boole return CLIENT_SECRET_BASIC; } else if (hasKeyConfiguration) { return PRIVATE_KEY_JWT; + } else if (hasCaConfig) { + return TLS_CLIENT_AUTH; } else { return NONE; } } } + + public static String getCalculatedMethod(String method, boolean hasSecret, boolean hasKeyConfiguration) { + return getCalculatedMethod(method, hasSecret, hasKeyConfiguration, false); + } } diff --git a/model/src/main/java/org/cloudfoundry/identity/uaa/oauth/token/TokenConstants.java b/model/src/main/java/org/cloudfoundry/identity/uaa/oauth/token/TokenConstants.java index 0c1d28df850..7bf976d4fea 100644 --- a/model/src/main/java/org/cloudfoundry/identity/uaa/oauth/token/TokenConstants.java +++ b/model/src/main/java/org/cloudfoundry/identity/uaa/oauth/token/TokenConstants.java @@ -80,6 +80,7 @@ public static List getStringValues() { public static final String CLIENT_AUTH_EMPTY = "empty"; public static final String CLIENT_AUTH_SECRET = "secret"; public static final String CLIENT_AUTH_PRIVATE_KEY_JWT = ClientAuthentication.PRIVATE_KEY_JWT; + public static final String CLIENT_AUTH_TLS_CLIENT_AUTH = ClientAuthentication.TLS_CLIENT_AUTH; public static final String ID_TOKEN_HINT_PROMPT = "prompt"; public static final String ID_TOKEN_HINT_PROMPT_NONE = "none"; diff --git a/model/src/test/java/org/cloudfoundry/identity/uaa/account/OpenIdConfigurationTests.java b/model/src/test/java/org/cloudfoundry/identity/uaa/account/OpenIdConfigurationTests.java index 6530d0206ad..151bb679181 100644 --- a/model/src/test/java/org/cloudfoundry/identity/uaa/account/OpenIdConfigurationTests.java +++ b/model/src/test/java/org/cloudfoundry/identity/uaa/account/OpenIdConfigurationTests.java @@ -7,6 +7,7 @@ import org.springframework.test.util.ReflectionTestUtils; import java.lang.reflect.Field; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; @@ -27,7 +28,7 @@ void defaultClaims() { assertThat(defaultConfig.getIssuer()).isEqualTo("issuer"); assertThat(defaultConfig.getAuthUrl()).isEqualTo("/uaa/oauth/authorize"); assertThat(defaultConfig.getTokenUrl()).isEqualTo("/uaa/oauth/token"); - assertThat(defaultConfig.getTokenAMR()).containsExactly(new String[]{"client_secret_basic", "client_secret_post", "private_key_jwt"}); + assertThat(defaultConfig.getTokenAMR()).containsExactly(new String[]{"client_secret_basic", "client_secret_post", "private_key_jwt", "tls_client_auth"}); assertThat(defaultConfig.getTokenEndpointAuthSigningValues()).containsExactly(new String[]{"RS256", "HS256"}); assertThat(defaultConfig.getUserInfoUrl()).isEqualTo("/uaa/userinfo"); assertThat(defaultConfig.getJwksUri()).isEqualTo("/uaa/token_keys"); @@ -65,4 +66,39 @@ void allNulls() throws Exception { assertThat(json.from("OpenIdConfiguration-nulls.json", this.getClass())) .hasEmptyJsonPathValue("issuer"); } + + @Test + void mtlsEndpointAliasesIsNullByDefault() { + OpenIdConfiguration conf = new OpenIdConfiguration("/uaa", "https://uaa.example.com"); + assertThat(conf.getMtlsEndpointAliases()).isNull(); + } + + @Test + void mtlsEndpointAliasesCanBeSet() { + OpenIdConfiguration conf = new OpenIdConfiguration("/uaa", "https://uaa.example.com"); + conf.setMtlsEndpointAliases(Map.of("token_endpoint", "https://uaa.example.com/oauth/mtls/token")); + assertThat(conf.getMtlsEndpointAliases()) + .containsEntry("token_endpoint", "https://uaa.example.com/oauth/mtls/token"); + } + + @Test + void tlsClientAuthIsInSupportedAuthMethods() { + OpenIdConfiguration conf = new OpenIdConfiguration("/uaa", "https://uaa.example.com"); + assertThat(conf.getTokenAMR()).contains("tls_client_auth"); + } + + @Test + void tlsClientAuthIsExcludedWhenMtlsDisabled() { + OpenIdConfiguration conf = new OpenIdConfiguration("/uaa", "https://uaa.example.com", false); + assertThat(conf.getTokenAMR()) + .containsExactlyInAnyOrder("client_secret_basic", "client_secret_post", "private_key_jwt") + .doesNotContain("tls_client_auth"); + } + + @Test + void tlsClientAuthIsIncludedWhenMtlsEnabled() { + OpenIdConfiguration conf = new OpenIdConfiguration("/uaa", "https://uaa.example.com", true); + assertThat(conf.getTokenAMR()) + .containsExactlyInAnyOrder("client_secret_basic", "client_secret_post", "private_key_jwt", "tls_client_auth"); + } } 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 new file mode 100644 index 00000000000..3b149a93bf7 --- /dev/null +++ b/model/src/test/java/org/cloudfoundry/identity/uaa/client/TlsClientAuthConfigurationTest.java @@ -0,0 +1,188 @@ +package org.cloudfoundry.identity.uaa.client; + +import tools.jackson.databind.json.JsonMapper; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class TlsClientAuthConfigurationTest { + + private static final String EXAMPLE_CA = "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n"; + + @Test + void roundTripsViaJson() throws Exception { + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration( + EXAMPLE_CA, + List.of(new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^app:(.+)$", "app_guid")) + ); + + JsonMapper mapper = new JsonMapper(); + String json = mapper.writeValueAsString(config); + TlsClientAuthConfiguration deserialized = mapper.readValue(json, TlsClientAuthConfiguration.class); + + assertThat(deserialized.getTrustedCaPem()).isEqualTo(EXAMPLE_CA); + assertThat(deserialized.getClaimMappings()).hasSize(1); + assertThat(deserialized.getClaimMappings().get(0).getClaim()).isEqualTo("app_guid"); + } + + @Test + void nullCaMeansNotConfigured() { + assertThat(TlsClientAuthConfiguration.isConfigured(null)).isFalse(); + assertThat(TlsClientAuthConfiguration.isConfigured(new TlsClientAuthConfiguration(null, null))).isFalse(); + } + + @Test + void nonNullCaMeansConfigured() { + assertThat(TlsClientAuthConfiguration.isConfigured( + new TlsClientAuthConfiguration(EXAMPLE_CA, null))).isTrue(); + } + + @Test + void claimMappingWithoutPatternUsesFieldDirectly() { + TlsClientAuthConfiguration.ClaimMapping mapping = + new TlsClientAuthConfiguration.ClaimMapping("subject_cn", null, "instance_guid"); + assertThat(mapping.getPattern()).isNull(); + assertThat(mapping.getClaim()).isEqualTo("instance_guid"); + } + + @Test + void equalConfigurations() { + TlsClientAuthConfiguration a = new TlsClientAuthConfiguration( + EXAMPLE_CA, + List.of(new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^app:(.+)$", "app_guid")) + ); + TlsClientAuthConfiguration b = new TlsClientAuthConfiguration( + EXAMPLE_CA, + List.of(new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^app:(.+)$", "app_guid")) + ); + assertThat(a).isEqualTo(b); + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + } + + @Test + void unequalWhenCaDiffers() { + TlsClientAuthConfiguration a = new TlsClientAuthConfiguration("ca-a", null); + TlsClientAuthConfiguration b = new TlsClientAuthConfiguration("ca-b", null); + assertThat(a).isNotEqualTo(b); + } + + @Test + void subTemplateRoundTripsViaJson() throws Exception { + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration( + EXAMPLE_CA, + List.of(new TlsClientAuthConfiguration.ClaimMapping("subject_cn", null, "cf_instance_guid")) + ); + config.setSubTemplate("o/{cf.org}/s/{cf.space}/a/{cf.app}"); + + JsonMapper mapper = new JsonMapper(); + String json = mapper.writeValueAsString(config); + TlsClientAuthConfiguration deserialized = mapper.readValue(json, TlsClientAuthConfiguration.class); + + assertThat(deserialized.getSubTemplate()).isEqualTo("o/{cf.org}/s/{cf.space}/a/{cf.app}"); + } + + @Test + void audTemplatesRoundTripsViaJson() throws Exception { + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(EXAMPLE_CA, null); + config.setAudTemplates(List.of( + "o/{cf.org}/s/{cf.space}/a/{cf.app}", + "o/{cf.org}/s/{cf.space}", + "o/{cf.org}" + )); + + JsonMapper mapper = new JsonMapper(); + String json = mapper.writeValueAsString(config); + TlsClientAuthConfiguration deserialized = mapper.readValue(json, TlsClientAuthConfiguration.class); + + assertThat(deserialized.getAudTemplates()).containsExactly( + "o/{cf.org}/s/{cf.space}/a/{cf.app}", + "o/{cf.org}/s/{cf.space}", + "o/{cf.org}" + ); + } + + @Test + void nullSubTemplateAndAudTemplatesOmittedFromJson() throws Exception { + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(EXAMPLE_CA, null); + // subTemplate and audTemplates left null + + JsonMapper mapper = new JsonMapper(); + String json = mapper.writeValueAsString(config); + + assertThat(json).doesNotContain("tls-client-auth-sub-template"); + assertThat(json).doesNotContain("tls-client-auth-aud-templates"); + } + + @Test + void equalityIncludesSubTemplateAndAudTemplates() { + TlsClientAuthConfiguration a = new TlsClientAuthConfiguration(EXAMPLE_CA, null); + a.setSubTemplate("o/{cf.org}"); + a.setAudTemplates(List.of("o/{cf.org}")); + + TlsClientAuthConfiguration b = new TlsClientAuthConfiguration(EXAMPLE_CA, null); + b.setSubTemplate("o/{cf.org}"); + b.setAudTemplates(List.of("o/{cf.org}")); + + TlsClientAuthConfiguration c = new TlsClientAuthConfiguration(EXAMPLE_CA, null); + c.setSubTemplate("different"); + + TlsClientAuthConfiguration d = new TlsClientAuthConfiguration(EXAMPLE_CA, null); + d.setSubTemplate("o/{cf.org}"); // same as a + // d.audTemplates left null // differs from a + + assertThat(a).isEqualTo(b); + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + assertThat(a).isNotEqualTo(c); + assertThat(a).isNotEqualTo(d); + } + + @Test + void trustedProxyCaPemRoundTripsViaJson() throws Exception { + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(EXAMPLE_CA, null); + config.setTrustedProxyCaPem("-----BEGIN CERTIFICATE-----\nPROXY\n-----END CERTIFICATE-----\n"); + + JsonMapper mapper = new JsonMapper(); + String json = mapper.writeValueAsString(config); + TlsClientAuthConfiguration deserialized = mapper.readValue(json, TlsClientAuthConfiguration.class); + + assertThat(deserialized.getTrustedProxyCaPem()) + .isEqualTo("-----BEGIN CERTIFICATE-----\nPROXY\n-----END CERTIFICATE-----\n"); + assertThat(json).contains("tls-client-auth-trusted-proxy-ca"); + } + + @Test + void unequalWhenTrustedProxyCaPemDiffers() { + TlsClientAuthConfiguration config1 = new TlsClientAuthConfiguration(EXAMPLE_CA, null); + config1.setTrustedProxyCaPem("proxy-ca-1"); + TlsClientAuthConfiguration config2 = new TlsClientAuthConfiguration(EXAMPLE_CA, null); + config2.setTrustedProxyCaPem("proxy-ca-2"); + + assertThat(config1).isNotEqualTo(config2); + } + + @Test + void requiredClaimsRoundTripsViaJson() throws Exception { + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(EXAMPLE_CA, null); + config.setRequiredClaims(Map.of("space_guid", "the-expected-space-guid")); + + JsonMapper mapper = new JsonMapper(); + String json = mapper.writeValueAsString(config); + TlsClientAuthConfiguration deserialized = mapper.readValue(json, TlsClientAuthConfiguration.class); + + assertThat(deserialized.getRequiredClaims()).containsEntry("space_guid", "the-expected-space-guid"); + assertThat(json).contains("tls-client-auth-required-claims"); + } + + @Test + void unequalWhenRequiredClaimsDiffer() { + TlsClientAuthConfiguration config1 = new TlsClientAuthConfiguration(EXAMPLE_CA, null); + config1.setRequiredClaims(Map.of("space_guid", "space-a")); + TlsClientAuthConfiguration config2 = new TlsClientAuthConfiguration(EXAMPLE_CA, null); + config2.setRequiredClaims(Map.of("space_guid", "space-b")); + + assertThat(config1).isNotEqualTo(config2); + } +} 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 f05aab0ac8b..bdb3145c6ed 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 @@ -59,6 +59,22 @@ void copiesAdditionalInformation() { .containsEntry("key", "value"); } + @Test + void copiesFlatTlsClientAuthAdditionalInformationWhenTypedConfigurationIsNull() { + Map tlsClientAuthAdditionalInformation = Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "trusted-ca", + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, "claim-mappings", + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, "sub-template", + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, "aud-templates", + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA, "trusted-proxy-ca", + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS, "required-claims"); + testClient.setAdditionalInformation(tlsClientAuthAdditionalInformation); + + UaaClientDetails copy = new UaaClientDetails(testClient); + + assertThat(copy.getAdditionalInformation()).containsExactlyInAnyOrderEntriesOf(tlsClientAuthAdditionalInformation); + } + @Test void clientJwtConfig() { UaaClientDetails copy = new UaaClientDetails(testClient); @@ -208,6 +224,45 @@ void isSecretRequired() { assertThat(details.isSecretRequired()).isFalse(); } + @Test + void tlsClientAuthConfigRoundTripsViaJson() throws Exception { + UaaClientDetails details = new UaaClientDetails(); + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration( + "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n", + null + ); + details.setTlsClientAuthConfiguration(config); + + String json = new JsonMapper().writeValueAsString(details); + UaaClientDetails deserialized = new JsonMapper().readValue(json, UaaClientDetails.class); + + Object raw = deserialized.getAdditionalInformation() + .get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); + assertThat(raw).isEqualTo(config.getTrustedCaPem()); + } + + @Test + void setTlsClientAuthConfiguration_whenCleared_removesAllPersistedSettings() { + UaaClientDetails details = new UaaClientDetails(); + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration( + "trusted-ca", List.of(new TlsClientAuthConfiguration.ClaimMapping("field", "pattern", "claim"))); + config.setSubTemplate("sub-template"); + config.setAudTemplates(List.of("aud-template")); + config.setTrustedProxyCaPem("trusted-proxy-ca"); + config.setRequiredClaims(Map.of("required-claim", "value")); + + details.setTlsClientAuthConfiguration(config); + details.setTlsClientAuthConfiguration(null); + + assertThat(details.getAdditionalInformation()).doesNotContainKeys( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA, + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS); + } + @Test void autoApprove() { UaaClientDetails details = new UaaClientDetails(); @@ -221,7 +276,7 @@ void testHashCode() { uaaClientDetails.setRegisteredRedirectUri(Set.of("http://localhost:8080/uaa")); uaaClientDetails.setRefreshTokenValiditySeconds(1); uaaClientDetails.setAccessTokenValiditySeconds(1); - assertThat(uaaClientDetails.hashCode()).isPositive(); + assertThat(uaaClientDetails.hashCode()).isNotZero(); } } diff --git a/model/src/test/java/org/cloudfoundry/identity/uaa/constants/ClientAuthenticationTest.java b/model/src/test/java/org/cloudfoundry/identity/uaa/constants/ClientAuthenticationTest.java index 1f7350d733c..90da419c95a 100644 --- a/model/src/test/java/org/cloudfoundry/identity/uaa/constants/ClientAuthenticationTest.java +++ b/model/src/test/java/org/cloudfoundry/identity/uaa/constants/ClientAuthenticationTest.java @@ -7,6 +7,7 @@ import static org.cloudfoundry.identity.uaa.constants.ClientAuthentication.CLIENT_SECRET_POST; import static org.cloudfoundry.identity.uaa.constants.ClientAuthentication.NONE; import static org.cloudfoundry.identity.uaa.constants.ClientAuthentication.PRIVATE_KEY_JWT; +import static org.cloudfoundry.identity.uaa.constants.ClientAuthentication.TLS_CLIENT_AUTH; class ClientAuthenticationTest { @@ -24,6 +25,15 @@ void isMethodSupported() { assertThat(ClientAuthentication.isMethodSupported("foo")).isFalse(); } + @Test + void externalOAuthMethodsSupportStandardMethodsButNotTlsClientAuth() { + assertThat(ClientAuthentication.isExternalOAuthMethodSupported(CLIENT_SECRET_BASIC)).isTrue(); + assertThat(ClientAuthentication.isExternalOAuthMethodSupported(CLIENT_SECRET_POST)).isTrue(); + assertThat(ClientAuthentication.isExternalOAuthMethodSupported(PRIVATE_KEY_JWT)).isTrue(); + assertThat(ClientAuthentication.isExternalOAuthMethodSupported(NONE)).isTrue(); + assertThat(ClientAuthentication.isExternalOAuthMethodSupported(TLS_CLIENT_AUTH)).isFalse(); + } + @Test void isValidMethodTrue() { assertThat(ClientAuthentication.isValidMethod(NONE, false, false)).isTrue(); @@ -78,4 +88,61 @@ void isAuthMethodEqualFalse() { assertThat(ClientAuthentication.isAuthMethodEqual(PRIVATE_KEY_JWT, CLIENT_SECRET_BASIC)).isFalse(); assertThat(ClientAuthentication.isAuthMethodEqual(PRIVATE_KEY_JWT, NONE)).isFalse(); } + + @Test + void tlsClientAuthIsARecognisedMethod() { + assertThat(ClientAuthentication.isMethodSupported(TLS_CLIENT_AUTH)).isTrue(); + } + + @Test + void tlsClientAuthDoesNotRequireASecret() { + assertThat(ClientAuthentication.secretNeeded(TLS_CLIENT_AUTH)).isFalse(); + } + + @Test + void tlsClientAuthIsCalculatedWhenHasCaConfig() { + String method = ClientAuthentication.getCalculatedMethod(null, false, false, true); + assertThat(method).isEqualTo(TLS_CLIENT_AUTH); + } + + @Test + void tlsClientAuthIsValidMethodWhenHasCaConfig() { + assertThat(ClientAuthentication.isValidMethod( + TLS_CLIENT_AUTH, false, false, true)).isTrue(); + } + + @Test + void tlsClientAuthIsInvalidWithoutCaConfig() { + assertThat(ClientAuthentication.isValidMethod( + ClientAuthentication.TLS_CLIENT_AUTH, false, false, false)).isFalse(); + } + + @Test + void tlsClientAuthIsInvalidWhenHasSecret() { + assertThat(ClientAuthentication.isValidMethod( + ClientAuthentication.TLS_CLIENT_AUTH, true, false, true)).isFalse(); + } + + @Test + void tlsClientAuthIsInvalidWhenHasKeyConfig() { + assertThat(ClientAuthentication.isValidMethod( + ClientAuthentication.TLS_CLIENT_AUTH, false, true, true)).isFalse(); + } + + @Test + void tlsClientAuthIsValidMethodWhenHasCaConfigAndMethodUnset() { + // method left null (unset), matching how getCalculatedMethod derives tls_client_auth + // from CA config alone -- see tlsClientAuthIsCalculatedWhenHasCaConfig above. + assertThat(ClientAuthentication.isValidMethod(null, false, false, true)).isTrue(); + } + + @Test + void tlsClientAuthIsInvalidWhenMethodUnsetAndHasSecretAndCaConfig() { + assertThat(ClientAuthentication.isValidMethod(null, true, false, true)).isFalse(); + } + + @Test + void tlsClientAuthIsInvalidWhenMethodUnsetAndHasKeyConfigAndCaConfig() { + assertThat(ClientAuthentication.isValidMethod(null, false, true, true)).isFalse(); + } } diff --git a/model/src/test/resources/org/cloudfoundry/identity/uaa/account/OpenIdConfiguration.json b/model/src/test/resources/org/cloudfoundry/identity/uaa/account/OpenIdConfiguration.json index 2a102387fae..b49d0e65b7c 100644 --- a/model/src/test/resources/org/cloudfoundry/identity/uaa/account/OpenIdConfiguration.json +++ b/model/src/test/resources/org/cloudfoundry/identity/uaa/account/OpenIdConfiguration.json @@ -5,7 +5,8 @@ "token_endpoint_auth_methods_supported": [ "client_secret_basic", "client_secret_post", - "private_key_jwt" + "private_key_jwt", + "tls_client_auth" ], "token_endpoint_auth_signing_alg_values_supported": [ "RS256", diff --git a/server/build.gradle.kts b/server/build.gradle.kts index e52e19f1537..6adca444f0f 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -29,6 +29,7 @@ dependencies { implementation(libs.springBootStarterMail) implementation(libs.springBootSql) implementation(libs.springBootJdbc) + implementation(libs.springBootTomcat) implementation(libs.springBootTransaction) implementation(libs.openSamlApi) implementation(libs.springSecuritySamlServiceProvider) @@ -40,6 +41,8 @@ dependencies { implementation(libs.bouncyCastleTlsFips) implementation(libs.bouncyCastleUtilFips) + implementation(libs.javaBuildpackClientCertificateMapper) + implementation(libs.guava) implementation(libs.aspectJWeaver) diff --git a/server/src/integrationTest/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapProdEncoderTest.java b/server/src/integrationTest/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapProdEncoderTest.java index 5be6a36618b..fdf317eb003 100644 --- a/server/src/integrationTest/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapProdEncoderTest.java +++ b/server/src/integrationTest/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapProdEncoderTest.java @@ -85,7 +85,8 @@ void emptySecretHashIsStableAcrossBootstrapRuns() { Collections.emptySet(), Collections.emptySet(), jdbcTemplate, - Collections.emptySet()); + Collections.emptySet(), + false); // First run — simulates UAA startup 1 bootstrap.afterPropertiesSet(); 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 e7515dd3232..f4cb03acb89 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlBeansConfiguration.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlBeansConfiguration.java @@ -144,8 +144,9 @@ String loginUrl(@Value("${login.url:http://localhost:8080/uaa}") String loginUrl ClientAdminEndpointsValidator clientDetailsValidator( SecurityContextAccessor securityContextAccessor, @Qualifier("clientDetailsService") QueryableResourceManager clientDetailsService, - @Qualifier("zoneAwareClientSecretPolicyValidator") ClientSecretValidator clientDetailsValidator) { - ClientAdminEndpointsValidator bean = new ClientAdminEndpointsValidator(securityContextAccessor, identityZoneManager); + @Qualifier("zoneAwareClientSecretPolicyValidator") ClientSecretValidator clientDetailsValidator, + @Value("${uaa.mtls-enabled:false}") boolean mtlsEnabled) { + ClientAdminEndpointsValidator bean = new ClientAdminEndpointsValidator(securityContextAccessor, identityZoneManager, mtlsEnabled); bean.setClientDetailsService(clientDetailsService); bean.setClientSecretValidator(clientDetailsValidator); return bean; diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java b/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java index ca2b4c7e20d..e127480f1d4 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java @@ -6,6 +6,8 @@ import org.cloudfoundry.identity.uaa.metrics.UaaMetricsFilter; import org.cloudfoundry.identity.uaa.metrics.UaaMetricsManagedBean; import org.cloudfoundry.identity.uaa.oauth.DisableIdTokenResponseTypeFilter; +import org.cloudfoundry.identity.uaa.oauth.tls.MtlsPathGuardedFilter; +import org.cloudfoundry.identity.uaa.oauth.tls.RawPeerCertificateCaptureFilter; import org.cloudfoundry.identity.uaa.provider.IdentityProviderProvisioning; import org.cloudfoundry.identity.uaa.ratelimiting.RateLimitingFilter; import org.cloudfoundry.identity.uaa.scim.DisableInternalUserManagementFilter; @@ -231,4 +233,54 @@ public FilterRegistrationBean httpHeaderSecurityFilter bean.setEnabled(false); return bean; } + + @Bean + public FilterRegistrationBean rawPeerCertificateCaptureFilter() { + FilterRegistrationBean bean = + new FilterRegistrationBean<>(new RawPeerCertificateCaptureFilter()); + // No addUrlPatterns(...): registered on the default (all-requests) pattern, like every other + // filter in this class. RawPeerCertificateCaptureFilter internally no-ops unless the request's + // effective (post-ZonePathContextRewritingFilter) servlet path is /oauth/mtls/token/** -- see + // RawPeerCertificateCaptureFilter.isMtlsTokenPath(...) -- so it still runs for zone-path- + // prefixed mTLS requests (e.g. /z/{subdomain}/oauth/mtls/token). A container URL-pattern + // registration for a literal "/oauth/mtls/token/**" is matched against the request's original, + // pre-rewrite URI, so it would never include this filter in the chain for such a request. + // Must run before clientCertificateMapperFilter() (order -200) so it captures the genuine + // TLS-handshake peer certificate before that filter overwrites the same standard + // jakarta.servlet.request.X509Certificate attribute with the XFCC-header-derived certificate. + bean.setOrder(-300); + return bean; + } + + @Bean + public FilterRegistrationBean clientCertificateMapperFilter() { + // ClientCertificateMapper is a package-private final class in + // org.cloudfoundry.router.jakarta; its constructor is also package-private. + // The library is designed for Spring Boot autoconfiguration or Servlet container + // initializer use — direct instantiation from outside the package requires + // reflection. setAccessible(true) is the only available mechanism. + try { + Class mapperClass = Class.forName("org.cloudfoundry.router.jakarta.ClientCertificateMapper"); + java.lang.reflect.Constructor ctor = mapperClass.getDeclaredConstructor(); + ctor.setAccessible(true); + jakarta.servlet.Filter delegate = (jakarta.servlet.Filter) ctor.newInstance(); + FilterRegistrationBean bean = + new FilterRegistrationBean<>(new MtlsPathGuardedFilter(delegate)); + // No addUrlPatterns(...): see rawPeerCertificateCaptureFilter() above. + // MtlsPathGuardedFilter internally scopes the delegate ClientCertificateMapper to the + // effective (post-ZonePathContextRewritingFilter) /oauth/mtls/token/** servlet path, so a literal + // "/oauth/mtls/token/**" URL-pattern registration -- which would not match zone-path-prefixed + // requests -- is not needed here either. + // Spring Boot registers its Security filter in the servlet container at order -100 + // (org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterProperties + // .DEFAULT_FILTER_ORDER). This filter must run strictly before that so the + // jakarta.servlet.request.X509Certificate request attribute it derives from the + // XFCC header is already populated when ClientDetailsAuthenticationProvider / + // TlsClientAuthentication authenticate the /oauth/mtls/token request. + bean.setOrder(-200); + return bean; + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Failed to instantiate ClientCertificateMapper", e); + } + } } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpoints.java b/server/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpoints.java index cd734dee90d..4744caa35c5 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpoints.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpoints.java @@ -9,21 +9,27 @@ import jakarta.servlet.http.HttpServletRequest; import java.net.URISyntaxException; +import java.util.Map; import static org.springframework.http.HttpStatus.OK; @Controller public class OpenIdConnectEndpoints { + private static final String MTLS_TOKEN_ENDPOINT_PATH = "/oauth/mtls/token"; + private final String issuer; private final IdentityZoneManager identityZoneManager; + private final boolean mtlsEnabled; public OpenIdConnectEndpoints( final @Value("${issuer.uri}") String issuer, - final IdentityZoneManager identityZoneManager + final IdentityZoneManager identityZoneManager, + final @Value("${uaa.mtls-enabled:false}") boolean mtlsEnabled ) { this.issuer = issuer; this.identityZoneManager = identityZoneManager; + this.mtlsEnabled = mtlsEnabled; } @GetMapping(value = { @@ -31,7 +37,11 @@ public OpenIdConnectEndpoints( "/oauth/token/.well-known/openid-configuration" }) public ResponseEntity getOpenIdConfiguration(HttpServletRequest request) throws URISyntaxException { - OpenIdConfiguration conf = new OpenIdConfiguration(getServerContextPath(request), getTokenEndpoint()); + String contextPath = getServerContextPath(request); + OpenIdConfiguration conf = new OpenIdConfiguration(contextPath, getTokenEndpoint(), mtlsEnabled); + if (mtlsEnabled) { + conf.setMtlsEndpointAliases(Map.of("token_endpoint", contextPath + MTLS_TOKEN_ENDPOINT_PATH)); + } return new ResponseEntity<>(conf, OK); } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java index 13b5cfbf5a0..d3f72f3dc77 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java @@ -13,9 +13,13 @@ *******************************************************************************/ package org.cloudfoundry.identity.uaa.authentication; +import org.cloudfoundry.identity.uaa.client.TlsClientAuthConfiguration; import org.cloudfoundry.identity.uaa.client.UaaClient; +import org.cloudfoundry.identity.uaa.util.JsonUtils; import org.cloudfoundry.identity.uaa.oauth.jwt.JwtClientAuthentication; import org.cloudfoundry.identity.uaa.oauth.pkce.PkceValidationService; +import org.cloudfoundry.identity.uaa.oauth.tls.TlsClientAuthentication; +import org.cloudfoundry.identity.uaa.oauth.tls.RawPeerCertificateCaptureFilter; import org.cloudfoundry.identity.uaa.oauth.token.ClaimConstants; import org.cloudfoundry.identity.uaa.oauth.token.TokenConstants; import org.springframework.security.authentication.AbstractAuthenticationToken; @@ -29,13 +33,18 @@ import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; +import tools.jackson.core.type.TypeReference; + +import java.security.cert.X509Certificate; import java.util.Collections; +import java.util.List; import java.util.Map; import java.util.Optional; import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.CLIENT_AUTH_EMPTY; import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.CLIENT_AUTH_NONE; import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.CLIENT_AUTH_PRIVATE_KEY_JWT; +import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.CLIENT_AUTH_TLS_CLIENT_AUTH; import static org.cloudfoundry.identity.uaa.util.UaaStringUtils.getSafeParameterValue; /** @@ -50,11 +59,14 @@ public class ClientDetailsAuthenticationProvider extends DaoAuthenticationProvider { private final JwtClientAuthentication jwtClientAuthentication; + private final TlsClientAuthentication tlsClientAuthentication; - public ClientDetailsAuthenticationProvider(UserDetailsService userDetailsService, PasswordEncoder encoder, JwtClientAuthentication jwtClientAuthentication) { + public ClientDetailsAuthenticationProvider(UserDetailsService userDetailsService, PasswordEncoder encoder, + JwtClientAuthentication jwtClientAuthentication, TlsClientAuthentication tlsClientAuthentication) { super(userDetailsService); setPasswordEncoder(encoder); this.jwtClientAuthentication = jwtClientAuthentication; + this.tlsClientAuthentication = tlsClientAuthentication; } @Override @@ -73,6 +85,19 @@ protected void additionalAuthenticationChecks(UserDetails userDetails, UsernameP for (String pwd : passwordList) { try { UaaClient uaaClient = new UaaClient(userDetails, pwd); + if (TlsClientAuthConfiguration.isConfigured(getTlsClientAuthConfiguration(uaaClient))) { + if (!ObjectUtils.isEmpty(authentication.getCredentials()) + || !isTlsClientAuthPath(authentication.getDetails())) { + error = new BadCredentialsException( + "tls_client_auth: configured clients must authenticate at /oauth/mtls/token without client credentials"); + } else { + setAuthenticationMethod(authentication, CLIENT_AUTH_TLS_CLIENT_AUTH); + if (!validateTlsClientAuth(uaaClient)) { + error = new BadCredentialsException("tls_client_auth: certificate validation failed"); + } + } + break; + } if (ObjectUtils.isEmpty(authentication.getCredentials())) { if (isPublicGrantTypeUsageAllowed(authentication.getDetails()) && uaaClient.isAllowPublic()) { // in case of grant_type=authorization_code and code_verifier passed (PKCE) we check if client has option allowpublic with true and continue even if no secret is in request @@ -84,6 +109,12 @@ protected void additionalAuthenticationChecks(UserDetails userDetails, UsernameP error = new BadCredentialsException("Bad client_assertion type"); } break; + } else if (isTlsClientAuthPath(authentication.getDetails())) { + setAuthenticationMethod(authentication, CLIENT_AUTH_TLS_CLIENT_AUTH); + if (!validateTlsClientAuth(uaaClient)) { + error = new BadCredentialsException("tls_client_auth: certificate validation failed"); + } + break; } else { // set internally empty as client_auth_method e.g. cf client setAuthenticationMethod(authentication, CLIENT_AUTH_EMPTY); @@ -165,4 +196,91 @@ private boolean validatePrivateKeyJwt(Object uaaAuthenticationDetails, UaaClient return jwtClientAuthentication.validateClientJwt(getRequestParameters(getUaaAuthenticationDetails(uaaAuthenticationDetails)), uaaClient.getClientJwtConfiguration(), uaaClient.getUsername()); } + + static boolean isTlsClientAuthPath(Object uaaAuthenticationDetails) { + UaaAuthenticationDetails details = getUaaAuthenticationDetails(uaaAuthenticationDetails); + String path = details != null ? details.getRequestPath() : null; + return RawPeerCertificateCaptureFilter.isMtlsTokenPath(path); + } + + boolean validateTlsClientAuth(UaaClient uaaClient) { + // Cheap presence-only check (no config resolution, no JSON/claim-mapping parsing) before + // doing any work to resolve this client's TlsClientAuthConfiguration. + if (!tlsClientAuthentication.hasCertificateFromRequest()) { + return false; + } + TlsClientAuthConfiguration config = getTlsClientAuthConfiguration(uaaClient); + X509Certificate[] chain = tlsClientAuthentication.getCertificateChainFromRequest(config); + if (chain == null || chain.length == 0) { + return false; + } + return tlsClientAuthentication.validateClientCert(chain, config).isPresent() + && tlsClientAuthentication.certificateSatisfiesRequiredClaims(chain[0], config); + } + + static TlsClientAuthConfiguration getTlsClientAuthConfiguration(UaaClient uaaClient) { + Map info = uaaClient.getAdditionalInformation(); + if (info == null) { + return null; + } + Object rawConfig = info.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); + if (rawConfig instanceof String pem) { + try { + List claimMappings = null; + Object rawMappings = info.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS); + if (rawMappings instanceof String mappingsJson) { + claimMappings = JsonUtils.readValue(mappingsJson, + new TypeReference>() {}); + } else if (rawMappings instanceof List mappingsList) { + // Jackson may parse a JSON array directly as a List when additionalInformation + // is deserialized from JDBC without a String-encoded wrapper. + String mappingsJson = JsonUtils.writeValueAsString(mappingsList); + claimMappings = JsonUtils.readValue(mappingsJson, + new TypeReference>() {}); + } + String subTemplate = null; + Object rawSubTemplate = info.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE); + if (rawSubTemplate instanceof String st && !st.isBlank()) { + subTemplate = st; + } + + List audTemplates = null; + Object rawAudTemplates = info.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES); + if (rawAudTemplates instanceof String audJson) { + audTemplates = JsonUtils.readValue(audJson, new TypeReference>() {}); + } else if (rawAudTemplates instanceof List audList) { + audTemplates = JsonUtils.readValue( + JsonUtils.writeValueAsString(audList), + new TypeReference>() {}); + } + + String trustedProxyCaPem = null; + Object rawTrustedProxyCa = info.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA); + if (rawTrustedProxyCa instanceof String tpc && !tpc.isBlank()) { + trustedProxyCaPem = tpc; + } + + Map requiredClaims = null; + Object rawRequiredClaims = info.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS); + if (rawRequiredClaims instanceof String requiredClaimsJson) { + requiredClaims = JsonUtils.readValue(requiredClaimsJson, + new TypeReference>() {}); + } else if (rawRequiredClaims instanceof Map requiredClaimsMap) { + requiredClaims = JsonUtils.readValue( + JsonUtils.writeValueAsString(requiredClaimsMap), + new TypeReference>() {}); + } + + TlsClientAuthConfiguration cfg = new TlsClientAuthConfiguration(pem, claimMappings); + cfg.setSubTemplate(subTemplate); + cfg.setAudTemplates(audTemplates); + cfg.setTrustedProxyCaPem(trustedProxyCaPem); + cfg.setRequiredClaims(requiredClaims); + return cfg; + } catch (Exception e) { + return null; + } + } + return null; + } } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrap.java b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrap.java index 92daa4a23bc..f49f8065da3 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrap.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrap.java @@ -65,6 +65,7 @@ public class ClientAdminBootstrap implements private final Set autoApproveClients; private final Set allowPublicClients; private final boolean defaultOverride; + private final boolean mtlsEnabled; /** * @param defaultOverride the default override flag to set. Flag to indicate @@ -80,6 +81,9 @@ public class ClientAdminBootstrap implements * into the client details store. * @param allowPublicClients A set of client ids that are allowed to be used * without client_secret parameter but with PKCE S256 method + * @param mtlsEnabled whether platform-wide RFC 8705 mTLS client auth is enabled; + * gates whether bootstrapped clients may set + * tls-client-auth-ca / tls-client-auth-trusted-proxy-ca */ ClientAdminBootstrap( @Qualifier("nonCachingPasswordEncoder") final PasswordEncoder passwordEncoder, @@ -90,7 +94,8 @@ public class ClientAdminBootstrap implements @Value("#{@applicationProperties.containsKey('oauth.client.autoapprove') ? @config['oauth']['client']['autoapprove'] : 'cf'}") final Collection autoApproveClients, @Value("#{@config['delete']==null ? null : @config['delete']['clients']}") final Collection clientsToDelete, final JdbcTemplate jdbcTemplate, - final Set allowPublicClients) { + final Set allowPublicClients, + @Value("${uaa.mtls-enabled:false}") final boolean mtlsEnabled) { this.passwordEncoder = passwordEncoder; this.clientRegistrationService = clientRegistrationService; this.clientMetadataProvisioning = clientMetadataProvisioning; @@ -100,6 +105,7 @@ public class ClientAdminBootstrap implements this.clientsToDelete = new HashSet<>(ofNullable(clientsToDelete).orElse(Collections.emptySet())); this.jdbcTemplate = jdbcTemplate; this.allowPublicClients = new HashSet<>(ofNullable(allowPublicClients).orElse(Collections.emptySet())); + this.mtlsEnabled = mtlsEnabled; } @Override @@ -214,6 +220,9 @@ private void addNewClients() { } client.setAdditionalInformation(info); + ClientAdminEndpointsValidator.checkMtlsClientConfigAllowed(client.getAdditionalInformation(), mtlsEnabled, clientId); + ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig( + client.getAdditionalInformation(), clientId); ClientJwtConfiguration keyConfig = null; if (map.get(JWKS_URI) instanceof String || map.get(JWKS) instanceof String) { 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 9f66ee9d340..4716dfab347 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 @@ -18,6 +18,7 @@ import org.cloudfoundry.identity.uaa.resources.QueryableResourceManager; import org.cloudfoundry.identity.uaa.security.beans.SecurityContextAccessor; import org.cloudfoundry.identity.uaa.util.JsonUtils; +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.beans.IdentityZoneManager; @@ -29,14 +30,20 @@ import org.springframework.util.Assert; import org.springframework.util.StringUtils; +import tools.jackson.core.type.TypeReference; + import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.GRANT_TYPE_AUTHORIZATION_CODE; import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.GRANT_TYPE_CLIENT_CREDENTIALS; @@ -82,14 +89,20 @@ public class ClientAdminEndpointsValidator implements InitializingBean, ClientDe private final IdentityZoneManager identityZoneManager; + private final boolean mtlsEnabled; + + private static final String TOKEN_ENDPOINT_AUTH_METHOD = "token-endpoint-auth-method"; + private final Set reservedClientIds = StringUtils.commaDelimitedListToSet(OriginKeys.UAA); private final Set invalidClientIdsCharacters = Set.of('/', '\\'); public ClientAdminEndpointsValidator(final SecurityContextAccessor securityContextAccessor, - final IdentityZoneManager identityZoneManager) { + final IdentityZoneManager identityZoneManager, + final boolean mtlsEnabled) { this.securityContextAccessor = securityContextAccessor; this.identityZoneManager = identityZoneManager; + this.mtlsEnabled = mtlsEnabled; } /** @@ -123,6 +136,10 @@ public ClientDetails validate(ClientDetails prototype, boolean create, boolean c } client.setAdditionalInformation(prototype.getAdditionalInformation()); + + checkMtlsClientConfigAllowed(client.getAdditionalInformation(), mtlsEnabled, client.getClientId()); + validateTlsClientAuthClaimConfig(client.getAdditionalInformation(), client.getClientId()); + String clientId = client.getClientId(); if (create) { if (reservedClientIds.contains(clientId)) { @@ -352,6 +369,249 @@ public static void checkRequestedGrantTypes(Set requestedGrantTypes) { } } + public static void checkMtlsClientConfigAllowed(Map additionalInfo, boolean mtlsEnabled, String clientId) { + if (additionalInfo.containsKey(TOKEN_ENDPOINT_AUTH_METHOD)) { + throw new InvalidClientDetailsException( + "token-endpoint-auth-method is not supported; configure tls-client-auth-ca to enable mTLS for client_id=" + + clientId); + } + if (!mtlsEnabled + && (additionalInfo.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA) + || additionalInfo.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA))) { + throw new InvalidClientDetailsException( + "tls-client-auth-ca / tls-client-auth-trusted-proxy-ca require uaa.mtls-enabled " + + "to be true on this UAA deployment. ClientID: " + clientId); + } + if (additionalInfo.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA)) { + try { + PemCertificateParser.parseCertificate((String) additionalInfo.get( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA)); + } catch (Exception e) { + throw new InvalidClientDetailsException( + "Invalid tls-client-auth-trusted-proxy-ca for client_id=" + clientId + ": " + e.getMessage(), e); + } + } + if (additionalInfo.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA)) { + if (!(additionalInfo.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA) instanceof String)) { + throw new InvalidClientDetailsException( + "Invalid tls-client-auth-ca for client_id=" + clientId + ": must be a PEM string."); + } + try { + PemCertificateParser.parseCertificate(getTlsClientAuthCaPem(additionalInfo)); + } catch (Exception e) { + throw new InvalidClientDetailsException( + "Invalid tls-client-auth-ca for client_id=" + clientId + ": " + e.getMessage(), e); + } + } + } + + private static String getTlsClientAuthCaPem(Map additionalInfo) { + Object rawConfig = additionalInfo.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); + if (rawConfig instanceof String pem) { + return pem; + } + throw new IllegalArgumentException("Not a supported tls-client-auth-ca configuration."); + } + + /** + * Mirrors {@link org.cloudfoundry.identity.uaa.oauth.tls.MtlsClaimsEnhancer}'s private + * {@code PLACEHOLDER} field -- must be kept in sync with it. + */ + private static final Pattern PLACEHOLDER = Pattern.compile("\\{([^}]++)\\}"); + + /** + * Bounds the length of {@code tls-client-auth-sub-template} / {@code tls-client-auth-aud-templates} + * entries before they are ever passed to {@link #PLACEHOLDER}'s regex. This is the actual fix for + * the CodeQL "polynomial regular expression on uncontrolled data" finding: {@link Matcher#find()} + * retries the full match attempt at every character position in the input, giving O(n^2) worst-case + * cost regardless of the possessive-ness of the {@code [^}]++} quantifier. Bounding input length + * caps the worst case at a small, fixed constant. 256 is generous -- real templates (e.g. + * {@code "o/{cf.org}/s/{cf.space}/a/{cf.app}"}) are a few dozen characters at most. + * + *

Mirrored in {@link org.cloudfoundry.identity.uaa.oauth.tls.MtlsClaimsEnhancer#MAX_TEMPLATE_LENGTH} + * for defense-in-depth on the BOSH-flat-config bootstrap path, which bypasses this validator entirely. + */ + static final int MAX_TEMPLATE_LENGTH = 256; + + /** + * Validates a client's mTLS claim-related configuration ({@code tls-client-auth-claim-mappings}, + * {@code tls-client-auth-sub-template}, {@code tls-client-auth-aud-templates}, and + * {@code tls-client-auth-required-claims}) at client creation/update time, before it is + * persisted, so malformed configuration is rejected here rather than causing an unhandled + * exception at token-issuance time (see {@link org.cloudfoundry.identity.uaa.oauth.tls.TlsClientAuthentication#extractClaimMappingValues} + * and {@link org.cloudfoundry.identity.uaa.oauth.tls.MtlsClaimsEnhancer#enhance}). + * + *

A no-op when {@code additionalInfo} is {@code null}. + */ + public static void validateTlsClientAuthClaimConfig(Map additionalInfo, String clientId) { + if (additionalInfo == null) { + return; + } + + List claimMappings = List.of(); + if (additionalInfo.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS)) { + try { + Object rawMappings = additionalInfo.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS); + if (rawMappings instanceof String mappingsJson) { + claimMappings = JsonUtils.readValue(mappingsJson, + new TypeReference>() {}); + } else { + claimMappings = JsonUtils.readValue( + JsonUtils.writeValueAsString(rawMappings), + new TypeReference>() {}); + } + } catch (Exception e) { + throw new InvalidClientDetailsException( + "Invalid tls-client-auth-claim-mappings for client_id=" + clientId + ": " + e.getMessage(), e); + } + } + + if (claimMappings == null) { + claimMappings = List.of(); + } + + Set declaredClaims = new HashSet<>(); + for (TlsClientAuthConfiguration.ClaimMapping mapping : claimMappings) { + if (mapping == null) { + throw new InvalidClientDetailsException( + "tls-client-auth-claim-mappings entry cannot be null for client_id=" + clientId); + } + String field = mapping.getField(); + if (field == null + || !(field.equals("subject_cn") || field.equals("subject_ou") || field.equals("subject_o"))) { + throw new InvalidClientDetailsException( + "tls-client-auth-claim-mappings entry has invalid field '" + field + + "' for client_id=" + clientId + + ". Must be one of: subject_cn, subject_ou, subject_o"); + } + String claim = mapping.getClaim(); + if (claim == null || claim.isBlank()) { + throw new InvalidClientDetailsException( + "tls-client-auth-claim-mappings entry has a blank claim for client_id=" + clientId); + } + String pattern = mapping.getPattern(); + if (pattern != null && !pattern.isBlank()) { + try { + Pattern.compile(pattern); + } catch (PatternSyntaxException e) { + throw new InvalidClientDetailsException( + "tls-client-auth-claim-mappings entry has an invalid pattern '" + pattern + + "' for client_id=" + clientId + ": " + e.getMessage(), e); + } + } + declaredClaims.add(claim); + } + + Object rawSubTemplate = additionalInfo.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE); + if (additionalInfo.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE) + && !(rawSubTemplate instanceof String)) { + throw new InvalidClientDetailsException( + "tls-client-auth-sub-template must be a string for client_id=" + clientId); + } + if (rawSubTemplate instanceof String subTemplate && !subTemplate.isBlank()) { + checkTemplateLength(subTemplate, TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, clientId); + validateTemplatePlaceholders(subTemplate, declaredClaims, + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, clientId); + } + + if (additionalInfo.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES)) { + List audTemplates; + try { + Object rawAudTemplates = additionalInfo.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES); + if (rawAudTemplates instanceof String audJson) { + audTemplates = JsonUtils.readValue(audJson, new TypeReference>() {}); + } else { + audTemplates = JsonUtils.readValue( + JsonUtils.writeValueAsString(rawAudTemplates), + new TypeReference>() {}); + } + } catch (Exception e) { + throw new InvalidClientDetailsException( + "Invalid tls-client-auth-aud-templates for client_id=" + clientId + ": " + e.getMessage(), e); + } + if (audTemplates != null) { + for (String template : audTemplates) { + if (template == null) { + throw new InvalidClientDetailsException( + "tls-client-auth-aud-templates entry cannot be null for client_id=" + clientId); + } + if (!template.isBlank()) { + checkTemplateLength(template, TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, clientId); + validateTemplatePlaceholders(template, declaredClaims, + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, clientId); + } + } + } + } + + if (additionalInfo.containsKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS)) { + Map requiredClaims; + try { + Object rawRequiredClaims = additionalInfo.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS); + if (rawRequiredClaims instanceof String requiredClaimsJson) { + requiredClaims = JsonUtils.readValue(requiredClaimsJson, + new TypeReference>() {}); + } else { + requiredClaims = JsonUtils.readValue( + JsonUtils.writeValueAsString(rawRequiredClaims), + new TypeReference>() {}); + } + } catch (Exception e) { + throw new InvalidClientDetailsException( + "Invalid tls-client-auth-required-claims for client_id=" + clientId + ": " + e.getMessage(), e); + } + if (requiredClaims != null) { + for (Map.Entry requiredClaim : requiredClaims.entrySet()) { + if (!declaredClaims.contains(requiredClaim.getKey())) { + throw new InvalidClientDetailsException( + "tls-client-auth-required-claims references undeclared claim '" + requiredClaim.getKey() + + "' for client_id=" + clientId + + ". Every required claim must be produced by a tls-client-auth-claim-mappings entry."); + } + String requiredValue = requiredClaim.getValue(); + if (requiredValue == null || requiredValue.isBlank()) { + throw new InvalidClientDetailsException( + "tls-client-auth-required-claims has a null or blank value for required claim '" + + requiredClaim.getKey() + "' for client_id=" + clientId); + } + } + } + } + } + + /** + * Rejects templates longer than {@link #MAX_TEMPLATE_LENGTH} before they reach the + * {@link #PLACEHOLDER} regex -- see {@link #MAX_TEMPLATE_LENGTH}'s javadoc for why. + */ + private static void checkTemplateLength(String template, String propertyName, String clientId) { + if (template.length() > MAX_TEMPLATE_LENGTH) { + throw new InvalidClientDetailsException( + propertyName + " for client_id=" + clientId + " has length " + template.length() + + ", which exceeds the maximum allowed length of " + MAX_TEMPLATE_LENGTH + "."); + } + } + + /** + * Validates that every {@code {placeholderName}} referenced in {@code template} is present in + * {@code declaredClaims} -- i.e. actually produced by some {@code tls-client-auth-claim-mappings} + * entry -- otherwise the placeholder could never be resolved by + * {@link org.cloudfoundry.identity.uaa.oauth.tls.MtlsClaimsEnhancer#renderTemplate}, silently + * dropping the whole template at token-issuance time. + */ + private static void validateTemplatePlaceholders( + String template, Set declaredClaims, String propertyName, String clientId) { + Matcher m = PLACEHOLDER.matcher(template); + while (m.find()) { + String placeholder = m.group(1); + if (!declaredClaims.contains(placeholder)) { + throw new InvalidClientDetailsException( + propertyName + " references undeclared claim placeholder '{" + placeholder + + "}' for client_id=" + clientId + + ". Every placeholder must be produced by a tls-client-auth-claim-mappings entry."); + } + } + } + @Override public ClientSecretValidator getClientSecretValidator() { return this.clientSecretValidator; diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/UaaTokenServices.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/UaaTokenServices.java index ae1a3663dba..baebee5a68e 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/UaaTokenServices.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/UaaTokenServices.java @@ -556,8 +556,21 @@ private Map createJWTAccessToken(OAuth2AccessToken token, claims.put(JTI, token.getAdditionalInformation().get(JTI)); claims.putAll(token.getAdditionalInformation()); + // Apply enhancer-supplied claims that are NOT one of UAA's own protected/reserved claim + // names (NON_ADDITIONAL_ROOT_CLAIMS) now, before any UAA-owned default below is set -- so + // the corresponding claims.put(...) calls below always win over an enhancer's value for + // the same reserved claim name (e.g. scope, client_id, authorities, iss, grant_type). This + // closes a gap where a client-configurable enhancer (e.g. certificate-derived mTLS claim + // mappings) could otherwise overwrite any UAA-owned/protected claim. sub and aud are the + // two explicitly-supported late overrides (e.g. mTLS cert-identity templates rendering + // their own sub/aud) and are re-applied after all defaults below, once it is safe for them + // to win. if (additionalRootClaims != null) { - claims.putAll(additionalRootClaims); + additionalRootClaims.forEach((key, value) -> { + if (!NON_ADDITIONAL_ROOT_CLAIMS.contains(key)) { + claims.put(key, value); + } + }); } claims.put(SUB, clientId); @@ -590,6 +603,18 @@ private Map createJWTAccessToken(OAuth2AccessToken token, claims.put(AUD, UaaStringUtils.getValuesOrDefaultValue(resourceIds, clientId)); + // Re-apply only the two explicitly-supported late overrides (e.g. mTLS cert-identity + // templates rendering their own sub/aud). Every other claim name in additionalRootClaims + // was already rejected above (see NON_ADDITIONAL_ROOT_CLAIMS) and must not win here either. + if (additionalRootClaims != null) { + if (additionalRootClaims.containsKey(SUB)) { + claims.put(SUB, additionalRootClaims.get(SUB)); + } + if (additionalRootClaims.containsKey(AUD)) { + claims.put(AUD, additionalRootClaims.get(AUD)); + } + } + for (String excludedClaim : getExcludedClaims()) { claims.remove(excludedClaim); } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointBeanConfiguration.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointBeanConfiguration.java index 301f353b5a3..ee2ba295826 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointBeanConfiguration.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointBeanConfiguration.java @@ -46,6 +46,7 @@ import org.cloudfoundry.identity.uaa.oauth.UaaTokenStore; import org.cloudfoundry.identity.uaa.oauth.jwt.JwtClientAuthentication; import org.cloudfoundry.identity.uaa.oauth.openid.IdTokenClaimEnhancer; +import org.cloudfoundry.identity.uaa.oauth.tls.TlsClientAuthentication; import org.cloudfoundry.identity.uaa.oauth.openid.IdTokenCreator; import org.cloudfoundry.identity.uaa.oauth.openid.IdTokenEnhancer; import org.cloudfoundry.identity.uaa.oauth.openid.IdTokenGranter; @@ -460,12 +461,14 @@ ClientAuthenticationPublisher clientAuthenticationPublisher() { ClientDetailsAuthenticationProvider clientAuthenticationProvider( @Qualifier("clientDetailsUserService") UserDetailsService clientDetailsUserService, @Qualifier("cachingPasswordEncoder") PasswordEncoder cachingPasswordEncoder, - @Qualifier("jwtClientAuthentication") JwtClientAuthentication jwtClientAuthentication + @Qualifier("jwtClientAuthentication") JwtClientAuthentication jwtClientAuthentication, + TlsClientAuthentication tlsClientAuthentication ) { return new ClientDetailsAuthenticationProvider( clientDetailsUserService, cachingPasswordEncoder, - jwtClientAuthentication + jwtClientAuthentication, + tlsClientAuthentication ); } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointSecurityConfiguration.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointSecurityConfiguration.java index be14d690012..f12c59b991f 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointSecurityConfiguration.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointSecurityConfiguration.java @@ -29,6 +29,7 @@ import org.cloudfoundry.identity.uaa.oauth.UaaTokenServices; import org.cloudfoundry.identity.uaa.oauth.UserManagedAuthzApprovalHandler; import org.cloudfoundry.identity.uaa.oauth.pkce.PkceValidationService; +import org.cloudfoundry.identity.uaa.oauth.tls.RawPeerCertificateCaptureFilter; import org.cloudfoundry.identity.uaa.oauth.provider.OAuth2RequestFactory; import org.cloudfoundry.identity.uaa.oauth.provider.TokenGranter; import org.cloudfoundry.identity.uaa.oauth.provider.authentication.OAuth2AuthenticationProcessingFilter; @@ -50,6 +51,7 @@ import org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManager; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -463,6 +465,42 @@ UaaFilterChain externalOAuthCallbackEndpointSecurity(HttpSecurity http) throws E return new UaaFilterChain(chain, "externalOAuthCallbackEndpointSecurity"); } + /** + * Security filter chain for the mTLS token endpoint ({@code /oauth/mtls/token}). + * + *

The {@code ClientCertificateMapper} servlet filter (registered separately) converts the + * {@code X-Forwarded-Client-Cert} header set by the Gorouter into a + * {@code jakarta.servlet.request.X509Certificate} request attribute before this chain runs. + * CSRF is disabled because this is a stateless machine-to-machine API endpoint. + */ + @Bean + @ConditionalOnProperty(name = "uaa.mtls-enabled", havingValue = "true") + @Order(FilterChainOrder.OAUTH_11) + UaaFilterChain mtlsTokenEndpointSecurity(HttpSecurity http) throws Exception { + SecurityFilterChain chain = http + .securityMatcher(RawPeerCertificateCaptureFilter.MTLS_TOKEN_PATH, + RawPeerCertificateCaptureFilter.MTLS_TOKEN_PATH + "/**") + .authenticationManager(clientAuthenticationManager) + .authorizeHttpRequests(auth -> { + auth.requestMatchers("/**").access(anyOf().fullyAuthenticated()); + auth.anyRequest().denyAll(); + }) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .addFilterBefore(getClientParameterAuthenticationFilter(), BasicAuthenticationFilter.class) + .addFilterAt(clientAuthenticationFilter.getFilter(), BasicAuthenticationFilter.class) + .addFilterAfter(tokenEndpointAuthenticationFilter.getFilter(), BasicAuthenticationFilter.class) + .anonymous(AnonymousConfigurer::disable) + .csrf(CsrfConfigurer::disable) + .exceptionHandling(exception -> + exception.authenticationEntryPoint(basicAuthenticationEntryPoint) + .accessDeniedHandler(oauthAccessDeniedHandler) + ) + .securityContext(sc -> sc.requireExplicitSave(false)) + .build(); + + return new UaaFilterChain(chain, "mtlsTokenEndpointSecurity"); + } + @Bean @Order(FilterChainOrder.OAUTH_10) UaaFilterChain oldAuthzEndpointSecurity(HttpSecurity http) throws Exception { diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/provider/client/ClientCredentialsTokenGranter.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/provider/client/ClientCredentialsTokenGranter.java index a8ee326e167..5390dfdec0d 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/provider/client/ClientCredentialsTokenGranter.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/provider/client/ClientCredentialsTokenGranter.java @@ -12,6 +12,7 @@ import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.CLIENT_AUTH_PRIVATE_KEY_JWT; import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.CLIENT_AUTH_SECRET; +import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.CLIENT_AUTH_TLS_CLIENT_AUTH; import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.GRANT_TYPE_CLIENT_CREDENTIALS; /** @@ -24,7 +25,11 @@ */ public class ClientCredentialsTokenGranter extends AbstractTokenGranter { - private static final List ALLOWED_AUTH_METHODS = List.of(CLIENT_AUTH_SECRET, CLIENT_AUTH_PRIVATE_KEY_JWT); + private static final List ALLOWED_AUTH_METHODS = List.of(CLIENT_AUTH_SECRET, CLIENT_AUTH_PRIVATE_KEY_JWT, CLIENT_AUTH_TLS_CLIENT_AUTH); + + public static boolean isAllowedAuthMethod(String method) { + return ALLOWED_AUTH_METHODS.contains(method); + } public ClientCredentialsTokenGranter(AuthorizationServerTokenServices tokenServices, ClientDetailsService clientDetailsService, OAuth2RequestFactory requestFactory) { 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 new file mode 100644 index 00000000000..9c29a33824d --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancer.java @@ -0,0 +1,287 @@ +package org.cloudfoundry.identity.uaa.oauth.tls; + +import org.cloudfoundry.identity.uaa.client.TlsClientAuthConfiguration; +import org.cloudfoundry.identity.uaa.client.UaaClientDetails; +import org.cloudfoundry.identity.uaa.constants.ClientAuthentication; +import org.cloudfoundry.identity.uaa.oauth.UaaTokenEnhancer; +import org.cloudfoundry.identity.uaa.oauth.provider.ClientDetailsService; +import org.cloudfoundry.identity.uaa.oauth.provider.OAuth2Authentication; +import org.cloudfoundry.identity.uaa.util.JsonUtils; +import org.cloudfoundry.identity.uaa.util.UaaSecurityContextUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import tools.jackson.core.type.TypeReference; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateEncodingException; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * A {@link UaaTokenEnhancer} that enriches access tokens with claims derived from the + * mTLS client certificate presented during the {@code /oauth/mtls/token} flow. + * + *

When a client configured with {@code tls-client-auth} authenticates, this enhancer: + *

    + *
  • Maps certificate subject fields (CN, OU, O) to JWT claims as configured per-client.
  • + *
  • Adds a {@code cnf.x5t#S256} confirmation claim (RFC 8705 §3.1).
  • + *
+ * + *

Spring auto-wires this bean into + * {@link org.cloudfoundry.identity.uaa.oauth.UaaTokenServices#setUaaTokenEnhancers} via + * {@code @Autowired(required = false)}. + */ +@Component +public class MtlsClaimsEnhancer implements UaaTokenEnhancer { + + private static final Pattern PLACEHOLDER = Pattern.compile("\\{([^}]++)\\}"); + + /** + * Bounds template length before it reaches {@link #PLACEHOLDER}'s regex -- see + * {@link org.cloudfoundry.identity.uaa.client.ClientAdminEndpointsValidator}'s + * {@code MAX_TEMPLATE_LENGTH} javadoc for the full rationale (same value, duplicated here + * because the two classes are in different packages/modules; kept in sync by convention, + * same as {@code PLACEHOLDER} itself). This guard is defense-in-depth for clients configured + * via the BOSH-flat-config bootstrap path ({@link #loadTlsConfig}), which bypasses + * ClientAdminEndpointsValidator's admin-API-time validation entirely. + */ + static final int MAX_TEMPLATE_LENGTH = 256; + + private final TlsClientAuthentication tlsClientAuthentication; + private final ClientDetailsService clientDetailsService; + + @Autowired + public MtlsClaimsEnhancer(TlsClientAuthentication tlsClientAuthentication, + ClientDetailsService clientDetailsService) { + this.tlsClientAuthentication = tlsClientAuthentication; + this.clientDetailsService = clientDetailsService; + } + + /** + * Not used — all enrichment is performed in {@link #enhance}. + */ + @Override + public Map getExternalAttributes(OAuth2Authentication authentication) { + return Map.of(); + } + + /** + * Returns a map of additional top-level JWT claims derived from the client certificate. + * Returns an empty map when no certificate is present on the request, or when the client + * did not actually authenticate via {@code tls_client_auth} (e.g. a client with both a + * secret and TLS config configured that authenticated via {@code client_secret_basic} on + * the mTLS alias) — the certificate mapped onto the request in that case was never + * validated by {@link TlsClientAuthentication#validateClientCert}, so it must not be + * trusted as a source of identity claims. + */ + @Override + public Map enhance(Map claims, OAuth2Authentication authentication) { + // Cheap presence-only check (no trust decision, no database lookup) -- avoids resolving the + // client's TlsClientAuthConfiguration at all when there is clearly nothing to process. + if (!tlsClientAuthentication.hasCertificateFromRequest()) { + return new HashMap<>(); + } + + if (!ClientAuthentication.TLS_CLIENT_AUTH.equals( + UaaSecurityContextUtils.getClientAuthenticationMethod(authentication))) { + return new HashMap<>(); + } + + String clientId = authentication.getOAuth2Request().getClientId(); + UaaClientDetails clientDetails = (UaaClientDetails) clientDetailsService.loadClientByClientId(clientId); + + // Check the typed field first (set directly on in-memory / admin-API clients); + // fall back to additionalInformation for JDBC-loaded clients. + TlsClientAuthConfiguration config = clientDetails.getTlsClientAuthConfiguration(); + if (config == null) { + config = loadTlsConfig(clientDetails.getAdditionalInformation()); + } + if (!TlsClientAuthConfiguration.isConfigured(config)) { + return new HashMap<>(); + } + + // Now do the real, per-client trust decision: only a certificate validated against *this + // client's* tls-client-auth-trusted-proxy-ca is used from here on. + X509Certificate cert = tlsClientAuthentication.getCertificateFromRequest(config); + if (cert == null) { + return new HashMap<>(); + } + + // PHASE 1 — extract cert subject fields into vars (keyed by claim name) + Map vars = tlsClientAuthentication.extractClaimMappingValues(cert, config); + + // PHASE 2 — build JWT claims: dot-notation → nested object; flat → top-level + Map result = new HashMap<>(); + Map> nestedClaims = new HashMap<>(); + for (Map.Entry entry : vars.entrySet()) { + String key = entry.getKey(); + String value = entry.getValue(); + // Only a single dot level is supported (spec: UAA-RFC8705-001 configurable-token-shape). + // A key like "cf.app.id" would produce parent="cf", child="app.id" (not deeper nesting). + int dotIdx = key.indexOf('.'); + if (dotIdx > 0 && dotIdx < key.length() - 1) { + String parent = key.substring(0, dotIdx); + String child = key.substring(dotIdx + 1); + nestedClaims.computeIfAbsent(parent, k -> new HashMap<>()).put(child, value); + } else { + result.put(key, value); + } + } + // Nested maps overwrite any flat claim that shares the same parent key + result.putAll(nestedClaims); + + // Always add cnf.x5t#S256 (RFC 8705 §3.1 confirmation claim) + try { + byte[] derEncoded = cert.getEncoded(); + byte[] sha256 = MessageDigest.getInstance("SHA-256").digest(derEncoded); + String thumbprint = Base64.getUrlEncoder().withoutPadding().encodeToString(sha256); + result.put("cnf", Map.of("x5t#S256", thumbprint)); + } catch (CertificateEncodingException | NoSuchAlgorithmException e) { + // Fail closed: an already-validated peer certificate should always be re-encodable, + // and SHA-256 is a guaranteed JCE algorithm, so this is practically impossible. If it + // ever happens, we must not silently issue an unbound bearer token in place of a + // certificate-bound (RFC 8705 §3.1) one -- fail the whole token request instead (same + // fail-closed philosophy as the client-details lookup failure above). + throw new IllegalStateException( + "Failed to compute cnf.x5t#S256 confirmation claim for client_id=" + + clientId + ": " + e.getMessage(), e); + } + + // PHASE 3 — template rendering for sub and aud + if (config.getSubTemplate() != null) { + String rendered = renderTemplate(config.getSubTemplate(), vars); + if (rendered != null) { + result.put("sub", rendered); + } + } + + if (config.getAudTemplates() != null && !config.getAudTemplates().isEmpty()) { + List audList = new ArrayList<>(); + for (String tmpl : config.getAudTemplates()) { + // Legacy persisted clients can contain null entries created before validation. + if (tmpl == null) { + continue; + } + String rendered = renderTemplate(tmpl, vars); + if (rendered != null) { + audList.add(rendered); + } + } + if (!audList.isEmpty()) { + result.put("aud", audList); + } + } + + return result; + } + + /** + * Renders a template string by substituting all {@code {varName}} placeholders + * from {@code vars}. Returns {@code null} if any placeholder has no corresponding + * value in {@code vars} (the whole template is then dropped by the caller). + * + *

Variable names may contain dots (e.g. {@code {cf.org}}); dots inside braces + * are treated as part of the name, not as path separators. + * + *

Returns {@code null} without attempting to match if {@code template} exceeds + * {@link #MAX_TEMPLATE_LENGTH}, treating an oversized template the same as an unresolvable + * one (silently dropped by the caller) rather than a hard failure -- a hard failure here + * would break every future token request for a client with a pre-existing, already-persisted + * oversized template. + */ + private String renderTemplate(String template, Map vars) { + if (template.length() > MAX_TEMPLATE_LENGTH) { + return null; + } + StringBuilder sb = new StringBuilder(); + Matcher m = PLACEHOLDER.matcher(template); + while (m.find()) { + String varName = m.group(1); + String value = vars.get(varName); + if (value == null) { + return null; // unresolved placeholder → caller should drop this template + } + m.appendReplacement(sb, Matcher.quoteReplacement(value)); + } + m.appendTail(sb); + return sb.toString(); + } + + /** + * Builds a {@link TlsClientAuthConfiguration} from the client's {@code additionalInformation} map. + * Reads the documented flat configuration from a DB-loaded client's additional information. + */ + private static TlsClientAuthConfiguration loadTlsConfig(Map info) { + if (info == null) { + return null; + } + Object raw = info.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); + if (raw instanceof String pem) { + try { + List claimMappings = null; + Object rawMappings = info.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS); + if (rawMappings instanceof String mappingsJson) { + claimMappings = JsonUtils.readValue(mappingsJson, + new TypeReference>() {}); + } else if (rawMappings instanceof List mappingsList) { + // Jackson may parse a JSON array directly as a List when additionalInformation + // is deserialized from JDBC without a String-encoded wrapper. + String mappingsJson = JsonUtils.writeValueAsString(mappingsList); + claimMappings = JsonUtils.readValue(mappingsJson, + new TypeReference>() {}); + } + String subTemplate = null; + Object rawSubTemplate = info.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE); + if (rawSubTemplate instanceof String st && !st.isBlank()) { + subTemplate = st; + } + + List audTemplates = null; + Object rawAudTemplates = info.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES); + if (rawAudTemplates instanceof String audJson) { + audTemplates = JsonUtils.readValue(audJson, new TypeReference>() {}); + } else if (rawAudTemplates instanceof List audList) { + // Jackson may deserialise a JSON array as a List when additionalInformation + // is loaded from JDBC without a String-encoded wrapper. + audTemplates = JsonUtils.readValue( + JsonUtils.writeValueAsString(audList), + new TypeReference>() {}); + } + + String trustedProxyCaPem = null; + Object rawTrustedProxyCa = info.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA); + if (rawTrustedProxyCa instanceof String tpc && !tpc.isBlank()) { + trustedProxyCaPem = tpc; + } + + Map requiredClaims = null; + Object rawRequiredClaims = info.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS); + if (rawRequiredClaims instanceof String requiredClaimsJson) { + requiredClaims = JsonUtils.readValue(requiredClaimsJson, + new TypeReference>() {}); + } else if (rawRequiredClaims instanceof Map requiredClaimsMap) { + requiredClaims = JsonUtils.readValue( + JsonUtils.writeValueAsString(requiredClaimsMap), + new TypeReference>() {}); + } + + TlsClientAuthConfiguration cfg = new TlsClientAuthConfiguration(pem, claimMappings); + cfg.setSubTemplate(subTemplate); + cfg.setAudTemplates(audTemplates); + cfg.setTrustedProxyCaPem(trustedProxyCaPem); + cfg.setRequiredClaims(requiredClaims); + return cfg; + } catch (Exception e) { + return null; + } + } + return null; + } +} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsPathGuardedFilter.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsPathGuardedFilter.java new file mode 100644 index 00000000000..349be684ff8 --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsPathGuardedFilter.java @@ -0,0 +1,45 @@ +package org.cloudfoundry.identity.uaa.oauth.tls; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; + +import java.io.IOException; + +/** + * Wraps a delegate {@link Filter} so it only runs for requests whose effective (post + * {@code ZonePathContextRewritingFilter}) servlet path is {@code /oauth/mtls/token/**} -- see + * {@link RawPeerCertificateCaptureFilter#isMtlsTokenPath(HttpServletRequest)}. + * + *

Used in {@code SpringServletXmlFiltersConfiguration} to scope the third-party, package-private + * {@code ClientCertificateMapper} filter to the mTLS token endpoint without relying on a container + * URL-pattern registration. A URL-pattern registration is matched against the request's original, + * pre-rewrite URI, so it would not include the filter in the chain for a zone-path-prefixed mTLS + * request (e.g. {@code /z/{subdomain}/oauth/mtls/token}), even though downstream code (including this + * guard) sees the same effective path as a direct request. + */ +public class MtlsPathGuardedFilter implements Filter { + + private final Filter delegate; + + public MtlsPathGuardedFilter(Filter delegate) { + this.delegate = delegate; + } + + Filter getDelegate() { + return delegate; + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + if (RawPeerCertificateCaptureFilter.isMtlsTokenPath((HttpServletRequest) request)) { + delegate.doFilter(request, response, chain); + } else { + chain.doFilter(request, response); + } + } +} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilter.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilter.java new file mode 100644 index 00000000000..d5d97667eb5 --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilter.java @@ -0,0 +1,77 @@ +package org.cloudfoundry.identity.uaa.oauth.tls; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; + +import java.io.IOException; + +/** + * Captures the genuine TLS-handshake peer certificate before the downstream {@code ClientCertificateMapper} + * filter has a chance to overwrite it. + * + *

With {@code certificateVerification=optionalNoCA} configured on the servlet container, the container + * populates the standard {@code jakarta.servlet.request.X509Certificate} request attribute with whatever + * certificate the immediate TLS peer actually presented during the handshake (e.g. the Gorouter's + * {@code gorouter_backend_tls} client cert). The {@code ClientCertificateMapper} filter (registered at + * order -200 in {@code SpringServletXmlFiltersConfiguration}) later overwrites that same attribute with a + * certificate it decodes from the {@code X-Forwarded-Client-Cert} header whenever that header is present, + * discarding the genuine handshake value. + * + *

This filter must be registered to run before {@code ClientCertificateMapper} so that the + * genuine peer certificate is preserved in a dedicated attribute + * ({@link #RAW_PEER_CERTIFICATE_ATTRIBUTE}). This is what allows + * {@link TlsClientAuthentication#isCertificateFromTrustedProxy(org.cloudfoundry.identity.uaa.client.TlsClientAuthConfiguration)} + * to validate "what the immediate TLS peer actually presented" (this attribute) against a specific + * client's configured {@code tls-client-auth-trusted-proxy-ca}, confirming the + * {@code X-Forwarded-Client-Cert} header was genuinely set by a trusted proxy (e.g. the Gorouter) + * rather than a direct caller spoofing it -- the actual value returned to callers (e.g. via + * {@link TlsClientAuthentication#getCertificateFromRequest(org.cloudfoundry.identity.uaa.client.TlsClientAuthConfiguration)}) + * still comes from the standard, XFCC-derived {@code jakarta.servlet.request.X509Certificate} + * attribute -- this filter's captured value is used only for the trust check, never as the + * authenticated client certificate itself. + * + *

Registered in {@code SpringServletXmlFiltersConfiguration} on the default (all-requests) URL + * pattern, not a literal {@code /oauth/mtls/token/**} one: {@link #isMtlsTokenPath(HttpServletRequest)} guards + * this filter's work internally instead, checking the request's effective servlet path (i.e. + * after {@code ZonePathContextRewritingFilter}, which runs first, has rewritten it). A container + * URL-pattern registration is matched against the request's original, pre-rewrite URI, so it would + * never include this filter in the chain for a zone-path-prefixed mTLS request (e.g. + * {@code /z/{subdomain}/oauth/mtls/token}), even though downstream code sees the same effective path as + * a direct request. + */ +public class RawPeerCertificateCaptureFilter implements Filter { + + public static final String RAW_PEER_CERTIFICATE_ATTRIBUTE = + "org.cloudfoundry.identity.uaa.oauth.tls.rawPeerCertificate"; + + private static final String X509_CERTIFICATE_ATTRIBUTE = "jakarta.servlet.request.X509Certificate"; + public static final String MTLS_TOKEN_PATH = "/oauth/mtls/token"; + private static final String MTLS_TOKEN_PATH_PREFIX = MTLS_TOKEN_PATH + "/"; + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + if (isMtlsTokenPath((HttpServletRequest) request)) { + request.setAttribute(RAW_PEER_CERTIFICATE_ATTRIBUTE, request.getAttribute(X509_CERTIFICATE_ATTRIBUTE)); + } + chain.doFilter(request, response); + } + + /** + * Matches the request's effective servlet path -- i.e. after {@code ZonePathContextRewritingFilter} + * (which runs first in the filter chain) has stripped any {@code /z/{subdomain}} prefix -- against + * {@code /oauth/mtls/token/**}. Also used by {@link MtlsPathGuardedFilter} to scope the (externally + * supplied, package-private) {@code ClientCertificateMapper} filter to the same effective path. + */ + static boolean isMtlsTokenPath(HttpServletRequest request) { + return isMtlsTokenPath(request.getServletPath()); + } + + public static boolean isMtlsTokenPath(String path) { + return path != null && (path.equals(MTLS_TOKEN_PATH) || path.startsWith(MTLS_TOKEN_PATH_PREFIX)); + } +} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java new file mode 100644 index 00000000000..222b8ba28e9 --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthentication.java @@ -0,0 +1,541 @@ +package org.cloudfoundry.identity.uaa.oauth.tls; + +import jakarta.servlet.http.HttpServletRequest; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; +import org.bouncycastle.openssl.PEMParser; +import org.cloudfoundry.identity.uaa.client.InvalidClientDetailsException; +import org.cloudfoundry.identity.uaa.client.TlsClientAuthConfiguration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import javax.naming.NamingEnumeration; +import javax.naming.NamingException; +import javax.naming.directory.Attribute; +import javax.naming.ldap.LdapName; +import javax.naming.ldap.Rdn; +import javax.security.auth.x500.X500Principal; +import java.io.StringReader; +import java.security.cert.CertPathValidator; +import java.security.cert.CertPathValidatorException; +import java.security.cert.CertificateFactory; +import java.security.cert.PKIXParameters; +import java.security.cert.TrustAnchor; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Validates mTLS client certificates against a configured CA and extracts + * the certificate from the current HTTP request. + * + *

Analogous to {@link org.cloudfoundry.identity.uaa.oauth.jwt.JwtClientAuthentication} + * but for RFC 8705 mutual-TLS client authentication. + */ +@Component +public class TlsClientAuthentication { + + private static final Logger logger = LoggerFactory.getLogger(TlsClientAuthentication.class); + + private static final String XFCC_HEADER = "X-Forwarded-Client-Cert"; + + /** + * Returns {@code true} when any certificate is present on the current request under the + * standard {@code jakarta.servlet.request.X509Certificate} attribute -- whether that attribute + * holds a certificate derived from the {@code X-Forwarded-Client-Cert} header, or (when no XFCC + * header was sent) the raw TLS-handshake peer certificate -- regardless of whether it is + * trustworthy. This is a cheap, non-trust-deciding presence check intended as an early exit before + * resolving a client's {@link TlsClientAuthConfiguration} (which may require a database lookup) -- + * it does not grant or imply any authorization by itself, since any caller (trusted or not) that + * sets the header will cause the servlet container to populate this attribute. + */ + public boolean hasCertificateFromRequest() { + ServletRequestAttributes attrs = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attrs == null) { + return false; + } + Object certs = attrs.getRequest().getAttribute("jakarta.servlet.request.X509Certificate"); + return certs instanceof X509Certificate[] arr && arr.length > 0; + } + + /** + * Returns the first X.509 certificate from the current request that is trustworthy for + * {@code clientConfig} -- i.e. only when {@link #isCertificateFromTrustedProxy(TlsClientAuthConfiguration)} + * is {@code true} for this client's configuration. + * + * @return the client certificate, or {@code null} if none is present or not from a trusted proxy + */ + public X509Certificate getCertificateFromRequest(TlsClientAuthConfiguration clientConfig) { + X509Certificate[] chain = getCertificateChainFromRequest(clientConfig); + return (chain != null && chain.length > 0) ? chain[0] : null; + } + + /** + * Returns the full X.509 certificate chain the current request should be authenticated with, + * per {@code clientConfig}'s configured trust model: + * + *

    + *
  • If {@code clientConfig} has no {@code tls-client-auth-trusted-proxy-ca} configured, + * this client is direct-connection-only: always returns the genuine TLS-handshake peer + * certificate chain (captured by {@link RawPeerCertificateCaptureFilter}), ignoring any + * {@code X-Forwarded-Client-Cert} header entirely.
  • + *
  • If {@code tls-client-auth-trusted-proxy-ca} is configured, this client is + * proxy-only: requires an {@code X-Forwarded-Client-Cert} header to be present and + * {@link #isCertificateFromTrustedProxy(TlsClientAuthConfiguration)} to be {@code true} + * (the genuine TLS peer -- e.g. the Gorouter -- must validate against the configured + * proxy CA) before returning the header-derived certificate chain from the standard + * {@code jakarta.servlet.request.X509Certificate} attribute. Additionally, the standard + * attribute's certificate chain must differ from the raw peer certificate chain + * captured by {@link RawPeerCertificateCaptureFilter}: the third-party + * {@code ClientCertificateMapper} servlet filter does not clear or null the standard + * attribute when it fails to parse the XFCC header -- it simply never replaces it, + * leaving the genuine raw peer certificate (e.g. the proxy's own certificate) in place. + * If the two attributes are identical, the mapper did not actually run successfully, + * and the request is rejected rather than treating the proxy's own certificate as the + * client's.
  • + *
+ * + *

The two modes are mutually exclusive per client: a client cannot accept both a direct + * connection and a proxy-forwarded one. An operator needing both registers two separate UAA + * clients. Index 0 of the returned chain is the end-entity (leaf) certificate. + * + * @return the client certificate chain, or {@code null} if none is present or the request + * doesn't match this client's configured trust model + */ + public X509Certificate[] getCertificateChainFromRequest(TlsClientAuthConfiguration clientConfig) { + ServletRequestAttributes attrs = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attrs == null) { + return null; + } + HttpServletRequest request = attrs.getRequest(); + boolean trustedProxyConfigured = clientConfig != null + && clientConfig.getTrustedProxyCaPem() != null + && !clientConfig.getTrustedProxyCaPem().isBlank(); + + if (!trustedProxyConfigured) { + // Direct-connection-only client: always use the genuine TLS-handshake peer + // certificate, regardless of any X-Forwarded-Client-Cert header -- this client's + // trust model has no proxy in it at all. + X509Certificate[] rawPeerCerts = (X509Certificate[]) + request.getAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE); + return (rawPeerCerts != null && rawPeerCerts.length > 0) ? rawPeerCerts : null; + } + + // Proxy-only client: require the XFCC header to actually be present -- a direct + // connection (no header) is always rejected, even if its own certificate would validate + // against tls-client-auth-trusted-proxy-ca -- plus the genuine peer (the proxy) must + // validate against it. + String xfccHeader = request.getHeader(XFCC_HEADER); + if (xfccHeader == null || xfccHeader.isBlank() || !isCertificateFromTrustedProxy(clientConfig)) { + return null; + } + X509Certificate[] certs = (X509Certificate[]) + request.getAttribute("jakarta.servlet.request.X509Certificate"); + if (certs == null || certs.length == 0) { + return null; + } + // Guard against ClientCertificateMapper silently failing to parse the XFCC header: it + // does not clear/null the standard attribute on a parse failure, it simply never replaces + // it, leaving the genuine raw peer certificate (e.g. the proxy's own certificate) in + // place. If the standard attribute is unchanged from the raw peer capture, the mapper did + // not actually run successfully -- treating it as the client's certificate would let the + // proxy authenticate as the client whenever the proxy's own certificate happens to + // validate against this client's tls-client-auth-ca. + X509Certificate[] rawPeerCerts = (X509Certificate[]) + request.getAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE); + if (Arrays.equals(certs, rawPeerCerts)) { + logger.warn("getCertificateChainFromRequest: X-Forwarded-Client-Cert header present and peer " + + "validated as trusted proxy, but the standard X509Certificate attribute was unchanged " + + "from the raw peer certificate -- ClientCertificateMapper likely failed to parse the " + + "XFCC header; rejecting rather than treating the proxy's own certificate as the client's"); + return null; + } + return certs; + } + + /** + * Returns {@code true} only when the genuine TLS-handshake peer certificate captured by + * {@link RawPeerCertificateCaptureFilter} (the certificate the immediate TCP peer actually + * presented during the TLS handshake -- distinct from any certificate derived from the + * {@code X-Forwarded-Client-Cert} header) validates against {@code clientConfig}'s + * {@code tls-client-auth-trusted-proxy-ca}. + * + *

Scoped per-client (like {@code tls-client-auth-ca}) rather than a single global CA: the value + * is stored in {@code additionalInformation} and is therefore API-mutable at runtime. The Tomcat + * connector (see {@code MtlsClientAuthTomcatCustomizer}) performs no CA validation at the TLS layer + * at all ({@code certificateVerification=optionalNoCA}), so there is no static allowlist that could + * drift out of sync with this per-client value. + * + *

Like {@link #validateClientCert(X509Certificate[], TlsClientAuthConfiguration)}, PKIX path + * validation alone does not enforce that the validated peer leaf is actually meant to be used as a + * TLS client authentication credential -- since the connector's trust manager accepts any certificate + * at the TLS layer, a CA certificate or a certificate whose Key Usage/Extended Key Usage extensions + * explicitly exclude client authentication could otherwise still be accepted as the trusted proxy's + * own credential. This method therefore also applies {@link #validateEndEntityConstraints} to the + * validated peer leaf, returning {@code false} (via the catch-all below) if it fails. + * + * @return {@code false} if {@code clientConfig} is {@code null}, has no + * {@code tls-client-auth-trusted-proxy-ca} configured, there is no current request or no + * captured peer certificate, the peer certificate does not validate against the configured + * proxy CA, or the validated peer leaf fails an end-entity constraint check + */ + public boolean isCertificateFromTrustedProxy(TlsClientAuthConfiguration clientConfig) { + String trustedProxyCaPem = clientConfig != null ? clientConfig.getTrustedProxyCaPem() : null; + if (trustedProxyCaPem == null || trustedProxyCaPem.isBlank()) { + return false; + } + ServletRequestAttributes attrs = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attrs == null) { + return false; + } + Object raw = attrs.getRequest() + .getAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE); + if (!(raw instanceof X509Certificate[] peerChain) || peerChain.length == 0) { + return false; + } + try { + X509Certificate caCert = parsePemCertificate(trustedProxyCaPem); + Optional validated = validateCertPath(peerChain, caCert); + if (validated.isPresent()) { + validateEndEntityConstraints(validated.get()); + } + return validated.isPresent(); + } catch (Exception e) { + logger.warn("isCertificateFromTrustedProxy: peer certificate did not validate against " + + "tls-client-auth-trusted-proxy-ca: {}", e.getMessage()); + return false; + } + } + + /** + * Extracts claim-name -> value pairs from {@code cert}'s subject fields per {@code config}'s + * {@code tls-client-auth-claim-mappings}. Shared by {@link MtlsClaimsEnhancer} (to build JWT + * claims) and {@link #certificateSatisfiesRequiredClaims} (to enforce + * {@code tls-client-auth-required-claims} at authentication time, before any claim is built). + * + * @return an empty map if {@code cert} or {@code config} is {@code null}, or if + * {@code config} has no {@code tls-client-auth-claim-mappings} configured + */ + public Map extractClaimMappingValues(X509Certificate cert, TlsClientAuthConfiguration config) { + if (cert == null || config == null || config.getClaimMappings() == null) { + return Map.of(); + } + X500Principal subject = cert.getSubjectX500Principal(); + String dn = subject.getName(X500Principal.RFC2253); + String cn = extractRdnValue(dn, "CN"); + List ous = extractOus(dn); + + Map vars = new HashMap<>(); + for (TlsClientAuthConfiguration.ClaimMapping mapping : config.getClaimMappings()) { + String value = switch (mapping.getField()) { + case "subject_cn" -> cn; + case "subject_ou" -> matchFirstOu(ous, mapping.getPattern()); + case "subject_o" -> extractRdnValue(dn, "O"); + default -> null; + }; + if (value != null && !value.isBlank()) { + vars.put(mapping.getClaim(), value); + } + } + return vars; + } + + /** + * Returns {@code true} when {@code config} has no {@code tls-client-auth-required-claims} + * configured (unrestricted, current behavior), or when every required claim name maps to + * exactly the required value once extracted from {@code cert} via + * {@code tls-client-auth-claim-mappings}. This is what lets a client be scoped to e.g. a + * specific CF space/org/app -- closing the gap where any certificate chaining to a shared CA + * could otherwise authenticate as any client that trusts that CA. + */ + public boolean certificateSatisfiesRequiredClaims(X509Certificate cert, TlsClientAuthConfiguration config) { + if (config == null || config.getRequiredClaims() == null || config.getRequiredClaims().isEmpty()) { + return true; + } + Map vars = extractClaimMappingValues(cert, config); + for (Map.Entry required : config.getRequiredClaims().entrySet()) { + if (!required.getValue().equals(vars.get(required.getKey()))) { + logger.debug("certificateSatisfiesRequiredClaims: required claim '{}' did not match " + + "(expected '{}', extracted '{}')", + required.getKey(), required.getValue(), vars.get(required.getKey())); + return false; + } + } + return true; + } + + /** + * Validates {@code clientCert} against the trusted CA PEM configured in {@code config} + * using PKIX path validation. + * For chains with intermediates, prefer + * {@link #validateClientCert(X509Certificate[], TlsClientAuthConfiguration)}. + * + * @param clientCert the certificate presented by the client, may be {@code null} + * @param config the per-client TLS configuration, may be {@code null} + * @return {@code Optional.of(clientCert)} when validation succeeds; + * {@code Optional.empty()} when cert or config is absent + * @throws InvalidClientDetailsException if the CA PEM is malformed or the cert chain is invalid + */ + public Optional validateClientCert( + X509Certificate clientCert, TlsClientAuthConfiguration config) { + return validateClientCert( + clientCert != null ? new X509Certificate[]{clientCert} : null, config); + } + + /** + * Validates a full certificate chain against the trusted CA PEM configured in {@code config} + * using PKIX path validation. Supports chains that include intermediate CAs. + * + *

PKIX path validation alone only proves the presented chain structurally links to the + * configured trust anchor -- it does not enforce that the end-entity (leaf) certificate, + * {@code chain[0]}, is actually meant to be used as a TLS client authentication credential. + * Because the connector accepts this call without any TLS-layer CA validation of its own + * (see {@code MtlsClientAuthTomcatCustomizer}, {@code certificateVerification=optionalNoCA}), + * this method additionally rejects {@code chain[0]} (after PKIX validation succeeds) when it: + *

    + *
  • is itself a CA certificate (a {@code BasicConstraints} extension with {@code CA=true}),
  • + *
  • has a Key Usage extension present that does not permit {@code digitalSignature}, or
  • + *
  • has an Extended Key Usage extension present that does not include + * {@code id-kp-clientAuth} or {@code anyExtendedKeyUsage}.
  • + *
+ * These checks only reject when an extension is present and explicitly excludes client + * authentication -- an absent extension imposes no restriction (RFC 5280), preserving + * backward compatibility with CAs that don't set these extensions at all. + * + * @param chain the full certificate chain (index 0 = end-entity), may be {@code null} + * @param config the per-client TLS configuration, may be {@code null} + * @return {@code Optional.of(chain[0])} when validation succeeds; + * {@code Optional.empty()} when chain or config is absent + * @throws InvalidClientDetailsException if the CA PEM is malformed, the cert chain is invalid, + * or {@code chain[0]} fails an end-entity constraint check + */ + public Optional validateClientCert( + X509Certificate[] chain, TlsClientAuthConfiguration config) { + + if (chain == null || chain.length == 0 || !TlsClientAuthConfiguration.isConfigured(config)) { + return Optional.empty(); + } + + try { + X509Certificate caCert = parsePemCertificate(config.getTrustedCaPem()); + Optional validated = validateCertPath(chain, caCert); + if (validated.isPresent()) { + validateEndEntityConstraints(validated.get()); + } + return validated; + } catch (CertPathValidatorException e) { + throw new InvalidClientDetailsException( + "tls_client_auth: certificate chain validation failed: " + e.getMessage()); + } catch (Exception e) { + throw new InvalidClientDetailsException( + "tls_client_auth: CA configuration error: " + e.getMessage()); + } + } + + /** + * Extended Key Usage OID for TLS client authentication ({@code id-kp-clientAuth}, RFC 5280 §4.2.1.12). + */ + private static final String EKU_CLIENT_AUTH = "1.3.6.1.5.5.7.3.2"; + + /** + * Extended Key Usage OID meaning "any extended key usage is acceptable" (RFC 5280 §4.2.1.12). + */ + private static final String EKU_ANY = "2.5.29.37.0"; + + /** + * Rejects {@code leaf} when it carries an extension that explicitly signals it is unsuitable + * as a TLS client authentication credential. See {@link #validateClientCert(X509Certificate[], + * TlsClientAuthConfiguration)} for the full rationale. Absent extensions are never rejected. + * + * @throws CertPathValidatorException if {@code leaf} is itself a CA certificate, its Key + * Usage extension (if present) does not permit {@code digitalSignature}, or its + * Extended Key Usage extension (if present, or unparsable) does not include + * {@code id-kp-clientAuth} or {@code anyExtendedKeyUsage} + */ + private static void validateEndEntityConstraints(X509Certificate leaf) throws CertPathValidatorException { + if (leaf.getBasicConstraints() != -1) { + throw new CertPathValidatorException( + "presented end-entity certificate is itself a CA certificate"); + } + + boolean[] keyUsage = leaf.getKeyUsage(); + if (keyUsage != null && (keyUsage.length < 1 || !keyUsage[0])) { + throw new CertPathValidatorException( + "presented end-entity certificate's Key Usage extension does not permit digitalSignature"); + } + + List extendedKeyUsage; + try { + extendedKeyUsage = leaf.getExtendedKeyUsage(); + } catch (java.security.cert.CertificateParsingException e) { + throw new CertPathValidatorException( + "presented end-entity certificate has an unparsable Extended Key Usage extension: " + + e.getMessage()); + } + if (extendedKeyUsage != null + && !extendedKeyUsage.contains(EKU_CLIENT_AUTH) + && !extendedKeyUsage.contains(EKU_ANY)) { + throw new CertPathValidatorException( + "presented end-entity certificate's Extended Key Usage extension does not include " + + "clientAuth or anyExtendedKeyUsage"); + } + } + + /** + * Validates {@code chain} against {@code caCert} using PKIX path validation, without requiring any + * per-client {@link TlsClientAuthConfiguration}. Shared by {@link #validateClientCert} and + * {@link #isCertificateFromTrustedProxy}. + * + * @return {@code Optional.of(chain[0])} when validation succeeds + * @throws CertPathValidatorException if the chain does not validate against {@code caCert} + * @throws Exception if {@code caCert} or the PKIX machinery is misconfigured + */ + private static Optional validateCertPath(X509Certificate[] chain, X509Certificate caCert) + throws Exception { + TrustAnchor anchor = new TrustAnchor(caCert, null); + PKIXParameters params = new PKIXParameters(Set.of(anchor)); + params.setRevocationEnabled(false); + + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + var certPath = cf.generateCertPath(Arrays.asList(chain)); + + CertPathValidator validator = CertPathValidator.getInstance("PKIX"); + validator.validate(certPath, params); + + return Optional.of(chain[0]); + } + + private static X509Certificate parsePemCertificate(String pem) throws Exception { + try (PEMParser parser = new PEMParser(new StringReader(pem))) { + Object obj = parser.readObject(); + if (!(obj instanceof X509CertificateHolder holder)) { + throw new IllegalArgumentException( + obj == null + ? "No PEM object found in tls-client-auth-ca" + : "PEM object is not a certificate: " + obj.getClass().getSimpleName()); + } + return new JcaX509CertificateConverter() + .setProvider(BouncyCastleFipsProvider.PROVIDER_NAME) + .getCertificate(holder); + } + } + + /** + * Parses an RFC 2253 DN string into its RDNs, ordered most-specific-first + * (i.e. matching the left-to-right order of the original DN string). + * + *

{@link LdapName#getRdns()} returns RDNs least-specific-first (root/rightmost + * component at index 0), so the list is reversed here. Using {@link LdapName} instead + * of a naive {@code dn.split(",")} correctly handles backslash-escaped commas/quotes + * within attribute values (RFC 2253 §2.4), which a plain string split would mis-parse. + * Returns an empty list if {@code dn} cannot be parsed as a valid DN. + */ + private static List parseRdnsMostSpecificFirst(String dn) { + try { + List rdns = new ArrayList<>(new LdapName(dn).getRdns()); + Collections.reverse(rdns); + return rdns; + } catch (NamingException e) { + return List.of(); + } + } + + /** + * Returns one value of the given attribute {@code type} (e.g. {@code "CN"}) from an RDN, + * including multi-valued RDNs (attributes joined by {@code +}). Attribute type matching + * is case-insensitive, per LDAP semantics. Where an RDN contains repeated AVAs of the same + * type, the chosen value follows provider enumeration order. Returns {@code null} if not present. + */ + private static String rdnAttributeValue(Rdn rdn, String type) { + List values = rdnAttributeValues(rdn, type); + return values.isEmpty() ? null : values.get(0); + } + + /** + * Returns every value of an attribute from an RDN. A multi-valued RDN can contain the same + * attribute type more than once, as Diego instance identity certificates do for OU. + */ + private static List rdnAttributeValues(Rdn rdn, String type) { + List values = new ArrayList<>(); + try { + NamingEnumeration attrs = rdn.toAttributes().getAll(); + while (attrs.hasMore()) { + Attribute attr = attrs.next(); + if (attr.getID().equalsIgnoreCase(type)) { + NamingEnumeration attributeValues = attr.getAll(); + while (attributeValues.hasMore()) { + Object value = attributeValues.next(); + if (value != null) { + values.add(value.toString()); + } + } + } + } + } catch (NamingException e) { + // fall through to values collected so far + } + return values; + } + + /** + * Extracts the value of a single-valued RDN attribute (e.g. {@code "CN"}) from a RFC 2253 + * DN string. Handles multi-valued RDNs (attributes joined by {@code +}). + * Returns {@code null} if no matching RDN is found. + */ + private static String extractRdnValue(String dn, String type) { + for (Rdn rdn : parseRdnsMostSpecificFirst(dn)) { + String value = rdnAttributeValue(rdn, type); + if (value != null) { + return value; + } + } + return null; + } + + /** + * Collects all matching OU AVAs from an RFC 2253 DN string, including multi-valued RDNs + * (attributes joined by {@code +}). + */ + private static List extractOus(String dn) { + List ous = new ArrayList<>(); + for (Rdn rdn : parseRdnsMostSpecificFirst(dn)) { + ous.addAll(rdnAttributeValues(rdn, "OU")); + } + return ous; + } + + /** + * Returns the first captured group from the first OU that matches {@code patternStr}. + * When {@code patternStr} is null or blank, returns the first collected OU value verbatim; + * selection among repeated AVAs follows collected/provider order. Use a pattern to select a + * specific value. + */ + private static String matchFirstOu(List ous, String patternStr) { + if (patternStr == null || patternStr.isBlank()) { + return ous.isEmpty() ? null : ous.get(0); + } + Pattern pat = Pattern.compile(patternStr); + for (String ou : ous) { + Matcher m = pat.matcher(ou); + if (m.matches() && m.groupCount() >= 1) { + return m.group(1); + } + } + return null; + } +} 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 66653097bc3..7ceb57ba397 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 @@ -31,7 +31,7 @@ import static org.springframework.util.StringUtils.hasText; @Controller -@RequestMapping(value = "/oauth/token") //used simply because TokenEndpoint wont match /oauth/token/alias/saml-entity-id +@RequestMapping(value = {"/oauth/token", "/oauth/mtls/token"}) //used simply because TokenEndpoint wont match /oauth/token/alias/saml-entity-id public class UaaTokenEndpoint extends TokenEndpoint { private final boolean allowQueryString; diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthAuthenticationManager.java b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthAuthenticationManager.java index 885fc4ebc2e..e844ad8cdcc 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthAuthenticationManager.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthAuthenticationManager.java @@ -845,6 +845,10 @@ protected > String final IdentityProvider provider ) { final T config = provider.getConfig(); + if (ClientAuthentication.TLS_CLIENT_AUTH.equals(config.getAuthMethod())) { + throw new ProviderConfigurationException( + "External OpenID Connect provider configuration does not support tls_client_auth."); + } if (StringUtils.hasText(codeToken.getIdToken()) && ID_TOKEN.equals(getResponseType(config))) { log.debug("ExternalOAuthCodeToken contains id_token, not exchanging code."); @@ -1043,6 +1047,10 @@ public String oidcJwtBearerGrant(UaaAuthenticationDetails details, public String oauthTokenRequest(UaaAuthenticationDetails details, final IdentityProvider identityProvider, String grantType, MultiValueMap additionalParameters) { final OIDCIdentityProviderDefinition config = identityProvider.getConfig(); + if (ClientAuthentication.TLS_CLIENT_AUTH.equals(config.getAuthMethod())) { + throw new ProviderConfigurationException( + "External OpenID Connect provider configuration does not support tls_client_auth."); + } //Token per RestCall URL tokenUrl = config.getTokenUrl(); diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthIdentityProviderConfigValidator.java b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthIdentityProviderConfigValidator.java index 1478658fcaf..4c7a4bec444 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthIdentityProviderConfigValidator.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthIdentityProviderConfigValidator.java @@ -67,8 +67,8 @@ public void validate(AbstractIdentityProviderDefinition definition) { if (hasText(def.getAuthMethod())) { String authMethod = def.getAuthMethod(); - if (!ClientAuthentication.isMethodSupported(authMethod)) { - errors.add("Relying Party Authentication Method must be set to either " + String.join(" or ", ClientAuthentication.UAA_SUPPORTED_METHODS)); + if (!ClientAuthentication.isExternalOAuthMethodSupported(authMethod)) { + errors.add("Relying Party Authentication Method must be set to either " + String.join(" or ", ClientAuthentication.EXTERNAL_OAUTH_SUPPORTED_METHODS)); } else if (!ClientAuthentication.isAuthMethodEqual(ClientAuthentication.getCalculatedMethod(authMethod, def.getRelyingPartySecret() != null, hasKeyConfigured), (getAuthMethod(definition)))) { errors.add("Relying Party Authentication Method [%s] does not match with expected on [%s]".formatted(authMethod, getAuthMethod(definition))); } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/OauthIDPWrapperFactoryBean.java b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/OauthIDPWrapperFactoryBean.java index 0fef9235649..e168e4e4fdf 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/OauthIDPWrapperFactoryBean.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/OauthIDPWrapperFactoryBean.java @@ -191,7 +191,7 @@ protected void setCommonProperties(Map idpDefinitionMap, Abstrac idpDefinition.setCacheJwks((boolean) idpDefinitionMap.get("cacheJwks")); } if (idpDefinitionMap.get("authMethod") instanceof String definedAuthMethod) { - if (ClientAuthentication.isMethodSupported(definedAuthMethod)) { + if (ClientAuthentication.isExternalOAuthMethodSupported(definedAuthMethod)) { idpDefinition.setAuthMethod(definedAuthMethod); } else { throw new IllegalArgumentException("Invalid IdP authentication method"); diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/web/FilterChainOrder.java b/server/src/main/java/org/cloudfoundry/identity/uaa/web/FilterChainOrder.java index 749d68f4f21..2db781875d6 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/web/FilterChainOrder.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/web/FilterChainOrder.java @@ -35,6 +35,7 @@ public class FilterChainOrder { public static final int OAUTH_08 = 208; public static final int OAUTH_09 = 209; public static final int OAUTH_10 = 210; + public static final int OAUTH_11 = 211; // scim-endpoints.xml: 300 public static final int SCIM_PASSWORD = 300; diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSESSLContext.java b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSESSLContext.java new file mode 100644 index 00000000000..d206da387cb --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSESSLContext.java @@ -0,0 +1,112 @@ +package org.cloudfoundry.identity.uaa.web.tomcat; + +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.security.Provider; +import java.security.SecureRandom; +import java.security.Security; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLServerSocketFactory; +import javax.net.ssl.SSLSessionContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509KeyManager; +import javax.net.ssl.X509TrustManager; + +import org.apache.tomcat.util.net.SSLContext; +import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; + +/** + * A Tomcat {@link SSLContext} backed by the FIPS Bouncy Castle JSSE provider (BCJSSE). Mirrors Tomcat's + * own (package-private) {@code JSSESSLContext} but sources the underlying {@code javax.net.ssl.SSLContext} + * from the registered {@link BouncyCastleJsseProvider} instead of the default (SunJSSE) provider. + * + *

This is what lets the {@code uaa.mtls-enabled} connector both negotiate TLS 1.3 and request an + * optional client certificate: unlike SunJSSE (which sends no {@code CertificateRequest} under TLS 1.3, + * JDK-8206923), BCJSSE sends an in-handshake TLS 1.3 {@code CertificateRequest}. + * + *

Requires {@link MtlsClientAuthTomcatCustomizer#ensureJsseProviderRegistered()} to have run, so that + * the {@code BCJSSE} provider is registered. + */ +public final class BCJSSESSLContext implements SSLContext { + + private final javax.net.ssl.SSLContext context; + private KeyManager[] kms; + private TrustManager[] tms; + + public BCJSSESSLContext(String protocol) throws NoSuchAlgorithmException { + Provider provider = Security.getProvider(BouncyCastleJsseProvider.PROVIDER_NAME); + if (provider == null) { + throw new NoSuchAlgorithmException( + "The " + BouncyCastleJsseProvider.PROVIDER_NAME + " provider is not registered; " + + "call MtlsClientAuthTomcatCustomizer.ensureJsseProviderRegistered() before " + + "building the connector SSLContext"); + } + this.context = javax.net.ssl.SSLContext.getInstance(protocol, provider); + } + + @Override + public void init(KeyManager[] kms, TrustManager[] tms, SecureRandom sr) throws KeyManagementException { + this.kms = kms; + this.tms = tms; + context.init(kms, tms, sr); + } + + @Override + public void destroy() { + // No-op, matching Tomcat's JSSESSLContext. + } + + @Override + public SSLSessionContext getServerSessionContext() { + return context.getServerSessionContext(); + } + + @Override + public SSLEngine createSSLEngine() { + return context.createSSLEngine(); + } + + @Override + public SSLServerSocketFactory getServerSocketFactory() { + return context.getServerSocketFactory(); + } + + @Override + public SSLParameters getSupportedSSLParameters() { + return context.getSupportedSSLParameters(); + } + + @Override + public X509Certificate[] getCertificateChain(String alias) { + X509Certificate[] result = null; + if (kms != null) { + for (int i = 0; i < kms.length && result == null; i++) { + if (kms[i] instanceof X509KeyManager) { + result = ((X509KeyManager) kms[i]).getCertificateChain(alias); + } + } + } + return result; + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + Set certs = new HashSet<>(); + if (tms != null) { + for (TrustManager tm : tms) { + if (tm instanceof X509TrustManager) { + X509Certificate[] accepted = ((X509TrustManager) tm).getAcceptedIssuers(); + certs.addAll(Arrays.asList(accepted)); + } + } + } + return certs.toArray(new X509Certificate[0]); + } +} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSESslImplementation.java b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSESslImplementation.java new file mode 100644 index 00000000000..26addff76b0 --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSESslImplementation.java @@ -0,0 +1,20 @@ +package org.cloudfoundry.identity.uaa.web.tomcat; + +import org.apache.tomcat.util.net.SSLHostConfigCertificate; +import org.apache.tomcat.util.net.SSLUtil; +import org.apache.tomcat.util.net.jsse.JSSEImplementation; + +/** + * A Tomcat {@link org.apache.tomcat.util.net.SSLImplementation} that serves the connector's + * {@link SSLUtil} from a {@link BCJSSEUtil}, i.e. backed by the FIPS Bouncy Castle JSSE provider + * (BCJSSE). Referenced from {@link MtlsClientAuthTomcatCustomizer} via the connector's + * {@code sslImplementationName} so that only this connector uses BCJSSE; every other JVM SSLContext + * (LDAP, DB, outbound TLS) stays on the default provider. + */ +public final class BCJSSESslImplementation extends JSSEImplementation { + + @Override + public SSLUtil getSSLUtil(SSLHostConfigCertificate certificate) { + return new BCJSSEUtil(certificate); + } +} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSEUtil.java b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSEUtil.java new file mode 100644 index 00000000000..128b80253b9 --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSEUtil.java @@ -0,0 +1,75 @@ +package org.cloudfoundry.identity.uaa.web.tomcat; + +import java.security.GeneralSecurityException; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import javax.net.ssl.SSLParameters; + +import org.apache.tomcat.util.net.SSLContext; +import org.apache.tomcat.util.net.SSLHostConfigCertificate; +import org.apache.tomcat.util.net.jsse.JSSEUtil; + +/** + * A {@link JSSEUtil} that builds the connector's {@link SSLContext} from the FIPS Bouncy Castle JSSE + * provider (BCJSSE) via {@link BCJSSESSLContext}, and declares that TLS 1.3 renegotiable + * (post-handshake-requestable) client authentication is available -- which is precisely what JSSE + * cannot do (the reason the connector previously pinned {@code all,-TLSv1.3}). + * + *

{@code getImplementedProtocols()}/{@code getImplementedCiphers()} are overridden to source from + * BCJSSE's own {@code getSupportedSSLParameters()} rather than {@link JSSEUtil}'s private + * {@code initialise()}, which probes a SunJSSE-backed {@code SSLContext} instead. This matters because + * {@code SSLUtilBase}'s constructor only strips {@code SSLv2Hello}/{@code TLSv1.3} from the connector's + * configured protocol set when the implemented set doesn't contain them -- and SunJSSE's + * implemented set (unlike BCJSSE's) includes {@code SSLv2Hello}, which the BC-backed engine then + * rejects at handshake time ({@code ProvSSLParameters.setProtocols}: "'protocols' cannot be null, or + * contain unsupported protocols"), breaking every TLS handshake on this connector. + * + *

Everything else (keystore loading, {@code trustManagerClassName} handling) is inherited from + * {@link JSSEUtil}/{@link org.apache.tomcat.util.net.SSLUtilBase}. + */ +public final class BCJSSEUtil extends JSSEUtil { + + public BCJSSEUtil(SSLHostConfigCertificate certificate) { + super(certificate); + } + + @Override + public SSLContext createSSLContextInternal(List negotiableProtocols) throws NoSuchAlgorithmException { + return new BCJSSESSLContext(sslHostConfig.getSslProtocol()); + } + + @Override + protected boolean isTls13RenegAuthAvailable() { + return true; + } + + @Override + protected Set getImplementedProtocols() { + return new HashSet<>(Arrays.asList(supportedSslParameters().getProtocols())); + } + + @Override + protected Set getImplementedCiphers() { + return new HashSet<>(Arrays.asList(supportedSslParameters().getCipherSuites())); + } + + /** + * Recomputed on every call, deliberately not cached in an instance field: {@code SSLUtilBase}'s + * constructor invokes {@link #getImplementedProtocols()}/{@link #getImplementedCiphers()} via + * dynamic dispatch before this subclass's own field initializers run, so caching here would risk + * a stale/uninitialized value being read on that first, superclass-constructor-driven call. + */ + private SSLParameters supportedSslParameters() { + try { + BCJSSESSLContext context = new BCJSSESSLContext(sslHostConfig.getSslProtocol()); + context.init(null, null, null); + return context.getSupportedSSLParameters(); + } catch (GeneralSecurityException e) { + throw new IllegalArgumentException(e); + } + } +} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java new file mode 100644 index 00000000000..b0aa8fbf46a --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizer.java @@ -0,0 +1,152 @@ +package org.cloudfoundry.identity.uaa.web.tomcat; + +import org.apache.coyote.http11.AbstractHttp11Protocol; +import org.apache.tomcat.util.net.SSLHostConfig; +import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; +import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.tomcat.servlet.TomcatServletWebServerFactory; +import org.springframework.boot.web.server.WebServerFactoryCustomizer; +import org.springframework.stereotype.Component; + +import java.security.Provider; +import java.security.Security; + +/** + * When {@code uaa.mtls-enabled} is true, configures the embedded Tomcat connector to request a client + * certificate during the TLS handshake without validating it against any CA at the TLS layer + * ({@code certificateVerification=optionalNoCA}) -- proof of private-key possession still happens as + * part of the handshake itself, but the trust decision (which CA, if any, is acceptable) is deferred + * entirely to per-client application logic in {@link org.cloudfoundry.identity.uaa.oauth.tls.TlsClientAuthentication}. + * + *

This is deliberately different from Spring Boot's own {@code server.ssl.client-auth} property, + * which only supports Tomcat's {@code none}/{@code optional}/{@code required} verification levels -- + * not {@code optionalNoCA}. A static, deploy-time CA truststore was considered and rejected: it would + * need to be kept in sync with whatever CA(s) are configured per-client at runtime via the client-admin + * API, which is an operational hazard. + * + *

Uses the FIPS Bouncy Castle JSSE provider (BCJSSE) for this connector. OpenJDK's JSSE never + * implemented server-side TLS 1.3 client authentication (JDK-8206923): requesting a client certificate + * without requiring/validating it against a CA ({@code certificateVerification=optionalNoCA}, or Tomcat's + * {@code optional} mode) relies on requesting the certificate again via post-handshake authentication + * (PHA), which JSSE's TLS 1.3 implementation does not support -- and on at least one JDK build the server + * silently never sends a {@code CertificateRequest} at all under TLS 1.3, silently defeating this feature. + * BCJSSE implements TLS 1.3 client authentication in-handshake, so the connector can offer TLS 1.3 again + * (reverting this PR's earlier {@code all,-TLSv1.3} pin and restoring the pre-PR {@code TLSv1.2,TLSv1.3} + * protocol set). The BCJSSE provider is registered idempotently, and the connector's protocol handler is + * pointed at {@link BCJSSESslImplementation} via {@code sslImplementationName} so that only this connector + * uses BCJSSE; all other JVM TLS stays on the default provider. + * + *

Also installs a custom {@link NoAcceptedIssuersTrustManager} on the connector, via + * {@code SSLHostConfig#setTrustManagerClassName(String)}. Even with {@code optionalNoCA} (which + * disables certificate validation), Tomcat/JSSE still populates the + * {@code CertificateRequest} handshake message's "certificate_authorities" field from whatever trust + * store/trust manager is configured on the connector -- and absent an explicit one, JSSE falls back to + * the JVM's default {@code cacerts} (a large list of public root CAs, e.g. DigiCert, Let's Encrypt, + * etc.), none of which sign any real client's mTLS certificate here. Well-behaved TLS clients -- + * including Go's {@code crypto/tls}, used by the real Gorouter -- select which certificate (if any) to + * present by matching its issuer against that advertised list, and send an empty Certificate + * message if nothing matches, per the TLS spec. Confirmed empirically against a live deployment: + * {@code openssl s_client}'s "Acceptable client certificate CA names" output listed only unrelated + * public root CAs, never {@code service_cf_internal_ca} (the CA that signs the Gorouter's own backend + * mTLS certificate) -- and packet capture confirmed the Gorouter's actual backend connection sent a + * zero-length certificate_list in response, silently defeating this entire feature. An empty trust + * store was tried first but rejected: Tomcat's PKIX-based trust manager path + * ({@code TrustManagerFactory}'s default algorithm on at least one JDK build) throws + * {@code InvalidAlgorithmParameterException: the trustAnchors parameter must be non-empty} for an + * empty {@link java.security.KeyStore} -- PKIX fundamentally requires at least one trust anchor. + * {@link NoAcceptedIssuersTrustManager} sidesteps that entirely by bypassing the KeyStore/algorithm + * path via Tomcat's {@code trustManagerClassName} extension point, advertising no acceptable-issuer + * constraint at all -- consistent with this customizer's overall design of deferring the trust + * decision entirely to per-client application logic. + * + *

Runs after Spring Boot's own SSL connector configuration so it can override the already-configured + * {@link SSLHostConfig}(s) on the connector. + */ +@Component +public class MtlsClientAuthTomcatCustomizer implements WebServerFactoryCustomizer { + + private final boolean mtlsEnabled; + + public MtlsClientAuthTomcatCustomizer(@Value("${uaa.mtls-enabled:false}") boolean mtlsEnabled) { + this.mtlsEnabled = mtlsEnabled; + } + + @Override + public void customize(TomcatServletWebServerFactory factory) { + if (!mtlsEnabled) { + return; + } + ensureJsseProviderRegistered(); + factory.addConnectorCustomizers(connector -> { + if (!(connector.getProtocolHandler() instanceof AbstractHttp11Protocol protocol)) { + throw new IllegalStateException( + "uaa.mtls-enabled requires an HTTP/1.1 Tomcat connector (got " + + connector.getProtocolHandler().getClass().getName() + + "); cannot install BCJSSESslImplementation for TLS 1.3 client-cert support"); + } + protocol.setSslImplementationName(BCJSSESslImplementation.class.getName()); + for (SSLHostConfig sslHostConfig : connector.findSslHostConfigs()) { + sslHostConfig.setCertificateVerification("optionalNoCA"); + sslHostConfig.setTrustManagerClassName(NoAcceptedIssuersTrustManager.class.getName()); + } + }); + } + + /** + * Registers the FIPS Bouncy Castle provider ({@code BCFIPS}) and the FIPS Bouncy Castle JSSE + * provider ({@code BCJSSE}) idempotently, if not already present. Registering the low-level + * crypto provider first is required: {@code BouncyCastleJsseProvider} built in FIPS mode binds + * to it, which is what makes {@code SSLContext.getInstance("TLS", "BCJSSE")} usable (and is what + * supplies FIPS-compliant {@code SecureRandom}s for the TLS handshake). + * + *

If a provider is already registered under the {@code BCFIPS} or {@code BCJSSE} name -- e.g. via + * the JVM's {@code java.security} configuration file, or some other library -- its mere presence is + * not sufficient: it must genuinely be the expected Bouncy Castle provider class, or this connector's + * promised FIPS guarantee (documented on this class) would be silently defeated. Fails fast with + * {@link IllegalStateException} rather than silently proceeding with a wrong/impostor provider. + * {@link BouncyCastleFipsProvider} is a {@code final} class with no FIPS/non-FIPS mode distinction -- + * it is inherently and only a FIPS-approved crypto provider by construction -- so only an + * {@code instanceof} check is needed for it, unlike {@link BouncyCastleJsseProvider}, which also + * needs its FIPS-mode flag verified. + * + * @throws IllegalStateException if a provider already registered under the {@code BCFIPS} name is + * not a {@link BouncyCastleFipsProvider}, or if a provider already registered under the + * {@code BCJSSE} name is not a {@link BouncyCastleJsseProvider}, or is one but not in FIPS mode + */ + static void ensureJsseProviderRegistered() { + Provider existingFipsProvider = Security.getProvider(BouncyCastleFipsProvider.PROVIDER_NAME); + if (existingFipsProvider == null) { + Security.addProvider(new BouncyCastleFipsProvider()); + } else if (!(existingFipsProvider instanceof BouncyCastleFipsProvider)) { + throw new IllegalStateException( + "uaa.mtls-enabled requires the FIPS BouncyCastleFipsProvider registered under the name '" + + BouncyCastleFipsProvider.PROVIDER_NAME + + "', but a different provider is already registered under that name: " + + existingFipsProvider.getClass().getName() + + " -- refusing to silently proceed without the promised FIPS guarantee"); + } + Provider existingJsseProvider = Security.getProvider(BouncyCastleJsseProvider.PROVIDER_NAME); + if (existingJsseProvider == null) { + Security.addProvider(new BouncyCastleJsseProvider(true, + Security.getProvider(BouncyCastleFipsProvider.PROVIDER_NAME))); + return; + } + if (!(existingJsseProvider instanceof BouncyCastleJsseProvider bcJsseProvider)) { + throw new IllegalStateException( + "uaa.mtls-enabled requires the FIPS BouncyCastleJsseProvider registered under the name '" + + BouncyCastleJsseProvider.PROVIDER_NAME + + "', but a different provider is already registered under that name: " + + existingJsseProvider.getClass().getName() + + " -- refusing to silently proceed without the promised FIPS guarantee"); + } + if (!bcJsseProvider.isFipsMode()) { + throw new IllegalStateException( + "uaa.mtls-enabled requires the FIPS BouncyCastleJsseProvider registered under the name '" + + BouncyCastleJsseProvider.PROVIDER_NAME + + "', and a " + BouncyCastleJsseProvider.class.getName() + + " is indeed registered under that name, but it was not constructed in FIPS mode" + + " -- refusing to silently proceed without the promised FIPS guarantee"); + } + } +} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/NoAcceptedIssuersTrustManager.java b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/NoAcceptedIssuersTrustManager.java new file mode 100644 index 00000000000..11cd6face6e --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/web/tomcat/NoAcceptedIssuersTrustManager.java @@ -0,0 +1,51 @@ +package org.cloudfoundry.identity.uaa.web.tomcat; + +import javax.net.ssl.X509TrustManager; +import java.security.cert.X509Certificate; + +/** + * A {@link X509TrustManager} used by {@link MtlsClientAuthTomcatCustomizer} to make the mTLS + * connector advertise no acceptable-issuer constraint in the TLS {@code CertificateRequest} + * handshake message, while performing no certificate validation at the TLS layer at all -- the trust + * decision is deferred entirely to per-client application logic in + * {@link org.cloudfoundry.identity.uaa.oauth.tls.TlsClientAuthentication}. + * + *

Must be a public, top-level class with a public no-arg constructor: Tomcat instantiates trust + * manager classes configured via {@code SSLHostConfig#setTrustManagerClassName(String)} via + * reflection ({@code Class#getConstructor()} / {@code Constructor#newInstance()}), which requires + * exactly that shape. + * + *

An empty accepted-issuers list is not merely "no restriction is enforced" -- it changes what + * Tomcat/JSSE actually puts on the wire. Even with {@code certificateVerification=optionalNoCA} (which + * only disables validation), Tomcat still populates the handshake's "certificate_authorities" field + * from whatever trust store/trust manager is configured, and absent one, JSSE falls back to the JVM's + * default {@code cacerts} (a long list of unrelated public root CAs). Well-behaved TLS clients -- + * including Go's {@code crypto/tls}, used by the real Gorouter -- select which certificate (if any) to + * present by matching its issuer against that advertised list, and send an empty Certificate + * message if nothing matches, per the TLS spec -- silently withholding a legitimate client certificate + * whose CA simply isn't a public root CA. An empty accepted-issuers list here means "any CA is + * acceptable" on the wire, so such clients present whatever certificate they have configured. + * + * @see MtlsClientAuthTomcatCustomizer + */ +public final class NoAcceptedIssuersTrustManager implements X509TrustManager { + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) { + // No-op: TLS-layer client certificate validation is intentionally disabled + // (certificateVerification=optionalNoCA on the connector). Proof of private-key possession + // still happens as part of the handshake itself; the trust decision (which CA, if any, is + // acceptable for a given client) is deferred entirely to per-client application logic. + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) { + // Not used: this trust manager is installed on a server-side connector to evaluate + // certificates presented BY clients, not to validate a server certificate. + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } +} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java index 250e91e8718..ba10729f4c7 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java @@ -2,16 +2,21 @@ import org.cloudfoundry.identity.uaa.client.ClientDetailsValidator; import org.cloudfoundry.identity.uaa.client.InvalidClientDetailsException; +import org.cloudfoundry.identity.uaa.client.TlsClientAuthConfiguration; import org.cloudfoundry.identity.uaa.client.UaaClientDetails; import org.cloudfoundry.identity.uaa.constants.OriginKeys; import org.cloudfoundry.identity.uaa.oauth.client.ClientConstants; +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.authority.AuthorityUtils; import org.cloudfoundry.identity.uaa.oauth.provider.ClientDetails; import org.springframework.stereotype.Component; import java.util.Collections; +import java.util.Map; +import static org.cloudfoundry.identity.uaa.client.ClientAdminEndpointsValidator.checkMtlsClientConfigAllowed; import static org.cloudfoundry.identity.uaa.client.ClientAdminEndpointsValidator.checkRequestedGrantTypes; +import static org.cloudfoundry.identity.uaa.client.ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig; import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.GRANT_TYPE_AUTHORIZATION_CODE; import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.GRANT_TYPE_CLIENT_CREDENTIALS; import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.GRANT_TYPE_JWT_BEARER; @@ -26,16 +31,23 @@ public class ZoneEndpointsClientDetailsValidator implements ClientDetailsValidat private static final String REQUIRED_SCOPE = "zones.write"; private final ClientSecretValidator clientSecretValidator; + private final boolean mtlsEnabled; public ZoneEndpointsClientDetailsValidator( - final ClientSecretValidator clientSecretValidator) { + final ClientSecretValidator clientSecretValidator, + @Value("${uaa.mtls-enabled:false}") final boolean mtlsEnabled) { this.clientSecretValidator = clientSecretValidator; + this.mtlsEnabled = mtlsEnabled; } @Override public ClientDetails validate(ClientDetails clientDetails, Mode mode) throws InvalidClientDetailsException { if (mode == Mode.CREATE) { + Map additionalInformation = clientDetails.getAdditionalInformation(); + if (additionalInformation == null) { + additionalInformation = Collections.emptyMap(); + } if (!Collections.singleton("openid").equals(clientDetails.getScope())) { throw new InvalidClientDetailsException("only openid scope is allowed"); } @@ -46,6 +58,9 @@ public ClientDetails validate(ClientDetails clientDetails, Mode mode) throws Inv throw new InvalidClientDetailsException("client_id cannot be blank"); } checkRequestedGrantTypes(clientDetails.getAuthorizedGrantTypes()); + checkMtlsClientConfigAllowed(additionalInformation, mtlsEnabled, clientDetails.getClientId()); + validateTlsClientAuthClaimConfig(additionalInformation, clientDetails.getClientId()); + boolean hasTlsClientAuthCa = hasNonblankTlsClientAuthCa(additionalInformation); if (clientDetails.getAuthorizedGrantTypes().contains(GRANT_TYPE_CLIENT_CREDENTIALS) || clientDetails.getAuthorizedGrantTypes().contains(GRANT_TYPE_AUTHORIZATION_CODE) || clientDetails.getAuthorizedGrantTypes().contains(GRANT_TYPE_USER_TOKEN) || @@ -54,12 +69,13 @@ public ClientDetails validate(ClientDetails clientDetails, Mode mode) throws Inv clientDetails.getAuthorizedGrantTypes().contains(GRANT_TYPE_JWT_BEARER) || clientDetails.getAuthorizedGrantTypes().contains(GRANT_TYPE_TOKEN_EXCHANGE) || clientDetails.getAuthorizedGrantTypes().contains(GRANT_TYPE_PASSWORD)) { - if (clientDetails.getClientSecret() == null || clientDetails.getClientSecret().isBlank()) { + if (!hasTlsClientAuthCa + && (clientDetails.getClientSecret() == null || clientDetails.getClientSecret().isBlank())) { throw new InvalidClientDetailsException("client_secret cannot be blank"); } clientSecretValidator.validate(clientDetails.getClientSecret()); } - if (!Collections.singletonList(OriginKeys.UAA).equals(clientDetails.getAdditionalInformation().get(ClientConstants.ALLOWED_PROVIDERS))) { + if (!Collections.singletonList(OriginKeys.UAA).equals(additionalInformation.get(ClientConstants.ALLOWED_PROVIDERS))) { throw new InvalidClientDetailsException("only the internal IdP ('uaa') is allowed"); } @@ -79,6 +95,18 @@ public ClientDetails validate(ClientDetails clientDetails, Mode mode) throws Inv throw new IllegalStateException("This validator must be called with a mode"); } + static boolean hasNonblankTlsClientAuthCa(Map additionalInformation) { + if (additionalInformation == null) { + return false; + } + + Object rawConfig = additionalInformation.get(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); + if (rawConfig instanceof String pem) { + return !pem.isBlank(); + } + return false; + } + @Override public ClientSecretValidator getClientSecretValidator() { return this.clientSecretValidator; diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpointsTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpointsTest.java new file mode 100644 index 00000000000..c4084afd933 --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpointsTest.java @@ -0,0 +1,94 @@ +package org.cloudfoundry.identity.uaa.account; + +import org.cloudfoundry.identity.uaa.constants.ClientAuthentication; +import org.cloudfoundry.identity.uaa.zone.IdentityZone; +import org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class OpenIdConnectEndpointsTest { + + private OpenIdConnectEndpoints endpoints; + private IdentityZoneManager mockIdentityZoneManager; + + @BeforeEach + void setUp() { + mockIdentityZoneManager = mock(IdentityZoneManager.class); + when(mockIdentityZoneManager.getCurrentIdentityZone()).thenReturn(IdentityZone.getUaa()); + endpoints = new OpenIdConnectEndpoints("https://uaa.example.com/oauth/token", mockIdentityZoneManager, true); + } + + @Test + void mtlsEndpointAliasesIsPopulatedInDiscovery() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/.well-known/openid-configuration"); + request.setScheme("https"); + request.setServerName("uaa.example.com"); + request.setServerPort(443); + request.setContextPath(""); + + ResponseEntity response = endpoints.getOpenIdConfiguration(request); + + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getMtlsEndpointAliases()) + .isNotNull() + .containsEntry("token_endpoint", "https://uaa.example.com/oauth/mtls/token"); + } + + @Test + void mtlsEndpointAliasesIsAbsentWhenMtlsDisabled() throws Exception { + OpenIdConnectEndpoints mtlsDisabledEndpoints = + new OpenIdConnectEndpoints("https://uaa.example.com/oauth/token", mockIdentityZoneManager, false); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/.well-known/openid-configuration"); + request.setScheme("https"); + request.setServerName("uaa.example.com"); + request.setServerPort(443); + request.setContextPath(""); + + ResponseEntity response = mtlsDisabledEndpoints.getOpenIdConfiguration(request); + + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getMtlsEndpointAliases()).isNull(); + } + + @Test + void mtlsAdvertisementsAreConsistentWhenMtlsEnabled() throws Exception { + // Guards against the two mTLS discovery gates (tokenAMR's tls_client_auth entry and + // mtls_endpoint_aliases) ever drifting apart: whenever mTLS is enabled, a discovery client + // must see BOTH tls_client_auth advertised AND the mtls_endpoint_aliases pointing at it -- + // never just one without the other, which would itself be a contradictory discovery document. + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/.well-known/openid-configuration"); + request.setScheme("https"); + request.setServerName("uaa.example.com"); + request.setServerPort(443); + request.setContextPath(""); + + OpenIdConfiguration conf = endpoints.getOpenIdConfiguration(request).getBody(); + + assertThat(conf).isNotNull(); + assertThat(conf.getTokenAMR()).contains(ClientAuthentication.TLS_CLIENT_AUTH); + assertThat(conf.getMtlsEndpointAliases()).isNotNull().containsKey("token_endpoint"); + } + + @Test + void mtlsAdvertisementsAreConsistentWhenMtlsDisabled() throws Exception { + OpenIdConnectEndpoints mtlsDisabledEndpoints = + new OpenIdConnectEndpoints("https://uaa.example.com/oauth/token", mockIdentityZoneManager, false); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/.well-known/openid-configuration"); + request.setScheme("https"); + request.setServerName("uaa.example.com"); + request.setServerPort(443); + request.setContextPath(""); + + OpenIdConfiguration conf = mtlsDisabledEndpoints.getOpenIdConfiguration(request).getBody(); + + assertThat(conf).isNotNull(); + assertThat(conf.getTokenAMR()).doesNotContain(ClientAuthentication.TLS_CLIENT_AUTH); + assertThat(conf.getMtlsEndpointAliases()).isNull(); + } +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java new file mode 100644 index 00000000000..574dcf36f4e --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProviderTests.java @@ -0,0 +1,273 @@ +package org.cloudfoundry.identity.uaa.authentication; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.cloudfoundry.identity.uaa.client.TlsClientAuthConfiguration; +import org.cloudfoundry.identity.uaa.client.UaaClient; +import org.cloudfoundry.identity.uaa.oauth.jwt.JwtClientAuthentication; +import org.cloudfoundry.identity.uaa.oauth.tls.RawPeerCertificateCaptureFilter; +import org.cloudfoundry.identity.uaa.oauth.tls.TlsClientAuthentication; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.Security; +import java.security.cert.X509Certificate; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ClientDetailsAuthenticationProviderTests { + + @BeforeEach + void setUp() { + Security.addProvider(new BouncyCastleFipsProvider()); + } + + @Test + void tlsClientAuthPathIsDetectedAsTlsClientAuth() { + UaaAuthenticationDetails details = mock(UaaAuthenticationDetails.class); + when(details.getRequestPath()).thenReturn("/oauth/mtls/token"); + assertThat(ClientDetailsAuthenticationProvider.isTlsClientAuthPath(details)).isTrue(); + } + + @Test + void tlsClientAuthPathIncludesTokenDescendants() { + UaaAuthenticationDetails details = mock(UaaAuthenticationDetails.class); + when(details.getRequestPath()).thenReturn("/oauth/mtls/token/alias"); + assertThat(ClientDetailsAuthenticationProvider.isTlsClientAuthPath(details)).isTrue(); + } + + @Test + void unrelatedMtlsPathIsNotTlsClientAuth() { + UaaAuthenticationDetails details = mock(UaaAuthenticationDetails.class); + when(details.getRequestPath()).thenReturn("/oauth/mtls/not-token"); + assertThat(ClientDetailsAuthenticationProvider.isTlsClientAuthPath(details)).isFalse(); + } + + @Test + void regularTokenPathIsNotTlsClientAuth() { + UaaAuthenticationDetails details = mock(UaaAuthenticationDetails.class); + when(details.getRequestPath()).thenReturn("/oauth/token"); + assertThat(ClientDetailsAuthenticationProvider.isTlsClientAuthPath(details)).isFalse(); + } + + @Test + void tlsConfigIsReadFromFlatAdditionalInfo() { + Map additionalInfo = new HashMap<>(); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, + "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n"); + + UaaClient mockClient = mock(UaaClient.class); + when(mockClient.getAdditionalInformation()).thenReturn(additionalInfo); + + TlsClientAuthConfiguration config = + ClientDetailsAuthenticationProvider.getTlsClientAuthConfiguration(mockClient); + assertThat(config).isNotNull(); + assertThat(config.getTrustedCaPem()) + .isEqualTo("-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n"); + } + + @Test + void validateTlsClientAuthPassesClientConfigToCertificateChainLookup() { + UaaClient uaaClient = mock(UaaClient.class); + when(uaaClient.getAdditionalInformation()).thenReturn(Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem", + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA, "proxy-ca-pem" + )); + TlsClientAuthentication tlsClientAuthentication = mock(TlsClientAuthentication.class); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + ClientDetailsAuthenticationProvider provider = new ClientDetailsAuthenticationProvider( + mock(UserDetailsService.class), mock(PasswordEncoder.class), + mock(JwtClientAuthentication.class), tlsClientAuthentication); + + provider.validateTlsClientAuth(uaaClient); + + ArgumentCaptor configCaptor = + ArgumentCaptor.forClass(TlsClientAuthConfiguration.class); + verify(tlsClientAuthentication).getCertificateChainFromRequest(configCaptor.capture()); + assertThat(configCaptor.getValue().getTrustedProxyCaPem()).isEqualTo("proxy-ca-pem"); + } + + @Test + void validateTlsClientAuthShortCircuitsWithoutResolvingConfigWhenNoCertificatePresent() { + UaaClient uaaClient = mock(UaaClient.class); + TlsClientAuthentication tlsClientAuthentication = mock(TlsClientAuthentication.class); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(false); + ClientDetailsAuthenticationProvider provider = new ClientDetailsAuthenticationProvider( + mock(UserDetailsService.class), mock(PasswordEncoder.class), + mock(JwtClientAuthentication.class), tlsClientAuthentication); + + boolean result = provider.validateTlsClientAuth(uaaClient); + + assertThat(result).isFalse(); + verify(uaaClient, never()).getAdditionalInformation(); + verify(tlsClientAuthentication, never()).getCertificateChainFromRequest(any()); + } + + @Test + void getTlsClientAuthConfigurationReadsTrustedProxyCaFromFlatStringPath() { + UaaClient uaaClient = mock(UaaClient.class); + when(uaaClient.getAdditionalInformation()).thenReturn(Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem", + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA, "proxy-ca-pem" + )); + + TlsClientAuthConfiguration config = + ClientDetailsAuthenticationProvider.getTlsClientAuthConfiguration(uaaClient); + + assertThat(config).isNotNull(); + assertThat(config.getTrustedProxyCaPem()).isEqualTo("proxy-ca-pem"); + } + + @Test + void getTlsClientAuthConfigurationTrustedProxyCaNullWhenAbsent() { + UaaClient uaaClient = mock(UaaClient.class); + when(uaaClient.getAdditionalInformation()).thenReturn(Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem" + )); + + TlsClientAuthConfiguration config = + ClientDetailsAuthenticationProvider.getTlsClientAuthConfiguration(uaaClient); + + assertThat(config).isNotNull(); + assertThat(config.getTrustedProxyCaPem()).isNull(); + } + + @Test + void getTlsClientAuthConfigurationReadsRequiredClaimsFromFlatStringPath() { + UaaClient uaaClient = mock(UaaClient.class); + when(uaaClient.getAdditionalInformation()).thenReturn(Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem", + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS, + "{\"space_guid\":\"the-expected-space-guid\"}" + )); + + TlsClientAuthConfiguration config = + ClientDetailsAuthenticationProvider.getTlsClientAuthConfiguration(uaaClient); + + assertThat(config).isNotNull(); + assertThat(config.getRequiredClaims()).containsEntry("space_guid", "the-expected-space-guid"); + } + + @Test + void getTlsClientAuthConfigurationRequiredClaimsNullWhenAbsent() { + UaaClient uaaClient = mock(UaaClient.class); + when(uaaClient.getAdditionalInformation()).thenReturn(Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "ca-pem" + )); + + TlsClientAuthConfiguration config = + ClientDetailsAuthenticationProvider.getTlsClientAuthConfiguration(uaaClient); + + assertThat(config).isNotNull(); + assertThat(config.getRequiredClaims()).isNull(); + } + + @Test + void validateTlsClientAuthEnforcesRequiredClaimsAgainstAClientSharingTheSameCa() throws Exception { + // Reproduces the reviewer's impersonation scenario (PR review comment on + // TlsClientAuthentication.java:150, also flagged at line 175): two UAA clients share the + // same tls-client-auth-ca (e.g. Diego's shared instance-identity CA). Without a + // tls-client-auth-required-claims constraint, a certificate for one app could authenticate + // as ANY client trusting that CA. A client that configures tls-client-auth-required-claims + // now rejects a certificate belonging to a different space, while an unconstrained client + // sharing the same CA still accepts it. + KeyPair caKeyPair = generateKeyPair(); + X500Name caName = new X500Name("CN=Shared Diego Instance Identity CA"); + X509Certificate caCert = signCert(caName, caName, caKeyPair.getPublic(), caKeyPair.getPrivate(), true, BigInteger.ONE); + + KeyPair appKeyPair = generateKeyPair(); + X500Name appSubject = new X500Name("CN=app-instance,OU=space:some-other-space-guid"); + X509Certificate appCert = signCert(appSubject, caName, appKeyPair.getPublic(), caKeyPair.getPrivate(), false, BigInteger.TWO); + + TlsClientAuthentication tlsClientAuthentication = new TlsClientAuthentication(); + + MockHttpServletRequest request = new MockHttpServletRequest(); + X509Certificate[] presentedChain = new X509Certificate[]{appCert}; + request.setAttribute("jakarta.servlet.request.X509Certificate", presentedChain); + request.setAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE, presentedChain); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + UaaClient unconstrainedClient = mock(UaaClient.class); + when(unconstrainedClient.getAdditionalInformation()).thenReturn(Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, toPem(caCert))); + + UaaClient constrainedClient = mock(UaaClient.class); + when(constrainedClient.getAdditionalInformation()).thenReturn(Map.ofEntries( + Map.entry(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, toPem(caCert)), + Map.entry(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(Map.of("field", "subject_ou", "pattern", "^space:(.+)$", "claim", "space_guid"))), + Map.entry(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS, + Map.of("space_guid", "the-expected-space-guid")))); + + ClientDetailsAuthenticationProvider provider = new ClientDetailsAuthenticationProvider( + mock(UserDetailsService.class), mock(PasswordEncoder.class), + mock(JwtClientAuthentication.class), tlsClientAuthentication); + + assertThat(provider.validateTlsClientAuth(unconstrainedClient)) + .as("the unconstrained client (no tls-client-auth-required-claims) still accepts any cert from the shared CA") + .isTrue(); + assertThat(provider.validateTlsClientAuth(constrainedClient)) + .as("the constrained client rejects a cert whose space_guid doesn't match its required claim") + .isFalse(); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + private static KeyPair generateKeyPair() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA", BouncyCastleFipsProvider.PROVIDER_NAME); + kpg.initialize(2048); + return kpg.generateKeyPair(); + } + + private static X509Certificate signCert(X500Name subject, X500Name issuer, PublicKey subjectKey, + PrivateKey signerKey, boolean isCa, BigInteger serial) throws Exception { + Date notBefore = new Date(System.currentTimeMillis() - 60_000); + Date notAfter = new Date(System.currentTimeMillis() + 3_600_000); + JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( + issuer, serial, notBefore, notAfter, subject, subjectKey); + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(isCa)); + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA") + .setProvider(BouncyCastleFipsProvider.PROVIDER_NAME) + .build(signerKey); + X509CertificateHolder holder = builder.build(signer); + return new JcaX509CertificateConverter() + .setProvider(BouncyCastleFipsProvider.PROVIDER_NAME) + .getCertificate(holder); + } + + private static String toPem(X509Certificate cert) throws Exception { + java.io.StringWriter sw = new java.io.StringWriter(); + try (org.bouncycastle.util.io.pem.PemWriter pemWriter = new org.bouncycastle.util.io.pem.PemWriter(sw)) { + pemWriter.writeObject(new org.bouncycastle.util.io.pem.PemObject("CERTIFICATE", cert.getEncoded())); + } + return sw.toString(); + } +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/UaaClientAuthenticationProviderTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/UaaClientAuthenticationProviderTest.java index 87f68db57b0..f12c48a83bf 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/UaaClientAuthenticationProviderTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/UaaClientAuthenticationProviderTest.java @@ -7,6 +7,7 @@ import org.cloudfoundry.identity.uaa.client.UaaClientDetailsUserDetailsService; import org.cloudfoundry.identity.uaa.oauth.client.ClientConstants; import org.cloudfoundry.identity.uaa.oauth.jwt.JwtClientAuthentication; +import org.cloudfoundry.identity.uaa.oauth.tls.TlsClientAuthentication; import org.cloudfoundry.identity.uaa.oauth.provider.ClientDetails; import org.cloudfoundry.identity.uaa.user.UaaUser; import org.cloudfoundry.identity.uaa.util.AlphanumericRandomValueStringGenerator; @@ -46,6 +47,7 @@ class UaaClientAuthenticationProviderTest { private ClientDetails client; private ClientDetailsAuthenticationProvider authenticationProvider; private JwtClientAuthentication jwtClientAuthentication; + private TlsClientAuthentication tlsClientAuthentication; @Autowired private NamedParameterJdbcTemplate namedJdbcTemplate; @@ -57,12 +59,13 @@ class UaaClientAuthenticationProviderTest { void setUpForClientTests() { IdentityZoneManager mockIdentityZoneManager = mock(IdentityZoneManager.class); jwtClientAuthentication = mock(JwtClientAuthentication.class); + tlsClientAuthentication = mock(TlsClientAuthentication.class); when(mockIdentityZoneManager.getCurrentIdentityZoneId()).thenReturn(IdentityZone.getUaaZoneId()); jdbcClientDetailsService = new MultitenantJdbcClientDetailsService(namedJdbcTemplate, mockIdentityZoneManager, passwordEncoder); UaaClientDetailsUserDetailsService clientDetailsService = new UaaClientDetailsUserDetailsService(jdbcClientDetailsService); client = createClient(); - authenticationProvider = new ClientDetailsAuthenticationProvider(clientDetailsService, passwordEncoder, jwtClientAuthentication); + authenticationProvider = new ClientDetailsAuthenticationProvider(clientDetailsService, passwordEncoder, jwtClientAuthentication, tlsClientAuthentication); } public UaaClientDetails createClient() { @@ -123,6 +126,16 @@ void provider_authenticate_client_with_one_password() { testClientAuthentication(a); } + @Test + void provider_rejectsClientSecretForClientConfiguredForTlsClientAuth() { + client = createClient("tls-client-auth-ca", "-----BEGIN CERTIFICATE-----\nCA\n-----END CERTIFICATE-----"); + Authentication authentication = getToken(client.getClientId(), SECRET); + + assertThatThrownBy(() -> authenticationProvider.authenticate(authentication)) + .isInstanceOf(BadCredentialsException.class) + .hasMessageContaining("tls_client_auth"); + } + @Test void provider_authenticate_client_without_password_public_string() { client = createClient(ClientConstants.ALLOW_PUBLIC, "true"); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapMultipleSecretsTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapMultipleSecretsTest.java index 6ebd777054b..967200cf375 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapMultipleSecretsTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapMultipleSecretsTest.java @@ -67,7 +67,7 @@ void setUp() { ClientMetadataProvisioning clientMetadataProvisioning = mock(ClientMetadataProvisioning.class); clientAdminBootstrap = new ClientAdminBootstrap(passwordEncoder, clientRegistrationService, clientMetadataProvisioning, defaultOverride, clients, autoApproveClients, clientsToDelete, null, - allowPublicClients); + allowPublicClients, false); oneSecretClient = new UaaClientDetails(); oneSecretClient.setClientId(clientId); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapMultipleSecretsUpdateTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapMultipleSecretsUpdateTests.java index ba0fac5c9c9..1fa7d6e312f 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapMultipleSecretsUpdateTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrapMultipleSecretsUpdateTests.java @@ -61,7 +61,8 @@ void passwordHashFirstSecretDidNotChangeButSecondIsNullDuringBootstrap() throws Collections.singleton(autoApproveId), Collections.emptySet(), null, - Collections.singleton(allowPublicId)); + Collections.singleton(allowPublicId), + false); /* setup first a client with 2 secrets */ Map map = ClientAdminBootstrapTests.createClientMap("foo"); 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 a8ff145577b..b674ceacbd5 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 @@ -1,6 +1,7 @@ package org.cloudfoundry.identity.uaa.client; import org.assertj.core.api.InstanceOfAssertFactories; +import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; import org.cloudfoundry.identity.uaa.annotations.WithDatabaseContext; import org.cloudfoundry.identity.uaa.audit.event.EntityDeletedEvent; import org.cloudfoundry.identity.uaa.authentication.SystemAuthentication; @@ -35,6 +36,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.security.Security; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThat; @@ -61,6 +63,30 @@ @WithDatabaseContext class ClientAdminBootstrapTests { + private static final String VALID_CERT = """ + -----BEGIN CERTIFICATE----- + MIIDXTCCAkWgAwIBAgIJAOpOBuLToBXJMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV + BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX + aWRnaXRzIFB0eSBMdGQwHhcNMTcwNzE0MTcxNDE4WhcNMTcwODEzMTcxNDE4WjBF + MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 + ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB + CgKCAQEA3+07F4S5Fz3wv/UFm/OWsJXm6s3pKI2mp4fSAY8rx9+0cyLAHsedWzeq + 5uKcDeRW858DOdnClaTOZC73FcvOmv1bw2eYcmfsbqHEhyR0dp+rDHt/7pr6kajC + yUvAW+hoRRSMpooiZckxrjJ7LOa5iqRyZRwshfGN+mFSygfVguMDKrsE2rvpK6/K + tkG/lcToLHiw4OnMnZ9ocrNRDAoCkzKGZTLJkUEr3MgOKmr2EO0P6KOAmNnOEmCf + 05ohcrUXeFZVnS5MMUzoGAOzBstZhA0dd7l297IDnWH9uIhCANCvZ9sovZWz/o3J + pc2LyXsaI1cV7O1cGV4aEEn8zzWWGwIDAQABo1AwTjAdBgNVHQ4EFgQUXBO1+qo7 + w6iiiv1pnm+zdrQ3CzkwHwYDVR0jBBgwFoAUXBO1+qo7w6iiiv1pnm+zdrQ3Czkw + DAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAT78lT5VEIetWPGk3szPz + CT9zNpR1F+7o3rvRTI6Psyjz4tGlyX5iU0Z99Xa9yimIEhWme2UVsgQ9uOzk2IgH + wMbB2TTP/RRK5+eO4BUu4zWWIXsIcfC6Rqw9Y3Hki+mRpuWMv+5pcOz/H+aYeSfy + WvVYfRZJOhcztysII4HWIxw8qqwBrf5kX8IRKZXay+A2W04A6kjjX3zfN2OzljTA + jZbtHedUGxSHvK8x6tHEwS0lZ9eZh+V4DWyRvrunwDCtA7zJQmrJd1qbM84H/1C8 + cAC6dglvc82n1BTAZbZwWHYt+Ro3Vp0GMPsZLOXJ0g03LbkhXg4krwXjJPD42nus + 3A== + -----END CERTIFICATE----- + """; + private ClientAdminBootstrap clientAdminBootstrap; private MultitenantJdbcClientDetailsService multitenantJdbcClientDetailsService; private ClientMetadataProvisioning clientMetadataProvisioning; @@ -83,6 +109,7 @@ class ClientAdminBootstrapTests { @BeforeEach void setUpClientAdminTests() { + Security.addProvider(new BouncyCastleFipsProvider()); randomValueStringGenerator = new RandomValueStringGenerator(); IdentityZoneManager mockIdentityZoneManager = mock(IdentityZoneManager.class); @@ -105,7 +132,8 @@ void setUpClientAdminTests() { Collections.singleton(autoApproveId), Collections.emptySet(), null, - Collections.singleton(allowPublicId)); + Collections.singleton(allowPublicId), + false); mockApplicationEventPublisher = mock(ApplicationEventPublisher.class); clientAdminBootstrap.setApplicationEventPublisher(mockApplicationEventPublisher); @@ -125,7 +153,8 @@ void setUp() { Collections.emptySet(), Collections.emptySet(), null, - Collections.emptySet()); + Collections.emptySet(), + false); } @Test @@ -161,7 +190,7 @@ void setUp() { clients, Collections.singleton(clientIdToDelete), Collections.singleton(clientIdToDelete), - null, Collections.singleton(clientIdToDelete)); + null, Collections.singleton(clientIdToDelete), false); clientAdminBootstrap.setApplicationEventPublisher(mockApplicationEventPublisher); } @@ -372,7 +401,7 @@ void setUp() { clients, Collections.singleton(autoApproveId), Collections.emptySet(), - null, Collections.singleton(allowPublicId)); + null, Collections.singleton(allowPublicId), false); when(mockClientMetadataProvisioning.update(any(ClientMetadata.class), anyString())).thenReturn(new ClientMetadata()); } @@ -461,7 +490,7 @@ void setUp() { clients, Collections.singleton(autoApproveId), Collections.emptySet(), - null, Collections.singleton(allowPublicId)); + null, Collections.singleton(allowPublicId), false); } @Test @@ -646,6 +675,111 @@ void clientWithoutGrantTypeFails() { .hasMessageContaining("Client must have at least one authorized-grant-type"); } + @Test + void mtlsClientConfigRejectedWhenMtlsDisabled() { + Map map = createClientMap("foo"); + map.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT); + clients.put((String) map.get("id"), map); + + assertThatThrownBy(() -> clientAdminBootstrap.afterPropertiesSet()) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("uaa.mtls-enabled"); + } + + @Test + void mtlsClientTrustedProxyConfigRejectedWhenMtlsDisabled() { + Map map = createClientMap("foo"); + map.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA, "some-trusted-proxy-ca"); + clients.put((String) map.get("id"), map); + + assertThatThrownBy(() -> clientAdminBootstrap.afterPropertiesSet()) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("uaa.mtls-enabled"); + } + + @Test + void mtlsClientConfigAllowedWhenMtlsEnabled() { + ClientAdminBootstrap mtlsEnabledBootstrap = new ClientAdminBootstrap( + passwordEncoder, + multitenantJdbcClientDetailsService, + clientMetadataProvisioning, + true, + clients, + Collections.singleton(autoApproveId), + Collections.emptySet(), + null, + Collections.singleton(allowPublicId), + true); + + Map map = createClientMap("foo"); + map.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT); + clients.put((String) map.get("id"), map); + + mtlsEnabledBootstrap.afterPropertiesSet(); + + ClientDetails created = multitenantJdbcClientDetailsService.loadClientByClientId("foo"); + assertThat(created.getAdditionalInformation()).containsEntry(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT); + } + + @Test + void nestedTlsClientAuthConfigurationIsRejectedDuringBootstrap() { + ClientAdminBootstrap mtlsEnabledBootstrap = new ClientAdminBootstrap( + passwordEncoder, + multitenantJdbcClientDetailsService, + clientMetadataProvisioning, + true, + clients, + Collections.singleton(autoApproveId), + Collections.emptySet(), + null, + Collections.singleton(allowPublicId), + true); + + Map map = createClientMap("foo"); + map.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, + Map.of(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT)); + clients.put((String) map.get("id"), map); + + assertThatThrownBy(mtlsEnabledBootstrap::afterPropertiesSet) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA) + .hasMessageContaining("PEM string"); + } + + @Test + void invalidTlsClientAuthClaimConfigIsRejectedDuringBootstrap() { + ClientAdminBootstrap mtlsEnabledBootstrap = new ClientAdminBootstrap( + passwordEncoder, + multitenantJdbcClientDetailsService, + clientMetadataProvisioning, + true, + clients, + Collections.singleton(autoApproveId), + Collections.emptySet(), + null, + Collections.singleton(allowPublicId), + true); + + Map map = createClientMap("foo"); + map.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT); + map.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(Map.of("field", "invalid", "claim", "cf_app"))); + clients.put((String) map.get("id"), map); + + assertThatThrownBy(mtlsEnabledBootstrap::afterPropertiesSet) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("tls-client-auth-claim-mappings") + .hasMessageContaining("invalid field"); + } + + @Test + void ordinaryClientBootstrapsSuccessfullyWhenMtlsDisabled() { + Map map = createClientMap("foo"); + ClientDetails created = doSimpleTest(map, clientAdminBootstrap, multitenantJdbcClientDetailsService, clients); + assertThat(created.getAdditionalInformation()).doesNotContainKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); + assertThat(created.getAdditionalInformation()).doesNotContainKey(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA); + } + static ClientDetails doSimpleTest( final Map map, final ClientAdminBootstrap clientAdminBootstrap, diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsTests.java index e30c76e2c9c..8c236ab24b6 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsTests.java @@ -121,7 +121,7 @@ void setUp() { clientRegistrationService = Mockito.mock(MultitenantClientServices.class, withSettings().extraInterfaces(SystemDeletable.class)); mockAuthenticationManager = Mockito.mock(AuthenticationManager.class); ApprovalStore approvalStore = mock(ApprovalStore.class); - clientDetailsValidator = new ClientAdminEndpointsValidator(mockSecurityContextAccessor, new IdentityZoneManagerImpl()); + clientDetailsValidator = new ClientAdminEndpointsValidator(mockSecurityContextAccessor, new IdentityZoneManagerImpl(), false); clientDetailsValidator.setClientDetailsService(clientDetailsService); clientDetailsValidator.setClientSecretValidator( new ZoneAwareClientSecretPolicyValidator(new ClientSecretPolicy(0, 255, 0, 0, 0, 0, 6))); 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 3f4288bcf3c..dcbd6e44108 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 @@ -15,6 +15,7 @@ package org.cloudfoundry.identity.uaa.client; import org.assertj.core.api.InstanceOfAssertFactories; +import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; import org.cloudfoundry.identity.uaa.oauth.provider.ClientDetails; import org.cloudfoundry.identity.uaa.resources.QueryableResourceManager; import org.cloudfoundry.identity.uaa.security.beans.SecurityContextAccessor; @@ -35,6 +36,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.security.Security; import static org.assertj.core.api.Assertions.*; import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.GRANT_TYPE_AUTHORIZATION_CODE; @@ -50,6 +52,30 @@ class ClientAdminEndpointsValidatorTests { + private static final String VALID_CERT = """ + -----BEGIN CERTIFICATE----- + MIIDXTCCAkWgAwIBAgIJAOpOBuLToBXJMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV + BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX + aWRnaXRzIFB0eSBMdGQwHhcNMTcwNzE0MTcxNDE4WhcNMTcwODEzMTcxNDE4WjBF + MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 + ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB + CgKCAQEA3+07F4S5Fz3wv/UFm/OWsJXm6s3pKI2mp4fSAY8rx9+0cyLAHsedWzeq + 5uKcDeRW858DOdnClaTOZC73FcvOmv1bw2eYcmfsbqHEhyR0dp+rDHt/7pr6kajC + yUvAW+hoRRSMpooiZckxrjJ7LOa5iqRyZRwshfGN+mFSygfVguMDKrsE2rvpK6/K + tkG/lcToLHiw4OnMnZ9ocrNRDAoCkzKGZTLJkUEr3MgOKmr2EO0P6KOAmNnOEmCf + 05ohcrUXeFZVnS5MMUzoGAOzBstZhA0dd7l297IDnWH9uIhCANCvZ9sovZWz/o3J + pc2LyXsaI1cV7O1cGV4aEEn8zzWWGwIDAQABo1AwTjAdBgNVHQ4EFgQUXBO1+qo7 + w6iiiv1pnm+zdrQ3CzkwHwYDVR0jBBgwFoAUXBO1+qo7w6iiiv1pnm+zdrQ3Czkw + DAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAT78lT5VEIetWPGk3szPz + CT9zNpR1F+7o3rvRTI6Psyjz4tGlyX5iU0Z99Xa9yimIEhWme2UVsgQ9uOzk2IgH + wMbB2TTP/RRK5+eO4BUu4zWWIXsIcfC6Rqw9Y3Hki+mRpuWMv+5pcOz/H+aYeSfy + WvVYfRZJOhcztysII4HWIxw8qqwBrf5kX8IRKZXay+A2W04A6kjjX3zfN2OzljTA + jZbtHedUGxSHvK8x6tHEwS0lZ9eZh+V4DWyRvrunwDCtA7zJQmrJd1qbM84H/1C8 + cAC6dglvc82n1BTAZbZwWHYt+Ro3Vp0GMPsZLOXJ0g03LbkhXg4krwXjJPD42nus + 3A== + -----END CERTIFICATE----- + """; + UaaClientDetails client; UaaClientDetails caller; ClientAdminEndpointsValidator validator; @@ -70,11 +96,12 @@ class ClientAdminEndpointsValidatorTests { @BeforeEach void createClient() { + Security.addProvider(new BouncyCastleFipsProvider()); client = new UaaClientDetails("newclient", "", "", "client_credentials", ""); client.setClientSecret("secret"); caller = new UaaClientDetails("caller", "", "", "client_credentials", "clients.write"); SecurityContextAccessor mockSecurityContextAccessor = mock(SecurityContextAccessor.class); - validator = new ClientAdminEndpointsValidator(mockSecurityContextAccessor, new IdentityZoneManagerImpl()); + validator = new ClientAdminEndpointsValidator(mockSecurityContextAccessor, new IdentityZoneManagerImpl(), false); secretValidator = new ZoneAwareClientSecretPolicyValidator(new ClientSecretPolicy(0, 255, 0, 0, 0, 0, 6)); validator.setClientSecretValidator(secretValidator); @@ -314,4 +341,387 @@ void validate_create_invalidJwtCredsList_throws() { assertThatThrownBy(() -> validator.validate(client, true, true)) .isInstanceOf(InvalidClientDetailsException.class); } + + @Test + void rejectsTlsClientAuthCaWhenMtlsDisabled() { + ClientAdminEndpointsValidator mtlsDisabledValidator = new ClientAdminEndpointsValidator( + mock(SecurityContextAccessor.class), new IdentityZoneManagerImpl(), false); + + client.setAuthorizedGrantTypes(java.util.Set.of("client_credentials")); + Map additionalInfo = new java.util.HashMap<>(); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT); + client.setAdditionalInformation(additionalInfo); + + assertThatThrownBy(() -> mtlsDisabledValidator.validate(client, false, false)) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("uaa.mtls-enabled"); + } + + @Test + void rejectsTlsClientAuthTrustedProxyCaWhenMtlsDisabled() { + ClientAdminEndpointsValidator mtlsDisabledValidator = new ClientAdminEndpointsValidator( + mock(SecurityContextAccessor.class), new IdentityZoneManagerImpl(), false); + + client.setAuthorizedGrantTypes(java.util.Set.of("client_credentials")); + Map additionalInfo = new java.util.HashMap<>(); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA, "proxy-ca-pem"); + client.setAdditionalInformation(additionalInfo); + + assertThatThrownBy(() -> mtlsDisabledValidator.validate(client, false, false)) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("uaa.mtls-enabled"); + } + + @Test + void allowsTlsClientAuthCaWhenMtlsEnabled() { + ClientAdminEndpointsValidator mtlsEnabledValidator = new ClientAdminEndpointsValidator( + mock(SecurityContextAccessor.class), new IdentityZoneManagerImpl(), true); + + client.setAuthorizedGrantTypes(java.util.Set.of("client_credentials")); + Map additionalInfo = new java.util.HashMap<>(); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT); + client.setAdditionalInformation(additionalInfo); + + ClientDetails validated = mtlsEnabledValidator.validate(client, false, false); + + assertThat(validated.getAdditionalInformation()) + .containsEntry(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT); + } + + @Test + void rejectsNestedTlsClientAuthConfigurationWhenMtlsEnabled() { + ClientAdminEndpointsValidator mtlsEnabledValidator = new ClientAdminEndpointsValidator( + mock(SecurityContextAccessor.class), new IdentityZoneManagerImpl(), true); + + client.setAuthorizedGrantTypes(java.util.Set.of("client_credentials")); + client.setAdditionalInformation(Map.of(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, + Map.of(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT))); + + assertThatThrownBy(() -> mtlsEnabledValidator.validate(client, false, false)) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA) + .hasMessageContaining("PEM string"); + } + + @Test + void rejectsBlankTlsClientAuthTrustedProxyCaWhenMtlsEnabled() { + ClientAdminEndpointsValidator mtlsEnabledValidator = new ClientAdminEndpointsValidator( + mock(SecurityContextAccessor.class), new IdentityZoneManagerImpl(), true); + + client.setAuthorizedGrantTypes(java.util.Set.of("client_credentials")); + Map additionalInfo = new java.util.HashMap<>(); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA, " "); + client.setAdditionalInformation(additionalInfo); + + assertThatThrownBy(() -> mtlsEnabledValidator.validate(client, false, false)) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA) + .hasMessageContaining("blank"); + } + + @Test + void rejectsMalformedTlsClientAuthTrustedProxyCaWhenMtlsEnabled() { + ClientAdminEndpointsValidator mtlsEnabledValidator = new ClientAdminEndpointsValidator( + mock(SecurityContextAccessor.class), new IdentityZoneManagerImpl(), true); + + client.setAuthorizedGrantTypes(java.util.Set.of("client_credentials")); + Map additionalInfo = new java.util.HashMap<>(); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA, "not-a-certificate"); + client.setAdditionalInformation(additionalInfo); + + assertThatThrownBy(() -> mtlsEnabledValidator.validate(client, false, false)) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA); + } + + @Test + void rejectsMalformedTlsClientAuthCaWhenMtlsEnabled() { + ClientAdminEndpointsValidator mtlsEnabledValidator = new ClientAdminEndpointsValidator( + mock(SecurityContextAccessor.class), new IdentityZoneManagerImpl(), true); + + client.setAuthorizedGrantTypes(java.util.Set.of("client_credentials")); + Map additionalInfo = new java.util.HashMap<>(); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, "not-a-certificate"); + client.setAdditionalInformation(additionalInfo); + + assertThatThrownBy(() -> mtlsEnabledValidator.validate(client, false, false)) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); + } + + @Test + void allowsClientWithoutMtlsFieldsWhenMtlsDisabled() { + ClientAdminEndpointsValidator mtlsDisabledValidator = new ClientAdminEndpointsValidator( + mock(SecurityContextAccessor.class), new IdentityZoneManagerImpl(), false); + + client.setAuthorizedGrantTypes(java.util.Set.of("client_credentials")); + client.setClientSecret("secret"); + + ClientDetails validated = mtlsDisabledValidator.validate(client, false, false); + + assertThat(validated.getClientId()).isEqualTo(client.getClientId()); + } + + @Test + void validateTlsClientAuthClaimConfig_noOpWhenNoClaimMappingsKey() { + assertThatNoException().isThrownBy(() -> + ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(Map.of(), "client-id")); + } + + @Test + void validateTlsClientAuthClaimConfig_acceptsValidNativeClaimMappings() { + Map info = Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(Map.of("field", "subject_cn", "claim", "cf_instance_guid", "pattern", "^(.+)$")) + ); + + assertThatNoException().isThrownBy(() -> + ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")); + } + + @Test + void validateTlsClientAuthClaimConfig_acceptsValidJsonStringClaimMappings() { + // Same logical field/claim/pattern data as + // validateTlsClientAuthClaimConfig_acceptsValidNativeClaimMappings, but supplied as a + // JSON string, to genuinely prove the two parsing shapes (native List/Map vs. JSON + // string) handle identical input equivalently. + Map info = Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + "[{\"field\":\"subject_cn\",\"claim\":\"cf_instance_guid\",\"pattern\":\"^(.+)$\"}]" + ); + + assertThatNoException().isThrownBy(() -> + ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")); + } + + @Test + void validateTlsClientAuthClaimConfig_rejectsMissingField() { + Map info = Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(Map.of("claim", "cf_instance_guid")) + ); + + assertThatThrownBy(() -> ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")) + .isInstanceOf(InvalidClientDetailsException.class); + } + + @Test + void validateTlsClientAuthClaimConfig_rejectsUnrecognizedField() { + Map info = Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(Map.of("field", "subject_email", "claim", "cf_instance_guid")) + ); + + assertThatThrownBy(() -> ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")) + .isInstanceOf(InvalidClientDetailsException.class); + } + + @Test + void validateTlsClientAuthClaimConfig_rejectsBlankClaim() { + Map info = Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(Map.of("field", "subject_cn", "claim", " ")) + ); + + assertThatThrownBy(() -> ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")) + .isInstanceOf(InvalidClientDetailsException.class); + } + + @Test + void validateTlsClientAuthClaimConfig_rejectsInvalidRegexPattern() { + Map info = Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(Map.of("field", "subject_ou", "claim", "cf_org", "pattern", "[")) + ); + + assertThatThrownBy(() -> ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")) + .isInstanceOf(InvalidClientDetailsException.class); + } + + @Test + void validateTlsClientAuthClaimConfig_rejectsSubTemplateReferencingUndeclaredClaim() { + Map info = new java.util.HashMap<>(); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(Map.of("field", "subject_cn", "claim", "cf_instance_guid"))); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, "{cf_undeclared}"); + + assertThatThrownBy(() -> ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")) + .isInstanceOf(InvalidClientDetailsException.class); + } + + @Test + void validateTlsClientAuthClaimConfig_rejectsNonStringSubTemplate() { + Map info = new java.util.HashMap<>(); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, List.of("template")); + + assertThatThrownBy(() -> ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE) + .hasMessageContaining("client-id"); + } + + @Test + void validateTlsClientAuthClaimConfig_rejectsAudTemplateReferencingUndeclaredClaim() { + Map info = new java.util.HashMap<>(); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(Map.of("field", "subject_cn", "claim", "cf_instance_guid"))); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, + List.of("https://valid.example.com/{cf_undeclared}")); + + assertThatThrownBy(() -> ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")) + .isInstanceOf(InvalidClientDetailsException.class); + } + + @Test + void validateTlsClientAuthClaimConfig_rejectsNativeNullAudTemplateEntry() { + Map info = Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(Map.of("field", "subject_cn", "claim", "cf_instance_guid")), + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, + Collections.singletonList(null)); + + assertThatThrownBy(() -> ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES) + .hasMessageContaining("entry cannot be null") + .hasMessageContaining("client-id"); + } + + @Test + void validateTlsClientAuthClaimConfig_rejectsJsonNullAudTemplateEntry() { + Map info = Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(Map.of("field", "subject_cn", "claim", "cf_instance_guid")), + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, + "[null]"); + + assertThatThrownBy(() -> ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES) + .hasMessageContaining("entry cannot be null") + .hasMessageContaining("client-id"); + } + + @Test + void validateTlsClientAuthClaimConfig_rejectsRequiredClaimsReferencingUndeclaredClaim() { + Map info = new java.util.HashMap<>(); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(Map.of("field", "subject_cn", "claim", "cf_instance_guid"))); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS, + Map.of("cf_undeclared", "some-value")); + + assertThatThrownBy(() -> ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")) + .isInstanceOf(InvalidClientDetailsException.class); + } + + @Test + void validateTlsClientAuthClaimConfig_rejectsRequiredClaimsWithoutClaimMappings() { + Map info = new java.util.HashMap<>(); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS, + Map.of("cf_instance_guid", "instance-guid")); + + assertThatThrownBy(() -> ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS) + .hasMessageContaining("undeclared claim") + .hasMessageContaining("client-id"); + } + + @Test + void validateTlsClientAuthClaimConfig_rejectsRequiredClaimsWithNullValue() { + Map info = new java.util.HashMap<>(); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(Map.of("field", "subject_ou", "claim", "cf_org"))); + Map requiredClaims = new java.util.HashMap<>(); + requiredClaims.put("cf_org", null); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS, requiredClaims); + + assertThatThrownBy(() -> ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")) + .isInstanceOf(InvalidClientDetailsException.class); + } + + @Test + void validateTlsClientAuthClaimConfig_rejectsRequiredClaimsWithBlankValue() { + Map info = new java.util.HashMap<>(); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(Map.of("field", "subject_ou", "claim", "cf_org"))); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS, + Map.of("cf_org", " ")); + + assertThatThrownBy(() -> ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")) + .isInstanceOf(InvalidClientDetailsException.class); + } + + @Test + void validateTlsClientAuthClaimConfig_acceptsFullyValidConfig() { + Map info = new java.util.HashMap<>(); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of( + Map.of("field", "subject_cn", "claim", "cf_instance_guid"), + Map.of("field", "subject_ou", "claim", "cf_org", "pattern", "^org:(.+)$") + )); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, "{cf_instance_guid}"); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, + List.of("https://valid.example.com/{cf_org}")); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS, + Map.of("cf_org", "myorg")); + + assertThatNoException().isThrownBy(() -> + ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")); + } + + @Test + void validateTlsClientAuthClaimConfig_rejectsSubTemplateExceedingMaxLength() { + // CodeQL: js/polynomial-redos on the PLACEHOLDER regex (\{([^}]+)\}). The possessive + // quantifier fix ([^}]++) only reduces the constant factor -- Matcher.find() still + // retries the full match attempt at every character position, so the real fix is to + // bound the input length before it ever reaches the regex. + String oversizedSubTemplate = "{".repeat(ClientAdminEndpointsValidator.MAX_TEMPLATE_LENGTH + 1); + Map info = new java.util.HashMap<>(); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(Map.of("field", "subject_cn", "claim", "cf_instance_guid"))); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, oversizedSubTemplate); + + assertThatThrownBy(() -> ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("client-id") + .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE) + .hasMessageContaining(String.valueOf(ClientAdminEndpointsValidator.MAX_TEMPLATE_LENGTH)); + } + + @Test + void validateTlsClientAuthClaimConfig_rejectsAudTemplateExceedingMaxLength() { + String oversizedAudTemplate = "{".repeat(ClientAdminEndpointsValidator.MAX_TEMPLATE_LENGTH + 1); + Map info = new java.util.HashMap<>(); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(Map.of("field", "subject_cn", "claim", "cf_instance_guid"))); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, List.of(oversizedAudTemplate)); + + assertThatThrownBy(() -> ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("client-id") + .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES) + .hasMessageContaining(String.valueOf(ClientAdminEndpointsValidator.MAX_TEMPLATE_LENGTH)); + } + + @Test + void validateTlsClientAuthClaimConfig_acceptsSubTemplateAtExactlyMaxLength() { + // A pathological all-'{' template of exactly MAX_TEMPLATE_LENGTH characters must still + // be processed quickly, confirming the bound (combined with the possessive quantifier) + // makes this genuinely fast rather than merely rejected. + String maxLengthSubTemplate = "{".repeat(ClientAdminEndpointsValidator.MAX_TEMPLATE_LENGTH); + Map info = new java.util.HashMap<>(); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + List.of(Map.of("field", "subject_cn", "claim", "cf_instance_guid"))); + info.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, maxLengthSubTemplate); + + long start = System.nanoTime(); + // The all-'{' template never closes a placeholder, so no undeclared-placeholder + // exception is thrown -- validateTemplatePlaceholders() simply finds no matches. + assertThatNoException().isThrownBy(() -> + ClientAdminEndpointsValidator.validateTlsClientAuthClaimConfig(info, "client-id")); + long elapsedMillis = (System.nanoTime() - start) / 1_000_000; + + assertThat(elapsedMillis).isLessThan(100); + } } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java index 8d20909fba5..469cba9a5c1 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/ZoneEndpointsClientDetailsValidatorTests.java @@ -1,8 +1,10 @@ package org.cloudfoundry.identity.uaa.oauth; import org.assertj.core.api.InstanceOfAssertFactories; +import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; import org.cloudfoundry.identity.uaa.client.ClientDetailsValidator.Mode; import org.cloudfoundry.identity.uaa.client.InvalidClientDetailsException; +import org.cloudfoundry.identity.uaa.client.TlsClientAuthConfiguration; import org.cloudfoundry.identity.uaa.client.UaaClientDetails; import org.cloudfoundry.identity.uaa.constants.OriginKeys; import org.cloudfoundry.identity.uaa.extensions.PollutionPreventionExtension; @@ -12,16 +14,23 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.security.Security; +import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThatNoException; import static org.assertj.core.api.Assertions.assertThat; import static org.cloudfoundry.identity.uaa.oauth.client.ClientConstants.ALLOWED_PROVIDERS; +import static org.mockito.Mockito.verify; import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.GRANT_TYPE_AUTHORIZATION_CODE; import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.GRANT_TYPE_JWT_BEARER; import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.GRANT_TYPE_REFRESH_TOKEN; @@ -33,12 +42,45 @@ @ExtendWith(PollutionPreventionExtension.class) class ZoneEndpointsClientDetailsValidatorTests { + private static final String VALID_CERT = """ + -----BEGIN CERTIFICATE----- + MIIDXTCCAkWgAwIBAgIJAOpOBuLToBXJMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV + BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX + aWRnaXRzIFB0eSBMdGQwHhcNMTcwNzE0MTcxNDE4WhcNMTcwODEzMTcxNDE4WjBF + MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 + ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB + CgKCAQEA3+07F4S5Fz3wv/UFm/OWsJXm6s3pKI2mp4fSAY8rx9+0cyLAHsedWzeq + 5uKcDeRW858DOdnClaTOZC73FcvOmv1bw2eYcmfsbqHEhyR0dp+rDHt/7pr6kajC + yUvAW+hoRRSMpooiZckxrjJ7LOa5iqRyZRwshfGN+mFSygfVguMDKrsE2rvpK6/K + tkG/lcToLHiw4OnMnZ9ocrNRDAoCkzKGZTLJkUEr3MgOKmr2EO0P6KOAmNnOEmCf + 05ohcrUXeFZVnS5MMUzoGAOzBstZhA0dd7l297IDnWH9uIhCANCvZ9sovZWz/o3J + pc2LyXsaI1cV7O1cGV4aEEn8zzWWGwIDAQABo1AwTjAdBgNVHQ4EFgQUXBO1+qo7 + w6iiiv1pnm+zdrQ3CzkwHwYDVR0jBBgwFoAUXBO1+qo7w6iiiv1pnm+zdrQ3Czkw + DAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAT78lT5VEIetWPGk3szPz + CT9zNpR1F+7o3rvRTI6Psyjz4tGlyX5iU0Z99Xa9yimIEhWme2UVsgQ9uOzk2IgH + wMbB2TTP/RRK5+eO4BUu4zWWIXsIcfC6Rqw9Y3Hki+mRpuWMv+5pcOz/H+aYeSfy + WvVYfRZJOhcztysII4HWIxw8qqwBrf5kX8IRKZXay+A2W04A6kjjX3zfN2OzljTA + jZbtHedUGxSHvK8x6tHEwS0lZ9eZh+V4DWyRvrunwDCtA7zJQmrJd1qbM84H/1C8 + cAC6dglvc82n1BTAZbZwWHYt+Ro3Vp0GMPsZLOXJ0g03LbkhXg4krwXjJPD42nus + 3A== + -----END CERTIFICATE----- + """; + @Mock private ClientSecretValidator mockClientSecretValidator; - @InjectMocks + @org.junit.jupiter.api.BeforeAll + static void addBouncyCastleFipsProvider() { + Security.addProvider(new BouncyCastleFipsProvider()); + } + private ZoneEndpointsClientDetailsValidator zoneEndpointsClientDetailsValidator; + @org.junit.jupiter.api.BeforeEach + void setUp() { + zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, false); + } + @Test void createLimitedClient() { UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "authorization_code,password", "uaa.resource"); @@ -80,6 +122,20 @@ void createClientNoSecretIsInvalid(final String grantType) { .hasMessageContaining("client_secret cannot be blank"); } + @Test + void rejectsSecretlessClientCredentialsClientWhenAdditionalInformationIsNull() { + UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource") { + @Override + public Map getAdditionalInformation() { + return null; + } + }; + + assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("client_secret cannot be blank"); + } + @Test void createClientNoSecretForImplicitIsValid() { UaaClientDetails clientDetails = new UaaClientDetails("client", null, "openid", "implicit", "uaa.resource"); @@ -106,4 +162,204 @@ void createAdminAuthorityClientIsInvalid() { ClientDetails clientDetails = new UaaClientDetails("admin-client", null, "openid", GRANT_TYPE_AUTHORIZATION_CODE, "uaa.admin"); assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)).asInstanceOf(InstanceOfAssertFactories.throwable(InvalidClientDetailsException.class)); } + + @Test + void rejectsTlsClientAuthCaWhenMtlsDisabled() { + zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, false); + + UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); + clientDetails.setClientSecret("secret"); + Map additionalInfo = new HashMap<>(); + additionalInfo.put(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT); + clientDetails.setAdditionalInformation(additionalInfo); + + assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining("uaa.mtls-enabled"); + } + + @Test + void allowsTlsClientAuthCaWhenMtlsEnabled() { + zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); + + UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); + clientDetails.setClientSecret("secret"); + Map additionalInfo = new HashMap<>(); + additionalInfo.put(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT); + clientDetails.setAdditionalInformation(additionalInfo); + + ClientDetails validated = zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE); + + assertThat(validated.getAdditionalInformation()) + .containsEntry(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT); + } + + @Test + void allowsSecretlessClientCredentialsClientWhenTlsClientAuthCaConfigured() { + zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); + + UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); + Map additionalInfo = new HashMap<>(); + additionalInfo.put(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT); + clientDetails.setAdditionalInformation(additionalInfo); + + assertThatNoException().isThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)); + } + + @Test + void rejectsSecretlessClientCredentialsClientWhenTlsClientAuthCaIsJsonMap() { + zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); + + UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); + Map additionalInfo = new HashMap<>(); + additionalInfo.put(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, + Map.of(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT)); + clientDetails.setAdditionalInformation(additionalInfo); + + assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); + } + + @ParameterizedTest + @MethodSource("unsupportedNestedTlsClientAuthCaValues") + void rejectsSecretlessClientCredentialsClientWhenTlsClientAuthCaMapHasUnsupportedCaValue(Object ca) { + zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); + + UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); + Map tlsClientAuthConfig = new HashMap<>(); + tlsClientAuthConfig.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, ca); + Map additionalInfo = new HashMap<>(); + additionalInfo.put(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, tlsClientAuthConfig); + clientDetails.setAdditionalInformation(additionalInfo); + + assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); + } + + @Test + void rejectsSecretlessClientCredentialsClientWhenTlsClientAuthCaMapIsMalformed() { + zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); + + UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); + Map additionalInfo = new HashMap<>(); + additionalInfo.put(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, Map.of("unexpected", "value")); + clientDetails.setAdditionalInformation(additionalInfo); + + assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); + } + + @ParameterizedTest + @ValueSource(strings = {"", " ", "\t"}) + void rejectsSecretlessClientCredentialsClientWhenTlsClientAuthCaIsBlank(final String ca) { + zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); + + UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); + Map additionalInfo = new HashMap<>(); + additionalInfo.put(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, ca); + clientDetails.setAdditionalInformation(additionalInfo); + + assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); + } + + @Test + void rejectsSecretlessClientCredentialsClientWhenTlsClientAuthCaIsNotAString() { + zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); + + UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); + Map additionalInfo = new HashMap<>(); + additionalInfo.put(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, 42); + clientDetails.setAdditionalInformation(additionalInfo); + + assertThatThrownBy(() -> zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE)) + .isInstanceOf(InvalidClientDetailsException.class) + .hasMessageContaining(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA); + } + + @Test + void stillValidatesSuppliedSecretWhenTlsClientAuthCaConfigured() { + zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, true); + + UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); + clientDetails.setClientSecret("supplied-secret"); + Map additionalInfo = new HashMap<>(); + additionalInfo.put(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); + additionalInfo.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT); + clientDetails.setAdditionalInformation(additionalInfo); + + zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE); + + verify(mockClientSecretValidator).validate("supplied-secret"); + } + + @Test + void allowsClientWithoutMtlsFieldsWhenMtlsDisabled() { + zoneEndpointsClientDetailsValidator = new ZoneEndpointsClientDetailsValidator(mockClientSecretValidator, false); + + UaaClientDetails clientDetails = new UaaClientDetails("valid-client", null, "openid", "client_credentials", "uaa.resource"); + clientDetails.setClientSecret("secret"); + clientDetails.addAdditionalInformation(ALLOWED_PROVIDERS, Collections.singletonList(OriginKeys.UAA)); + + ClientDetails validated = zoneEndpointsClientDetailsValidator.validate(clientDetails, Mode.CREATE); + + assertThat(validated.getClientId()).isEqualTo(clientDetails.getClientId()); + } + + private static Stream unsupportedNestedTlsClientAuthCaValues() { + return Stream.of(42, true); + } + + private static Stream invalidNestedTypedTlsClientAuthConfigurations() { + TlsClientAuthConfiguration subTemplateConfig = new TlsClientAuthConfiguration(VALID_CERT, null); + subTemplateConfig.setSubTemplate("{undeclared}"); + TlsClientAuthConfiguration audTemplatesConfig = new TlsClientAuthConfiguration(VALID_CERT, null); + audTemplatesConfig.setAudTemplates(Collections.singletonList("{undeclared}")); + TlsClientAuthConfiguration requiredClaimsConfig = new TlsClientAuthConfiguration(VALID_CERT, null); + requiredClaimsConfig.setRequiredClaims(Map.of("undeclared", "value")); + return Stream.of( + Arguments.of(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, subTemplateConfig), + Arguments.of(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, audTemplatesConfig), + Arguments.of(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS, requiredClaimsConfig)); + } + + private static Stream invalidNestedMapTlsClientAuthConfigurations() { + return Stream.of( + Arguments.of(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, + nestedTlsClientAuthConfig(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, "{undeclared}")), + Arguments.of(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, + nestedTlsClientAuthConfig(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, + Collections.singletonList("{undeclared}"))), + Arguments.of(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS, + nestedTlsClientAuthConfig(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS, + Map.of("undeclared", "value")))); + } + + private static Stream parserNullNestedMapClaimMappings() { + return Stream.of( + Arguments.of("", TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, "{undeclared}"), + Arguments.of(" ", TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, + Collections.singletonList("{undeclared}")), + Arguments.of("null", TlsClientAuthConfiguration.TLS_CLIENT_AUTH_REQUIRED_CLAIMS, + Map.of("undeclared", "value"))); + } + + private static Map nestedTlsClientAuthConfig(String property, Object value) { + Map config = new HashMap<>(); + config.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, VALID_CERT); + config.put(property, value); + return config; + } } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointSecurityConfigurationTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointSecurityConfigurationTests.java new file mode 100644 index 00000000000..2f9b4911eda --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointSecurityConfigurationTests.java @@ -0,0 +1,20 @@ +package org.cloudfoundry.identity.uaa.oauth.beans; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + +import static org.assertj.core.api.Assertions.assertThat; + +class OauthEndpointSecurityConfigurationTests { + + @Test + void createsMtlsTokenSecurityChainOnlyWhenMtlsIsEnabled() throws NoSuchMethodException { + ConditionalOnProperty condition = OauthEndpointSecurityConfiguration.class + .getDeclaredMethod("mtlsTokenEndpointSecurity", org.springframework.security.config.annotation.web.builders.HttpSecurity.class) + .getAnnotation(ConditionalOnProperty.class); + + assertThat(condition).isNotNull(); + assertThat(condition.name()).containsExactly("uaa.mtls-enabled"); + assertThat(condition.havingValue()).isEqualTo("true"); + } +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/provider/client/ClientCredentialsTokenGranterTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/provider/client/ClientCredentialsTokenGranterTests.java index 1fba1b9eabc..e3976a08918 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/provider/client/ClientCredentialsTokenGranterTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/provider/client/ClientCredentialsTokenGranterTests.java @@ -67,6 +67,12 @@ void grantNoToken() { assertThat(clientCredentialsTokenGranter.grant(TokenConstants.GRANT_TYPE_CLIENT_CREDENTIALS, tokenRequest)).isNull(); } + @Test + void tlsClientAuthIsAllowedForClientCredentials() { + assertThat(ClientCredentialsTokenGranter.isAllowedAuthMethod( + TokenConstants.CLIENT_AUTH_TLS_CLIENT_AUTH)).isTrue(); + } + @Test void grantNoSecretFails() { UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken("username", null, null); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/ClientCertificateMapperFilterTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/ClientCertificateMapperFilterTest.java new file mode 100644 index 00000000000..1a54489f041 --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/ClientCertificateMapperFilterTest.java @@ -0,0 +1,192 @@ +package org.cloudfoundry.identity.uaa.oauth.tls; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.cloudfoundry.identity.uaa.SpringServletXmlFiltersConfiguration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.Security; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Comparator; +import java.util.Date; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +class ClientCertificateMapperFilterTest { + + @BeforeEach + void setUp() { + Security.addProvider(new BouncyCastleFipsProvider()); + } + + @Test + void clientCertificateMapperFilter_registersClientCertificateMapperForMtlsEndpoint() { + SpringServletXmlFiltersConfiguration config = new SpringServletXmlFiltersConfiguration(); + FilterRegistrationBean bean = config.clientCertificateMapperFilter(); + assertThat(bean.getFilter()).isInstanceOf(MtlsPathGuardedFilter.class); + assertThat(((MtlsPathGuardedFilter) bean.getFilter()).getDelegate().getClass().getName()) + .isEqualTo("org.cloudfoundry.router.jakarta.ClientCertificateMapper"); + // No addUrlPatterns(...): registered on the default (all-requests) pattern, guarded internally + // by MtlsPathGuardedFilter -- see RawPeerCertificateCaptureFilterRegistrationTest for why a + // container URL-pattern registration cannot correctly scope this filter to zone-path requests. + assertThat(bean.getUrlPatterns()).isEmpty(); + } + + @Test + void doesNotInvokeTheDelegateForUnrelatedPaths() throws Exception { + SpringServletXmlFiltersConfiguration config = new SpringServletXmlFiltersConfiguration(); + FilterRegistrationBean mapperBean = config.clientCertificateMapperFilter(); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setServletPath("/oauth/mtls/not-token"); + request.addHeader("X-Forwarded-Client-Cert", + Base64.getEncoder().encodeToString(generateSelfSignedCert().getEncoded())); + MockHttpServletResponse response = new MockHttpServletResponse(); + + mapperBean.getFilter().doFilter(request, response, (req, res) -> { }); + + assertThat(request.getAttribute("jakarta.servlet.request.X509Certificate")) + .as("ClientCertificateMapper must not run for a path other than /oauth/mtls/token/**") + .isNull(); + } + + @Test + void clientCertificateMapperFilterPopulatesCertAttributeBeforeSpringSecurityRuns() throws Exception { + // Behavioural regression test for the ordering bug reported in PR review + // (SpringServletXmlFiltersConfiguration.java:250): the ClientCertificateMapper filter + // must run *before* Spring Boot's Security filter in servlet-container dispatch order + // (filters run in ascending getOrder() value), so that the + // jakarta.servlet.request.X509Certificate attribute it derives from the + // X-Forwarded-Client-Cert header is already populated when Spring Security's + // authentication logic (ClientDetailsAuthenticationProvider / TlsClientAuthentication) + // reads it for the /oauth/mtls/token request. + // + // Rather than asserting on the raw order integer in isolation, this drives a real + // two-filter chain -- the actual ClientCertificateMapper filter plus a stand-in for + // Spring Boot's registered Security filter at its real documented order (-100, + // org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterProperties + // .DEFAULT_FILTER_ORDER) -- through a real request carrying a real X-Forwarded-Client-Cert + // header, and observes what the "security" filter actually sees. + SpringServletXmlFiltersConfiguration config = new SpringServletXmlFiltersConfiguration(); + FilterRegistrationBean mapperBean = config.clientCertificateMapperFilter(); + + AtomicReference certSeenBySecurityFilter = new AtomicReference<>(); + FilterRegistrationBean securityFilterBean = + fakeSpringSecurityFilterBean(certSeenBySecurityFilter, -100); + + MockHttpServletRequest request = requestWithClientCertHeader(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + runContainerFilterChain(List.of(mapperBean, securityFilterBean), request, response); + + assertThat(certSeenBySecurityFilter.get()) + .as("X509Certificate request attribute must be populated before Spring Security's filter runs") + .isInstanceOf(X509Certificate[].class); + } + + @Test + void securityFilterOrderedFirstWouldNotSeeCertAttribute() throws Exception { + // Companion/control test proving the pre-fix behaviour really was broken: with the + // ClientCertificateMapper filter's order set the way it was before this fix (10, which + // is *after* Spring Security's -100), the cert attribute is not yet populated when + // Spring Security's filter runs. + Filter clientCertificateMapper = + new SpringServletXmlFiltersConfiguration().clientCertificateMapperFilter().getFilter(); + FilterRegistrationBean mapperBeanWithBuggyOrder = new FilterRegistrationBean<>(clientCertificateMapper); + mapperBeanWithBuggyOrder.setOrder(10); // the old, buggy order + + AtomicReference certSeenBySecurityFilter = new AtomicReference<>(); + FilterRegistrationBean securityFilterBean = + fakeSpringSecurityFilterBean(certSeenBySecurityFilter, -100); + + MockHttpServletRequest request = requestWithClientCertHeader(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + runContainerFilterChain(List.of(mapperBeanWithBuggyOrder, securityFilterBean), request, response); + + assertThat(certSeenBySecurityFilter.get()) + .as("with the old buggy order, Spring Security runs first and must NOT see the cert attribute yet") + .isNull(); + } + + private static FilterRegistrationBean fakeSpringSecurityFilterBean( + AtomicReference certSeenBySecurityFilter, int order) { + Filter fakeSpringSecurityFilter = (request, response, chain) -> { + certSeenBySecurityFilter.set( + ((HttpServletRequest) request).getAttribute("jakarta.servlet.request.X509Certificate")); + chain.doFilter(request, response); + }; + FilterRegistrationBean bean = new FilterRegistrationBean<>(fakeSpringSecurityFilter); + bean.setOrder(order); + return bean; + } + + /** + * Simulates servlet-container filter dispatch: registered filters run in ascending + * {@link FilterRegistrationBean#getOrder()} value, each delegating to the next via a + * standard {@link FilterChain}. + */ + private static void runContainerFilterChain( + List> registrations, + MockHttpServletRequest request, + MockHttpServletResponse response) throws Exception { + List> sorted = new ArrayList<>(registrations); + sorted.sort(Comparator.comparingInt(FilterRegistrationBean::getOrder)); + + FilterChain chain = (req, res) -> { }; + for (int i = sorted.size() - 1; i >= 0; i--) { + Filter filter = sorted.get(i).getFilter(); + FilterChain next = chain; + chain = (req, res) -> filter.doFilter(req, res, next); + } + chain.doFilter(request, response); + } + + private static MockHttpServletRequest requestWithClientCertHeader() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setServletPath("/oauth/mtls/token"); + request.addHeader("X-Forwarded-Client-Cert", + Base64.getEncoder().encodeToString(generateSelfSignedCert().getEncoded())); + return request; + } + + private static X509Certificate generateSelfSignedCert() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA", BouncyCastleFipsProvider.PROVIDER_NAME); + kpg.initialize(2048); + KeyPair kp = kpg.generateKeyPair(); + X500Name name = new X500Name("CN=leaf-instance"); + Date notBefore = new Date(System.currentTimeMillis() - 60_000); + Date notAfter = new Date(System.currentTimeMillis() + 3_600_000); + JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( + name, BigInteger.ONE, notBefore, notAfter, name, kp.getPublic()); + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false)); + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA") + .setProvider(BouncyCastleFipsProvider.PROVIDER_NAME) + .build(kp.getPrivate()); + X509CertificateHolder holder = builder.build(signer); + return new JcaX509CertificateConverter() + .setProvider(BouncyCastleFipsProvider.PROVIDER_NAME) + .getCertificate(holder); + } +} 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 new file mode 100644 index 00000000000..66083ca1743 --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/MtlsClaimsEnhancerTest.java @@ -0,0 +1,654 @@ +package org.cloudfoundry.identity.uaa.oauth.tls; + +import org.cloudfoundry.identity.uaa.client.TlsClientAuthConfiguration; +import org.cloudfoundry.identity.uaa.client.UaaClientDetails; +import org.cloudfoundry.identity.uaa.constants.ClientAuthentication; +import org.cloudfoundry.identity.uaa.oauth.provider.ClientDetailsService; +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.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import javax.security.auth.x500.X500Principal; +import java.io.Serializable; +import java.security.cert.CertificateEncodingException; +import java.security.cert.X509Certificate; +import java.util.Arrays; +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; +import static org.cloudfoundry.identity.uaa.oauth.token.ClaimConstants.CLIENT_AUTH_METHOD; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class MtlsClaimsEnhancerTest { + + private TlsClientAuthentication tlsClientAuthentication; + private ClientDetailsService clientDetailsService; + private MtlsClaimsEnhancer enhancer; + + @BeforeEach + void setUp() { + // A spy (not a plain mock): extractClaimMappingValues now lives on TlsClientAuthentication, + // and this test suite still needs it to actually run (real RDN parsing) to exercise + // MtlsClaimsEnhancer's claim-shape-building logic end-to-end, exactly as before this class + // was relocated. hasCertificateFromRequest()/getCertificateFromRequest(...) remain stubbed + // per-test exactly as before. + tlsClientAuthentication = spy(new TlsClientAuthentication()); + clientDetailsService = mock(ClientDetailsService.class); + enhancer = new MtlsClaimsEnhancer(tlsClientAuthentication, clientDetailsService); + } + + @Test + void extractsClaimsFromCertOuFields() throws Exception { + X509Certificate cert = mock(X509Certificate.class); + when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); + when(cert.getSubjectX500Principal()).thenReturn( + new X500Principal("CN=instance-guid, OU=app:app-guid-123, OU=space:space-guid-456, OU=organization:org-guid-789, O=Cloud Foundry")); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration(new TlsClientAuthConfiguration( + "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n", + List.of( + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^app:(.+)$", "app_guid"), + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^space:(.+)$", "space_guid"), + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^organization:(.+)$", "org_guid"), + new TlsClientAuthConfiguration.ClaimMapping("subject_cn", null, "cf_instance_guid") + ) + )); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + OAuth2Authentication auth = mockAuthentication("instance-identity"); + Map result = enhancer.enhance(new HashMap<>(), auth); + + assertThat(result).containsEntry("app_guid", "app-guid-123"); + assertThat(result).containsEntry("space_guid", "space-guid-456"); + assertThat(result).containsEntry("org_guid", "org-guid-789"); + assertThat(result).containsEntry("cf_instance_guid", "instance-guid"); + } + + @Test + void addsX5tThumbprintWhenCertPresent() throws Exception { + X509Certificate cert = mock(X509Certificate.class); + when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); + when(cert.getSubjectX500Principal()).thenReturn(new X500Principal("CN=test")); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration( + new TlsClientAuthConfiguration("-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n", null)); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + OAuth2Authentication auth = mockAuthentication("instance-identity"); + Map result = enhancer.enhance(new HashMap<>(), auth); + + assertThat(result).containsKey("cnf"); + @SuppressWarnings("unchecked") + Map cnf = (Map) result.get("cnf"); + assertThat(cnf).containsKey("x5t#S256"); + } + + @Test + void returnsEmptyWhenNoCertOnRequest() { + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(false); + OAuth2Authentication auth = mockAuthentication("instance-identity"); + Map result = enhancer.enhance(new HashMap<>(), auth); + assertThat(result).doesNotContainKey("app_guid"); + } + + @Test + void dotNotationClaimProducesNestedObject() throws Exception { + X509Certificate cert = mock(X509Certificate.class); + when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); + when(cert.getSubjectX500Principal()).thenReturn(new X500Principal( + "CN=inst-guid, OU=app:app-guid, OU=space:space-guid, OU=organization:org-guid, O=Cloud Foundry")); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration(new TlsClientAuthConfiguration( + "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n", + List.of( + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^app:(.+)$", "cf.app"), + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^space:(.+)$", "cf.space"), + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^organization:(.+)$", "cf.org"), + new TlsClientAuthConfiguration.ClaimMapping("subject_cn", null, "cf_instance_guid") + ) + )); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + OAuth2Authentication auth = mockAuthentication("instance-identity"); + Map result = enhancer.enhance(new HashMap<>(), auth); + + assertThat(result).containsKey("cf"); + @SuppressWarnings("unchecked") + Map cf = (Map) result.get("cf"); + assertThat(cf).containsEntry("app", "app-guid"); + assertThat(cf).containsEntry("space", "space-guid"); + assertThat(cf).containsEntry("org", "org-guid"); + assertThat(result).containsEntry("cf_instance_guid", "inst-guid"); + // Dot-notation keys must NOT appear as top-level claims + assertThat(result).doesNotContainKey("cf.app"); + assertThat(result).doesNotContainKey("cf.space"); + assertThat(result).doesNotContainKey("cf.org"); + } + + @Test + void flatClaimsStillWorkAfterRefactor() throws Exception { + // Existing flat-key behaviour must be unchanged + X509Certificate cert = mock(X509Certificate.class); + when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); + when(cert.getSubjectX500Principal()).thenReturn(new X500Principal( + "CN=instance-guid, OU=app:app-guid-123, OU=space:space-guid-456, OU=organization:org-guid-789, O=Cloud Foundry")); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration(new TlsClientAuthConfiguration( + "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n", + List.of( + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^app:(.+)$", "app_guid"), + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^space:(.+)$", "space_guid"), + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^organization:(.+)$", "org_guid"), + new TlsClientAuthConfiguration.ClaimMapping("subject_cn", null, "cf_instance_guid") + ) + )); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + OAuth2Authentication auth = mockAuthentication("instance-identity"); + Map result = enhancer.enhance(new HashMap<>(), auth); + + assertThat(result).containsEntry("app_guid", "app-guid-123"); + assertThat(result).containsEntry("space_guid", "space-guid-456"); + assertThat(result).containsEntry("org_guid", "org-guid-789"); + assertThat(result).containsEntry("cf_instance_guid","instance-guid"); + assertThat(result).doesNotContainKey("cf"); + } + + @Test + void dotNotationOverwritesFlatClaimWithSameParentKey() throws Exception { + X509Certificate cert = mock(X509Certificate.class); + when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); + when(cert.getSubjectX500Principal()).thenReturn(new X500Principal( + "CN=inst-guid, OU=app:app-guid, O=cf-org")); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration(new TlsClientAuthConfiguration( + "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n", + List.of( + new TlsClientAuthConfiguration.ClaimMapping("subject_o", null, "cf"), // flat "cf" + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^app:(.+)$", "cf.app") // nested "cf.app" + ) + )); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + OAuth2Authentication auth = mockAuthentication("instance-identity"); + Map result = enhancer.enhance(new HashMap<>(), auth); + + // Nested map must overwrite the flat "cf" string value + assertThat(result.get("cf")).isInstanceOf(Map.class); + @SuppressWarnings("unchecked") + Map cf = (Map) result.get("cf"); + assertThat(cf).containsEntry("app", "app-guid"); + } + + @Test + void subTemplateRendered() throws Exception { + X509Certificate cert = mockCfCert(); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); + + TlsClientAuthConfiguration config = cfMappingsConfig(); + config.setSubTemplate("o/{cf.org}/s/{cf.space}/a/{cf.app}"); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration(config); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + Map result = enhancer.enhance(new HashMap<>(), mockAuthentication("instance-identity")); + + assertThat(result).containsEntry("sub", + "o/org-guid/s/space-guid/a/app-guid"); + } + + @Test + void audTemplatesRenderedAndOverrideDefault() throws Exception { + X509Certificate cert = mockCfCert(); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); + + TlsClientAuthConfiguration config = cfMappingsConfig(); + config.setAudTemplates(List.of( + "o/{cf.org}/s/{cf.space}/a/{cf.app}", + "o/{cf.org}/s/{cf.space}", + "o/{cf.org}" + )); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration(config); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + Map result = enhancer.enhance(new HashMap<>(), mockAuthentication("instance-identity")); + + assertThat(result).containsKey("aud"); + @SuppressWarnings("unchecked") + List aud = (List) result.get("aud"); + assertThat(aud).containsExactly( + "o/org-guid/s/space-guid/a/app-guid", + "o/org-guid/s/space-guid", + "o/org-guid" + ); + } + + @Test + void skipsNullAudTemplateEntryForPersistedClient() throws Exception { + X509Certificate cert = mockCfCert(); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); + + TlsClientAuthConfiguration config = cfMappingsConfig(); + config.setAudTemplates(Arrays.asList(null, "o/{cf.org}")); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration(config); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + Map result = enhancer.enhance(new HashMap<>(), mockAuthentication("instance-identity")); + + assertThat(result.get("aud")).asInstanceOf(org.assertj.core.api.InstanceOfAssertFactories.list(String.class)) + .containsExactly("o/org-guid"); + } + + @Test + void subOmittedWhenTemplateVarMissing() throws Exception { + // Cert has no OU fields → cf.org will not be in vars + X509Certificate cert = mock(X509Certificate.class); + when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); + when(cert.getSubjectX500Principal()).thenReturn(new X500Principal("CN=only-cn")); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration( + "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n", + List.of(new TlsClientAuthConfiguration.ClaimMapping("subject_cn", null, "cf_instance_guid")) + ); + config.setSubTemplate("o/{cf.org}"); // {cf.org} will have no value + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration(config); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + Map result = enhancer.enhance(new HashMap<>(), mockAuthentication("instance-identity")); + + assertThat(result).doesNotContainKey("sub"); + assertThat(result).containsEntry("cf_instance_guid", "only-cn"); + } + + @Test + void audEntryDroppedWhenTemplateVarMissing() throws Exception { + X509Certificate cert = mockCfCert(); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); + + TlsClientAuthConfiguration config = cfMappingsConfig(); + config.setAudTemplates(List.of( + "a/{cf.app}", // will resolve + "x/{missing_var}" // {missing_var} not in vars → dropped + )); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration(config); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + Map result = enhancer.enhance(new HashMap<>(), mockAuthentication("instance-identity")); + + assertThat(result).containsKey("aud"); + @SuppressWarnings("unchecked") + List aud = (List) result.get("aud"); + assertThat(aud).containsExactly("a/app-guid"); + } + + @Test + void audOmittedWhenAllTemplateEntriesFail() throws Exception { + X509Certificate cert = mockCfCert(); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); + + TlsClientAuthConfiguration config = cfMappingsConfig(); + config.setAudTemplates(List.of("x/{missing}", "y/{also_missing}")); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration(config); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + Map result = enhancer.enhance(new HashMap<>(), mockAuthentication("instance-identity")); + + assertThat(result).doesNotContainKey("aud"); + } + + @Test + void noTemplatesConfiguredLeavesSubAndAudAbsent() throws Exception { + X509Certificate cert = mockCfCert(); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); + + // Config with no subTemplate/audTemplates (original behaviour) + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration(cfMappingsConfig()); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + Map result = enhancer.enhance(new HashMap<>(), mockAuthentication("instance-identity")); + + assertThat(result).doesNotContainKey("sub"); + assertThat(result).doesNotContainKey("aud"); + } + + @Test + void stringPathInAdditionalInformationLoadsSubTemplateAndAudTemplates() throws Exception { + X509Certificate cert = mockCfCert(); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + // Do NOT call setTlsClientAuthConfiguration — use String values directly + clientDetails.setAdditionalInformation(Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, + "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n", + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + "[{\"field\":\"subject_ou\",\"pattern\":\"^app:(.+)$\",\"claim\":\"cf.app\"}," + + "{\"field\":\"subject_cn\",\"claim\":\"cf_instance_guid\"}]", + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_SUB_TEMPLATE, + "app/{cf.app}", + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_AUD_TEMPLATES, + "[\"app/{cf.app}\"]" + )); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + Map result = enhancer.enhance(new HashMap<>(), mockAuthentication("instance-identity")); + + assertThat(result).containsEntry("sub", "app/app-guid"); + assertThat(result).containsKey("aud"); + @SuppressWarnings("unchecked") + List aud = (List) result.get("aud"); + assertThat(aud).containsExactly("app/app-guid"); + } + + @Test + void stringPathInAdditionalInformationLoadsTrustedProxyCa() throws Exception { + X509Certificate cert = mockCfCert(); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + // doReturn/when (not when/thenReturn) — this test verifies the exact invocation count of + // getCertificateFromRequest below; when/thenReturn on a spy invokes the real method once + // during stub setup, which would be double-counted as an extra interaction. + doReturn(cert).when(tlsClientAuthentication).getCertificateFromRequest(any()); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + // Do NOT call setTlsClientAuthConfiguration — use String values directly, mirroring how + // additionalInformation looks once round-tripped through the DB/JDBC. + clientDetails.setAdditionalInformation(Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, + "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n", + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_TRUSTED_PROXY_CA, + "-----BEGIN CERTIFICATE-----\nMIIBproxy\n-----END CERTIFICATE-----\n" + )); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + enhancer.enhance(new HashMap<>(), mockAuthentication("instance-identity")); + + ArgumentCaptor configCaptor = + ArgumentCaptor.forClass(TlsClientAuthConfiguration.class); + verify(tlsClientAuthentication).getCertificateFromRequest(configCaptor.capture()); + assertThat(configCaptor.getValue().getTrustedProxyCaPem()) + .isEqualTo("-----BEGIN CERTIFICATE-----\nMIIBproxy\n-----END CERTIFICATE-----\n"); + } + + @Test + void extractsCnValueContainingEscapedComma() throws Exception { + // RFC 2253 escapes literal commas inside an attribute value with a backslash. + // A naive dn.split(",") breaks on the escaped comma and mangles the CN value. + X509Certificate cert = mock(X509Certificate.class); + when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); + when(cert.getSubjectX500Principal()).thenReturn(new X500Principal( + "CN=Smith\\, John,OU=app:app-guid-123,O=Cloud Foundry")); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration(new TlsClientAuthConfiguration( + "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n", + List.of( + new TlsClientAuthConfiguration.ClaimMapping("subject_cn", null, "cf_instance_guid"), + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^app:(.+)$", "app_guid") + ) + )); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + Map result = enhancer.enhance(new HashMap<>(), mockAuthentication("instance-identity")); + + assertThat(result).containsEntry("cf_instance_guid", "Smith, John"); + assertThat(result).containsEntry("app_guid", "app-guid-123"); + } + + @Test + void extractsOuValueContainingEscapedComma() throws Exception { + // Same RFC 2253 escaping issue, but for a multi-valued OU list: an escaped comma + // inside one OU must not be treated as an RDN separator, and subsequent OUs must + // still be collected correctly. + X509Certificate cert = mock(X509Certificate.class); + when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); + when(cert.getSubjectX500Principal()).thenReturn(new X500Principal( + "CN=inst-guid,OU=team\\, ops,OU=app:app-guid-123,O=Cloud Foundry")); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration(new TlsClientAuthConfiguration( + "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n", + List.of( + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^(team, ops)$", "team"), + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^app:(.+)$", "app_guid") + ) + )); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + Map result = enhancer.enhance(new HashMap<>(), mockAuthentication("instance-identity")); + + assertThat(result).containsEntry("team", "team, ops"); + assertThat(result).containsEntry("app_guid", "app-guid-123"); + } + + @Test + void enhanceReturnsEmptyWhenClientAuthenticatedViaClientSecretInsteadOfTlsClientAuth() throws Exception { + // Reproduces PR review concern (MtlsClaimsEnhancer.java:76): a client with both a + // client_secret AND tls-client-auth-ca configured could hit /oauth/mtls/token, present a + // harvested/unvalidated certificate (via the mapped X509Certificate attribute), but + // authenticate with the secret instead -- bypassing validateTlsClientAuth entirely. + // The enhancer must not derive identity/cnf claims from a certificate that was never + // actually validated as the proof of authentication. + X509Certificate cert = mock(X509Certificate.class); + when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); + when(cert.getSubjectX500Principal()).thenReturn(new X500Principal( + "CN=instance-guid, OU=app:app-guid-123, O=Cloud Foundry")); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration(new TlsClientAuthConfiguration( + "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n", + List.of(new TlsClientAuthConfiguration.ClaimMapping("subject_cn", null, "cf_instance_guid")) + )); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + OAuth2Authentication auth = mockAuthenticationWithMethod("instance-identity", "client_secret_basic"); + Map result = enhancer.enhance(new HashMap<>(), auth); + + assertThat(result).doesNotContainKey("cf_instance_guid"); + assertThat(result).doesNotContainKey("cnf"); + assertThat(result).isEmpty(); + } + + @Test + void enhanceReturnsEmptyWhenClientAuthMethodExtensionIsMissing() throws Exception { + // Fail closed: if the client_auth_method extension isn't present at all (e.g. an older + // token-granting path that never set it), claims must not be derived either. + X509Certificate cert = mock(X509Certificate.class); + when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); + when(cert.getSubjectX500Principal()).thenReturn(new X500Principal("CN=instance-guid")); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration(new TlsClientAuthConfiguration( + "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n", + List.of(new TlsClientAuthConfiguration.ClaimMapping("subject_cn", null, "cf_instance_guid")) + )); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + OAuth2Authentication auth = mockAuthenticationWithMethod("instance-identity", null); + Map result = enhancer.enhance(new HashMap<>(), auth); + + assertThat(result).isEmpty(); + } + + @Test + void enhancePropagatesExceptionWhenClientDetailsLookupFails() throws Exception { + // PR review concern (MtlsClaimsEnhancer.java:94): a transient failure loading client + // details (e.g. a database error) must fail the whole token request closed, not be + // swallowed into an incomplete/degraded token missing identity + cnf claims. + X509Certificate cert = mockCfCert(); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); + + when(clientDetailsService.loadClientByClientId("instance-identity")) + .thenThrow(new ClientRegistrationException("db unavailable")); + + OAuth2Authentication auth = mockAuthentication("instance-identity"); + + assertThatThrownBy(() -> enhancer.enhance(new HashMap<>(), auth)) + .isInstanceOf(ClientRegistrationException.class) + .hasMessage("db unavailable"); + } + + @Test + void enhanceThrowsWhenCertEncodingFailsInsteadOfSilentlyDroppingCnfClaim() throws Exception { + // PR review concern (MtlsClaimsEnhancer.java:135): a failure to DER-encode the cert or + // compute its SHA-256 digest must fail the whole token request closed, not silently + // downgrade a certificate-bound (RFC 8705 sec:3.1) token into an unbound bearer token by + // dropping the cnf claim. + X509Certificate cert = mock(X509Certificate.class); + when(cert.getEncoded()).thenThrow(new CertificateEncodingException("boom")); + when(cert.getSubjectX500Principal()).thenReturn(new X500Principal("CN=test")); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration( + new TlsClientAuthConfiguration("-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n", null)); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + OAuth2Authentication auth = mockAuthentication("instance-identity"); + + assertThatThrownBy(() -> enhancer.enhance(new HashMap<>(), auth)) + .isInstanceOf(IllegalStateException.class) + .hasCauseInstanceOf(CertificateEncodingException.class); + } + + @Test + void subOmittedWhenTemplateExceedsMaxLength() throws Exception { + // Defense-in-depth: covers BOSH-flat-config-bootstrapped clients, which bypass + // ClientAdminEndpointsValidator's admin-API-time length check entirely. An oversized + // template must be dropped (not hang or throw), consistent with renderTemplate's + // existing "unresolved placeholder" contract. + X509Certificate cert = mockCfCert(); + when(tlsClientAuthentication.hasCertificateFromRequest()).thenReturn(true); + when(tlsClientAuthentication.getCertificateFromRequest(any())).thenReturn(cert); + + TlsClientAuthConfiguration config = cfMappingsConfig(); + String oversizedSubTemplate = "{".repeat(MtlsClaimsEnhancer.MAX_TEMPLATE_LENGTH + 1) + "cf.org}"; + config.setSubTemplate(oversizedSubTemplate); + + UaaClientDetails clientDetails = new UaaClientDetails(); + clientDetails.setClientId("instance-identity"); + clientDetails.setTlsClientAuthConfiguration(config); + when(clientDetailsService.loadClientByClientId("instance-identity")).thenReturn(clientDetails); + + long start = System.nanoTime(); + Map result = enhancer.enhance(new HashMap<>(), mockAuthentication("instance-identity")); + long elapsedMillis = (System.nanoTime() - start) / 1_000_000; + + assertThat(result).doesNotContainKey("sub"); + assertThat(elapsedMillis).isLessThan(100); + } + + private X509Certificate mockCfCert() throws Exception { + X509Certificate cert = mock(X509Certificate.class); + when(cert.getEncoded()).thenReturn(new byte[]{1, 2, 3}); + when(cert.getSubjectX500Principal()).thenReturn(new X500Principal( + "CN=inst-guid, OU=app:app-guid, OU=space:space-guid, OU=organization:org-guid, O=Cloud Foundry")); + return cert; + } + + private TlsClientAuthConfiguration cfMappingsConfig() { + return new TlsClientAuthConfiguration( + "-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----\n", + List.of( + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^app:(.+)$", "cf.app"), + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^space:(.+)$", "cf.space"), + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^organization:(.+)$", "cf.org"), + new TlsClientAuthConfiguration.ClaimMapping("subject_cn", null, "cf_instance_guid") + ) + ); + } + + private OAuth2Authentication mockAuthentication(String clientId) { + // Default: represents a client that actually authenticated via tls_client_auth, + // matching what the mTLS token endpoint's ClientDetailsAuthenticationProvider sets + // after validateTlsClientAuth succeeds. + return mockAuthenticationWithMethod(clientId, ClientAuthentication.TLS_CLIENT_AUTH); + } + + private OAuth2Authentication mockAuthenticationWithMethod(String clientId, String clientAuthMethod) { + OAuth2Request request = mock(OAuth2Request.class); + when(request.getClientId()).thenReturn(clientId); + Map extensions = clientAuthMethod == null + ? Map.of() + : Map.of(CLIENT_AUTH_METHOD, clientAuthMethod); + when(request.getExtensions()).thenReturn(extensions); + OAuth2Authentication auth = mock(OAuth2Authentication.class); + when(auth.getOAuth2Request()).thenReturn(request); + return auth; + } +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilterRegistrationTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilterRegistrationTest.java new file mode 100644 index 00000000000..9bcba47b0d4 --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilterRegistrationTest.java @@ -0,0 +1,262 @@ +package org.cloudfoundry.identity.uaa.oauth.tls; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.cloudfoundry.identity.uaa.SpringServletXmlFiltersConfiguration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.Security; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Comparator; +import java.util.Date; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class RawPeerCertificateCaptureFilterRegistrationTest { + + @BeforeEach + void setUp() { + Security.addProvider(new BouncyCastleFipsProvider()); + } + + @Test + void rawPeerCertificateCaptureFilterRunsBeforeClientCertificateMapper() { + SpringServletXmlFiltersConfiguration config = new SpringServletXmlFiltersConfiguration(); + + FilterRegistrationBean captureBean = config.rawPeerCertificateCaptureFilter(); + FilterRegistrationBean mapperBean = config.clientCertificateMapperFilter(); + + assertThat(captureBean.getFilter()).isInstanceOf(RawPeerCertificateCaptureFilter.class); + // No addUrlPatterns(...): registered on the default (all-requests) pattern -- see + // isMtlsTokenPathAcceptsTheEffectivePostZoneRewriteServletPath() below for why a container + // URL-pattern registration cannot correctly scope this filter to zone-path requests. + assertThat(captureBean.getUrlPatterns()).isEmpty(); + assertThat(captureBean.getOrder()).isLessThan(mapperBean.getOrder()); + } + + @Test + void isMtlsTokenPathAcceptsTheEffectivePostZoneRewriteServletPath() { + // ZonePathContextRewritingFilter (which runs first in the filter chain) wraps the request so + // that request.getServletPath() reflects the effective path *after* the /z/{subdomain} prefix + // is stripped -- e.g. a request whose original URI is /z/myzone/oauth/mtls/token presents + // getServletPath() == "/oauth/mtls/token" to filters running after it, same as a direct + // (non-zone-path) request. A container URL-pattern registration for "/oauth/mtls/*" is matched + // against the *original* request URI before any filter runs, so it would never include this + // filter in the chain for a zone-path request -- checking the effective servlet path instead, + // from inside the filter, works uniformly for both cases. + MockHttpServletRequest direct = new MockHttpServletRequest(); + direct.setServletPath("/oauth/mtls/token"); + assertThat(RawPeerCertificateCaptureFilter.isMtlsTokenPath(direct)).isTrue(); + + MockHttpServletRequest zonePathRewritten = new MockHttpServletRequest(); + zonePathRewritten.setContextPath("/uaa/z/myzone"); + zonePathRewritten.setRequestURI("/uaa/z/myzone/oauth/mtls/token"); + zonePathRewritten.setServletPath("/oauth/mtls/token"); + assertThat(RawPeerCertificateCaptureFilter.isMtlsTokenPath(zonePathRewritten)) + .as("must match the effective (post zone-path-rewrite) servlet path") + .isTrue(); + + MockHttpServletRequest tokenDescendant = new MockHttpServletRequest(); + tokenDescendant.setServletPath("/oauth/mtls/token/alias"); + assertThat(RawPeerCertificateCaptureFilter.isMtlsTokenPath(tokenDescendant)).isTrue(); + + MockHttpServletRequest unrelated = new MockHttpServletRequest(); + unrelated.setServletPath("/oauth/mtls/not-token"); + assertThat(RawPeerCertificateCaptureFilter.isMtlsTokenPath(unrelated)).isFalse(); + } + + @Test + void doesNotCaptureAnAttributeForUnrelatedPaths() throws Exception { + RawPeerCertificateCaptureFilter filter = new RawPeerCertificateCaptureFilter(); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setServletPath("/oauth/mtls/not-token"); + request.setAttribute("jakarta.servlet.request.X509Certificate", + new X509Certificate[]{generateSelfSignedCert("CN=some-peer")}); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, (req, res) -> { }); + + assertThat(request.getAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE)).isNull(); + } + + @Test + void capturedAttributeSurvivesClientCertificateMapperOverwritingTheStandardAttribute() throws Exception { + // Behavioural proof, not just an order-integer comparison: simulates a real TLS handshake + // having already populated the standard jakarta.servlet.request.X509Certificate attribute + // with the genuine peer certificate (as Tomcat would when uaa.mtls-enabled configures + // certificateVerification=optionalNoCA), then runs the real two-filter chain -- this filter + // followed by the real ClientCertificateMapper -- with an X-Forwarded-Client-Cert header + // present (a *different* certificate than the genuine peer one). ClientCertificateMapper is + // expected to overwrite the standard attribute with the XFCC-derived certificate, while + // RAW_PEER_CERTIFICATE_ATTRIBUTE must retain the original, genuine peer certificate. + SpringServletXmlFiltersConfiguration config = new SpringServletXmlFiltersConfiguration(); + FilterRegistrationBean captureBean = config.rawPeerCertificateCaptureFilter(); + FilterRegistrationBean mapperBean = config.clientCertificateMapperFilter(); + + X509Certificate genuinePeerCert = generateSelfSignedCert("CN=gorouter"); + X509Certificate xfccDerivedCert = generateSelfSignedCert("CN=app-instance"); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setServletPath("/oauth/mtls/token"); + // Simulates what Tomcat's TLS handshake would have already set before any filter runs. + request.setAttribute("jakarta.servlet.request.X509Certificate", new X509Certificate[]{genuinePeerCert}); + request.addHeader("X-Forwarded-Client-Cert", + Base64.getEncoder().encodeToString(xfccDerivedCert.getEncoded())); + MockHttpServletResponse response = new MockHttpServletResponse(); + + runContainerFilterChain(List.of(captureBean, mapperBean), request, response); + + X509Certificate[] capturedRawPeerCert = + (X509Certificate[]) request.getAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE); + X509Certificate[] finalStandardAttribute = + (X509Certificate[]) request.getAttribute("jakarta.servlet.request.X509Certificate"); + + assertThat(capturedRawPeerCert) + .as("RAW_PEER_CERTIFICATE_ATTRIBUTE must retain the genuine TLS peer cert, unaffected by ClientCertificateMapper") + .containsExactly(genuinePeerCert); + assertThat(finalStandardAttribute) + .as("ClientCertificateMapper should still overwrite the standard attribute with the XFCC-derived cert") + .containsExactly(xfccDerivedCert); + } + + @Test + void standardAttributeAndRawAttributeBothHoldTheGenuinePeerCertForADirectConnection() throws Exception { + // Regression test for a PR review concern: TlsClientAuthentication.hasCertificateFromRequest() + // (used as a cheap early exit by ClientDetailsAuthenticationProvider and MtlsClaimsEnhancer + // before calling getCertificateChainFromRequest(config)) only inspects the standard + // jakarta.servlet.request.X509Certificate attribute, never RAW_PEER_CERTIFICATE_ATTRIBUTE. + // The concern: could a direct-only client (no tls-client-auth-trusted-proxy-ca configured) + // hit a case where the standard attribute is empty/null while the genuine raw peer cert is + // still present, causing hasCertificateFromRequest() to incorrectly short-circuit before the + // (correct) direct-connection logic in getCertificateChainFromRequest ever runs? + // + // Per the decompiled ClientCertificateMapper bytecode: when the X-Forwarded-Client-Cert + // header is absent, blank, or fails to parse, the filter's certificate list is empty and it + // never calls setAttribute at all -- it neither clears nor nulls the standard attribute, it + // simply leaves whatever was already there (i.e. Tomcat's TLS-handshake-populated value, the + // same value RawPeerCertificateCaptureFilter had already copied to + // RAW_PEER_CERTIFICATE_ATTRIBUTE moments earlier). So for a direct connection with no XFCC + // header, both attributes end up holding the same genuine peer cert: hasCertificateFromRequest() + // (checking only the standard attribute) correctly returns true, and there is no gap. + SpringServletXmlFiltersConfiguration config = new SpringServletXmlFiltersConfiguration(); + FilterRegistrationBean captureBean = config.rawPeerCertificateCaptureFilter(); + FilterRegistrationBean mapperBean = config.clientCertificateMapperFilter(); + + X509Certificate genuinePeerCert = generateSelfSignedCert("CN=app-instance"); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setServletPath("/oauth/mtls/token"); + // Simulates what Tomcat's TLS handshake would have already set before any filter runs. + request.setAttribute("jakarta.servlet.request.X509Certificate", new X509Certificate[]{genuinePeerCert}); + // No X-Forwarded-Client-Cert header at all -- a direct connection. + MockHttpServletResponse response = new MockHttpServletResponse(); + + runContainerFilterChain(List.of(captureBean, mapperBean), request, response); + + X509Certificate[] capturedRawPeerCert = + (X509Certificate[]) request.getAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE); + X509Certificate[] finalStandardAttribute = + (X509Certificate[]) request.getAttribute("jakarta.servlet.request.X509Certificate"); + + assertThat(capturedRawPeerCert) + .as("RAW_PEER_CERTIFICATE_ATTRIBUTE must hold the genuine TLS peer cert") + .containsExactly(genuinePeerCert); + assertThat(finalStandardAttribute) + .as("ClientCertificateMapper must leave the standard attribute untouched when no XFCC " + + "header is present, so hasCertificateFromRequest() sees the genuine peer cert too") + .containsExactly(genuinePeerCert); + } + + @Test + void capturesTheGenuinePeerCertificateForAZonePathMtlsRequest() throws Exception { + // Regression test for PR review comment on SpringServletXmlFiltersConfiguration.java:262: + // simulates a request that arrived as /z/myzone/oauth/mtls/token and was already rewritten by + // ZonePathContextRewritingFilter (which runs first) before reaching this filter -- same + // servlet path as a direct request, but a zone-prefixed context path/request URI. Both + // filters must still run: a container URL-pattern registration for "/oauth/mtls/*" would not + // have included them in the chain at all for the original /z/myzone/... request URI. + SpringServletXmlFiltersConfiguration config = new SpringServletXmlFiltersConfiguration(); + FilterRegistrationBean captureBean = config.rawPeerCertificateCaptureFilter(); + FilterRegistrationBean mapperBean = config.clientCertificateMapperFilter(); + + X509Certificate genuinePeerCert = generateSelfSignedCert("CN=gorouter"); + X509Certificate xfccDerivedCert = generateSelfSignedCert("CN=app-instance"); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setContextPath("/uaa/z/myzone"); + request.setRequestURI("/uaa/z/myzone/oauth/mtls/token"); + request.setServletPath("/oauth/mtls/token"); + request.setAttribute("jakarta.servlet.request.X509Certificate", new X509Certificate[]{genuinePeerCert}); + request.addHeader("X-Forwarded-Client-Cert", + Base64.getEncoder().encodeToString(xfccDerivedCert.getEncoded())); + MockHttpServletResponse response = new MockHttpServletResponse(); + + runContainerFilterChain(List.of(captureBean, mapperBean), request, response); + + assertThat((X509Certificate[]) request.getAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE)) + .as("must still capture the genuine peer cert for a zone-path mtls request") + .containsExactly(genuinePeerCert); + assertThat((X509Certificate[]) request.getAttribute("jakarta.servlet.request.X509Certificate")) + .as("ClientCertificateMapper must still run for a zone-path mtls request") + .containsExactly(xfccDerivedCert); + } + + /** + * Simulates servlet-container filter dispatch: registered filters run in ascending + * {@link FilterRegistrationBean#getOrder()} value, each delegating to the next via a standard + * {@link FilterChain}. + */ + private static void runContainerFilterChain( + List> registrations, + MockHttpServletRequest request, + MockHttpServletResponse response) throws Exception { + List> sorted = new ArrayList<>(registrations); + sorted.sort(Comparator.comparingInt(FilterRegistrationBean::getOrder)); + + FilterChain chain = (req, res) -> { }; + for (int i = sorted.size() - 1; i >= 0; i--) { + Filter filter = sorted.get(i).getFilter(); + FilterChain next = chain; + chain = (req, res) -> filter.doFilter(req, res, next); + } + chain.doFilter(request, response); + } + + private static X509Certificate generateSelfSignedCert(String subjectDn) throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA", BouncyCastleFipsProvider.PROVIDER_NAME); + kpg.initialize(2048); + KeyPair kp = kpg.generateKeyPair(); + X500Name name = new X500Name(subjectDn); + Date notBefore = new Date(System.currentTimeMillis() - 60_000); + Date notAfter = new Date(System.currentTimeMillis() + 3_600_000); + JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( + name, BigInteger.ONE, notBefore, notAfter, name, kp.getPublic()); + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false)); + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA") + .setProvider(BouncyCastleFipsProvider.PROVIDER_NAME) + .build(kp.getPrivate()); + X509CertificateHolder holder = builder.build(signer); + return new JcaX509CertificateConverter() + .setProvider(BouncyCastleFipsProvider.PROVIDER_NAME) + .getCertificate(holder); + } +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilterTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilterTest.java new file mode 100644 index 00000000000..bddc596296d --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/RawPeerCertificateCaptureFilterTest.java @@ -0,0 +1,45 @@ +package org.cloudfoundry.identity.uaa.oauth.tls; + +import jakarta.servlet.FilterChain; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.security.cert.X509Certificate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class RawPeerCertificateCaptureFilterTest { + + @Test + void copiesGenuinePeerCertificateIntoDedicatedAttributeBeforeChainContinues() throws Exception { + X509Certificate[] genuinePeerCert = new X509Certificate[]{mock(X509Certificate.class)}; + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setServletPath("/oauth/mtls/token"); + request.setAttribute("jakarta.servlet.request.X509Certificate", genuinePeerCert); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + + new RawPeerCertificateCaptureFilter().doFilter(request, response, chain); + + assertThat(request.getAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE)) + .isEqualTo(genuinePeerCert); + verify(chain).doFilter(request, response); + } + + @Test + void setsNullAttributeWhenNoPeerCertificatePresent() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setServletPath("/oauth/mtls/token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + + new RawPeerCertificateCaptureFilter().doFilter(request, response, chain); + + assertThat(request.getAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE)) + .isNull(); + verify(chain).doFilter(request, response); + } +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java new file mode 100644 index 00000000000..42c932c9480 --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/tls/TlsClientAuthenticationTest.java @@ -0,0 +1,862 @@ +package org.cloudfoundry.identity.uaa.oauth.tls; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.util.io.pem.PemObject; +import org.bouncycastle.util.io.pem.PemWriter; +import org.cloudfoundry.identity.uaa.client.TlsClientAuthConfiguration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.io.StringWriter; +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.Security; +import java.security.cert.X509Certificate; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +class TlsClientAuthenticationTest { + + private TlsClientAuthentication service; + + @BeforeEach + void setUp() { + service = new TlsClientAuthentication(); + Security.addProvider(new BouncyCastleFipsProvider()); + } + + @Test + void nullCertReturnsEmptyOptional() { + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("...", null); + assertThat(service.validateClientCert((X509Certificate) null, config)).isEmpty(); + } + + @Test + void nullConfigReturnsEmptyOptional() { + X509Certificate cert = mock(X509Certificate.class); + assertThat(service.validateClientCert(cert, null)).isEmpty(); + } + + @Test + void extractClaimMappingValuesExtractsSubjectCnOuAndO() throws Exception { + KeyPair kp = generateKeyPair(); + X500Name subject = new X500Name("CN=instance-guid,OU=app:app-guid-123,OU=space:space-guid-456,O=Cloud Foundry"); + X509Certificate cert = signCert(subject, subject, kp.getPublic(), kp.getPrivate(), false, BigInteger.ONE); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", List.of( + new TlsClientAuthConfiguration.ClaimMapping("subject_cn", null, "cf_instance_guid"), + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^app:(.+)$", "app_guid"), + new TlsClientAuthConfiguration.ClaimMapping("subject_o", null, "org_name") + )); + + Map vars = service.extractClaimMappingValues(cert, config); + + assertThat(vars).containsEntry("cf_instance_guid", "instance-guid"); + assertThat(vars).containsEntry("app_guid", "app-guid-123"); + assertThat(vars).containsEntry("org_name", "Cloud Foundry"); + } + + @Test + void extractClaimMappingValuesExtractsEveryOuFromMultiValuedRdn() throws Exception { + KeyPair kp = generateKeyPair(); + X500Name subject = new X500Name("CN=instance-guid,OU=organization:org-guid+OU=space:space-guid+OU=app:app-guid"); + X509Certificate cert = signCert(subject, subject, kp.getPublic(), kp.getPrivate(), false, BigInteger.ONE); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", List.of( + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^app:(.+)$", "app_guid"), + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^space:(.+)$", "space_guid"), + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^organization:(.+)$", "org_guid") + )); + + assertThat(service.extractClaimMappingValues(cert, config)).containsExactlyInAnyOrderEntriesOf(Map.of( + "app_guid", "app-guid", + "space_guid", "space-guid", + "org_guid", "org-guid" + )); + } + + @Test + void extractClaimMappingValuesReturnsEmptyMapWhenNoClaimMappingsConfigured() throws Exception { + KeyPair kp = generateKeyPair(); + X500Name subject = new X500Name("CN=instance-guid"); + X509Certificate cert = signCert(subject, subject, kp.getPublic(), kp.getPrivate(), false, BigInteger.ONE); + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", null); + + assertThat(service.extractClaimMappingValues(cert, config)).isEmpty(); + } + + @Test + void extractClaimMappingValuesReturnsEmptyMapWhenCertOrConfigIsNull() { + assertThat(service.extractClaimMappingValues(null, new TlsClientAuthConfiguration("ca", null))).isEmpty(); + } + + @Test + void certificateSatisfiesRequiredClaimsTrueWhenNoConstraintConfigured() throws Exception { + KeyPair kp = generateKeyPair(); + X500Name subject = new X500Name("CN=instance-guid"); + X509Certificate cert = signCert(subject, subject, kp.getPublic(), kp.getPrivate(), false, BigInteger.ONE); + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", null); + + assertThat(service.certificateSatisfiesRequiredClaims(cert, config)).isTrue(); + } + + @Test + void certificateSatisfiesRequiredClaimsTrueWhenExtractedValueMatchesRequiredValue() throws Exception { + KeyPair kp = generateKeyPair(); + X500Name subject = new X500Name("CN=instance-guid,OU=space:space-guid-456"); + X509Certificate cert = signCert(subject, subject, kp.getPublic(), kp.getPrivate(), false, BigInteger.ONE); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", List.of( + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^space:(.+)$", "space_guid") + )); + config.setRequiredClaims(Map.of("space_guid", "space-guid-456")); + + assertThat(service.certificateSatisfiesRequiredClaims(cert, config)).isTrue(); + } + + @Test + void certificateSatisfiesRequiredClaimsFalseWhenExtractedValueDiffersFromRequiredValue() throws Exception { + KeyPair kp = generateKeyPair(); + X500Name subject = new X500Name("CN=instance-guid,OU=space:some-other-space"); + X509Certificate cert = signCert(subject, subject, kp.getPublic(), kp.getPrivate(), false, BigInteger.ONE); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", List.of( + new TlsClientAuthConfiguration.ClaimMapping("subject_ou", "^space:(.+)$", "space_guid") + )); + config.setRequiredClaims(Map.of("space_guid", "space-guid-456")); + + assertThat(service.certificateSatisfiesRequiredClaims(cert, config)).isFalse(); + } + + @Test + void certificateSatisfiesRequiredClaimsFalseWhenRequiredClaimNeverExtracted() throws Exception { + KeyPair kp = generateKeyPair(); + X500Name subject = new X500Name("CN=instance-guid"); + X509Certificate cert = signCert(subject, subject, kp.getPublic(), kp.getPrivate(), false, BigInteger.ONE); + + // No claim-mappings configured at all -> "space_guid" is never extracted + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", null); + config.setRequiredClaims(Map.of("space_guid", "space-guid-456")); + + assertThat(service.certificateSatisfiesRequiredClaims(cert, config)).isFalse(); + } + + @Test + void invalidCaThrowsInvalidClientDetailsException() { + X509Certificate cert = mock(X509Certificate.class); + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("not-a-cert", null); + assertThatThrownBy(() -> service.validateClientCert(cert, config)) + .hasMessageContaining("tls_client_auth"); + } + + @Test + void validateClientCertSucceedsWhenChainOmitsTrustAnchor() throws Exception { + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Test Root CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + + KeyPair leafKp = generateKeyPair(); + X509Certificate leafCert = signCert( + new X500Name("CN=leaf-instance"), rootName, leafKp.getPublic(), rootKp.getPrivate(), false, BigInteger.TWO); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(toPem(rootCert), null); + + Optional result = service.validateClientCert( + new X509Certificate[]{leafCert}, config); + + assertThat(result).contains(leafCert); + } + + @Test + void validateClientCertRejectsExpiredLeafCertificate() throws Exception { + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Test Root CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + + KeyPair leafKp = generateKeyPair(); + X509Certificate expiredLeafCert = signCertWithValidity( + new X500Name("CN=expired-leaf-instance"), rootName, leafKp.getPublic(), rootKp.getPrivate(), false, + BigInteger.TWO, new Date(System.currentTimeMillis() - 7_200_000), new Date(System.currentTimeMillis() - 3_600_000)); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(toPem(rootCert), null); + + assertThatThrownBy(() -> service.validateClientCert(new X509Certificate[]{expiredLeafCert}, config)) + .hasMessageContaining("validity check failed"); + } + + @Test + void validateClientCertSucceedsWhenChainIncludesTrustAnchor() throws Exception { + // Reproduces the reviewer's concern (PR #3972 discussion on TlsClientAuthentication.java:113): + // some proxies/clients forward the full chain including the trust anchor / root CA itself. + // RFC 5280 section 6.1 excludes trailing self-issued certificates from path validation + // accounting, and the JDK's PKIX CertPathValidator correctly implements this, so no + // stripping of the anchor from the presented chain is required. + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Test Root CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + + KeyPair leafKp = generateKeyPair(); + X509Certificate leafCert = signCert( + new X500Name("CN=leaf-instance"), rootName, leafKp.getPublic(), rootKp.getPrivate(), false, BigInteger.TWO); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(toPem(rootCert), null); + + // Chain includes the root CA cert itself, at the tail — unlike the "omits" test above. + Optional result = service.validateClientCert( + new X509Certificate[]{leafCert, rootCert}, config); + + assertThat(result).contains(leafCert); + } + + @Test + void validateClientCertSucceedsWithIntermediateChainIncludingTrustAnchor() throws Exception { + // Same as above but with an intermediate CA between leaf and root, matching a more + // realistic multi-tier CA hierarchy. + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Test Root CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + + KeyPair interKp = generateKeyPair(); + X500Name interName = new X500Name("CN=Test Intermediate CA"); + X509Certificate interCert = signCert(interName, rootName, interKp.getPublic(), rootKp.getPrivate(), true, BigInteger.TWO); + + KeyPair leafKp = generateKeyPair(); + X509Certificate leafCert = signCert( + new X500Name("CN=leaf-instance"), interName, leafKp.getPublic(), interKp.getPrivate(), false, BigInteger.valueOf(3)); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(toPem(rootCert), null); + + Optional result = service.validateClientCert( + new X509Certificate[]{leafCert, interCert, rootCert}, config); + + assertThat(result).contains(leafCert); + } + + @Test + void validateClientCertRejectsLeafThatIsItselfACaCertificate() throws Exception { + // A CA cert (BasicConstraints CA=true) presented as the "leaf" chain[0] genuinely PKIX- + // validates -- a 1-cert chain from the intermediate to the root trust anchor is + // structurally valid -- but must be rejected because the presented end-entity is itself + // a CA certificate, not a genuine client credential. + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Test Root CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + + KeyPair interKp = generateKeyPair(); + X500Name interName = new X500Name("CN=Test Intermediate CA"); + X509Certificate intermediateCaCert = signCert( + interName, rootName, interKp.getPublic(), rootKp.getPrivate(), true, BigInteger.TWO); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(toPem(rootCert), null); + + assertThatThrownBy(() -> service.validateClientCert(new X509Certificate[]{intermediateCaCert}, config)) + .hasMessageContaining("is itself a CA certificate"); + } + + @Test + void validateClientCertRejectsLeafWithExtendedKeyUsageExcludingClientAuth() throws Exception { + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Test Root CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + + KeyPair leafKp = generateKeyPair(); + X509Certificate leafCert = signCert( + new X500Name("CN=leaf-instance"), rootName, leafKp.getPublic(), rootKp.getPrivate(), false, BigInteger.TWO, + null, List.of(KeyPurposeId.id_kp_serverAuth)); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(toPem(rootCert), null); + + assertThatThrownBy(() -> service.validateClientCert(new X509Certificate[]{leafCert}, config)) + .hasMessageContaining("does not include clientAuth or anyExtendedKeyUsage"); + } + + @Test + void validateClientCertAcceptsLeafWithExtendedKeyUsageIncludingClientAuthAndServerAuth() throws Exception { + // Locks in "contains, not equals" semantics: an EKU listing clientAuth alongside an + // unrelated purpose must still be accepted. + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Test Root CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + + KeyPair leafKp = generateKeyPair(); + X509Certificate leafCert = signCert( + new X500Name("CN=leaf-instance"), rootName, leafKp.getPublic(), rootKp.getPrivate(), false, BigInteger.TWO, + null, List.of(KeyPurposeId.id_kp_clientAuth, KeyPurposeId.id_kp_serverAuth)); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(toPem(rootCert), null); + + Optional result = service.validateClientCert(new X509Certificate[]{leafCert}, config); + + assertThat(result).contains(leafCert); + } + + @Test + void validateClientCertAcceptsLeafWithExtendedKeyUsageIncludingClientAuth() throws Exception { + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Test Root CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + + KeyPair leafKp = generateKeyPair(); + X509Certificate leafCert = signCert( + new X500Name("CN=leaf-instance"), rootName, leafKp.getPublic(), rootKp.getPrivate(), false, BigInteger.TWO, + null, List.of(KeyPurposeId.id_kp_clientAuth)); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(toPem(rootCert), null); + + Optional result = service.validateClientCert(new X509Certificate[]{leafCert}, config); + + assertThat(result).contains(leafCert); + } + + @Test + void validateClientCertAcceptsLeafWithNoKeyUsageOrExtendedKeyUsageExtensions() throws Exception { + // Backward compatibility: certs that set neither extension at all (e.g. Diego's + // instance-identity CA, whose exact extension profile is not assumed here) must still be + // accepted -- an absent extension imposes no restriction per RFC 5280. + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Test Root CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + + KeyPair leafKp = generateKeyPair(); + X509Certificate leafCert = signCert( + new X500Name("CN=leaf-instance"), rootName, leafKp.getPublic(), rootKp.getPrivate(), false, BigInteger.TWO); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(toPem(rootCert), null); + + Optional result = service.validateClientCert(new X509Certificate[]{leafCert}, config); + + assertThat(result).contains(leafCert); + } + + @Test + void validateClientCertRejectsLeafWithKeyUsageExcludingDigitalSignature() throws Exception { + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Test Root CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + + KeyPair leafKp = generateKeyPair(); + // Key Usage present but only keyEncipherment -- digitalSignature explicitly excluded. + X509Certificate leafCert = signCert( + new X500Name("CN=leaf-instance"), rootName, leafKp.getPublic(), rootKp.getPrivate(), false, BigInteger.TWO, + KeyUsage.keyEncipherment, null); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(toPem(rootCert), null); + + assertThatThrownBy(() -> service.validateClientCert(new X509Certificate[]{leafCert}, config)) + .hasMessageContaining("does not permit digitalSignature"); + } + + @Test + void isCertificateFromTrustedProxyTrueWhenPeerCertSignedByClientsTrustedProxyCa() throws Exception { + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Trusted Proxy CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + + KeyPair peerKp = generateKeyPair(); + X509Certificate peerCert = signCert( + new X500Name("CN=gorouter.service.cf.internal"), rootName, peerKp.getPublic(), rootKp.getPrivate(), false, BigInteger.TWO); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", null); + config.setTrustedProxyCaPem(toPem(rootCert)); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE, + new X509Certificate[]{peerCert}); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.isCertificateFromTrustedProxy(config)).isTrue(); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void isCertificateFromTrustedProxyFalseWhenPeerCertNotSignedByClientsTrustedProxyCa() throws Exception { + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Trusted Proxy CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + + // An unrelated, self-signed certificate -- e.g. a harvested cert an attacker presents directly. + KeyPair attackerKp = generateKeyPair(); + X500Name attackerName = new X500Name("CN=attacker"); + X509Certificate attackerCert = signCert(attackerName, attackerName, attackerKp.getPublic(), attackerKp.getPrivate(), false, BigInteger.ONE); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", null); + config.setTrustedProxyCaPem(toPem(rootCert)); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE, + new X509Certificate[]{attackerCert}); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.isCertificateFromTrustedProxy(config)).isFalse(); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void isCertificateFromTrustedProxyFalseWhenNoPeerCertificatePresent() { + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", null); + config.setTrustedProxyCaPem("some-ca-pem"); + + MockHttpServletRequest request = new MockHttpServletRequest(); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.isCertificateFromTrustedProxy(config)).isFalse(); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void isCertificateFromTrustedProxyFalseWhenClientHasNoTrustedProxyCaConfigured() throws Exception { + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Some CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + KeyPair peerKp = generateKeyPair(); + X509Certificate peerCert = signCert( + new X500Name("CN=gorouter"), rootName, peerKp.getPublic(), rootKp.getPrivate(), false, BigInteger.TWO); + + // Client has tls-client-auth-ca configured but no tls-client-auth-trusted-proxy-ca. + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", null); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE, + new X509Certificate[]{peerCert}); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.isCertificateFromTrustedProxy(config)).isFalse(); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void isCertificateFromTrustedProxyFalseWhenPeerCertIsItselfACaCertificate() throws Exception { + // Mirrors validateClientCertRejectsLeafThatIsItselfACaCertificate, but for the trusted- + // proxy leaf: PKIX path validation alone would succeed (a 1-cert chain from the + // intermediate to the root trust anchor is structurally valid), but the presented + // end-entity is itself a CA certificate, not a genuine proxy TLS credential. + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Trusted Proxy CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + + KeyPair interKp = generateKeyPair(); + X500Name interName = new X500Name("CN=Trusted Proxy Intermediate CA"); + X509Certificate intermediateCaCert = signCert( + interName, rootName, interKp.getPublic(), rootKp.getPrivate(), true, BigInteger.TWO); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", null); + config.setTrustedProxyCaPem(toPem(rootCert)); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE, + new X509Certificate[]{intermediateCaCert}); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.isCertificateFromTrustedProxy(config)).isFalse(); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void isCertificateFromTrustedProxyFalseWhenPeerCertExtendedKeyUsageExcludesClientAuth() throws Exception { + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Trusted Proxy CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + + KeyPair peerKp = generateKeyPair(); + X509Certificate peerCert = signCert( + new X500Name("CN=gorouter.service.cf.internal"), rootName, peerKp.getPublic(), rootKp.getPrivate(), + false, BigInteger.TWO, null, List.of(KeyPurposeId.id_kp_serverAuth)); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", null); + config.setTrustedProxyCaPem(toPem(rootCert)); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE, + new X509Certificate[]{peerCert}); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.isCertificateFromTrustedProxy(config)).isFalse(); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void isCertificateFromTrustedProxyFalseWhenPeerCertKeyUsageExcludesDigitalSignature() throws Exception { + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Trusted Proxy CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + + KeyPair peerKp = generateKeyPair(); + // Key Usage present but only keyEncipherment -- digitalSignature explicitly excluded. + X509Certificate peerCert = signCert( + new X500Name("CN=gorouter.service.cf.internal"), rootName, peerKp.getPublic(), rootKp.getPrivate(), + false, BigInteger.TWO, KeyUsage.keyEncipherment, null); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", null); + config.setTrustedProxyCaPem(toPem(rootCert)); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE, + new X509Certificate[]{peerCert}); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.isCertificateFromTrustedProxy(config)).isFalse(); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void isCertificateFromTrustedProxyTrueWhenPeerCertHasClientAuthExtendedKeyUsage() throws Exception { + // Positive control: a genuinely valid, EKU-restricted-to-clientAuth proxy leaf must + // still be accepted -- no regression to the happy path from adding end-entity checks. + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Trusted Proxy CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + + KeyPair peerKp = generateKeyPair(); + X509Certificate peerCert = signCert( + new X500Name("CN=gorouter.service.cf.internal"), rootName, peerKp.getPublic(), rootKp.getPrivate(), + false, BigInteger.TWO, null, List.of(KeyPurposeId.id_kp_clientAuth)); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", null); + config.setTrustedProxyCaPem(toPem(rootCert)); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE, + new X509Certificate[]{peerCert}); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.isCertificateFromTrustedProxy(config)).isTrue(); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void isCertificateFromTrustedProxyFalseWhenConfigIsNull() { + MockHttpServletRequest request = new MockHttpServletRequest(); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.isCertificateFromTrustedProxy(null)).isFalse(); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void hasCertificateFromRequestTrueWhenXfccDerivedCertPresent() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute("jakarta.servlet.request.X509Certificate", + new X509Certificate[]{mock(X509Certificate.class)}); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.hasCertificateFromRequest()).isTrue(); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void hasCertificateFromRequestFalseWhenNoCertPresent() { + MockHttpServletRequest request = new MockHttpServletRequest(); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.hasCertificateFromRequest()).isFalse(); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void getCertificateChainFromRequestReturnsNullWhenNoTrustedProxyCaConfiguredAndNoPeerCertCaptured() { + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", null); + // no trusted-proxy CA configured for this client -> direct-connection-only, but no raw + // peer certificate was ever captured (e.g. uaa.mtls-enabled=false) + + MockHttpServletRequest request = new MockHttpServletRequest(); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.getCertificateChainFromRequest(config)).isNull(); + assertThat(service.getCertificateFromRequest(config)).isNull(); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void getCertificateChainFromRequestReturnsRawPeerCertForDirectConnectionWhenNoTrustedProxyCaConfigured() throws Exception { + KeyPair clientKp = generateKeyPair(); + X500Name clientCaName = new X500Name("CN=Instance Identity CA"); + X509Certificate clientCaCert = signCert(clientCaName, clientCaName, clientKp.getPublic(), clientKp.getPrivate(), true, BigInteger.ONE); + KeyPair leafKp = generateKeyPair(); + X509Certificate directPeerCert = signCert( + new X500Name("CN=app-instance"), clientCaName, leafKp.getPublic(), clientKp.getPrivate(), false, BigInteger.TWO); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(toPem(clientCaCert), null); + // no trusted-proxy CA configured for this client -> direct-connection-only + + MockHttpServletRequest request = new MockHttpServletRequest(); + // No X-Forwarded-Client-Cert header, no ClientCertificateMapper rewriting: the standard + // attribute would ordinarily equal the raw peer cert here, but this client never reads the + // standard attribute at all. + request.setAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE, + new X509Certificate[]{directPeerCert}); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.getCertificateChainFromRequest(config)).containsExactly(directPeerCert); + assertThat(service.getCertificateFromRequest(config)).isEqualTo(directPeerCert); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void getCertificateChainFromRequestIgnoresXfccWhenNoTrustedProxyCaConfigured() throws Exception { + KeyPair clientKp = generateKeyPair(); + X500Name clientCaName = new X500Name("CN=Instance Identity CA"); + X509Certificate clientCaCert = signCert(clientCaName, clientCaName, clientKp.getPublic(), clientKp.getPrivate(), true, BigInteger.ONE); + KeyPair leafKp = generateKeyPair(); + X509Certificate directPeerCert = signCert( + new X500Name("CN=app-instance"), clientCaName, leafKp.getPublic(), clientKp.getPrivate(), false, BigInteger.TWO); + X509Certificate xfccDerivedCert = mock(X509Certificate.class); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration(toPem(clientCaCert), null); + // no trusted-proxy CA configured for this client -> direct-connection-only + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE, + new X509Certificate[]{directPeerCert}); + // A different certificate somehow ended up in the standard attribute, and an XFCC header + // is present -- e.g. noise from an unrelated proxy somewhere in the network path. Neither + // should matter for a client with no trusted-proxy CA configured. + request.setAttribute("jakarta.servlet.request.X509Certificate", new X509Certificate[]{xfccDerivedCert}); + request.addHeader("X-Forwarded-Client-Cert", "irrelevant-base64-value"); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.getCertificateChainFromRequest(config)).containsExactly(directPeerCert); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void getCertificateChainFromRequestReturnsChainWhenFromClientsTrustedProxy() throws Exception { + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Trusted Proxy CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + KeyPair peerKp = generateKeyPair(); + X509Certificate peerCert = signCert( + new X500Name("CN=gorouter"), rootName, peerKp.getPublic(), rootKp.getPrivate(), false, BigInteger.TWO); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", null); + config.setTrustedProxyCaPem(toPem(rootCert)); + + X509Certificate[] xfccDerivedChain = new X509Certificate[]{mock(X509Certificate.class)}; + MockHttpServletRequest request = new MockHttpServletRequest(); + // The genuine TLS peer cert (captured separately) validates against this client's trusted-proxy CA... + request.setAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE, + new X509Certificate[]{peerCert}); + // ...so the XFCC-header-derived certificate (a completely different value) is trusted too, + // given the header that ClientCertificateMapper would have parsed it from is present. + request.setAttribute("jakarta.servlet.request.X509Certificate", xfccDerivedChain); + request.addHeader("X-Forwarded-Client-Cert", "irrelevant-base64-value"); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.getCertificateChainFromRequest(config)).isEqualTo(xfccDerivedChain); + assertThat(service.getCertificateFromRequest(config)).isEqualTo(xfccDerivedChain[0]); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void getCertificateChainFromRequestReturnsNullWhenTrustedProxyCaConfiguredButXfccHeaderAbsent() throws Exception { + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Trusted Proxy CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + // A direct peer whose own certificate happens to validate against the SAME CA configured + // as tls-client-auth-trusted-proxy-ca -- e.g. an operator who set them equal, or a + // coincidence. This must NOT be enough on its own: the client is proxy-only. + KeyPair peerKp = generateKeyPair(); + X509Certificate directPeerCert = signCert( + new X500Name("CN=direct-caller"), rootName, peerKp.getPublic(), rootKp.getPrivate(), false, BigInteger.TWO); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", null); + config.setTrustedProxyCaPem(toPem(rootCert)); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE, + new X509Certificate[]{directPeerCert}); + // No X-Forwarded-Client-Cert header at all -- ClientCertificateMapper never touched the + // standard attribute, so (if read) it would equal the raw peer cert above. But this client + // is proxy-only and must reject a request with no XFCC header, regardless. + request.setAttribute("jakarta.servlet.request.X509Certificate", new X509Certificate[]{directPeerCert}); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.getCertificateChainFromRequest(config)).isNull(); + assertThat(service.getCertificateFromRequest(config)).isNull(); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void getCertificateChainFromRequestReturnsNullWhenTrustedProxyCaConfiguredButXfccHeaderBlank() throws Exception { + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Trusted Proxy CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + KeyPair peerKp = generateKeyPair(); + X509Certificate peerCert = signCert( + new X500Name("CN=gorouter"), rootName, peerKp.getPublic(), rootKp.getPrivate(), false, BigInteger.TWO); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", null); + config.setTrustedProxyCaPem(toPem(rootCert)); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE, + new X509Certificate[]{peerCert}); + // X-Forwarded-Client-Cert header is present but blank -- e.g. a proxy or intermediate + // that clears the header without removing it. Per the design doc, this must be treated + // the same as the header being entirely absent: reject the request, even though the + // genuine peer certificate would otherwise validate against tls-client-auth-trusted-proxy-ca. + request.addHeader("X-Forwarded-Client-Cert", ""); + request.setAttribute("jakarta.servlet.request.X509Certificate", new X509Certificate[]{peerCert}); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.getCertificateChainFromRequest(config)).isNull(); + assertThat(service.getCertificateFromRequest(config)).isNull(); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + @Test + void getCertificateChainFromRequestReturnsNullWhenClientCertificateMapperSilentlyFailedToParseXfcc() throws Exception { + // Reproduces the reviewer's concern (PR #3972 discussion on TlsClientAuthentication.java:138): + // a trusted proxy (e.g. the Gorouter) sends a well-formed mTLS connection whose own + // certificate validates against tls-client-auth-trusted-proxy-ca, and a nonblank (but + // malformed) X-Forwarded-Client-Cert header. ClientCertificateMapper fails to parse the + // header and -- per its decompiled behavior -- never calls setAttribute, leaving the + // standard jakarta.servlet.request.X509Certificate attribute equal to the raw peer + // certificate captured by RawPeerCertificateCaptureFilter. This must be rejected, even + // though the XFCC header is present and the peer validates as a trusted proxy: reading + // the standard attribute here would wrongly authenticate the proxy's own certificate as + // the OAuth client. + KeyPair rootKp = generateKeyPair(); + X500Name rootName = new X500Name("CN=Trusted Proxy CA"); + X509Certificate rootCert = signCert(rootName, rootName, rootKp.getPublic(), rootKp.getPrivate(), true, BigInteger.ONE); + KeyPair peerKp = generateKeyPair(); + X509Certificate peerCert = signCert( + new X500Name("CN=gorouter"), rootName, peerKp.getPublic(), rootKp.getPrivate(), false, BigInteger.TWO); + + TlsClientAuthConfiguration config = new TlsClientAuthConfiguration("client-ca-pem", null); + config.setTrustedProxyCaPem(toPem(rootCert)); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(RawPeerCertificateCaptureFilter.RAW_PEER_CERTIFICATE_ATTRIBUTE, + new X509Certificate[]{peerCert}); + // ClientCertificateMapper did NOT replace the standard attribute -- it still holds the + // same certificate as the raw peer capture, because parsing the (malformed) XFCC header + // failed and the mapper filter never called setAttribute. + request.setAttribute("jakarta.servlet.request.X509Certificate", new X509Certificate[]{peerCert}); + request.addHeader("X-Forwarded-Client-Cert", "malformed-or-corrupted-base64-value"); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + try { + assertThat(service.getCertificateChainFromRequest(config)).isNull(); + assertThat(service.getCertificateFromRequest(config)).isNull(); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + } + + private static KeyPair generateKeyPair() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA", BouncyCastleFipsProvider.PROVIDER_NAME); + kpg.initialize(2048); + return kpg.generateKeyPair(); + } + + private static X509Certificate signCert(X500Name subject, X500Name issuer, PublicKey subjectKey, + PrivateKey signerKey, boolean isCa, BigInteger serial) throws Exception { + return signCert(subject, issuer, subjectKey, signerKey, isCa, serial, null, null); + } + + /** + * Overload allowing tests to optionally set a Key Usage extension ({@code keyUsageBits}, a + * bitmask built from {@link KeyUsage} constants, or {@code null} to omit the extension + * entirely) and/or an Extended Key Usage extension ({@code ekuPurposes}, or {@code null}/empty + * to omit it entirely). Existing call sites using the 6-arg overload above are unaffected, + * matching production certificates that typically don't set these extensions. + */ + private static X509Certificate signCert(X500Name subject, X500Name issuer, PublicKey subjectKey, + PrivateKey signerKey, boolean isCa, BigInteger serial, + Integer keyUsageBits, List ekuPurposes) throws Exception { + Date notBefore = new Date(System.currentTimeMillis() - 60_000); + Date notAfter = new Date(System.currentTimeMillis() + 3_600_000); + return signCertWithValidity(subject, issuer, subjectKey, signerKey, isCa, serial, notBefore, notAfter, keyUsageBits, ekuPurposes); + } + + private static X509Certificate signCertWithValidity(X500Name subject, X500Name issuer, PublicKey subjectKey, + PrivateKey signerKey, boolean isCa, BigInteger serial, Date notBefore, Date notAfter) throws Exception { + return signCertWithValidity(subject, issuer, subjectKey, signerKey, isCa, serial, notBefore, notAfter, null, null); + } + + private static X509Certificate signCertWithValidity(X500Name subject, X500Name issuer, PublicKey subjectKey, + PrivateKey signerKey, boolean isCa, BigInteger serial, Date notBefore, Date notAfter, + Integer keyUsageBits, List ekuPurposes) throws Exception { + JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( + issuer, serial, notBefore, notAfter, subject, subjectKey); + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(isCa)); + if (keyUsageBits != null) { + builder.addExtension(Extension.keyUsage, true, new KeyUsage(keyUsageBits)); + } + if (ekuPurposes != null && !ekuPurposes.isEmpty()) { + builder.addExtension(Extension.extendedKeyUsage, false, + new ExtendedKeyUsage(ekuPurposes.toArray(new KeyPurposeId[0]))); + } + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA") + .setProvider(BouncyCastleFipsProvider.PROVIDER_NAME) + .build(signerKey); + X509CertificateHolder holder = builder.build(signer); + return new JcaX509CertificateConverter() + .setProvider(BouncyCastleFipsProvider.PROVIDER_NAME) + .getCertificate(holder); + } + + private static String toPem(X509Certificate cert) throws Exception { + StringWriter sw = new StringWriter(); + try (PemWriter pemWriter = new PemWriter(sw)) { + pemWriter.writeObject(new PemObject("CERTIFICATE", cert.getEncoded())); + } + return sw.toString(); + } +} 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 90101779814..e6e83c8f27b 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 @@ -10,9 +10,13 @@ import org.springframework.http.ResponseEntity; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; import jakarta.servlet.http.HttpServletRequest; import java.security.Principal; +import java.util.Map; import java.util.Set; import static java.util.Collections.emptyMap; @@ -68,6 +72,20 @@ void setAllowedRequestMethods() { .containsExactlyInAnyOrder(POST, GET); } + @Test + void mapsMtlsTokenEndpointAndItsDescendants() throws NoSuchMethodException { + RequestMapping mapping = UaaTokenEndpoint.class.getAnnotation(RequestMapping.class); + + assertThat(mapping.value()) + .contains("/oauth/mtls/token"); + assertThat(UaaTokenEndpoint.class.getDeclaredMethod("doDelegateGet", Principal.class, Map.class) + .getAnnotation(GetMapping.class).value()) + .containsExactly("**"); + assertThat(UaaTokenEndpoint.class.getDeclaredMethod("doDelegatePost", Principal.class, Map.class, + HttpServletRequest.class).getAnnotation(PostMapping.class).value()) + .containsExactly("**"); + } + @Test void callToGetAlwaysThrowsSuperMethod() { endpoint = new UaaTokenEndpoint(null, null, null, null, false); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthAuthenticationManagerTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthAuthenticationManagerTest.java index 709008a5580..1a526b582b9 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthAuthenticationManagerTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthAuthenticationManagerTest.java @@ -16,6 +16,7 @@ import org.cloudfoundry.identity.uaa.authentication.UaaPrincipal; import org.cloudfoundry.identity.uaa.cache.StaleUrlCache; import org.cloudfoundry.identity.uaa.client.UaaClient; +import org.cloudfoundry.identity.uaa.constants.ClientAuthentication; import org.cloudfoundry.identity.uaa.constants.OriginKeys; import org.cloudfoundry.identity.uaa.login.Prompt; import org.cloudfoundry.identity.uaa.oauth.KeyInfo; @@ -109,6 +110,7 @@ import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; class ExternalOAuthAuthenticationManagerTest { @@ -852,6 +854,38 @@ void oidcPasswordGrant_requireAuthenticationStatement() { .hasMessage("External OpenID Connect provider configuration is missing relyingPartySecret, jwtClientAuthentication or authMethod."); } + @Test + void oauthTokenRequestRejectsStaleTlsClientAuthMethodBeforeSendingRequest() throws Exception { + oidcConfig.setAuthMethod(ClientAuthentication.TLS_CLIENT_AUTH); + oidcConfig.setTokenUrl(URI.create("https://idp.example.com/oauth/token").toURL()); + RestTemplate restTemplate = mock(RestTemplate.class); + authManager = new ExternalOAuthAuthenticationManager(identityProviderProvisioning, new IdentityZoneManagerImpl(), restTemplate, restTemplate, + tokenEndpointBuilder, new KeyInfoService(UAA_ISSUER_BASE_URL), oidcMetadataFetcher, false); + + assertThatThrownBy(() -> authManager.oauthTokenRequest( + null, provider, GRANT_TYPE_PASSWORD, new LinkedMaskingMultiValueMap<>())) + .isInstanceOf(ProviderConfigurationException.class) + .hasMessage("External OpenID Connect provider configuration does not support tls_client_auth."); + verifyNoInteractions(restTemplate); + } + + @Test + void authorizationCodeExchangeRejectsStaleTlsClientAuthMethodBeforeSendingRequest() throws Exception { + oidcConfig.setAuthMethod(ClientAuthentication.TLS_CLIENT_AUTH); + oidcConfig.setTokenUrl(URI.create("https://idp.example.com/oauth/token").toURL()); + RestTemplate restTemplate = mock(RestTemplate.class); + authManager = new ExternalOAuthAuthenticationManager(identityProviderProvisioning, new IdentityZoneManagerImpl(), restTemplate, restTemplate, + tokenEndpointBuilder, new KeyInfoService(UAA_ISSUER_BASE_URL), oidcMetadataFetcher, false); + + ExternalOAuthCodeToken codeToken = new ExternalOAuthCodeToken( + "authorization-code", ORIGIN, "https://uaa.example.com/callback", null, null, null); + + assertThatThrownBy(() -> authManager.authenticate(codeToken)) + .isInstanceOf(ProviderConfigurationException.class) + .hasMessage("External OpenID Connect provider configuration does not support tls_client_auth."); + verifyNoInteractions(restTemplate); + } + @Test void oidcPasswordGrantProviderJwtClientCredentials() throws Exception { // Given diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthIdentityProviderConfigValidatorTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthIdentityProviderConfigValidatorTest.java index 1e67be21cd0..99c1b38b517 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthIdentityProviderConfigValidatorTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthIdentityProviderConfigValidatorTest.java @@ -111,6 +111,16 @@ void configWithInvalidAuthMethod_ThrowsException() { validator.validate(definition)).asInstanceOf(InstanceOfAssertFactories.throwable(IllegalArgumentException.class)); } + @Test + void configWithTlsClientAuthMethod_ThrowsException() { + definition.setAuthMethod(ClientAuthentication.TLS_CLIENT_AUTH); + + assertThatThrownBy(() -> validator.validate(definition)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Relying Party Authentication Method") + .hasMessageNotContaining(ClientAuthentication.TLS_CLIENT_AUTH); + } + @Test void configWithShowLinkTextTrue_mustHaveLinkText() { definition.setShowLinkText(true); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/oauth/OauthIdentityProviderDefinitionFactoryBeanTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/oauth/OauthIdentityProviderDefinitionFactoryBeanTest.java index 21c2a0b17ab..b96a286bbf6 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/oauth/OauthIdentityProviderDefinitionFactoryBeanTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/oauth/OauthIdentityProviderDefinitionFactoryBeanTest.java @@ -328,6 +328,15 @@ void authMethodSetInvalidValue() { assertThatThrownBy(() -> factoryBean.setCommonProperties(idpDefinitionMap, providerDefinition)).asInstanceOf(InstanceOfAssertFactories.throwable(IllegalArgumentException.class)); } + @Test + void authMethodSetToTlsClientAuthIsRejected() { + idpDefinitionMap.put("authMethod", ClientAuthentication.TLS_CLIENT_AUTH); + + assertThatThrownBy(() -> factoryBean.setCommonProperties(idpDefinitionMap, providerDefinition)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid IdP authentication method"); + } + @Test void authMethodSet() { // given: jwtclientAuthentication, but overrule it with authMethod=none diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSESSLContextTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSESSLContextTest.java new file mode 100644 index 00000000000..00188139883 --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSESSLContextTest.java @@ -0,0 +1,42 @@ +package org.cloudfoundry.identity.uaa.web.tomcat; + +import java.security.NoSuchAlgorithmException; +import java.security.Security; + +import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BCJSSESSLContextTest { + + @BeforeEach + void setUp() { + MtlsClientAuthTomcatCustomizer.ensureJsseProviderRegistered(); + } + + @AfterEach + void tearDown() { + MtlsClientAuthTomcatCustomizer.ensureJsseProviderRegistered(); + } + + @Test + void supportsTls12AndTls13FromTheFipsBouncyCastleJsseProvider() throws Exception { + BCJSSESSLContext context = new BCJSSESSLContext("TLS"); + context.init(null, null, null); + + assertThat(context.getSupportedSSLParameters().getProtocols()).contains("TLSv1.3", "TLSv1.2"); + } + + @Test + void throwsWhenTheBcjsseProviderIsNotRegistered() { + Security.removeProvider(BouncyCastleJsseProvider.PROVIDER_NAME); + + assertThatThrownBy(() -> new BCJSSESSLContext("TLS")) + .isInstanceOf(NoSuchAlgorithmException.class) + .hasMessageContaining("ensureJsseProviderRegistered"); + } +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSEUtilTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSEUtilTest.java new file mode 100644 index 00000000000..0d65ea40d0e --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/BCJSSEUtilTest.java @@ -0,0 +1,28 @@ +package org.cloudfoundry.identity.uaa.web.tomcat; + +import org.apache.tomcat.util.net.SSLHostConfig; +import org.apache.tomcat.util.net.SSLHostConfigCertificate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class BCJSSEUtilTest { + + @BeforeEach + void setUp() { + MtlsClientAuthTomcatCustomizer.ensureJsseProviderRegistered(); + } + + @Test + void enabledProtocolsExcludeSslv2HelloAndIncludeTls13() { + SSLHostConfig sslHostConfig = new SSLHostConfig(); + SSLHostConfigCertificate certificate = + new SSLHostConfigCertificate(sslHostConfig, SSLHostConfigCertificate.Type.UNDEFINED); + + BCJSSEUtil util = new BCJSSEUtil(certificate); + + assertThat(util.getEnabledProtocols()).contains("TLSv1.3", "TLSv1.2"); + assertThat(util.getEnabledProtocols()).doesNotContain("SSLv2Hello", "SSLv3"); + } +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerIntegrationTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerIntegrationTest.java new file mode 100644 index 00000000000..3f0f70555a7 --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerIntegrationTest.java @@ -0,0 +1,470 @@ +package org.cloudfoundry.identity.uaa.web.tomcat; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; +import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.boot.tomcat.servlet.TomcatServletWebServerFactory; +import org.springframework.boot.web.server.Ssl; +import org.springframework.boot.web.server.WebServer; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509ExtendedKeyManager; +import javax.net.ssl.X509TrustManager; +import java.io.FileOutputStream; +import java.math.BigInteger; +import java.net.Socket; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.Principal; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.Security; +import java.security.cert.X509Certificate; +import java.util.Date; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Empirically verifies the customizer-ordering assumption underlying {@link MtlsClientAuthTomcatCustomizer}: + * that a {@code TomcatConnectorCustomizer} registered via {@code addConnectorCustomizers} runs + * after Spring Boot's own SSL bundle configuration has populated the connector's + * {@code SSLHostConfig}(s) -- so overriding {@code certificateVerification} there actually takes + * effect against a real embedded Tomcat TLS handshake, not just against a manually constructed + * {@code SSLHostConfig} in isolation (see {@link MtlsClientAuthTomcatCustomizerTest}). + * + *

Uses a real {@link SSLSocket} handshake against a real embedded Tomcat connector. Whether the + * server actually sent a {@code CertificateRequest} is detected from the client side: JSSE only + * invokes the client {@link X509ExtendedKeyManager#chooseClientAlias} callback when the server + * requested a certificate during the handshake. + */ +class MtlsClientAuthTomcatCustomizerIntegrationTest { + + private static final char[] KEYSTORE_PASSWORD = "changeit".toCharArray(); + + @TempDir + Path tempDir; + + private WebServer webServer; + + @BeforeEach + void setUp() { + Security.addProvider(new BouncyCastleFipsProvider()); + } + + @AfterEach + void tearDown() { + if (webServer != null) { + webServer.stop(); + } + } + + @Test + void requestsAndAcceptsAnUntrustedClientCertificateWhenMtlsEnabled() throws Exception { + int port = startServer(true); + AtomicBoolean clientCertRequested = new AtomicBoolean(false); + + try (SSLSocket socket = clientSocketPresentingArbitraryCert(port, clientCertRequested)) { + socket.startHandshake(); + } + + assertThat(clientCertRequested) + .as("server should have requested a client certificate (certificateVerification=optionalNoCA)") + .isTrue(); + } + + /** + * Regression test for a real-deployment finding (Task 14 end-to-end verification): even with + * {@code certificateVerification=optionalNoCA}, Tomcat/JSSE still populates the + * {@code CertificateRequest}'s "certificate_authorities" field from the connector's trust store -- + * which, absent any explicit trust store configuration, falls back to the JVM's default + * {@code cacerts} (a large list of public root CAs). Confirmed empirically against a live + * deployment via {@code openssl s_client -tls1_2}, whose "Acceptable client certificate CA names" + * output listed only unrelated public root CAs (Certainly, Cybertrust, QuoVadis, etc.), never + * {@code service_cf_internal_ca} (the CA that signs the Gorouter's own backend mTLS certificate). + * Go's {@code crypto/tls} client (used by the real Gorouter) correctly implements TLS's client + * certificate selection rules: when none of its available certificates' issuers appear in that + * list, it sends an empty Certificate message rather than presenting a cert the server + * didn't ask for -- confirmed via packet capture showing a zero-length certificate_list. The + * existing {@link #requestsAndAcceptsAnUntrustedClientCertificateWhenMtlsEnabled()} test only + * verifies the client's {@code chooseClientAlias} callback fires (proving a + * {@code CertificateRequest} was sent) -- not that an alias was actually chosen and a certificate + * actually transmitted, so it did not catch this. This test checks the KeyManager's actual return + * value (the alias it chose, or {@code null} if none matched), which is what real TLS clients like + * Go's use to decide whether to present a certificate at all. + */ + @Test + void chosenClientAliasIsNotNullEvenWhenCertIssuerIsNotInDefaultCaCerts() throws Exception { + int port = startServer(true); + AtomicReference chosenAlias = new AtomicReference<>("not-yet-invoked"); + + try (SSLSocket socket = clientSocketTrackingChosenAlias(port, chosenAlias)) { + socket.startHandshake(); + } + + assertThat(chosenAlias) + .as("the KeyManager must actually choose an alias (not null) for a certificate whose " + + "issuer is not in the JVM's default cacerts -- otherwise real TLS clients " + + "(e.g. Go's crypto/tls, used by the Gorouter) will send an empty certificate " + + "message instead of the client cert, silently defeating this entire feature " + + "for any backend TLS connection whose CA isn't a public root CA") + .doesNotHaveValue(null); + } + + @Test + void negotiatesTlsV13AndRequestsClientCertificateWhenMtlsEnabled() throws Exception { + int port = startServer(true); + AtomicBoolean clientCertRequested = new AtomicBoolean(false); + + try (SSLSocket socket = clientSocketOfferingBothTls12And13TrackingCertRequest(port, clientCertRequested)) { + socket.startHandshake(); + + assertThat(socket.getSession().getProtocol()) + .as("BCJSSE supports TLS 1.3 client-auth in-handshake, so the connector re-enables " + + "TLS 1.3 (JSSE could not, because it cannot send a CertificateRequest " + + "under TLS 1.3 -- JDK-8206923)") + .isEqualTo("TLSv1.3"); + } + + assertThat(clientCertRequested) + .as("the connector must send a CertificateRequest under TLS 1.3 (BCJSSE does this " + + "in-handshake), so an optional client certificate is captured") + .isTrue(); + } + + @Test + void negotiatesTlsV12AndRequestsClientCertificateWhenMtlsEnabled() throws Exception { + int port = startServer(true); + AtomicBoolean clientCertRequested = new AtomicBoolean(false); + + try (SSLSocket socket = clientSocketTrackingCertRequestOnTls12(port, clientCertRequested)) { + socket.startHandshake(); + + assertThat(socket.getSession().getProtocol()) + .as("TLS 1.2 client-auth continues to work under BCJSSE") + .isEqualTo("TLSv1.2"); + } + + assertThat(clientCertRequested).isTrue(); + } + + @Test + void connectorServesFromTheFipsBouncyCastleJsseProvider() throws Exception { + startServer(true); + + assertThat(Security.getProvider(BouncyCastleJsseProvider.PROVIDER_NAME)).isNotNull(); + assertThat(((BouncyCastleJsseProvider) Security.getProvider(BouncyCastleJsseProvider.PROVIDER_NAME)).isFipsMode()) + .isTrue(); + } + + @Test + void doesNotRequestAClientCertificateWhenMtlsDisabled() throws Exception { + int port = startServer(false); + AtomicBoolean clientCertRequested = new AtomicBoolean(false); + + try (SSLSocket socket = clientSocketPresentingArbitraryCert(port, clientCertRequested)) { + socket.startHandshake(); + } + + assertThat(clientCertRequested) + .as("server should not request a client certificate (certificateVerification=none, the Tomcat default)") + .isFalse(); + } + + private int startServer(boolean mtlsEnabled) throws Exception { + KeyPair serverKeyPair = generateKeyPair(); + X500Name serverName = new X500Name("CN=localhost"); + X509Certificate serverCert = signCert(serverName, serverName, serverKeyPair.getPublic(), serverKeyPair.getPrivate(), false, BigInteger.ONE); + + Path keystorePath = tempDir.resolve("server.p12"); + KeyStore serverKeyStore = KeyStore.getInstance("PKCS12"); + serverKeyStore.load(null, null); + serverKeyStore.setKeyEntry("server", serverKeyPair.getPrivate(), KEYSTORE_PASSWORD, new X509Certificate[]{serverCert}); + try (FileOutputStream out = new FileOutputStream(keystorePath.toFile())) { + serverKeyStore.store(out, KEYSTORE_PASSWORD); + } + + TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory(0); + Ssl ssl = new Ssl(); + ssl.setEnabled(true); + ssl.setKeyStore(keystorePath.toString()); + ssl.setKeyStorePassword(new String(KEYSTORE_PASSWORD)); + ssl.setKeyAlias("server"); + ssl.setKeyStoreType("PKCS12"); + factory.setSsl(ssl); + + // This is the exact bean-under-test, invoked exactly as Spring would invoke any + // WebServerFactoryCustomizer -- after Spring Boot's own + // SSL auto-configuration would have already called factory.setSsl(...) above. + new MtlsClientAuthTomcatCustomizer(mtlsEnabled).customize(factory); + + webServer = factory.getWebServer(); + webServer.start(); + return webServer.getPort(); + } + + private SSLSocket clientSocketPresentingArbitraryCert(int port, AtomicBoolean clientCertRequested) throws Exception { + KeyPair clientKeyPair = generateKeyPair(); + X500Name clientName = new X500Name("CN=arbitrary-untrusted-client"); + X509Certificate clientCert = signCert(clientName, clientName, clientKeyPair.getPublic(), clientKeyPair.getPrivate(), false, BigInteger.TWO); + + KeyStore clientKeyStore = KeyStore.getInstance("PKCS12"); + clientKeyStore.load(null, null); + clientKeyStore.setKeyEntry("client", clientKeyPair.getPrivate(), KEYSTORE_PASSWORD, new X509Certificate[]{clientCert}); + + KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyManagerFactory.init(clientKeyStore, KEYSTORE_PASSWORD); + + KeyManager[] trackingKeyManagers = trackClientAliasRequests(keyManagerFactory.getKeyManagers(), clientCertRequested); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(trackingKeyManagers, new TrustManager[]{trustAnyServerCertificate()}, null); + + return (SSLSocket) sslContext.getSocketFactory().createSocket("localhost", port); + } + + /** + * A client socket whose only certificate is signed by a throwaway, arbitrary self-signed CA (i.e. + * NOT one of the JVM's default {@code cacerts} public root CAs). Tracks the actual alias the + * KeyManager chooses for {@code chooseClientAlias}/{@code chooseEngineClientAlias} -- {@code null} + * means no matching certificate was found for the server's advertised acceptable-issuer list, so no + * certificate will actually be transmitted (see {@link #chosenClientAliasIsNotNullEvenWhenCertIssuerIsNotInDefaultCaCerts()}). + */ + private SSLSocket clientSocketTrackingChosenAlias(int port, AtomicReference chosenAlias) throws Exception { + KeyPair clientKeyPair = generateKeyPair(); + X500Name clientName = new X500Name("CN=arbitrary-untrusted-client"); + X509Certificate clientCert = signCert(clientName, clientName, clientKeyPair.getPublic(), clientKeyPair.getPrivate(), false, BigInteger.valueOf(4)); + + KeyStore clientKeyStore = KeyStore.getInstance("PKCS12"); + clientKeyStore.load(null, null); + clientKeyStore.setKeyEntry("client", clientKeyPair.getPrivate(), KEYSTORE_PASSWORD, new X509Certificate[]{clientCert}); + + KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyManagerFactory.init(clientKeyStore, KEYSTORE_PASSWORD); + + KeyManager[] trackingKeyManagers = trackChosenClientAlias(keyManagerFactory.getKeyManagers(), chosenAlias); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(trackingKeyManagers, new TrustManager[]{trustAnyServerCertificate()}, null); + + SSLSocket socket = (SSLSocket) sslContext.getSocketFactory().createSocket("localhost", port); + socket.setEnabledProtocols(new String[]{"TLSv1.2"}); + return socket; + } + + /** + * Wraps each {@link X509ExtendedKeyManager} so we can observe the actual alias returned by + * {@code chooseClientAlias}/{@code chooseEngineClientAlias} -- {@code null} means the delegate + * KeyManager found no certificate whose issuer matched the server's advertised acceptable-issuer + * list, so nothing will be sent (unlike {@link #trackClientAliasRequests}, which only tracks + * whether the callback was invoked at all). + */ + private KeyManager[] trackChosenClientAlias(KeyManager[] keyManagers, AtomicReference chosenAlias) { + KeyManager[] wrapped = new KeyManager[keyManagers.length]; + for (int i = 0; i < keyManagers.length; i++) { + if (keyManagers[i] instanceof X509ExtendedKeyManager delegate) { + wrapped[i] = new X509ExtendedKeyManager() { + @Override + public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) { + String alias = delegate.chooseClientAlias(keyType, issuers, socket); + chosenAlias.set(alias); + return alias; + } + + @Override + public String chooseEngineClientAlias(String[] keyType, Principal[] issuers, SSLEngine engine) { + String alias = delegate.chooseEngineClientAlias(keyType, issuers, engine); + chosenAlias.set(alias); + return alias; + } + + @Override + public String[] getClientAliases(String keyType, Principal[] issuers) { + return delegate.getClientAliases(keyType, issuers); + } + + @Override + public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) { + return delegate.chooseServerAlias(keyType, issuers, socket); + } + + @Override + public String[] getServerAliases(String keyType, Principal[] issuers) { + return delegate.getServerAliases(keyType, issuers); + } + + @Override + public X509Certificate[] getCertificateChain(String alias) { + return delegate.getCertificateChain(alias); + } + + @Override + public PrivateKey getPrivateKey(String alias) { + return delegate.getPrivateKey(alias); + } + }; + } else { + wrapped[i] = keyManagers[i]; + } + } + return wrapped; + } + + private SSLSocket clientSocketOfferingBothTls12And13TrackingCertRequest(int port, AtomicBoolean clientCertRequested) throws Exception { + KeyPair clientKeyPair = generateKeyPair(); + X500Name clientName = new X500Name("CN=arbitrary-untrusted-client"); + X509Certificate clientCert = signCert(clientName, clientName, clientKeyPair.getPublic(), clientKeyPair.getPrivate(), false, BigInteger.valueOf(5)); + + KeyStore clientKeyStore = KeyStore.getInstance("PKCS12"); + clientKeyStore.load(null, null); + clientKeyStore.setKeyEntry("client", clientKeyPair.getPrivate(), KEYSTORE_PASSWORD, new X509Certificate[]{clientCert}); + + KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyManagerFactory.init(clientKeyStore, KEYSTORE_PASSWORD); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(trackClientAliasRequests(keyManagerFactory.getKeyManagers(), clientCertRequested), + new TrustManager[]{trustAnyServerCertificate()}, null); + + SSLSocket socket = (SSLSocket) sslContext.getSocketFactory().createSocket("localhost", port); + socket.setEnabledProtocols(new String[]{"TLSv1.2", "TLSv1.3"}); + return socket; + } + + private SSLSocket clientSocketTrackingCertRequestOnTls12(int port, AtomicBoolean clientCertRequested) throws Exception { + KeyPair clientKeyPair = generateKeyPair(); + X500Name clientName = new X500Name("CN=arbitrary-untrusted-client"); + X509Certificate clientCert = signCert(clientName, clientName, clientKeyPair.getPublic(), clientKeyPair.getPrivate(), false, BigInteger.valueOf(6)); + + KeyStore clientKeyStore = KeyStore.getInstance("PKCS12"); + clientKeyStore.load(null, null); + clientKeyStore.setKeyEntry("client", clientKeyPair.getPrivate(), KEYSTORE_PASSWORD, new X509Certificate[]{clientCert}); + + KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyManagerFactory.init(clientKeyStore, KEYSTORE_PASSWORD); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(trackClientAliasRequests(keyManagerFactory.getKeyManagers(), clientCertRequested), + new TrustManager[]{trustAnyServerCertificate()}, null); + + SSLSocket socket = (SSLSocket) sslContext.getSocketFactory().createSocket("localhost", port); + socket.setEnabledProtocols(new String[]{"TLSv1.2"}); + return socket; + } + + /** + * Wraps each {@link X509ExtendedKeyManager} so we can observe -- from the client side -- whether + * the server ever asked for a client certificate during the handshake. JSSE only calls + * {@code chooseClientAlias}/{@code chooseEngineClientAlias} when the server sent a + * {@code CertificateRequest}, which is exactly the behavior {@link MtlsClientAuthTomcatCustomizer} + * is meant to control. + */ + private KeyManager[] trackClientAliasRequests(KeyManager[] keyManagers, AtomicBoolean clientCertRequested) { + KeyManager[] wrapped = new KeyManager[keyManagers.length]; + for (int i = 0; i < keyManagers.length; i++) { + if (keyManagers[i] instanceof X509ExtendedKeyManager delegate) { + wrapped[i] = new X509ExtendedKeyManager() { + @Override + public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) { + clientCertRequested.set(true); + return delegate.chooseClientAlias(keyType, issuers, socket); + } + + @Override + public String chooseEngineClientAlias(String[] keyType, Principal[] issuers, SSLEngine engine) { + clientCertRequested.set(true); + return delegate.chooseEngineClientAlias(keyType, issuers, engine); + } + + @Override + public String[] getClientAliases(String keyType, Principal[] issuers) { + return delegate.getClientAliases(keyType, issuers); + } + + @Override + public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) { + return delegate.chooseServerAlias(keyType, issuers, socket); + } + + @Override + public String[] getServerAliases(String keyType, Principal[] issuers) { + return delegate.getServerAliases(keyType, issuers); + } + + @Override + public X509Certificate[] getCertificateChain(String alias) { + return delegate.getCertificateChain(alias); + } + + @Override + public PrivateKey getPrivateKey(String alias) { + return delegate.getPrivateKey(alias); + } + }; + } else { + wrapped[i] = keyManagers[i]; + } + } + return wrapped; + } + + private TrustManager trustAnyServerCertificate() { + return new X509TrustManager() { + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) { + // not used: this is the client-side trust manager for the server's cert + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) { + // the server cert is self-signed and not in any trust store; accept it for this test + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + }; + } + + private static KeyPair generateKeyPair() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA", BouncyCastleFipsProvider.PROVIDER_NAME); + kpg.initialize(2048); + return kpg.generateKeyPair(); + } + + private static X509Certificate signCert(X500Name subject, X500Name issuer, PublicKey subjectKey, + PrivateKey signerKey, boolean isCa, BigInteger serial) throws Exception { + Date notBefore = new Date(System.currentTimeMillis() - 60_000); + Date notAfter = new Date(System.currentTimeMillis() + 3_600_000); + JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( + issuer, serial, notBefore, notAfter, subject, subjectKey); + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(isCa)); + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA") + .setProvider(BouncyCastleFipsProvider.PROVIDER_NAME) + .build(signerKey); + X509CertificateHolder holder = builder.build(signer); + return new JcaX509CertificateConverter() + .setProvider(BouncyCastleFipsProvider.PROVIDER_NAME) + .getCertificate(holder); + } +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerTest.java new file mode 100644 index 00000000000..e5be196847b --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/web/tomcat/MtlsClientAuthTomcatCustomizerTest.java @@ -0,0 +1,195 @@ +package org.cloudfoundry.identity.uaa.web.tomcat; + +import org.apache.catalina.connector.Connector; +import org.apache.coyote.http11.AbstractHttp11Protocol; +import org.apache.tomcat.util.net.SSLHostConfig; +import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; +import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; +import org.junit.jupiter.api.Test; +import org.springframework.boot.tomcat.servlet.TomcatServletWebServerFactory; + +import java.security.Provider; +import java.security.Security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class MtlsClientAuthTomcatCustomizerTest { + + @Test + void setsOptionalNoCaWhenMtlsEnabled() { + MtlsClientAuthTomcatCustomizer customizer = new MtlsClientAuthTomcatCustomizer(true); + TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory(0); + + customizer.customize(factory); + + Connector connector = new Connector(); + SSLHostConfig sslHostConfig = new SSLHostConfig(); + connector.addSslHostConfig(sslHostConfig); + factory.getConnectorCustomizers().forEach(c -> c.customize(connector)); + + assertThat(sslHostConfig.getCertificateVerification()) + .isEqualTo(SSLHostConfig.CertificateVerification.OPTIONAL_NO_CA); + assertThat(sslHostConfig.getTrustManagerClassName()).isEqualTo(NoAcceptedIssuersTrustManager.class.getName()); + } + + @Test + void usesTheBouncyCastleJsseImplementationWhenMtlsEnabled() { + MtlsClientAuthTomcatCustomizer customizer = new MtlsClientAuthTomcatCustomizer(true); + TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory(0); + customizer.customize(factory); + + Connector connector = new Connector(); + factory.getConnectorCustomizers().forEach(c -> c.customize(connector)); + + assertThat(((AbstractHttp11Protocol) connector.getProtocolHandler()).getSslImplementationName()) + .isEqualTo(BCJSSESslImplementation.class.getName()); + } + + @Test + void doesNotExcludeTlsV13FromTheConnectorWhenMtlsEnabled() { + MtlsClientAuthTomcatCustomizer customizer = new MtlsClientAuthTomcatCustomizer(true); + TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory(0); + customizer.customize(factory); + + Connector connector = new Connector(); + SSLHostConfig sslHostConfig = new SSLHostConfig(); + connector.addSslHostConfig(sslHostConfig); + factory.getConnectorCustomizers().forEach(c -> c.customize(connector)); + + assertThat(sslHostConfig.getProtocols()) + .as("TLS 1.3 must not be excluded once the connector is served by BCJSSE") + .doesNotContain("all,-TLSv1.3"); + } + + @Test + void failsFastWhenTheConnectorIsNotHttp11Based() { + MtlsClientAuthTomcatCustomizer customizer = new MtlsClientAuthTomcatCustomizer(true); + TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory(0); + customizer.customize(factory); + + Connector connector = new Connector("AJP/1.3"); + + assertThatThrownBy(() -> factory.getConnectorCustomizers().forEach(c -> c.customize(connector))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("HTTP/1.1"); + } + + @Test + void doesNothingWhenMtlsDisabled() { + MtlsClientAuthTomcatCustomizer customizer = new MtlsClientAuthTomcatCustomizer(false); + TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory(0); + + customizer.customize(factory); + + assertThat(factory.getConnectorCustomizers()).isEmpty(); + } + + @Test + void registersTheFipsBouncyCastleJsseProviderIdempotently() { + MtlsClientAuthTomcatCustomizer.ensureJsseProviderRegistered(); + MtlsClientAuthTomcatCustomizer.ensureJsseProviderRegistered(); + + assertThat(Security.getProvider(BouncyCastleJsseProvider.PROVIDER_NAME)).isNotNull(); + BouncyCastleJsseProvider registered = + (BouncyCastleJsseProvider) Security.getProvider(BouncyCastleJsseProvider.PROVIDER_NAME); + assertThat(registered.isFipsMode()).isTrue(); + } + + @Test + void failsFastWhenAnExistingNonFipsProviderIsAlreadyRegisteredUnderTheBcjsseName() { + // Simulates a non-FIPS provider having already claimed the "BCJSSE" provider name (e.g. + // via JVM-wide java.security configuration) before this method runs -- must not be + // silently trusted as the genuine FIPS BouncyCastleJsseProvider. + // + // Security.addProvider(Provider) is a no-op (returns -1) if a provider with the same + // name is already registered -- and since Security providers are global, JVM-wide state + // with no automatic teardown, another test in this class (or a prior run of this same + // customizer) may have already registered the genuine FIPS provider under this name. + // Remove any existing registration first so the impostor is guaranteed to actually take + // its place, regardless of test execution order. + Security.removeProvider(BouncyCastleJsseProvider.PROVIDER_NAME); + Provider impostor = new Provider(BouncyCastleJsseProvider.PROVIDER_NAME, "1.0", "not actually BCJSSE") { + }; + Security.addProvider(impostor); + try { + assertThatThrownBy(MtlsClientAuthTomcatCustomizer::ensureJsseProviderRegistered) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining(BouncyCastleJsseProvider.PROVIDER_NAME) + .hasMessageContaining(impostor.getClass().getName()); + } finally { + Security.removeProvider(BouncyCastleJsseProvider.PROVIDER_NAME); + } + } + + @Test + void failsFastWhenTheGenuineProviderIsRegisteredButNotInFipsMode() { + // Simulates the correct BouncyCastleJsseProvider class having already been registered under + // the "BCJSSE" name, but constructed in non-FIPS mode -- a distinct failure mode from an + // entirely different provider class claiming the name (see the "impostor" test above). The + // error message must be specific to this case, not the generic "different provider" message. + Security.removeProvider(BouncyCastleJsseProvider.PROVIDER_NAME); + BouncyCastleJsseProvider nonFipsProvider = new BouncyCastleJsseProvider(false); + Security.addProvider(nonFipsProvider); + try { + assertThatThrownBy(MtlsClientAuthTomcatCustomizer::ensureJsseProviderRegistered) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining(BouncyCastleJsseProvider.PROVIDER_NAME) + .hasMessageContaining("not") + .hasMessageContaining("FIPS mode") + .hasMessageNotContaining("a different provider is already registered"); + } finally { + Security.removeProvider(BouncyCastleJsseProvider.PROVIDER_NAME); + } + } + + @Test + void succeedsWhenTheGenuineFipsProviderIsAlreadyRegisteredUnderTheBcjsseName() { + MtlsClientAuthTomcatCustomizer.ensureJsseProviderRegistered(); + + // Calling it again with the genuine FIPS provider already registered must not throw. + MtlsClientAuthTomcatCustomizer.ensureJsseProviderRegistered(); + + assertThat(Security.getProvider(BouncyCastleJsseProvider.PROVIDER_NAME)).isNotNull(); + } + + @Test + void failsFastWhenAnExistingNonFipsProviderIsAlreadyRegisteredUnderTheBcfipsName() { + // Simulates a non-FIPS provider having already claimed the "BCFIPS" name (e.g. via JVM-wide + // java.security configuration) before this method runs -- must not be silently trusted as + // the genuine BouncyCastleFipsProvider. + // + // Security.addProvider(Provider) is a no-op (returns -1) if a provider with the same name is + // already registered -- and since Security providers are global, JVM-wide state with no + // automatic teardown, another test in this class (or a prior run of this same customizer) may + // have already registered the genuine FIPS provider under this name. Remove any existing + // registration first so the impostor is guaranteed to actually take its place, regardless of + // test execution order. + Security.removeProvider(BouncyCastleFipsProvider.PROVIDER_NAME); + Provider impostor = new Provider(BouncyCastleFipsProvider.PROVIDER_NAME, "1.0", "not actually BCFIPS") { + }; + Security.addProvider(impostor); + try { + assertThatThrownBy(MtlsClientAuthTomcatCustomizer::ensureJsseProviderRegistered) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining(BouncyCastleFipsProvider.PROVIDER_NAME) + .hasMessageContaining(impostor.getClass().getName()); + } finally { + Security.removeProvider(BouncyCastleFipsProvider.PROVIDER_NAME); + } + } + + @Test + void succeedsWhenTheGenuineFipsProviderIsAlreadyRegisteredUnderTheBcfipsName() { + Security.removeProvider(BouncyCastleFipsProvider.PROVIDER_NAME); + Security.addProvider(new BouncyCastleFipsProvider()); + try { + // Calling it with the genuine FIPS crypto provider already registered must not throw. + MtlsClientAuthTomcatCustomizer.ensureJsseProviderRegistered(); + + assertThat(Security.getProvider(BouncyCastleFipsProvider.PROVIDER_NAME)).isNotNull(); + } finally { + Security.removeProvider(BouncyCastleFipsProvider.PROVIDER_NAME); + } + } +} diff --git a/uaa/build.gradle.kts b/uaa/build.gradle.kts index d3874f12d61..f17dc5bf684 100644 --- a/uaa/build.gradle.kts +++ b/uaa/build.gradle.kts @@ -107,6 +107,7 @@ dependencies { testImplementation(libs.xmlUnit) testImplementation(libs.awaitility) testImplementation(libs.nimbusJwt) + testImplementation(libs.bouncyCastlePkixFips) testRuntimeOnly(libs.jacocoAgent) testRuntimeOnly(libs.junit5PlatformLauncher) diff --git a/uaa/slateCustomizations/source/index.html.md.erb b/uaa/slateCustomizations/source/index.html.md.erb index 58e881b50c6..0e677c1239d 100644 --- a/uaa/slateCustomizations/source/index.html.md.erb +++ b/uaa/slateCustomizations/source/index.html.md.erb @@ -259,6 +259,40 @@ _Response Fields_ <%= render('TokenEndpointDocs/getTokenUsingClientCredentialGrantWithClientAssertion/response-fields.md') %> +### Mutual TLS Client Authentication ([RFC 8705](https://www.rfc-editor.org/rfc/rfc8705)) + +Authenticates using an X.509 certificate presented at the TLS layer, on the dedicated +`/oauth/mtls/token` endpoint, instead of a `client_secret`, `client_assertion`, or Basic +Authorization header. The client is identified by the certificate chaining to its configured +`tls-client-auth-ca`; no additional request parameter carries the certificate itself. See +[UAA Client Authentication](https://github.com/cloudfoundry/uaa/blob/develop/docs/UAA-Client-Authentication.md#tls_client_auth-rfc-8705) +for deployment and configuration details, including the connector-wide `uaa.mtls-enabled` +requirement. + +The certificate and private key are TLS-layer credentials, not form parameters. Substitute paths +and the UAA URL for your deployment: + +```bash +curl --cert /path/to/client-cert.pem \ + --key /path/to/client-key.pem \ + --cacert /path/to/uaa-server-ca.pem \ + --request POST \ + --header 'Accept: application/json' \ + --data 'grant_type=client_credentials&client_id=&token_format=jwt' \ + https://uaa.example.com/oauth/mtls/token +``` + +<%= render('TokenEndpointDocs/getTokenUsingClientCredentialGrantWithTlsClientAuth/http-request.md') %> +<%= render('TokenEndpointDocs/getTokenUsingClientCredentialGrantWithTlsClientAuth/http-response.md') %> + +_Request Parameters_ + +<%= render('TokenEndpointDocs/getTokenUsingClientCredentialGrantWithTlsClientAuth/form-parameters.md') %> + +_Response Fields_ + +<%= render('TokenEndpointDocs/getTokenUsingClientCredentialGrantWithTlsClientAuth/response-fields.md') %> + ## Password Grant ### Form Authentication 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 b86c402f5f8..10e94157f0a 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 @@ -1,14 +1,28 @@ package org.cloudfoundry.identity.uaa.login; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.util.io.pem.PemObject; +import org.bouncycastle.util.io.pem.PemWriter; import org.cloudfoundry.identity.uaa.authentication.UaaAuthentication; import org.cloudfoundry.identity.uaa.authentication.UaaPrincipal; +import org.cloudfoundry.identity.uaa.client.TlsClientAuthConfiguration; import org.cloudfoundry.identity.uaa.client.UaaClientDetails; import org.cloudfoundry.identity.uaa.mock.token.AbstractTokenMockMvcTests; import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils; import org.cloudfoundry.identity.uaa.oauth.common.OAuth2RefreshToken; +import org.cloudfoundry.identity.uaa.oauth.jwt.Jwt; +import org.cloudfoundry.identity.uaa.oauth.jwt.JwtHelper; import org.cloudfoundry.identity.uaa.oauth.jwt.JwtClientAuthentication; import org.cloudfoundry.identity.uaa.oauth.pkce.PkceValidationService; +import org.cloudfoundry.identity.uaa.oauth.tls.RawPeerCertificateCaptureFilter; import org.cloudfoundry.identity.uaa.oauth.token.CompositeToken; import org.cloudfoundry.identity.uaa.oauth.token.TokenConstants; import org.cloudfoundry.identity.uaa.provider.IdentityProvider; @@ -55,10 +69,22 @@ import org.springframework.web.util.UriComponents; import org.springframework.web.util.UriComponentsBuilder; +import java.io.StringWriter; +import java.math.BigInteger; import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.MessageDigest; +import java.security.PrivateKey; +import java.security.PublicKey; import java.security.Security; +import java.security.cert.X509Certificate; import java.util.Base64; import java.util.Collections; +import java.util.Date; +import java.util.Map; import static org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils.MockSecurityContext; import static org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils.getClientCredentialsOAuthAccessToken; @@ -83,6 +109,7 @@ import static org.cloudfoundry.identity.uaa.provider.saml.TestCredentialObjects.legacyPassphrase; import static org.cloudfoundry.identity.uaa.provider.saml.idp.SamlTestUtils.createLocalSamlIdpDefinition; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; import static org.cloudfoundry.identity.uaa.test.SnippetUtils.parameterWithName; import static org.springframework.http.HttpHeaders.AUTHORIZATION; import static org.springframework.http.HttpHeaders.HOST; @@ -108,7 +135,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -@TestPropertySource(properties = "login.entityBaseURL=") +@TestPropertySource(properties = {"login.entityBaseURL=", "uaa.mtls-enabled=true"}) @ExtendWith(JUnitRestDocumentationExtension.class) class TokenEndpointDocs extends AbstractTokenMockMvcTests { private static final Base64.Encoder ENCODER = Base64.getEncoder(); @@ -172,6 +199,18 @@ class TokenEndpointDocs extends AbstractTokenMockMvcTests { @Autowired FilterRegistrationBean zoneContextPathSessionFilterRegistration; + /** + * Registered in {@code SpringServletXmlFiltersConfiguration} but not automatically added to the + * servlet container by {@code MockMvcBuilders.webAppContextSetup(...)} -- must be added explicitly + * to the MockMvc filter chain (like the other filters below) so that + * {@link org.cloudfoundry.identity.uaa.oauth.tls.TlsClientAuthentication#getCertificateChainFromRequest} + * can read {@link RawPeerCertificateCaptureFilter#RAW_PEER_CERTIFICATE_ATTRIBUTE} for + * {@code /oauth/mtls/token} requests. + */ + @Qualifier("rawPeerCertificateCaptureFilter") + @Autowired + FilterRegistrationBean rawPeerCertificateCaptureFilterRegistration; + @BeforeAll static void beforeAll() { Security.addProvider(new BouncyCastleFipsProvider()); @@ -189,6 +228,7 @@ void setUpContext(ManualRestDocumentation manualRestDocumentation) { mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext) .addFilter(zonePathFilterRegistration.getFilter()) .addFilter(zoneContextPathSessionFilterRegistration.getFilter()) + .addFilter(rawPeerCertificateCaptureFilterRegistration.getFilter()) .addFilter(securityFilterChain) .apply(documentationConfiguration(manualRestDocumentation) .uris().withPort(80) @@ -470,6 +510,130 @@ void getTokenUsingClientCredentialGrantWithAuthorizationHeader() throws Exceptio .andDo(document("{ClassName}/{methodName}", preprocessResponse(prettyPrint()), formParameters, requestHeaders, responseFields)); } + /** + * Documents {@code /oauth/mtls/token} (RFC 8705 mutual-TLS client authentication, {@code tls_client_auth}). + * Unlike {@code client_secret}/{@code client_assertion}, this method has no request parameter at all -- + * the client authenticates by presenting an X.509 certificate at the TLS layer itself (or, behind a + * trusted proxy, via the {@code X-Forwarded-Client-Cert} header), which the Gorouter/servlet container + * populates on the request before this endpoint's client authentication runs. This test simulates that by + * setting the standard {@code jakarta.servlet.request.X509Certificate} request attribute directly, which + * {@link RawPeerCertificateCaptureFilter} (added to the MockMvc filter chain in {@link #setUpContext}, as + * it would run in the real filter chain) copies into the attribute + * {@link org.cloudfoundry.identity.uaa.oauth.tls.TlsClientAuthentication#getCertificateChainFromRequest} + * reads for {@code /oauth/mtls/token/**} requests -- exercising the same + * {@code ClientDetailsAuthenticationProvider.validateTlsClientAuth} path a genuine mTLS handshake would. + */ + @Test + void getTokenUsingClientCredentialGrantWithTlsClientAuth() throws Exception { + KeyPair caKeyPair = generateKeyPair(); + X500Name caSubject = new X500Name("CN=Test mTLS CA"); + X509Certificate caCert = signCert(caSubject, caSubject, caKeyPair.getPublic(), caKeyPair.getPrivate(), true, BigInteger.valueOf(1)); + + KeyPair leafKeyPair = generateKeyPair(); + X500Name leafSubject = new X500Name("CN=mtls-doc-client"); + X509Certificate leafCert = signCert(leafSubject, caSubject, leafKeyPair.getPublic(), caKeyPair.getPrivate(), false, BigInteger.valueOf(2)); + + String clientId = "mtlsdocclient" + generator.generate(); + setUpClients(clientId, "uaa.resource", "uaa.resource", GRANT_TYPE_CLIENT_CREDENTIALS, + false, null, null, -1, IdentityZone.getUaa(), + Map.of( + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, toPem(caCert), + TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CLAIM_MAPPINGS, + Collections.singletonList(new TlsClientAuthConfiguration.ClaimMapping( + "subject_cn", null, "instance_guid")))); + clientDetailsService.updateClientSecret(clientId, null); + assertThat(clientDetailsService.loadClientByClientId(clientId).getClientSecret()).isNull(); + + MockHttpServletRequestBuilder postForToken = RestDocumentationRequestBuilders.post("/oauth/mtls/token") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_FORM_URLENCODED) + .param(CLIENT_ID, clientId) + .param(GRANT_TYPE, GRANT_TYPE_CLIENT_CREDENTIALS) + .param(REQUEST_TOKEN_FORMAT, JWT.getStringValue()) + // RawPeerCertificateCaptureFilter.isMtlsTokenPath(...) matches on the *effective* + // servlet path (post-ZonePathContextRewritingFilter); MockMvc does not compute this + // itself from the request URI the way a real DispatcherServlet mapping would, so it + // must be set explicitly here to simulate the real /oauth/mtls/token servlet path. + .servletPath("/oauth/mtls/token") + .requestAttr("jakarta.servlet.request.X509Certificate", new X509Certificate[]{leafCert}); + + ParameterDescriptor mtlsClientIdParameter = parameterWithName(CLIENT_ID).required().type(STRING) + .description("Required. The client ID whose tls-client-auth-ca selects the certificate trust anchor for this mTLS token request."); + assertThat(mtlsClientIdParameter.getAttributes()).containsEntry("constraints", SnippetUtils.REQUIRED); + + Snippet formParameters = formParameters( + mtlsClientIdParameter, + grantTypeParameter.description("the type of authentication being used to obtain the token, in this case `client_credentials`"), + parameterWithName(REQUEST_TOKEN_FORMAT).optional("jwt").type(STRING) + .description("Set to `jwt` to receive a JSON Web Token containing the mTLS certificate-derived claims and RFC 8705 confirmation claim.") + ); + + Snippet responseFields = responseFields( + accessTokenFieldDescriptor, + tokenTypeFieldDescriptor, + expiresInFieldDescriptor, + scopeFieldDescriptorWhenClientCredentialsToken, + jtiFieldDescriptor + ); + + MvcResult result = mockMvc.perform(postForToken) + .andExpect(status().isOk()) + .andDo(document("{ClassName}/{methodName}", preprocessResponse(prettyPrint()), formParameters, responseFields)) + .andReturn(); + + String formParametersSnippet = Files.readString(Path.of( + System.getProperty("docs.build.generated.snippets.dir"), + "TokenEndpointDocs", + "getTokenUsingClientCredentialGrantWithTlsClientAuth", + "form-parameters.md")); + assertThat(formParametersSnippet) + .contains("`client_id`", "Required."); + + Map tokenResponse = JsonUtils.readValue(result.getResponse().getContentAsString(), Map.class); + Jwt accessToken = JwtHelper.decode((String) tokenResponse.get("access_token")); + String kid = accessToken.getHeader().getKid(); + assertThat(kid).isNotBlank(); + assertThatCode(() -> accessToken.verifySignature(keyInfoService.getKey(kid).getVerifier())) + .doesNotThrowAnyException(); + + Map claims = JsonUtils.readValue(accessToken.getClaims(), Map.class); + assertThat(claims).containsEntry("instance_guid", "mtls-doc-client"); + String expectedThumbprint = Base64.getUrlEncoder().withoutPadding() + .encodeToString(MessageDigest.getInstance("SHA-256").digest(leafCert.getEncoded())); + assertThat((Map) claims.get("cnf")) + .containsEntry("x5t#S256", expectedThumbprint); + } + + private static KeyPair generateKeyPair() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA", BouncyCastleFipsProvider.PROVIDER_NAME); + kpg.initialize(2048); + return kpg.generateKeyPair(); + } + + private static X509Certificate signCert(X500Name subject, X500Name issuer, PublicKey subjectKey, + PrivateKey signerKey, boolean isCa, BigInteger serial) throws Exception { + Date notBefore = new Date(System.currentTimeMillis() - 60_000); + Date notAfter = new Date(System.currentTimeMillis() + 3_600_000); + JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( + issuer, serial, notBefore, notAfter, subject, subjectKey); + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(isCa)); + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA") + .setProvider(BouncyCastleFipsProvider.PROVIDER_NAME) + .build(signerKey); + X509CertificateHolder holder = builder.build(signer); + return new JcaX509CertificateConverter() + .setProvider(BouncyCastleFipsProvider.PROVIDER_NAME) + .getCertificate(holder); + } + + private static String toPem(X509Certificate cert) throws Exception { + StringWriter sw = new StringWriter(); + try (PemWriter pemWriter = new PemWriter(sw)) { + pemWriter.writeObject(new PemObject("CERTIFICATE", cert.getEncoded())); + } + return sw.toString(); + } + @Test void getTokenUsingPasswordGrantWithClientSecret() throws Exception { MockHttpServletRequestBuilder postForToken = post("/oauth/token") 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 3f6ebc8eea9..e360856473c 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 @@ -827,6 +827,156 @@ public Integer getZoneValiditySeconds() { } } + @Nested + @DisplayName("when token enhancer overrides sub and aud") + class WhenTokenEnhancerOverridesSubAndAud { + + @Test + @DisplayName("enhancer sub and aud claims win over UAA defaults") + void enhancerSubAndAudClaimsWinOverUaaDefaults() { + UaaTokenEnhancer testEnhancer = new UaaTokenEnhancer() { + @Override + public Map getExternalAttributes(OAuth2Authentication authentication) { + return Map.of(); + } + + @Override + public Map enhance(Map claims, OAuth2Authentication authentication) { + Map result = new HashMap<>(); + result.put("sub", "enhancer-sub"); + result.put("aud", List.of("enhancer-aud")); + return result; + } + }; + + tokenServices.setUaaTokenEnhancers(List.of(testEnhancer)); + + try { + AuthorizationRequest authorizationRequest = constructAuthorizationRequest( + clientId, GRANT_TYPE_CLIENT_CREDENTIALS, CLIENT_SCOPES.split(",")); + OAuth2Authentication authentication = new OAuth2Authentication( + authorizationRequest.createOAuth2Request(), null); + + OAuth2AccessToken accessToken = tokenServices.createAccessToken(authentication); + + Jwt jwt = JwtHelper.decode(accessToken.getValue()); + Map tokenClaims = JsonUtils.readValue(jwt.getClaims(), + new TypeReference>() {}); + assertThat(tokenClaims).containsEntry("sub", "enhancer-sub"); + // JWT RFC 7519 §4.1.3: single-audience MAY be serialized as a plain string + Object aud = tokenClaims.get("aud"); + if (aud instanceof String s) { + assertThat(s).isEqualTo("enhancer-aud"); + } else { + assertThat(aud).asInstanceOf(InstanceOfAssertFactories.list(Object.class)) + .containsExactly("enhancer-aud"); + } + } finally { + tokenServices.setUaaTokenEnhancers(new ArrayList<>()); + } + } + } + + @Nested + @DisplayName("when token enhancer attempts to override protected claims") + class WhenTokenEnhancerAttemptsToOverrideProtectedClaims { + + @Test + @DisplayName("enhancer cannot override client_id, authorities, scope, or iss") + void enhancerCannotOverrideProtectedClaims() { + UaaTokenEnhancer maliciousEnhancer = new UaaTokenEnhancer() { + @Override + public Map getExternalAttributes(OAuth2Authentication authentication) { + return Map.of(); + } + + @Override + public Map enhance(Map claims, OAuth2Authentication authentication) { + Map result = new HashMap<>(); + result.put("client_id", "some-other-client"); + result.put("cid", "some-other-client"); + result.put("authorities", List.of("uaa.admin")); + result.put("scope", List.of("uaa.admin")); + result.put("iss", "https://attacker.example.com/oauth/token"); + result.put("grant_type", "authorization_code"); + return result; + } + }; + + tokenServices.setUaaTokenEnhancers(List.of(maliciousEnhancer)); + + try { + AuthorizationRequest authorizationRequest = constructAuthorizationRequest( + clientId, GRANT_TYPE_CLIENT_CREDENTIALS, CLIENT_SCOPES.split(",")); + OAuth2Authentication authentication = new OAuth2Authentication( + authorizationRequest.createOAuth2Request(), null); + + OAuth2AccessToken accessToken = tokenServices.createAccessToken(authentication); + + Jwt jwt = JwtHelper.decode(accessToken.getValue()); + Map tokenClaims = JsonUtils.readValue(jwt.getClaims(), + new TypeReference>() {}); + + assertThat(tokenClaims) + .as("client_id must remain the authenticated client, not the enhancer-supplied value") + .containsEntry("client_id", clientId) + .containsEntry("cid", clientId); + assertThat(tokenClaims.get("iss")) + .as("iss must remain UAA's own token endpoint, not the enhancer-supplied value") + .isNotEqualTo("https://attacker.example.com/oauth/token"); + assertThat(tokenClaims.get("grant_type")) + .as("grant_type must remain the actual grant used, not the enhancer-supplied value") + .isEqualTo(GRANT_TYPE_CLIENT_CREDENTIALS); + assertThat(tokenClaims.get("authorities")) + .as("authorities must remain the client's actual granted scopes") + .asInstanceOf(InstanceOfAssertFactories.list(Object.class)) + .doesNotContain("uaa.admin"); + assertThat(tokenClaims.get("scope")) + .as("scope must remain the actually granted scopes, not the enhancer-supplied value") + .asInstanceOf(InstanceOfAssertFactories.list(Object.class)) + .doesNotContain("uaa.admin"); + } finally { + tokenServices.setUaaTokenEnhancers(new ArrayList<>()); + } + } + + @Test + @DisplayName("enhancer-supplied custom (non-reserved) claims still apply") + void enhancerCanStillAddCustomClaims() { + UaaTokenEnhancer testEnhancer = new UaaTokenEnhancer() { + @Override + public Map getExternalAttributes(OAuth2Authentication authentication) { + return Map.of(); + } + + @Override + public Map enhance(Map claims, OAuth2Authentication authentication) { + return Map.of("cf.app", "app-guid", "cnf", Map.of("x5t#S256", "thumbprint")); + } + }; + + tokenServices.setUaaTokenEnhancers(List.of(testEnhancer)); + + try { + AuthorizationRequest authorizationRequest = constructAuthorizationRequest( + clientId, GRANT_TYPE_CLIENT_CREDENTIALS, CLIENT_SCOPES.split(",")); + OAuth2Authentication authentication = new OAuth2Authentication( + authorizationRequest.createOAuth2Request(), null); + + OAuth2AccessToken accessToken = tokenServices.createAccessToken(authentication); + + Jwt jwt = JwtHelper.decode(accessToken.getValue()); + Map tokenClaims = JsonUtils.readValue(jwt.getClaims(), + new TypeReference>() {}); + + assertThat(tokenClaims).containsEntry("cf.app", "app-guid"); + assertThat(tokenClaims).containsKey("cnf"); + } finally { + tokenServices.setUaaTokenEnhancers(new ArrayList<>()); + } + } + } + @Nested @DisplayName("when an id_token enhancer is provided") @DefaultTestContext diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointDocs.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointDocs.java index e691ee45cd2..593832820f0 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointDocs.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointDocs.java @@ -3,6 +3,7 @@ import org.cloudfoundry.identity.uaa.mock.EndpointDocs; import org.junit.jupiter.api.Test; import org.springframework.restdocs.snippet.Snippet; +import org.springframework.test.context.TestPropertySource; import static org.cloudfoundry.identity.uaa.test.SnippetUtils.fieldWithPath; import static org.springframework.http.MediaType.APPLICATION_JSON; @@ -13,6 +14,10 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +// mtls_endpoint_aliases is only present in the discovery document when uaa.mtls-enabled is true +// (the default is false) -- enabled here so this documented field is genuinely present in the +// response this test drives, rather than the endpoint silently omitting it. +@TestPropertySource(properties = "uaa.mtls-enabled=true") class OpenIdConnectEndpointDocs extends EndpointDocs { @Test void getWellKnownOpenidConf() throws Exception { @@ -35,7 +40,8 @@ void getWellKnownOpenidConf() throws Exception { fieldWithPath("claims_parameter_supported").description("Boolean value specifying whether the OP supports use of the claims parameter."), fieldWithPath("service_documentation").description("URL of a page containing human-readable information that developers might want or need to know when using the OpenID Provider."), fieldWithPath("code_challenge_methods_supported").description("UAA 75.5.0JSON array containing a list of [PKCE](https://tools.ietf.org/html/rfc7636) code challenge methods supported by this authorization endpoint."), - fieldWithPath("ui_locales_supported").description("Languages and scripts supported for the user interface.") + fieldWithPath("ui_locales_supported").description("Languages and scripts supported for the user interface."), + fieldWithPath("mtls_endpoint_aliases.token_endpoint").description("mTLS-specific token endpoint alias for RFC 8705 mutual-TLS client authentication.") ); mockMvc.perform( diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointsMockMvcTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointsMockMvcTests.java index f90f8dcb374..3fd27340280 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointsMockMvcTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointsMockMvcTests.java @@ -11,6 +11,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.MockMvc; import org.springframework.web.context.WebApplicationContext; @@ -25,7 +26,10 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +// mtls_endpoint_aliases/tls_client_auth are only advertised when uaa.mtls-enabled is true (the +// default is false) -- enabled here since this test asserts on their presence. @DefaultTestContext +@TestPropertySource(properties = "uaa.mtls-enabled=true") class OpenIdConnectEndpointsMockMvcTests { private IdentityZone identityZone; @@ -62,7 +66,7 @@ void wellKnownEndpoint() throws Exception { assertThat(openIdConfiguration.getIssuer()).isEqualTo("http://" + host + ":8080/uaa/oauth/token"); assertThat(openIdConfiguration.getAuthUrl()).isEqualTo("http://" + host + "/oauth/authorize"); assertThat(openIdConfiguration.getTokenUrl()).isEqualTo("http://" + host + "/oauth/token"); - assertThat(openIdConfiguration.getTokenAMR()).containsExactly(new String[]{"client_secret_basic", "client_secret_post", "private_key_jwt"}); + assertThat(openIdConfiguration.getTokenAMR()).containsExactly(new String[]{"client_secret_basic", "client_secret_post", "private_key_jwt", "tls_client_auth"}); assertThat(openIdConfiguration.getTokenEndpointAuthSigningValues()).containsExactly(new String[]{"RS256", "HS256"}); assertThat(openIdConfiguration.getUserInfoUrl()).isEqualTo("http://" + host + "/userinfo"); assertThat(openIdConfiguration.getScopes()).containsExactly(new String[]{"openid", "profile", "email", "phone", ROLES, USER_ATTRIBUTES}); @@ -74,6 +78,8 @@ void wellKnownEndpoint() throws Exception { assertThat(openIdConfiguration.isClaimsParameterSupported()).isFalse(); assertThat(openIdConfiguration.getServiceDocumentation()).isEqualTo("http://docs.cloudfoundry.org/api/uaa/"); assertThat(openIdConfiguration.getUiLocalesSupported()).containsExactly(new String[]{"en-US"}); + assertThat(openIdConfiguration.getMtlsEndpointAliases()) + .containsEntry("token_endpoint", "http://" + host + "/oauth/mtls/token"); } } } diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointsMockMvcZonePathTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointsMockMvcZonePathTests.java index bdfe0e3c814..156ac469859 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointsMockMvcZonePathTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointsMockMvcZonePathTests.java @@ -13,6 +13,7 @@ import org.junit.jupiter.api.BeforeEach; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.MockMvc; import org.springframework.web.context.WebApplicationContext; import org.cloudfoundry.identity.uaa.extensions.EnabledIfZonePathsEnabled; @@ -30,6 +31,9 @@ @DefaultTestContext @EnabledIfZonePathsEnabled +// mtls_endpoint_aliases/tls_client_auth are only advertised when uaa.mtls-enabled is true (the +// default is false) -- enabled here since this test asserts on their presence. +@TestPropertySource(properties = "uaa.mtls-enabled=true") class OpenIdConnectEndpointsMockMvcZonePathTests { private IdentityZone identityZone; @@ -76,7 +80,7 @@ void wellKnownEndpoint(ZoneResolutionMode mode) throws Exception { assertThat(openIdConfiguration.getIssuer()).isEqualTo("http://" + identityZone.getSubdomain() + ".localhost:8080/uaa/oauth/token"); assertThat(openIdConfiguration.getAuthUrl()).isEqualTo(expectedAuthUrl); assertThat(openIdConfiguration.getTokenUrl()).isEqualTo(expectedTokenUrl); - assertThat(openIdConfiguration.getTokenAMR()).containsExactly(new String[]{"client_secret_basic", "client_secret_post", "private_key_jwt"}); + assertThat(openIdConfiguration.getTokenAMR()).containsExactly(new String[]{"client_secret_basic", "client_secret_post", "private_key_jwt", "tls_client_auth"}); assertThat(openIdConfiguration.getTokenEndpointAuthSigningValues()).containsExactly(new String[]{"RS256", "HS256"}); assertThat(openIdConfiguration.getUserInfoUrl()).isEqualTo(expectedUserInfoUrl); assertThat(openIdConfiguration.getScopes()).containsExactly(new String[]{"openid", "profile", "email", "phone", ROLES, USER_ATTRIBUTES}); @@ -88,6 +92,11 @@ void wellKnownEndpoint(ZoneResolutionMode mode) throws Exception { assertThat(openIdConfiguration.isClaimsParameterSupported()).isFalse(); assertThat(openIdConfiguration.getServiceDocumentation()).isEqualTo("http://docs.cloudfoundry.org/api/uaa/"); assertThat(openIdConfiguration.getUiLocalesSupported()).containsExactly(new String[]{"en-US"}); + String expectedMtlsTokenUrl = mode == ZoneResolutionMode.ZONE_PATH + ? "http://localhost/z/" + identityZone.getSubdomain() + "/oauth/mtls/token" + : "http://" + host + "/oauth/mtls/token"; + assertThat(openIdConfiguration.getMtlsEndpointAliases()) + .containsEntry("token_endpoint", expectedMtlsTokenUrl); } }