Skip to content

Review or #3968 - SPIFFE Work - #4085

Closed
fhanik wants to merge 8 commits into
cloudfoundry:developfrom
fhanik:review/pr3968
Closed

fhanik wants to merge 8 commits into
cloudfoundry:developfrom
fhanik:review/pr3968

Conversation

@fhanik

@fhanik fhanik commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

PR #4076 — code-review guide

Pull request: #4076 ·
base: cloudfoundry/develop (diff taken from merge-base 6cc037111, the tip of develop that the
branch forked from) · head: review/pr3792-fix at 1b5b51b68+ ·
overall: 94 files, +13 935 / −53, of which docs are excluded below.

How to read this

  • Every file and line reference is a link into the PR's "Files changed" tab (#diff-<hash> anchors,
    R<n> = line n on the new side). They open the file's diff and scroll to the range.
    GitHub collapses large diffs — if an anchor lands on a collapsed file, click Load diff.
  • The links assume the PR head contains the latest push (1b5b51b68). Line anchors move if the branch is rebased.
  • Flag column (uaa.mtls-enabled, default false):
Mark Meaning
✅ Gated does nothing observable when the flag is off
⚪ Inert not flag-gated, but cannot act unless something only the flag enables is present (e.g. path that 404s, a config key that can't be registered)
⚠️ Un-gated change behaviour changes for every deployment — review these hardest
🔧 Build/plumbing no runtime behaviour of its own

Summary of the ⚠️ items (details in §1): M1 granted_scopes dropped from refresh-issued access tokens
(UaaTokenServices); M2 UaaAuthenticationDetails gains a field and has no
serialVersionUID (UaaAuthenticationDetails); widened
UAA_SUPPORTED_METHODS
(ClientAuthentication) — contained by an external-IdP split;
tls-client-auth-ca is now an interpreted additionalInformation key
(ClientDetailsAuthenticationProvider).
Fuller reasoning: docs/PR 4076 Thoughts/pr4076-develop-merge-evaluation.md.


Section 1 — Changed production files

Grouped by module; within a module, ordered roughly by how much a reviewer should care.

1.1 Server — request path, security chain, token issuance

UaaTokenServices

server/src/main/java/org/cloudfoundry/identity/uaa/oauth/UaaTokenServices.java

whole file NON_ADDITIONAL_ROOT_CLAIMS enhancer claims filter sub/aud re-apply
  • What changed: (1) GRANTED_SCOPES added to NON_ADDITIONAL_ROOT_CLAIMS. (2) In createJWTToken,
    enhancer-supplied claims are now applied before UAA's own defaults and skip every protected claim name, then
    sub/aud alone are re-applied at the end.
  • Why: (1) A refresh must not copy the full consented-scope set onto a deliberately narrowed access token; the old
    refreshTokenClaims.remove(GRANTED_SCOPES) ran after the copy loop and never worked. (2) MtlsClaimsEnhancer
    output is certificate-derived and client-configurable, so it must not be able to overwrite scope, client_id,
    iss, authorities… ; only sub/aud are meant to be overridable.
  • Flag: (1) ⚠️ Un-gated (M1). NON_ADDITIONAL_ROOT_CLAIMS is also read by the pre-existing
    getAdditionalRootClaims, which every grant_type=refresh_token call goes through → refreshed access tokens stop
    carrying granted_scopes on every deployment. Correct behaviour, but a signed-token change on the busiest non-mTLS
    path. (2) ✅ Gated: the loop only sees entries when uaaTokenEnhancers is non-empty, i.e. when MtlsClaimsEnhancer
    is a bean (flag on).

ClientDetailsAuthenticationProvider

server/src/main/java/org/cloudfoundry/identity/uaa/authentication/ClientDetailsAuthenticationProvider.java

whole file mTLS branch in additionalAuthenticationChecks path check / validateTlsClientAuth / config parsing
  • What changed: Adds a TlsClientAuthentication collaborator (constructor signature change). A client whose
    additionalInformation has tls-client-auth-ca is exclusively mTLS: it must authenticate at /oauth/mtls/token
    with no credentials, must register exactly one RFC 8705 §2.1.2 subject value, and is refused at /oauth/token.
    Conversely /oauth/mtls/token refuses any client without that config (so it is not an alias of /oauth/token).
    Validation = PKIX chain → TlsClientAuthSubjectMatcher → required-claims. InvalidClientDetailsException is
    converted to BadCredentialsException (previously a 500 on the Basic-auth path). isTlsClientAuthPath answers true
    if either the decoded servlet path or the raw request path resolves to the endpoint (fail closed; handles
    /oauth/%6dtls/token).
  • Why: RFC 8705 client authentication, and closing the cross-tenant impersonation hole where any cert chaining to
    a shared CA (Diego instance-identity) authenticated as any client trusting that CA.
  • Flag: ⚪ Inert when off, but not flag-gated in code. Reachability is removed upstream: the endpoint 404s and
    tls-client-auth-ca cannot be registered. Review point: a pre-existing client that already happens to carry
    an additionalInformation key literally named tls-client-auth-ca would now be treated as mTLS-only and refused at
    /oauth/token. Nothing in UAA defined that key before this PR; check there are no foreign users. Also a
    state-transition case: clients registered with the flag on become inert (not insecure) after it is turned off — test
    E8.

UaaAuthenticationDetails

server/src/main/java/org/cloudfoundry/identity/uaa/authentication/UaaAuthenticationDetails.java

whole file new field populated from request getter
  • What changed: New @JsonIgnore private final String servletPath (decoded servlet path) alongside requestPath,
    set in all three constructors, with a getter.
  • Why: requestPath comes from getRequestURI() (raw, undecoded); the mTLS gate must see the same decoded path
    every other gate uses (/oauth/%6dtls/token bypass, security review §2).
  • Flag: ⚠️ Un-gated (M2). The class implements Serializable and declares no serialVersionUID; adding a
    field changes the implicit one. It sits in the Spring-Session-JDBC-persisted security context, so session rows
    written by the previous version fail to deserialize after upgrade. Fix: make servletPath transient or pin the
    UID to develop's value.

UaaTokenEndpoint

server/src/main/java/org/cloudfoundry/identity/uaa/oauth/token/UaaTokenEndpoint.java

whole file @RequestMapping adds /oauth/mtls/token guards: grant type, RFC 8707 resource
  • What changed: Type-level @RequestMapping now also maps /oauth/mtls/token. doDelegateGet/Post call two new
    guards that act only when the servlet path is the mTLS endpoint: rejectNonWorkloadGrantAtMtlsEndpoint (only
    client_credentials) and enforceResourceIndicator (RFC 8707 resource: ≤1 value, absolute URI without fragment,
    must be in the client's tls-client-auth-allowed-resources; else invalid_target).
  • Why: Without the grant restriction a cert-authenticated client could trade a password for a user token stamped
    with cnf, asserting two identities in one token.
  • Flag: ⚪ Inert. The mapping exists unconditionally but MtlsEndpointAvailabilityFilter 404s the path when
    the flag is off; both guards early-return on any other path. Doc nit (L1): the javadoc on
    enforceResourceIndicator still claims this is the only path into the granter so the enhancer needs no second check
    — disproved by security-review finding 1.

OauthEndpointSecurityConfiguration

server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointSecurityConfiguration.java

whole file mtlsTokenEndpointSecurity chain
  • What changed: New mtlsTokenEndpointSecurity UaaFilterChain at FilterChainOrder.OAUTH_11, matching
    /oauth/mtls/token and below: client-auth filters as in the token endpoint chain, stateless, CSRF off, anonymous
    off, /** requires full authentication.
  • Why: Gives the endpoint its own OAuth chain instead of falling through to the catch-all browser (uiSecurity) chain.
  • Flag: ✅ Gated by @Conditional(MtlsEnabledCondition.class) — bean absent when off.
    (@ConditionalOnProperty was replaced because it only accepts the literal true; see MtlsEnabledCondition.)

SpringServletXmlFiltersConfiguration

server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java

whole file three new FilterRegistrationBeans
  • What changed: Registers MtlsEndpointAvailabilityFilter (order −290), RawPeerCertificateCaptureFilter (−300)
    and clientCertificateMapperFilter (−200, wraps the buildpack ClientCertificateMapper in MtlsPathGuardedFilter,
    reflectively because the class is package-private).
  • Why: Order matters: capture the genuine TLS-peer cert before the mapper overwrites
    jakarta.servlet.request.X509Certificate with the XFCC-derived one; availability filter must run after zone-path
    rewriting and before Spring Security (−100).
  • Flag: ✅ availability filter (flag arg) and ✅ mapper (registration setEnabled(false) and the buildpack class is
    never loaded when off). ⚪ capture filter is ungated — it only copies one attribute, and only on paths that 404 when
    off.

OauthEndpointBeanConfiguration

server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointBeanConfiguration.java

whole file clientAuthenticationProvider wiring
  • What changed: Injects the TlsClientAuthentication component into ClientDetailsAuthenticationProvider.
  • Why: Constructor change above.
  • Flag: ⚪ TlsClientAuthentication is an ungated @Component but only used after the provider has seen a
    tls-client-auth-ca client on the mTLS path.

ClientCredentialsTokenGranter

server/src/main/java/org/cloudfoundry/identity/uaa/oauth/provider/client/ClientCredentialsTokenGranter.java

whole file ALLOWED_AUTH_METHODS
  • What changed: tls_client_auth added to the allowed client-auth methods for client_credentials; new static isAllowedAuthMethod.
  • Why: Otherwise a cert-authenticated client could not obtain a client_credentials token.
  • Flag: ⚪ Only reachable by a request whose client_auth_method is tls_client_auth, which only the mTLS path produces.

FilterChainOrder

server/src/main/java/org/cloudfoundry/identity/uaa/web/FilterChainOrder.java

whole file OAUTH_11
  • What changed: Adds constant OAUTH_11 = 211.
  • Why: Order slot for the new chain (after OAUTH_10, which has a disjoint matcher).
  • Flag: 🔧 constant only.

1.2 Server — client registration and bootstrap

ClientAdminEndpointsValidator

server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminEndpointsValidator.java

whole file mtlsEnabled ctor param validate() hooks checkMtlsClientConfigAllowed + claim-config validation
  • What changed: Constructor takes mtlsEnabled. validate now calls checkMtlsClientConfigAllowed (rejects
    tls-client-auth-ca/-trusted-proxy-ca when off; parses PEMs; requires exactly one of the five §2.1.2 subject
    parameters) and validateTlsClientAuthClaimConfig (claim-mapping fields, reserved/dotted-reserved claim names,
    pattern only on subject_ou and with a capture group, template length ≤256 and ≥1 placeholder, placeholders must
    reference declared claims, required-claims shape, RFC 8707 allow-list shape, allow-list ⊕ aud-templates).
  • Why: Registration-time enforcement so malformed or forgeable mTLS config never reaches token issuance.
  • Flag: ✅ Gated: both calls are no-ops unless one of the tls-client-auth-* keys is present, and the CA keys
    throw when the flag is off. Existing clients without those keys are unaffected. L4: claim-mapping pattern has
    no complexity bound, unlike templates.

ZoneEndpointsClientDetailsValidator

server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZoneEndpointsClientDetailsValidator.java

whole file ctor + flag calls + secret relaxation hasNonblankTlsClientAuthCa
  • What changed: Takes mtlsEnabled; on CREATE runs the same two checks; a client with a non-blank CA string may
    omit client_secret; getAdditionalInformation() null-safe.
  • Why: Zone-admin client API is a separate registration path that must obey the same flag.
  • Flag: ✅ Gated. The secret relaxation can only trigger if checkMtlsClientConfigAllowed has already passed,
    i.e. flag on.

ClientAdminBootstrap

server/src/main/java/org/cloudfoundry/identity/uaa/client/ClientAdminBootstrap.java

whole file ctor + flag checks
  • What changed: Takes mtlsEnabled; bootstrapped (BOSH oauth.clients) clients run the same two checks.
  • Why: Bootstrap bypasses the admin API, so it needs its own enforcement.
  • Flag: ✅ Gated (same no-op-without-keys behaviour).

SpringServletXmlBeansConfiguration

server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlBeansConfiguration.java

whole file clientDetailsValidator bean
  • What changed: Passes ${uaa.mtls-enabled:false} into ClientAdminEndpointsValidator.
  • Why: Wiring.
  • Flag: ✅ carries the flag.

1.3 Server — discovery and external IdPs

OpenIdConnectEndpoints

server/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpoints.java

whole file flag + discovery
  • What changed: Takes mtlsEnabled; builds the document with the 3-arg OpenIdConfiguration; when on, sets mtls_endpoint_aliases.token_endpoint.
  • Why: RFC 8705 §5 / §3.3 metadata.
  • Flag: ✅ Gated. Off → no mtls_endpoint_aliases, no tls_client_auth in
    token_endpoint_auth_methods_supported. One additive field is always emitted:
    tls_client_certificate_bound_access_tokens (false when off — the RFC default).

ExternalOAuthAuthenticationManager

server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthAuthenticationManager.java

whole file code exchange oauthTokenRequest
  • What changed: Both outbound token-request sites throw ProviderConfigurationException if an IdP's authMethod
    is tls_client_auth.
  • Why: UAA-as-relying-party has no client-cert plumbing; a stale/forced value must fail loudly before any request
    is sent.
  • Flag: ⚠️→✅ ungated code, behaviour-preserving: external IdP accepts exactly the methods it accepted before.
    Tests pin the rejection of tls_client_auth.

ExternalOAuthIdentityProviderConfigValidator

server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthIdentityProviderConfigValidator.java

whole file validator
  • What changed: Uses the new isExternalOAuthMethodSupported / EXTERNAL_OAUTH_SUPPORTED_METHODS (the old
    four-method list).
  • Why: Widening UAA_SUPPORTED_METHODS must not leak tls_client_auth into relying-party config.
  • Flag: ⚠️→✅ ungated code, behaviour-preserving: external IdP accepts exactly the methods it accepted before.
    Tests pin the rejection of tls_client_auth.

OauthIDPWrapperFactoryBean

server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/OauthIDPWrapperFactoryBean.java

whole file authMethod check
  • What changed: Same switch to isExternalOAuthMethodSupported for BOSH-supplied IdP definitions.
  • Why: Same reason.
  • Flag: ⚠️→✅ ungated code, behaviour-preserving: external IdP accepts exactly the methods it accepted before.
    Tests pin the rejection of tls_client_auth.

1.4 Model module

ClientAuthentication

model/src/main/java/org/cloudfoundry/identity/uaa/constants/ClientAuthentication.java

whole file constants + lists isValidMethod getCalculatedMethod
  • What changed: Adds TLS_CLIENT_AUTH; adds it to UAA_SUPPORTED_METHODS; new
    EXTERNAL_OAUTH_SUPPORTED_METHODS (old list) and isExternalOAuthMethodSupported; 4-arg
    isValidMethod/getCalculatedMethod with hasCaConfig (3-arg overloads delegate with false).
  • Why: Registers the method and teaches the validity matrix that tls_client_auth needs a CA and forbids a secret/key.
  • Flag: ⚠️ Un-gated constant widening. Every caller of isMethodSupported/UAA_SUPPORTED_METHODS now treats
    tls_client_auth as known. The audited external-IdP callers are split off (above); re-check for any other caller
    when reviewing.

OpenIdConfiguration

model/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConfiguration.java

whole file tokenAMR default new fields + 2-arg ctor 3-arg ctor
  • What changed: tokenAMR default gains tls_client_auth, removed again in the constructor when mtlsEnabled
    is false
    ; new mtls_endpoint_aliases (NON_NULL) and tls_client_certificate_bound_access_tokens; new 3-arg
    constructor; the pre-existing 2-arg constructor now delegates with false.
  • Why: Discovery metadata. The 2-arg default was true earlier on the branch (fail-open); fixed so the unchanged
    public signature produces the unchanged document.
  • Flag: ✅ Gated by the constructor argument; default fails closed. Wire diff on a default deployment: one
    extra field, tls_client_certificate_bound_access_tokens:false. Note the field initializer default still contains
    tls_client_auth — only the constructors strip it; @NoArgsConstructor (Jackson) bypasses the strip.

UaaClientDetails

model/src/main/java/org/cloudfoundry/identity/uaa/client/UaaClientDetails.java

whole file field copy ctor setter / getter equals hashCode
  • What changed: New @JsonIgnore TlsClientAuthConfiguration field; setter mirrors it into additionalInformation
    (and clears all keys on null); included in the copy constructor, equals, hashCode.
  • Why: Typed access to the mTLS config.
  • Flag: ⚪ Inert — L3: nothing in production calls the setter (JDBC clients carry the config in
    additionalInformation), so the “typed field first” fast paths in MtlsClaimsEnhancer and
    UaaTokenEndpoint.allowedResourcesFor are always a miss; the field is not transient while
    TlsClientAuthConfiguration is not Serializable; and it makes equal-config clients compare unequal depending on
    how they were built.

OAuth2Exception

model/src/main/java/org/cloudfoundry/identity/uaa/oauth/common/exceptions/OAuth2Exception.java

whole file constant fromMap
  • What changed: Adds INVALID_TARGET and maps it to the new InvalidTargetException.
  • Why: RFC 8707 error code.
  • Flag: ⚪ additive constant/mapping; nothing emits it unless the mTLS path runs.

TokenConstants

model/src/main/java/org/cloudfoundry/identity/uaa/oauth/token/TokenConstants.java

whole file constant
  • What changed: CLIENT_AUTH_TLS_CLIENT_AUTH.
  • Why: Name for the client_auth_method claim value.
  • Flag: ⚪ additive constant/mapping; nothing emits it unless the mTLS path runs.

1.5 Build files

File Change Flag
libs.versions.toml adds java-buildpack-client-certificate-mapper-jakarta 2.0.1 and spring-boot-tomcat aliases 🔧 (mapper jar is on the classpath for everyone but only loaded when the flag is on; ClientCertificateMapperAutoConfigurationExclusionTest guards against it self-registering)
build.gradle.kts implementation of both (the Tomcat customizer needs spring-boot-tomcat; the filter needs the mapper) 🔧 (mapper jar is on the classpath for everyone but only loaded when the flag is on; ClientCertificateMapperAutoConfigurationExclusionTest guards against it self-registering)
build.gradle.kts testImplementation(bouncyCastlePkixFips) for cert generation in docs/MockMvc tests 🔧 (mapper jar is on the classpath for everyone but only loaded when the flag is on; ClientCertificateMapperAutoConfigurationExclusionTest guards against it self-registering)
index.html.md.erb API-docs: new mtls_endpoint_aliases / tls_client_certificate_bound_access_tokens rows and the mTLS token-endpoint section 🔧 (mapper jar is on the classpath for everyone but only loaded when the flag is on; ClientCertificateMapperAutoConfigurationExclusionTest guards against it self-registering)

Section 2 — Changed existing test files (the test itself changed)

Purely additive test methods are in §4a. This section is only for tests whose existing lines or configuration
changed. Across the PR, no pre-existing @Test/@ParameterizedTest/@Nested was deleted, renamed or disabled
(checked with the grep in pr4076-backwards-compatibility-audit.md §1).

2.1 Behavioural / expectation changes — read these

Test Change Why Impact
UaaClientDetailsTest:279 hashCode()).isPositive() → .isNotZero() hashCode now mixes in the new field and may be negative; isPositive asserted something Object.hashCode never promised Weakens a smoke assertion only; equals/hashCode consistency is covered elsewhere.
OpenIdConfiguration.json:77 adds "tls_client_certificate_bound_access_tokens": false fixture is the serialized default discovery document The default discovery document grows by exactly one field (additive; RFC 8705 §3.3 treats omitted as false). tls_client_auth is not in the fixture — default stays mTLS-free.
TokenEndpointDocs:138 @TestPropertySource gains uaa.mtls-enabled=true for the whole class; autowires rawPeerCertificateCaptureFilterRegistration (:217) the new mTLS docs example cannot document a disabled endpoint Enabling the flag registers MtlsClaimsEnhancer, which flips UaaTokenServices onto its enhancer code path for all 30 examples. The class javadoc records a measurement: 29 other examples' response key-sets and field tables are identical on/off. If the enhancer ever contributes claims for non-mTLS callers this guarantee breaks → move the example to its own class.
OpenIdConnectEndpointDocs:47 ui_locales_supported line gains a trailing comma; two fieldWithPath rows added (mtls_endpoint_aliases.token_endpoint optional/STRING, tls_client_certificate_bound_access_tokens) published discovery example Class stays on the default flag, so the published example shows what most deployments return; the alias is documented as conditional. The comma change is mechanical.
OpenIdConnectEndpointsMockMvcTests:77 and …ZonePathTests:91 extra assertion getMtlsEndpointAliases()).isNull() in the existing default-discovery test earlier on the branch these classes were switched to mtls-enabled=true, which silently removed the default-deployment regression; they were reverted and strengthened Pins that a default deployment (default zone and per-zone, both addressing modes) advertises no alias. Existing assertions untouched.

2.2 Mechanical updates for changed production signatures (no assertion changed)

Test Edit Because
UaaClientAuthenticationProviderTest.java (L50, L62, L68) adds a TlsClientAuthentication mock and passes it to the new 4-arg ClientDetailsAuthenticationProvider constructor ClientDetailsAuthenticationProvider ctor
ClientAdminEndpointsTests.java (L124) new ClientAdminEndpointsValidator(..., false) validator ctor gained mtlsEnabled
ClientAdminEndpointsValidatorTests.java (L107) same one-line ctor edit (the rest of the file is additive → §4a) validator ctor
ClientAdminBootstrapMultipleSecretsTest.java (L70) trailing , false argument ClientAdminBootstrap ctor gained mtlsEnabled
ClientAdminBootstrapMultipleSecretsUpdateTests.java (L64) trailing , false argument same
ClientAdminBootstrapTests.java (L112, L135, L156, L193, L404, L493) 5 ctor call sites get false; @BeforeEach registers BouncyCastleFipsProvider (the new tests parse real PEMs) same; (rest additive → §4a)
ClientAdminBootstrapProdEncoderTest.java (L88) trailing , false argument (integration-test source set) same
ZoneEndpointsClientDetailsValidatorTests.java (L72) @InjectMocks removed in favour of explicit construction with the flag; BC FIPS provider registered ZoneEndpointsClientDetailsValidator ctor gained mtlsEnabled (Mockito can't pick the primitive)

Source-compat note: these ctor changes remove the old public signatures (no deprecated overloads). Internal
Spring-wired classes, but anyone constructing them by hand must add the argument.


Section 3 — New production files

All new, so the diff is the whole file. Flag column as in the legend.

Module File Summary Flag
server TlsClientAuthentication.java @Component. PKIX validation of a client chain against the per-client CA (all PEMs in a bundle are anchors → CA rotation), end-entity constraints (not a CA, KeyUsage digitalSignature, EKU clientAuth/any), trusted-proxy validation for XFCC, claim-mapping extraction (CN/OU/O, escaped commas, multi-valued RDNs), required-claims check, and selection of the right chain per trust model (direct vs proxy-only). Revocation is off (setRevocationEnabled(false)). ⚪
server TlsClientAuthSubjectMatcher.java RFC 8705 §2.1.2 exact binding: tls_client_auth_subject_dn (LdapName compare, order-significant), …_san_dns/uri/ip/email (typed SAN match; IP compared in binary form). Fails closed unless exactly one binding is configured. This is the headline security fix over #3972. ⚪
server MtlsClaimsEnhancer.java UaaTokenEnhancer. For tls_client_auth requests only: certificate-derived claims (dot-notation nesting, reserved names skipped), cnf.x5t#S256, sub/aud templates, and the RFC 8707 resource → aud (re-checked against the allow-list even though the endpoint already did). Fails closed if cnf cannot be computed. L2: unguarded (UaaClientDetails) cast and a comment referring to handling that isn't there. ✅ @Conditional(MtlsEnabledCondition) (via the bean definition)
server MtlsEnabledCondition.java Spring Condition reading uaa.mtls-enabled via Environment.getProperty(.., Boolean.class, false) so 1/yes/on mean the same as for @Value boolean injection — prevents a half-enabled feature. ✅ is the gate
server MtlsEndpointAvailabilityFilter.java Order −290. Off → 404; any path below /oauth/mtls/token → 404 (closes the /oauth/mtls/token/oauth/token routing bypass); non-POST → 405 + Allow: POST. Only ever denies. ✅
server RawPeerCertificateCaptureFilter.java Order −300. Copies the genuine handshake peer cert into a private attribute before the mapper overwrites the standard one; exposes isMtlsTokenPath used across the feature (prefix semantics). ⚪ ungated, harmless
server MtlsPathGuardedFilter.java Wraps the buildpack ClientCertificateMapper so it only runs on the effective (post zone-rewrite) mTLS path. ✅ only constructed when on
server MtlsClientAuthTomcatCustomizer.java WebServerFactoryCustomizer: when on, installs BCJSSESslImplementation, sets certificateVerification=optionalNoCA and NoAcceptedIssuersTrustManager on every SSLHostConfig; idempotently registers BCFIPS + BCJSSE and fails fast if impostor/non-FIPS providers hold those names. Connector-wide, not per-endpoint — every TLS handshake now gets a CertificateRequest with no issuer list. ✅ returns before touching the connector when off
server BCJSSEUtil.java Tomcat JSSEUtil backed by BCJSSE; advertises TLS 1.3 renegotiable auth; sources implemented protocols/ciphers from BCJSSE (SunJSSE's set includes SSLv2Hello, which BCJSSE rejects). ✅ (only reachable via the customizer)
server BCJSSESSLContext.java Tomcat SSLContext adapter over javax.net.ssl.SSLContext from the BCJSSE provider. ✅
server BCJSSESslImplementation.java JSSEImplementation returning BCJSSEUtil; named via sslImplementationName so only this connector uses BCJSSE. ✅
server NoAcceptedIssuersTrustManager.java X509TrustManager with no-op checks and an empty accepted-issuers list, so Go-based clients (Gorouter) aren't told “only public CAs” and don't send an empty Certificate. ✅
model TlsClientAuthConfiguration.java Model for all tls-client-auth-* keys (CA, trusted-proxy CA, claim mappings, sub/aud templates, required claims, allowed resources, five §2.1.2 subject parameters), the reserved-claim set (isReservedClaimName also catches dotted children), isConfigured, configuredSubjectBindings. ⚪ data class
model InvalidTargetException.java RFC 8707 invalid_target, HTTP 400. ⚪

Section 4 — New tests

4a. Tests added to existing test classes

OpenIdConfigurationTests.java

Discovery model: default advertises nothing; flag-on advertises tls_client_auth; the 2-arg ctor fails closed.

Tests:

UaaClientDetailsTest.java

Copy-constructor keeps flat tls-client-auth-* info when typed config is null; JSON round trip; clearing removes all
six keys.

Tests:

ClientAuthenticationTest.java

The isValidMethod/getCalculatedMethod matrix with hasCaConfig; external-IdP list excludes tls_client_auth.

Tests:

UaaClientAuthenticationProviderTest.java

A secret presented for a tls-client-auth-ca client is refused (BadCredentialsException).

Tests:

ClientAdminBootstrapTests.java

BOSH-bootstrap path honours the flag and the §2.1.2 / claim-config rules (flag off rejects; on accepts; zero/two/blank
subject bindings rejected; SAN binding OK; ordinary client unaffected when off).

Tests:

ClientAdminEndpointsValidatorTests.java

Largest block (~560 lines): flag gating, the five-parameter §2.1.2 rule (parameterised), PEM validation, and ~30
validateTlsClientAuthClaimConfig_* cases (patterns only on subject_ou with a capture group, reserved and
dotted-reserved claims, templates, required-claims, length bounds). Several are labelled COPILOT REVIEW from the
upstream review round.

Added at:

Tests:

ZoneEndpointsClientDetailsValidatorTests.java

Zone-client API: flag gating, secretless clients allowed only with a valid non-blank CA string (JSON-map, non-string,
blank and unsupported values rejected), supplied secrets still policy-checked.

Tests:

ClientCredentialsTokenGranterTests.java

tls_client_auth is allowed for client_credentials.

Tests:

UaaTokenEndpointTests.java

Reflection test that the controller maps /oauth/mtls/token and its descendants (the premise of the routing-bypass fix).

Tests:

ExternalOAuthAuthenticationManagerTest.java

A stale tls_client_auth on an external IdP fails before any request is sent (both code-exchange and token-request paths).

Tests:

ExternalOAuthIdentityProviderConfigValidatorTest.java

Validator rejects tls_client_auth on an external IdP.

Tests:

OauthIdentityProviderDefinitionFactoryBeanTest.java

BOSH IdP factory rejects tls_client_auth.

Tests:

TokenEndpointDocs.java

REST-docs example for the mTLS client-credentials grant (generates a CA + leaf in-test).

Tests:

UaaTokenServicesTests.java

Two groups: M1 — refresh with a narrowed scope must not leak granted_scopes (this is the test that proves the
un-gated change); and the enhancer contract — sub/aud override, protected claims cannot be overridden, custom
claims still apply.

Tests:

4b. New test files

Counts are @Test/@ParameterizedTest annotations in the file. Ordered unit → Spring-context → MockMvc/integration.

File Tests What it proves
TlsClientAuthConfigurationTest.java 18 Model: reserved/dotted-reserved claim names, JSON round trips of every key, isConfigured, equality.
TlsClientAuthSubjectMatcherTest.java 17 §2.1.2 matching: DN (case/spacing-insensitive, order-sensitive, unparseable → no match), each SAN type, cross-type SAN confusion rejected, IP in binary form, exactly-one-binding rule.
TlsClientAuthenticationTest.java 39 PKIX validation (expired, intermediates, trust anchor in/out of chain, CA-as-leaf, EKU/KeyUsage), trusted-proxy checks, claim extraction incl. multi-valued RDNs, required-claims, chain selection for direct vs XFCC, mapper-silently-failed guard.
MtlsClaimsEnhancerTest.java 27 Claim mapping, nesting, reserved names, sub/aud templates, cnf thumbprint, only-for-tls_client_auth, fail-closed on lookup/encoding failure, RFC 8707 allow-list re-check.
MtlsEnabledConditionTest.java 3 All truthy spellings match; falsy and absent don't — pins the one-meaning flag.
RawPeerCertificateCaptureFilterTest.java 2 Peer cert copied to the private attribute (or null).
RawPeerCertificateCaptureFilterRegistrationTest.java 6 Filter ordering vs the mapper; effective-path matching incl. zone-path; captured attribute survives the mapper overwriting the standard one.
ClientCertificateMapperFilterTest.java 5 Mapper registered only for the mTLS path, not constructed when off, populates the cert attribute before Security runs.
ClientCertificateMapperAutoConfigurationExclusionTest.java 2 Fails the build if the mapper jar ever ships an auto-config or ServletContainerInitializer registration.
OauthEndpointSecurityConfigurationTests.java 1 The mTLS security chain exists only when the flag is on.
ClientDetailsAuthenticationProviderTests.java 13 mTLS path detection (decoded servlet path vs raw URI, descendants), config parsing from flat additionalInformation, required-claims enforced against a client sharing the same CA.
OpenIdConnectEndpointsTest.java 4 Discovery alias and the three advertisements move together, on and off.
MtlsClientAuthTomcatCustomizerTest.java 11 Connector settings, no-op when off, BCFIPS/BCJSSE registration idempotence and impostor/non-FIPS fail-fast.
BCJSSEUtilTest.java 1 Enabled protocols exclude SSLv2Hello, include TLS 1.3.
BCJSSESSLContextTest.java 2 TLS 1.2/1.3 from BCJSSE; clear error when provider missing.
MtlsClientAuthTomcatCustomizerIntegrationTest.java 6 Real embedded Tomcat: unauthenticated cert accepted & requested, TLS 1.2/1.3 negotiated, BCJSSE actually serving, nothing requested when off.
MtlsDisabledTokenEndpointMockMvcTests.java 9 The off-switch suite: endpoint 404 (default zone and both zone-addressing modes), registration API rejects TLS config, discovery advertises nothing, mapper filter not registered, a previously-persisted mTLS client cannot obtain a bound token (E8).
MtlsFlagConsistencyMockMvcTests.java 1 uaa.mtls-enabled=1 must not half-enable the feature.
MtlsTokenEndpointHardeningMockMvcTests.java 40 End-to-end security matrix in nested groups: credential confusion, grant types, validation failures, claim mapping/forgery, cert↔client binding (§2.1.2), cnf via introspect/check_token (JWT+opaque), CA rotation, POST-only, RFC 8707 resources, descendant-path routing bypass, discovery when on.
MtlsTokenEndpointMockMvcZonePathTests.java 8 Zone isolation in both zone-addressing modes: own-zone issuance and issuer, cross-zone clients unusable, per-zone CA for the same client id, zone-specific alias.

Generated from git diff 6cc037111..HEAD (develop merge-base); docs under docs/PR 4076 Thoughts/ are excluded from
all four sections. Companion documents: pr4076-develop-merge-evaluation.md (findings M1–M2, L1–L4),
pr4076-security-review.md, pr4076-backwards-compatibility-audit.md, pr4076-mtls-enabled-flag-audit.md.

Comment on lines +49 to +59
SecurityFilterChain chain = http
.securityMatcher("/jwt-svid/**")
.authorizeHttpRequests(auth -> {
auth.requestMatchers("/**").hasAuthority("uaa.resource");
auth.anyRequest().denyAll();
})
.authenticationManager(clientAuthenticationManager)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterAt(clientAuthenticationFilter.getFilter(), BasicAuthenticationFilter.class)
.anonymous(AnonymousConfigurer::disable)
.csrf(CsrfConfigurer::disable)
@fhanik fhanik changed the title Review or #3968 Review or #3968 - SPIFFE Work Sep 25, 2026
rkoster and others added 8 commits September 28, 2026 12:39
Adds POST /jwt-svid/sign so UAA can act as a SPIFFE identity server for
Cloud Foundry workloads via offline OIDC federation.

Squashed from 9 commits:

- feat(spiffe): add SpiffeProperties config binding
- feat(spiffe): add SpiffeId formatter and CfInstanceIdentity record
- feat(spiffe): parse org/space/app GUIDs from instance cert OUs
- feat(spiffe): verify instance cert against configured CA and validity
- feat(spiffe): verify proof-of-possession signature and freshness
- feat(spiffe): sign JWT-SVID with UAA active key and OIDC issuer
- feat(spiffe): add JWT-SVID signing endpoint and DTOs
- feat(spiffe): wire SPIFFE properties, CA bean, and security chain
- fix(spiffe): validate jwt-svid request inputs and lock fail-closed paths

The final commit's message, retained in full:

  Reject process_type not matching [A-Za-z0-9_-]{1,63} and audience that is
  blank, over 512 chars, or contains control characters (400). process_type
  is concatenated into the SPIFFE ID path and every proof-of-possession
  message field must be newline-free, so this closes SPIFFE-ID injection and
  PoP message ambiguity. Adds characterization tests pinning existing
  fail-closed behaviour: malformed PEM, future-dated and malformed-base64
  PoP, and a not-yet-valid instance cert.
…d/sign

The feature shipped with unit tests per collaborator but no test that drives
the endpoint through the real filter chain, so nothing covered authentication,
authorization, HTTP status mapping, or the actual signed claim set.

Adds 23 end-to-end tests over the real security chain with real BCFIPS
certificates and real proof-of-possession signatures, grouped as:

  A: issuance, and the exact claim set that goes out (sub/aud/exp/iat/jti/cf)
  B: endpoint auth -- unauthenticated 401, missing uaa.resource 403, bad secret
  C: certificate verification -- untrusted CA, expired, not-yet-valid, bad PEM
  D: proof-of-possession -- wrong key, stale timestamp, audience swap,
     process_type swap, malformed base64
  E: request validation -- process_type path separator, audience blank,
     over-long, and control characters
  F: a JWT-SVID must not double as a UAA access token

Because every SPIFFE bean is gated behind @ConditionalOnProperty, the CA is
generated at runtime and injected via an ApplicationContextInitializer, which
also gives these tests their own context instead of mutating the shared one.

Two tests are RED and stay red until the following commit. They assert correct
behaviour and currently document live defects, both confirmed by running them:

  E5 the configured CA's own self-signed certificate passes chain verification
     and is refused only later, incidentally, for missing CF OUs (400 not 401).
     The trust anchor is usable as a workload identity.
  E6 org/space/app OUs are concatenated into the SPIFFE ID without validation,
     so a certificate bearing OU=app:a/process/admin yields
     spiffe://td/cf/org/o/space/s/app/a/process/admin/process/web -- returns
     200. process_type was hardened against exactly this, but the other three
     path segments were not.

Group F documents a mitigating factor worth keeping: a JWT-SVID is signed with
the same key, same iss and same typ/kid header as a real access token, and only
the absence of client_id/cid/scope stops UAA's own introspection accepting one.
…ce gaps

Turns the two red tests from the previous commit green and hardens four
adjacent issues found reviewing the same code paths.

SPIFFE-ID injection via certificate OUs (was: HTTP 200)
  process_type was validated against [A-Za-z0-9_-]{1,63} precisely because it
  is concatenated into the SPIFFE ID path, but org/space/app -- which land in
  the same path, and in the same newline-delimited proof-of-possession message
  -- were interpolated unchecked straight from certificate OU attributes. A
  cert bearing OU=app:a/process/admin produced
  spiffe://td/cf/org/o/space/s/app/a/process/admin/process/web, so a relying
  party authorizing on a path prefix could be given a different workload's
  identity. SpiffeId now enforces the spec's own rule on every segment --
  "Individual path segments MUST contain only letters, numbers, dots, dashes,
  and underscores" -- rejects '.' and '..', and caps the ID at the specified
  2048 bytes. Segment errors do not echo the offending certificate value.

The trust anchor was usable as a workload identity (was: HTTP 400)
  A self-signed certificate verifies against its own public key, so checking
  only "signed by the CA key" accepted the configured CA certificate as if it
  were a leaf. It was refused solely because it happens to lack CF OUs. The CA
  certificate is public configuration. InstanceIdentityVerifier now rejects
  self-issued and CA-flagged certificates outright, and also checks the CA's
  own validity dates -- an expired trust anchor must stop anchoring trust.

Application-wide @ControllerAdvice
  JwtSvidController.ExceptionHandling was unscoped, and @ControllerAdvice is
  meta-annotated @component while the nested class carries no
  @ConditionalOnProperty of its own. Under
  @componentscan("org.cloudfoundry.identity.uaa") it therefore registered for
  every controller in UAA even with SPIFFE switched off, mapping any
  IllegalArgumentException anywhere in the application to a 400 carrying the
  raw exception message. Now scoped with assignableTypes, matching the only
  other advice in the codebase, HttpMethodNotSupportedAdvice.

Ambiguous duplicate OUs
  Two OUs sharing a prefix silently resolved to whichever the encoder placed
  last, making identity depend on RDN ordering. Now refused.

Unset trust domain
  Only instance-identity-ca gates the feature on, so trust_domain could be
  omitted and every workload on the foundation would be issued an identity
  under spiffe://null/, surfacing only at a relying party. Now a boot failure.
  Also corrects the IllegalStateException text, which named a property
  spelling (instance_identity_ca) that is valid in uaa.yml but is not the
  canonical form, and logs a loud warning when pop-enabled is false, since
  that setting lets any uaa.resource client impersonate any workload.

Verified: 23 end-to-end MockMvc tests and 80 server unit tests pass.
The feature shipped with no operator- or consumer-facing documentation.

API reference
  Adds JwtSvidEndpointDocs, generating Slate snippets for POST /jwt-svid/sign
  from a real request, and registers them in slateCustomizations' index under a
  new "JWT-SVID" section -- snippets are only rendered if explicitly included
  there, so generating them alone would have produced nothing. Documents every
  request and response field, the required Authorization header, and the 400 /
  401 / 403 cases. Like the MockMvc suite, it supplies a runtime-generated CA
  via an ApplicationContextInitializer, since the beans are conditional.

Feature guide (docs/UAA-SPIFFE-JWT-SVID.md)
  Explains what the feature is for -- turning the Diego instance certificate CF
  already issues into a credential that AWS/GCP/Azure workload identity
  federation can consume -- the SPIFFE concepts behind it, the SPIFFE ID format
  and its per-segment character rules, and the full request flow.

  The section worth reading is "the two identities involved": the caller (the
  SPIFFE Agent, authenticated by client credentials, needing uaa.resource) is
  deliberately distinct from the subject (the workload, attested by the
  certificate plus proof of possession). The agent's credentials authorize
  using the endpoint, never minting a particular identity, which is what makes
  one shared agent client safe.

  Also records the operational caveats found during review rather than leaving
  them implicit: bearer-token lifetime versus SPIRE's 5-minute default, replay
  within the proof-of-possession freshness window, the breadth of uaa.resource,
  absence of revocation, default-bucket-only rate limiting, and that SPIFFE
  config is global while the issued iss is per-zone. Explains why an instance
  certificate is not a secret and therefore why pop_enabled must stay on.

Configuration reference
  Documents all five uaa.spiffe.* properties in both halves of the reference
  (quick table and detailed descriptions) plus the ToC, noting that
  instance_identity_ca is the feature gate and that only a directly-issued
  leaf is supported.

Verified: generateDocs succeeds and the rendered HTML contains the section, its
nav entry, and the field tables, with no unrendered ERB.
Review comment cloudfoundry#13 on PR cloudfoundry#3968: the endpoint mints a credential with no
audit record, so there is no trail of which agent obtained which
workload's identity, when. Introduces the JwtSvidIssuedEvent audit
event (AuditEventType code 43) and wires an ApplicationEventPublisher
into JwtSvidController, but the controller does not yet publish the
event -- the new AuditEvents test is intentionally red until the next
commit.
Wires the ApplicationEventPublisher added in the previous commit into
JwtSvidController.sign(): every successful issuance now publishes a
JwtSvidIssuedEvent, giving UAA's audit framework a record of which
agent client obtained which SPIFFE ID, for which audience, and when.
Closes review comment cloudfoundry#13 on PR cloudfoundry#3968. Also injects IdentityZoneManager
to source the event's zone id, since the static IdentityZoneHolder is
deprecated.
…ning

Review comment cloudfoundry#14 on PR cloudfoundry#3968: uaa.spiffe.* configuration is global,
but the endpoint is reachable from any identity zone via subdomain
routing, and JwtSvidSigner takes the issuer from IdentityZoneHolder,
so the same trust domain gets served under differing issuers per zone.

Adds JwtSvidController.ZoneNotSupportedException (404, so the
endpoint's existence isn't revealed outside the default zone) plus a
unit test and an end-to-end MockMvc test asserting a request from a
non-default zone is refused. Both are red: the controller does not
yet perform the check, and the end-to-end test proves that today a
request from another zone is actually signed and returned (200).
Wires the ZoneNotSupportedException added in the previous commit into
JwtSvidController.sign(): a request that arrives via a non-default
zone's subdomain is now refused with 404 before any other processing.
Closes review comment cloudfoundry#14 on PR cloudfoundry#3968 -- uaa.spiffe.* stays a global,
default-zone-only feature rather than being silently reachable, and
re-issuable under a different issuer, from every zone.
@fhanik

fhanik commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

Closing, as we will not do spiffe for now. Also note that I accidentally changed the PR description with another PR's description.

@fhanik fhanik closed this Oct 1, 2026
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.

3 participants