Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
dc1149d
feat(model): add tls_client_auth constant and logic to ClientAuthenti…
rkoster Jul 3, 2026
15b2859
feat(model): add CLIENT_AUTH_TLS_CLIENT_AUTH to TokenConstants
rkoster Jul 3, 2026
07ac929
feat(model): add TlsClientAuthConfiguration model class
rkoster Jul 3, 2026
6a16d21
feat(model): add tlsClientAuthConfiguration field to UaaClientDetails
rkoster Jul 3, 2026
8a2838d
fix(model): add equals/hashCode to TlsClientAuthConfiguration and Cla…
rkoster Jul 3, 2026
b3c4146
feat(model): add mtls_endpoint_aliases and tls_client_auth to OpenIdC…
rkoster Jul 3, 2026
0897d44
feat(server): add java-buildpack-client-certificate-mapper-jakarta de…
rkoster Jul 3, 2026
8df59ec
feat(server): register ClientCertificateMapper filter for /oauth/mtls/*
rkoster Jul 3, 2026
ec0f706
feat(server): add TlsClientAuthentication cert chain validation service
rkoster Jul 3, 2026
046d6dd
feat(server): add isTlsClientAuth / validateTlsClientAuth to ClientDe…
rkoster Jul 3, 2026
d5b38c9
fix(server): deserialize TlsClientAuthConfiguration from additionalIn…
rkoster Jul 3, 2026
39b5c54
feat(server): allow tls_client_auth in ClientCredentialsTokenGranter
rkoster Jul 3, 2026
f8f3e97
feat(server): add MtlsClaimsEnhancer UaaTokenEnhancer for cert-derive…
rkoster Jul 3, 2026
2e23b24
feat(server): add mtls_endpoint_aliases to OIDC discovery document
rkoster Jul 3, 2026
7febe21
Fix @Value key in OpenIdConnectEndpoints to match ERB-emitted mtls.en…
rkoster Jul 3, 2026
f2974ef
fix(server): add OAUTH_11 order constant and dedicated mTLS security …
rkoster Jul 3, 2026
fe24445
fix(server): handle flat String PEM in getTlsClientAuthConfiguration;…
rkoster Jul 3, 2026
cf49990
fix(server): fix MtlsClaimsEnhancer for DB-loaded clients and Diego m…
rkoster Jul 3, 2026
2f77a0b
fix(review): address PR feedback on mTLS client auth
rkoster Jul 6, 2026
5438939
test: update OIDC discovery tests for mtls_endpoint_aliases and tls_c…
rkoster Jul 6, 2026
2856a5d
fix(review): fix JsonProperty collision and support full cert chain i…
rkoster Jul 6, 2026
864e086
test: update tlsClientAuthConfigRoundTripsViaJson for @JsonIgnore design
rkoster Jul 7, 2026
5d89307
feat: add subTemplate and audTemplates to TlsClientAuthConfiguration
rkoster Jul 7, 2026
7b31e15
test: fix audTemplates equality isolation in TlsClientAuthConfigurati…
rkoster Jul 7, 2026
5d6d833
refactor: restructure enhance() into phase 1/2 (vars + dot-notation)
rkoster Jul 7, 2026
d937561
fix: document single-level nesting, add conflict test, restore comment
rkoster Jul 7, 2026
5fa537b
feat: add phase 3 sub/aud template rendering to MtlsClaimsEnhancer
rkoster Jul 7, 2026
558717d
fix: static PLACEHOLDER pattern, StringBuilder, restore comment, rena…
rkoster Jul 7, 2026
0513c60
feat: read tls-client-auth-sub-template and aud-templates from flat B…
rkoster Jul 7, 2026
98c09ab
fix: apply token enhancer overrides after UAA defaults so sub/aud tem…
rkoster Jul 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ greenmail = "2.1.9"
guava = "33.6.0-jre"
jacoco = "4.0.2"
jacocoAgent = "0.8.15"
javaBuildpackClientCertificateMapper = "2.0.1"
nimbusJwt = "10.9.1"
opensaml = "5.2.3"
orgJson = "20260522"
Expand Down Expand Up @@ -71,6 +72,9 @@ bouncyCastleUtilFips = { module = "org.bouncycastle:bcutil-fips", version.ref =
bytebuddy = { module = "net.bytebuddy:byte-buddy" }
bytebuddyagent = { module = "net.bytebuddy:byte-buddy-agent" }

# 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" }

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
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.Map;

@Data
@NoArgsConstructor
public class OpenIdConfiguration {
Expand All @@ -19,7 +22,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"};
Expand Down Expand Up @@ -67,6 +70,10 @@ 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<String, String> mtlsEndpointAliases;

public OpenIdConfiguration(final String contextPath, final String issuer) {
this.issuer = issuer;
this.authUrl = contextPath + "/oauth/authorize";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
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.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";

@JsonProperty(TLS_CLIENT_AUTH_CA)
private String trustedCaPem;

@JsonProperty(TLS_CLIENT_AUTH_CLAIM_MAPPINGS)
private List<ClaimMapping> claimMappings;

@JsonProperty(TLS_CLIENT_AUTH_SUB_TEMPLATE)
private String subTemplate;

@JsonProperty(TLS_CLIENT_AUTH_AUD_TEMPLATES)
private List<String> audTemplates;

public TlsClientAuthConfiguration() {}

public TlsClientAuthConfiguration(String trustedCaPem, List<ClaimMapping> claimMappings) {
this.trustedCaPem = trustedCaPem;
this.claimMappings = claimMappings;
}

public String getTrustedCaPem() { return trustedCaPem; }
public void setTrustedCaPem(String trustedCaPem) { this.trustedCaPem = trustedCaPem; }

public List<ClaimMapping> getClaimMappings() { return claimMappings; }
public void setClaimMappings(List<ClaimMapping> claimMappings) { this.claimMappings = claimMappings; }

public String getSubTemplate() { return subTemplate; }
public void setSubTemplate(String subTemplate) { this.subTemplate = subTemplate; }

public List<String> getAudTemplates() { return audTemplates; }
public void setAudTemplates(List<String> audTemplates) { this.audTemplates = audTemplates; }

@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);
}

@Override
public int hashCode() {
return Objects.hash(trustedCaPem, claimMappings, subTemplate, audTemplates);
}

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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -86,6 +87,9 @@ public class UaaClientDetails implements ClientDetails {
@JsonProperty("client_jwt_config")
private String clientJwtConfig;

@JsonIgnore
private TlsClientAuthConfiguration tlsClientAuthConfiguration;

Comment thread
rkoster marked this conversation as resolved.
public UaaClientDetails() {
}

Expand All @@ -103,6 +107,7 @@ public UaaClientDetails(ClientDetails prototype) {
this.setAdditionalInformation(prototype.getAdditionalInformation());
if (prototype instanceof UaaClientDetails uaa) {
this.setClientJwtConfig(uaa.getClientJwtConfig());
this.setTlsClientAuthConfiguration(uaa.getTlsClientAuthConfiguration());
}
}

Expand Down Expand Up @@ -302,6 +307,21 @@ public void setClientJwtConfig(String clientJwtConfig) {
this.clientJwtConfig = clientJwtConfig;
}

public TlsClientAuthConfiguration getTlsClientAuthConfiguration() {
return tlsClientAuthConfiguration;
}

public void setTlsClientAuthConfiguration(TlsClientAuthConfiguration tlsClientAuthConfiguration) {
this.tlsClientAuthConfiguration = tlsClientAuthConfiguration;
// Keep additionalInformation in sync so JDBC-loaded clients (which only
// persist the additional_information JSON column) see the same value.
if (tlsClientAuthConfiguration != null) {
this.additionalInformation.put(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA, tlsClientAuthConfiguration);
} else {
this.additionalInformation.remove(TlsClientAuthConfiguration.TLS_CLIENT_AUTH_CA);
}
}

@Override
public boolean equals(Object obj) {
if (this == obj) {
Expand Down Expand Up @@ -344,7 +364,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
Expand Down Expand Up @@ -378,6 +401,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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -20,8 +19,10 @@ 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<String> UAA_SUPPORTED_METHODS = List.of(CLIENT_SECRET_BASIC, CLIENT_SECRET_POST, NONE, PRIVATE_KEY_JWT);
public static final List<String> UAA_SUPPORTED_METHODS =
List.of(CLIENT_SECRET_BASIC, CLIENT_SECRET_POST, NONE, PRIVATE_KEY_JWT, TLS_CLIENT_AUTH);

public static boolean secretNeeded(String method) {
return method == null || CLIENT_SECRET_POST.equals(method) || CLIENT_SECRET_BASIC.equals(method);
Expand All @@ -31,27 +32,41 @@ 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) && !hasSecret && !hasKeyConfiguration && hasCaConfig
|| isMethodSupported(method) && (NONE.equals(method) || method == null) && !hasSecret && !hasKeyConfiguration && !hasCaConfig
|| (method == null && (!hasSecret || !hasKeyConfiguration) && !hasCaConfig);
}
Comment on lines +35 to +42

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 {
if (hasSecret) {
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ public static List<String> 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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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");
Expand Down Expand Up @@ -65,4 +66,24 @@ 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");
}
}
Loading
Loading