Skip to content

Review of 3972 - #4075

Draft
fhanik wants to merge 133 commits into
cloudfoundry:developfrom
fhanik:review/pr3972
Draft

fhanik wants to merge 133 commits into
cloudfoundry:developfrom
fhanik:review/pr3972

Conversation

@fhanik

@fhanik fhanik commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

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/token to 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:

  • A certificate that doesn't chain to the client's tls-client-auth-ca is 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.
  • Claim mappings cannot forge scope, client_id or zidNON_ADDITIONAL_ROOT_CLAIMS in UaaTokenServices does its job.
  • tls-client-auth-required-claims correctly rejects a valid certificate from the same CA in the wrong space. The shared-CA mitigation the docs describe holds.
  • An mTLS-configured client can't fall back to its client_secret, can't mix cert+secret on one request, and a CA certificate presented as the leaf is rejected by validateEndEntityConstraints.

1. The mTLS endpoint serves every grant type, including password and refresh_token

POST /oauth/mtls/token with grant_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 password in its authorized_grant_types — it's what the resulting token claims. cnf.x5t#S256 is 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, and app_id (certificate-derived) sits beside user_id (password-derived) with nothing marking their provenance.

mtlsTokenEndpointSecurity installs BackwardsCompatibleTokenEndpointAuthenticationFilter exactly as tokenEndpointSecurity does, so every grant is reachable. Was that deliberate? If the endpoint is meant for workload identity, restricting it to client_credentials (and maybe jwt-bearer) would match the feature's purpose, and would keep cnf meaning 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 satisfies ObjectUtils.isEmpty(authentication.getCredentials()) in ClientDetailsAuthenticationProvider. ClientParametersAuthenticationFilter.wrapClientCredentialLogin skips whenever an Authorization header is present, so this is handled by ClientBasicAuthenticationFilter, which catches only AuthenticationException:

"http.path":"/oauth/mtls/token","http.status_code":"500"

Uncaught Exception:
error="invalid_client", error_description="tls_client_auth: certificate chain validation failed: ..."
	at TlsClientAuthentication.validateClientCert(TlsClientAuthentication.java:343)
	at ClientDetailsAuthenticationProvider.validateTlsClientAuth(ClientDetailsAuthenticationProvider.java:217)
	at ClientDetailsAuthenticationProvider.additionalAuthenticationChecks(ClientDetailsAuthenticationProvider.java:95)

Identical credentials and certificate as the 401 case above — only the filter differs. The clean 401 comes from AbstractClientParametersAuthenticationFilter.performClientAuthentication's catch (Exception e) -> BadCredentialsException, which is incidental rather than by design.

InvalidClientDetailsException is a UaaExceptionOAuth2ExceptionRuntimeException, not a Spring AuthenticationException, so it escapes the security chain to SecurityFilterChainPostProcessor's UaaLoggingFilter, which logs ERROR "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/token is an unrestricted alias of /oauth/token

A client with no tls-client-auth-ca at all, authenticating with client_secret_basic and presenting no certificate, gets a normal token from /oauth/mtls/token (200). Since mtls_endpoint_aliases.token_endpoint now advertises this path in discovery, it's advertising an endpoint that accepts non-mTLS authentication.

4. tls-client-auth-sub-template with no placeholders forges an arbitrary sub

MtlsClaimsEnhancer.renderTemplate returns a template containing no {placeholder} verbatim, and UaaTokenServices.createJWTAccessToken re-applies sub after its own defaults. ClientAdminEndpointsValidator.validateTemplatePlaceholders only 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 that sub. It needs clients.write, so it's client-admin → subject-spoofing rather than an anonymous hole, but sub is the canonical identity claim for every downstream resource server, and user_id/cid are protected while sub deliberately is not. Requiring at least one placeholder would close it.

5. Claim mappings can set amr and acr

"amr": "mfa", "acr": "urn:example:high"

NON_ADDITIONAL_ROOT_CLAIMS protects 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 resolves

The security chain is @ConditionalOnProperty, but UaaTokenEndpoint's @RequestMapping lists /oauth/mtls/token unconditionally 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 uiSecurity catch-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 throws IllegalStateException if it's absent — a hard startup failure for a feature that's off.

7. token-endpoint-auth-method rejection breaks unrelated clients

ClientAdminEndpointsValidator.checkMtlsClientConfigAllowed throws on the mere presence of that key in additionalInformation, 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 BOSH oauth.clients manifest land in additionalInformation, 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.customize iterates all connectors and all SSLHostConfigs with no port scoping, so enabling the feature makes UAA send a CertificateRequest on 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.getTlsClientAuthConfiguration and MtlsClaimsEnhancer.loadTlsConfig are ~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 on TlsClientAuthConfiguration would be safer.
  • parsePemCertificate does one parser.readObject(), so an operator pasting a root+intermediate bundle into tls-client-auth-ca silently 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.
  • The new endpoint inherits allowQueryStringForTokens (default true), so GET /oauth/mtls/token?grant_type=... works.
  • ClientCredentialsTokenGranter.isAllowedAuthMethod is 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.

rkoster added 30 commits August 18, 2026 08:50
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().
…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.
rkoster added 22 commits August 28, 2026 10:15
…client-auth

# Conflicts:
#	server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java
@fhanik

fhanik commented Sep 16, 2026

Copy link
Copy Markdown
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

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Development

Successfully merging this pull request may close these issues.

2 participants