Review of 3972 - #4075
Draft
fhanik wants to merge 133 commits into
Draft
Review of 3972#4075fhanik wants to merge 133 commits into
fhanik wants to merge 133 commits into
Conversation
Add TlsClientAuthConfiguration field serialized as tls-client-auth-ca in client JSON, following the clientJwtConfig pattern. Includes getter/setter, copy constructor support, equals/hashCode. Fix fragile isPositive() hash code assertion to isNotZero().
…tailsAuthenticationProvider
…dpoint The BOSH ERB template emits 'mtls.endpoint' (from the nested mtls.endpoint YAML block) but the @value annotation was reading 'uaa.mtls_endpoint_path', a key never emitted by the template. Align the annotation to the actual Spring property so operator-configured paths are honoured.
…filter chain Add FilterChainOrder.OAUTH_11 (211) so the mTLS security chain can be ordered before the OAUTH_10 catch-all token endpoint chain. Add mtlsTokenEndpointSecurity @order(OAUTH_11) that: - matches /oauth/mtls/token - runs client-credentials authentication with tls_client_auth support - disables CSRF (stateless machine-to-machine endpoint) - uses BasicAuthenticationEntryPoint so Spring returns 401, not a redirect Without this chain the /oauth/mtls/token path falls through to the LoginSecurityConfiguration catch-all which rejects requests with CSRF-related 403 errors.
… expose /oauth/mtls/token getTlsClientAuthConfiguration previously handled only TlsClientAuthConfiguration (in-memory) and Map (Jackson-deserialized BOSH config stored as nested object). BOSH flat config stores tls-client-auth-ca as a plain PEM string and tls-client-auth-claim-mappings as a JSON array string — add an 'instanceof String pem' branch that parses both. UaaTokenEndpoint @RequestMapping previously covered only /oauth/token. After Gorouter sanitize_set was set, client authentication started succeeding for /oauth/mtls/token but Spring MVC returned 404 because no controller was mapped to that path. Add /oauth/mtls/token to the value array so the same endpoint handles both paths.
…ulti-valued RDNs Two bugs prevented CF identity claims from appearing in tokens issued via /oauth/mtls/token: 1. DB-loaded clients: UaaClientDetails.getTlsClientAuthConfiguration() returns null for clients loaded via JDBC because the tlsClientAuthConfiguration field is only set through JSON deserialization, not through the JDBC row-mapper path (which populates additionalInformation instead). Switch to loadTlsConfig() which mirrors ClientDetailsAuthenticationProvider.getTlsClientAuthConfiguration and reads from additionalInformation directly, handling the TlsClientAuthConfiguration/Map/String cases. 2. Multi-valued RDNs in Diego instance-identity certs: Diego encodes all three OU attributes (app:, space:, organization:) as a single multi-valued RDN using '+' as separator (RFC 2253 §2.2). Splitting only on ',' left the entire '+OU=space:..+OU=org:..' string attached to the app GUID. Fix extractOus() and extractRdnValue() to split on '+' within each RDN component.
- UaaClientDetails.setTlsClientAuthConfiguration() now syncs to additionalInformation so JDBC-loaded clients see the typed value - MtlsClaimsEnhancer checks the typed getTlsClientAuthConfiguration() field first, falls back to loadTlsConfig(additionalInformation) - ClientDetailsAuthenticationProvider and MtlsClaimsEnhancer now handle rawMappings instanceof List<?> (Jackson parses JSON arrays natively from JDBC; not always a String) - Move java-buildpack-client-certificate-mapper-jakarta to Gradle version catalog (libs.versions.toml) - SpringServletXmlFiltersConfiguration: remove class-level @SuppressWarnings and raw FilterRegistrationBean; add comment explaining why reflection is required (ClientCertificateMapper is package-private)
…lient_auth - Add tls_client_auth to tokenAMR assertion in wellKnownEndpoint tests - Assert mtls_endpoint_aliases.token_endpoint in both MockMvc test variants (SUBDOMAIN and ZONE_PATH zone resolution modes) - Document mtls_endpoint_aliases.token_endpoint in REST Docs snippet
…n mTLS auth Thread 8: Replace @JsonProperty(TLS_CLIENT_AUTH_CA) with @JsonIgnore on tlsClientAuthConfiguration in UaaClientDetails. The field is set only programmatically; JSON wire format for tls-client-auth-ca flows through additionalInformation via @JsonAnyGetter/@JsonAnySetter, avoiding the nested serialisation and flat-PEM deserialization failure. Thread 9: Add getCertificateChainFromRequest() returning the full X509Certificate[] from the request attribute. Add chain-aware overload validateClientCert(X509Certificate[], TlsClientAuthConfiguration) that builds CertPath from Arrays.asList(chain) so intermediate CAs are included in PKIX path validation. ClientDetailsAuthenticationProvider now uses the chain-based methods. Single-cert overload kept for backward compat (delegates to chain overload).
After adding @JsonIgnore to UaaClientDetails.tlsClientAuthConfiguration, JSON round-trips populate additionalInformation (via @JsonAnySetter) rather than the typed getter. Update the test to assert on additionalInformation directly, which is the actual contract authentication providers depend on.
…plates win UaaTokenServices.createJWTAccessToken() was unconditionally setting sub and aud after spreading additionalRootClaims, causing MtlsClaimsEnhancer's rendered sub/aud templates to be silently discarded. Move the additionalRootClaims.putAll() to after all UAA-default claims are set (including sub and aud) so that enhancer claims take precedence. Explicit excluded claims are still removed last so operator exclusions win. Add regression test: WhenTokenEnhancerOverridesSubAndAud verifies that an enhancer-supplied sub and aud survive into the final JWT.
…client-auth # Conflicts: # server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java
fhanik
force-pushed
the
review/pr3972
branch
2 times, most recently
from
September 16, 2026 20:28
45bf9f0 to
9236277
Compare
fhanik
force-pushed
the
review/pr3972
branch
from
September 16, 2026 20:42
9236277 to
f36c1e2
Compare
Contributor
Author
|
Example fixes here #4076 At this point, I'm not comfortable merging this because it wasn't a pure TDD pattern. Tests were changed along with production code, making it more difficult to review. |
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Review notes: RFC 8705 mTLS client authentication
I spent some time on this branch and wrote a set of adversarial MockMvc tests against
/oauth/mtls/tokento check the boundaries rather than reason about them. Everything below is something the tests actually produced, unless marked (from reading).The trust model is carefully built — the XFCC-spoofing defenses in particular are better than I expected, and the
RawPeerCertificateCaptureFilter/ mapper-parse-failure guard is a real subtlety that's handled correctly. The findings below are mostly about the blast radius of the new endpoint rather than the certificate validation itself.What I verified as working
Worth stating up front, because these are the parts under the most load:
tls-client-auth-cais rejected cleanly:401 invalid_client,"tls_client_auth: certificate chain validation failed: Path does not chain with any of the trust anchors". Same for expired certs and a malformed CA PEM.scope,client_idorzid—NON_ADDITIONAL_ROOT_CLAIMSinUaaTokenServicesdoes its job.tls-client-auth-required-claimscorrectly rejects a valid certificate from the same CA in the wrong space. The shared-CA mitigation the docs describe holds.client_secret, can't mix cert+secret on one request, and a CA certificate presented as the leaf is rejected byvalidateEndEntityConstraints.1. The mTLS endpoint serves every grant type, including
passwordandrefresh_tokenPOST /oauth/mtls/tokenwithgrant_type=password, authenticating with a valid instance-identity certificate, returns 200. Decoded access token:{ "client_auth_method": "tls_client_auth", "cnf": { "x5t#S256": "YMmciXQkCQXoo0RJtoqsVM6pH39epZebkh6YnfNVHhk" }, "app_id": "b1-app", "grant_type": "password", "user_id": "d78bfe26-78ca-4400-b44f-15243c9b2e3d", "user_name": "...", "email": "...", "origin": "uaa", "scope": ["uaa.user"] }The refresh token it returns is then usable at the same endpoint (200,
"revocable": true,amr: ["pwd"]).The concern isn't that the client had
passwordin itsauthorized_grant_types— it's what the resulting token claims.cnf.x5t#S256is RFC 8705 §3 sender-constraint: a resource server reading it concludes the presenter holds that certificate. The same token also carries a full user identity. So a Diego app instance certificate ends up certificate-binding a user's token, andapp_id(certificate-derived) sits besideuser_id(password-derived) with nothing marking their provenance.mtlsTokenEndpointSecurityinstallsBackwardsCompatibleTokenEndpointAuthenticationFilterexactly astokenEndpointSecuritydoes, so every grant is reachable. Was that deliberate? If the endpoint is meant for workload identity, restricting it toclient_credentials(and maybejwt-bearer) would match the feature's purpose, and would keepcnfmeaning what RFC 8705 says it means.2. The same validation failure returns 500 with a stack trace, depending on which client-auth filter runs
Authorization: Basic base64("clientId:")— empty secret, which still satisfiesObjectUtils.isEmpty(authentication.getCredentials())inClientDetailsAuthenticationProvider.ClientParametersAuthenticationFilter.wrapClientCredentialLoginskips whenever anAuthorizationheader is present, so this is handled byClientBasicAuthenticationFilter, which catches onlyAuthenticationException:Identical credentials and certificate as the 401 case above — only the filter differs. The clean 401 comes from
AbstractClientParametersAuthenticationFilter.performClientAuthentication'scatch (Exception e) -> BadCredentialsException, which is incidental rather than by design.InvalidClientDetailsExceptionis aUaaException→OAuth2Exception→RuntimeException, not a SpringAuthenticationException, so it escapes the security chain toSecurityFilterChainPostProcessor'sUaaLoggingFilter, which logsERROR "Uncaught Exception:"and sends 500. That's an unauthenticated endpoint writing a stack trace per attempt.Smallest fix is in
validateTlsClientAuth: catch and convert rather than depending on a downstream filter's catch-all.3.
/oauth/mtls/tokenis an unrestricted alias of/oauth/tokenA client with no
tls-client-auth-caat all, authenticating withclient_secret_basicand presenting no certificate, gets a normal token from/oauth/mtls/token(200). Sincemtls_endpoint_aliases.token_endpointnow advertises this path in discovery, it's advertising an endpoint that accepts non-mTLS authentication.4.
tls-client-auth-sub-templatewith no placeholders forges an arbitrarysubMtlsClaimsEnhancer.renderTemplatereturns a template containing no{placeholder}verbatim, andUaaTokenServices.createJWTAccessTokenre-appliessubafter its own defaults.ClientAdminEndpointsValidator.validateTemplatePlaceholdersonly checks that placeholders that are present are declared — it never requires one.Configuring
tls-client-auth-sub-template: "00000000-0000-0000-0000-000000000000"produced a token with exactly thatsub. It needsclients.write, so it's client-admin → subject-spoofing rather than an anonymous hole, butsubis the canonical identity claim for every downstream resource server, anduser_id/cidare protected whilesubdeliberately is not. Requiring at least one placeholder would close it.5. Claim mappings can set
amrandacrNON_ADDITIONAL_ROOT_CLAIMSprotects UAA's own claims but not the authentication-context claims that downstream policy engines read, and the validator applies no allowlist to claim names. An allowlist (or a reserved-name denylist) at config time would be better than relying on the token-builder denylist.6. With
uaa.mtls-enabled=false, the endpoint still resolvesThe security chain is
@ConditionalOnProperty, butUaaTokenEndpoint's@RequestMappinglists/oauth/mtls/tokenunconditionally and both new servlet filters are registered unconditionally. With the feature off, a POST returns:{"error":"Could not verify the provided CSRF token because no token was found to compare."}That's the
uiSecuritycatch-all's CSRF filter — so the request reaches the browser login chain rather than 404ing. It fails closed here, but by accident. A disabled feature's endpoint should not resolve.Related (from reading):
clientCertificateMapperFilter()reflectively instantiates a package-private third-party class at startup unconditionally and throwsIllegalStateExceptionif it's absent — a hard startup failure for a feature that's off.7.
token-endpoint-auth-methodrejection breaks unrelated clientsClientAdminEndpointsValidator.checkMtlsClientConfigAllowedthrows on the mere presence of that key inadditionalInformation, for every client create/update, mTLS or not. Creating a plain client carrying it returns 400. That string appears nowhere else in UAA, but hyphenated unknown keys from a BOSHoauth.clientsmanifest land inadditionalInformation, so deployments may already have clients carrying it — previously ignored, now uncreatable and unupdatable. Is this a leftover from an earlier iteration?Smaller things, from reading rather than testing
MtlsClientAuthTomcatCustomizer.customizeiterates all connectors and allSSLHostConfigs with no port scoping, so enabling the feature makes UAA send aCertificateRequeston every TLS connection including the login page — browsers holding a client cert will prompt end users — and moves the whole connector to BCJSSE in FIPS mode. Worth scoping to a dedicated connector, or documenting loudly.ClientDetailsAuthenticationProvider.getTlsClientAuthConfigurationandMtlsClaimsEnhancer.loadTlsConfigare ~60 lines of identical parsing in different modules. One decides authentication, the other decides what identity claims go in the token; if they drift, those two disagree. A single static factory onTlsClientAuthConfigurationwould be safer.parsePemCertificatedoes oneparser.readObject(), so an operator pasting a root+intermediate bundle intotls-client-auth-casilently gets only the first certificate as trust anchor.setRevocationEnabled(false)is reasonable for short-lived instance certs but deserves a line in the docs, since the same config accepts arbitrary long-lived certificates.allowQueryStringForTokens(default true), soGET /oauth/mtls/token?grant_type=...works.ClientCredentialsTokenGranter.isAllowedAuthMethodis public static and called only from a test.Offer
I have the MockMvc tests that produced all of the above — roughly 17 tests covering credential confusion, grant types at the endpoint, validation-failure semantics, and claim-mapping reach, using real BouncyCastle-generated certificates rather than mocks. Happy to push them to the branch if useful; several of them are worth keeping as regression coverage regardless of what you decide about the findings, particularly the wrong-CA case and the protected-claim guard, which aren't currently covered.