This is an automated email from the ASF dual-hosted git repository. coheigea pushed a commit to branch 4.1.x-fixes in repository https://gitbox.apache.org/repos/asf/cxf.git
commit d130b732b4d4af28a26f47840f7272caf402b22a Author: Colm O hEigeartaigh <[email protected]> AuthorDate: Mon Jul 20 09:11:06 2026 +0100 Enforce TLS client certificate and store the application certificates for dynamic registration (#3316) (cherry picked from commit 413d75ec1d9bd1b6a2b50e72bf4e7f0e53258218) --- .../oauth2/services/AbstractTokenService.java | 37 ++++---- .../services/DynamicRegistrationService.java | 59 ++++++++++++ .../oauth2/services/AbstractTokenServiceTest.java | 90 ++++++++++++++++++ .../services/DynamicRegistrationServiceTest.java | 102 +++++++++++++++++++++ .../cxf/systest/jaxrs/security/oidc/client.xml | 3 + .../jaxrs/security/oidc/oidc-server-dynreg.xml | 2 +- 6 files changed, 275 insertions(+), 18 deletions(-) diff --git a/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/services/AbstractTokenService.java b/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/services/AbstractTokenService.java index 0f42687bb85..4b90ffe4609 100644 --- a/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/services/AbstractTokenService.java +++ b/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/services/AbstractTokenService.java @@ -161,26 +161,29 @@ public class AbstractTokenService extends AbstractOAuthService { } protected void checkCertificateBinding(Client client, TLSSessionInfo tlsSessionInfo) { - String subjectDn = client.getProperties().get(OAuthConstants.TLS_CLIENT_AUTH_SUBJECT_DN); - if (subjectDn == null && client.getApplicationCertificates().isEmpty()) { - LOG.warning("Client \"" + client.getClientId() + "\" can not be bound to the TLS certificate"); - reportInvalidClient(); - } X509Certificate cert = OAuthUtils.getRootTLSCertificate(tlsSessionInfo); - - if (subjectDn != null - && !subjectDn.equals(OAuthUtils.getSubjectDnFromTLSCertificates(cert))) { - LOG.warning("Client \"" + client.getClientId() + "\" can not be bound to the TLS certificate"); - reportInvalidClient(); - } - String issuerDn = client.getProperties().get(OAuthConstants.TLS_CLIENT_AUTH_ISSUER_DN); - if (issuerDn != null - && !issuerDn.equals(OAuthUtils.getIssuerDnFromTLSCertificates(cert))) { - LOG.warning("Client \"" + client.getClientId() + "\" can not be bound to the TLS certificate"); - reportInvalidClient(); - } if (!client.getApplicationCertificates().isEmpty()) { compareTlsCertificates(tlsSessionInfo, client.getApplicationCertificates()); + } else { + String subjectDn = client.getProperties().get(OAuthConstants.TLS_CLIENT_AUTH_SUBJECT_DN); + String issuerDn = client.getProperties().get(OAuthConstants.TLS_CLIENT_AUTH_ISSUER_DN); + if (subjectDn == null && issuerDn == null) { + LOG.warning("Client \"" + client.getClientId() + + "\" can not be bound to the TLS certificate"); + reportInvalidClient(); + } + if (subjectDn != null + && !subjectDn.equals(OAuthUtils.getSubjectDnFromTLSCertificates(cert))) { + LOG.warning("Client \"" + client.getClientId() + + "\" can not be bound to the TLS certificate"); + reportInvalidClient(); + } + if (issuerDn != null + && !issuerDn.equals(OAuthUtils.getIssuerDnFromTLSCertificates(cert))) { + LOG.warning("Client \"" + client.getClientId() + + "\" can not be bound to the TLS certificate"); + reportInvalidClient(); + } } OAuthUtils.setCertificateThumbprintConfirmation(getMessageContext(), cert); } diff --git a/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/services/DynamicRegistrationService.java b/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/services/DynamicRegistrationService.java index eb0e387a744..8be6b6d9bda 100644 --- a/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/services/DynamicRegistrationService.java +++ b/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/services/DynamicRegistrationService.java @@ -18,10 +18,13 @@ */ package org.apache.cxf.rs.security.oauth2.services; +import java.security.cert.X509Certificate; import java.util.Collections; import java.util.HashSet; import java.util.List; +import javax.security.auth.x500.X500Principal; + import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DELETE; import jakarta.ws.rs.GET; @@ -38,6 +41,7 @@ import jakarta.ws.rs.core.Response.ResponseBuilder; import jakarta.ws.rs.core.SecurityContext; import jakarta.ws.rs.core.UriBuilder; import org.apache.cxf.common.util.Base64UrlUtility; +import org.apache.cxf.common.util.Base64Utility; import org.apache.cxf.common.util.StringUtils; import org.apache.cxf.jaxrs.ext.MessageContext; import org.apache.cxf.jaxrs.utils.ExceptionUtils; @@ -51,6 +55,7 @@ import org.apache.cxf.rs.security.oauth2.utils.AuthorizationUtils; import org.apache.cxf.rs.security.oauth2.utils.OAuthConstants; import org.apache.cxf.rs.security.oauth2.utils.OAuthUtils; import org.apache.cxf.rt.security.crypto.CryptoUtils; +import org.apache.cxf.security.transport.TLSSessionInfo; @Path("register") public class DynamicRegistrationService { @@ -62,6 +67,7 @@ public class DynamicRegistrationService { private int clientIdSizeInBytes = DEFAULT_CLIENT_ID_SIZE; private MessageContext mc; private boolean supportRegistrationAccessTokens = true; + private boolean enforceTlsClientAuthCertificateBinding = true; private String userRole; private List<String> allowedClientScopes; @@ -274,6 +280,8 @@ public class DynamicRegistrationService { newClient.setTokenEndpointAuthMethod(tokenEndpointAuthMethod); if (OAuthConstants.TOKEN_ENDPOINT_AUTH_TLS.equals(tokenEndpointAuthMethod)) { + X509Certificate cert = enforceTlsClientAuthCertificateBinding + ? validateTlsClientAuthCertificateBinding(request) : getTlsClientCertificate(); String subjectDn = (String)request.getProperty(OAuthConstants.TLS_CLIENT_AUTH_SUBJECT_DN); if (subjectDn != null) { newClient.getProperties().put(OAuthConstants.TLS_CLIENT_AUTH_SUBJECT_DN, subjectDn); @@ -282,6 +290,15 @@ public class DynamicRegistrationService { if (issuerDn != null) { newClient.getProperties().put(OAuthConstants.TLS_CLIENT_AUTH_ISSUER_DN, issuerDn); } + if (cert != null) { + try { + newClient.getApplicationCertificates().add(Base64Utility.encode(cert.getEncoded())); + } catch (Exception ex) { + OAuthError error = + new OAuthError(INVALID_CLIENT_METADATA, "Unable to register TLS client certificate"); + reportInvalidRequestError(error); + } + } } // Client Registration Time newClient.setRegisteredAt(System.currentTimeMillis() / 1000L); @@ -298,6 +315,44 @@ public class DynamicRegistrationService { return newClient; } + protected X509Certificate getTlsClientCertificate() { + TLSSessionInfo tlsSessionInfo = (TLSSessionInfo)mc.get(TLSSessionInfo.class.getName()); + return tlsSessionInfo == null ? null : OAuthUtils.getRootTLSCertificate(tlsSessionInfo); + } + + protected X509Certificate validateTlsClientAuthCertificateBinding(ClientRegistration request) { + X509Certificate cert = getTlsClientCertificate(); + if (cert == null) { + OAuthError error = + new OAuthError(INVALID_CLIENT_METADATA, "TLS client certificate is required"); + reportInvalidRequestError(error); + } + + String subjectDn = (String)request.getProperty(OAuthConstants.TLS_CLIENT_AUTH_SUBJECT_DN); + if (subjectDn != null + && !isSameDistinguishedName(subjectDn, OAuthUtils.getSubjectDnFromTLSCertificates(cert))) { + OAuthError error = + new OAuthError(INVALID_CLIENT_METADATA, "Invalid tls_client_auth_subject_dn metadata"); + reportInvalidRequestError(error); + } + String issuerDn = (String)request.getProperty(OAuthConstants.TLS_CLIENT_AUTH_ISSUER_DN); + if (issuerDn != null + && !isSameDistinguishedName(issuerDn, OAuthUtils.getIssuerDnFromTLSCertificates(cert))) { + OAuthError error = + new OAuthError(INVALID_CLIENT_METADATA, "Invalid tls_client_auth_root_dn metadata"); + reportInvalidRequestError(error); + } + return cert; + } + + private boolean isSameDistinguishedName(String expectedDn, String actualDn) { + try { + return new X500Principal(expectedDn).equals(new X500Principal(actualDn)); + } catch (IllegalArgumentException ex) { + return false; + } + } + protected void fromClientRegistrationToClient(ClientRegistration request, Client client) { final List<String> grantTypes = client.getAllowedGrantTypes(); @@ -419,6 +474,10 @@ public class DynamicRegistrationService { this.supportRegistrationAccessTokens = supportRegistrationAccessTokens; } + public void setEnforceTlsClientAuthCertificateBinding(boolean enforceTlsClientAuthCertificateBinding) { + this.enforceTlsClientAuthCertificateBinding = enforceTlsClientAuthCertificateBinding; + } + public void setUserRole(String userRole) { this.userRole = userRole; } diff --git a/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/services/AbstractTokenServiceTest.java b/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/services/AbstractTokenServiceTest.java index 4e56b366987..91bba44fb2b 100644 --- a/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/services/AbstractTokenServiceTest.java +++ b/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/services/AbstractTokenServiceTest.java @@ -18,12 +18,21 @@ */ package org.apache.cxf.rs.security.oauth2.services; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; + +import javax.security.auth.x500.X500Principal; + import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.SecurityContext; +import org.apache.cxf.common.util.Base64Utility; +import org.apache.cxf.jaxrs.ext.MessageContext; import org.apache.cxf.jaxrs.impl.MetadataMap; import org.apache.cxf.rs.security.oauth2.common.Client; import org.apache.cxf.rs.security.oauth2.common.OAuthError; import org.apache.cxf.rs.security.oauth2.provider.OAuthServiceException; import org.apache.cxf.rs.security.oauth2.utils.OAuthConstants; +import org.apache.cxf.security.transport.TLSSessionInfo; import org.junit.Test; @@ -31,6 +40,8 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class AbstractTokenServiceTest { @@ -79,6 +90,76 @@ public class AbstractTokenServiceTest { assertSame(expected, actual); } + @Test + public void testCheckCertificateBindingAcceptsDnOnlyClient() { + TestTokenService service = new TestTokenService(); + Client client = new Client("c1", null, true); + client.getProperties().put(OAuthConstants.TLS_CLIENT_AUTH_SUBJECT_DN, "CN=peer,O=Test,C=US"); + + service.callCheckCertificateBinding(client, + makeTlsSession("CN=peer,O=Test,C=US", "CN=issuer,O=Test,C=US")); + } + + @Test + public void testCheckCertificateBindingRejectsDnOnlyClientMismatch() { + TestTokenService service = new TestTokenService(); + Client client = new Client("c1", null, true); + client.getProperties().put(OAuthConstants.TLS_CLIENT_AUTH_SUBJECT_DN, "CN=peer,O=Test,C=US"); + + InvalidClientException ex = assertThrows(InvalidClientException.class, + () -> service.callCheckCertificateBinding(client, + makeTlsSession("CN=other,O=Test,C=US", "CN=issuer,O=Test,C=US"))); + + assertNotNull(ex.getError()); + assertEquals(OAuthConstants.INVALID_CLIENT, ex.getError().getError()); + } + + @Test + public void testCheckCertificateBindingRejectsCertificateMismatch() { + TestTokenService service = new TestTokenService(); + Client client = new Client("c2", null, true); + client.getApplicationCertificates().add(Base64Utility.encode(new byte[] {9, 9, 9})); + + InvalidClientException ex = assertThrows(InvalidClientException.class, + () -> service.callCheckCertificateBinding(client, + makeTlsSession("CN=peer,O=Test,C=US", "CN=issuer,O=Test,C=US"))); + + assertNotNull(ex.getError()); + assertEquals(OAuthConstants.INVALID_CLIENT, ex.getError().getError()); + } + + @Test + public void testCheckCertificateBindingAcceptsCertificateMatch() throws Exception { + TestTokenService service = new TestTokenService(); + X509Certificate cert = createCertificate("CN=peer,O=Test,C=US", "CN=issuer,O=Test,C=US"); + + Client client = new Client("c3", null, true); + client.getApplicationCertificates().add(Base64Utility.encode(cert.getEncoded())); + + service.callCheckCertificateBinding(client, makeTlsSession(cert)); + } + + private static TLSSessionInfo makeTlsSession(String subjectDn, String issuerDn) { + X509Certificate cert = createCertificate(subjectDn, issuerDn); + return makeTlsSession(cert); + } + + private static TLSSessionInfo makeTlsSession(X509Certificate cert) { + return new TLSSessionInfo("TLS_FAKE", null, new Certificate[] {cert}); + } + + private static X509Certificate createCertificate(String subjectDn, String issuerDn) { + X509Certificate cert = mock(X509Certificate.class); + when(cert.getSubjectX500Principal()).thenReturn(new X500Principal(subjectDn)); + when(cert.getIssuerX500Principal()).thenReturn(new X500Principal(issuerDn)); + try { + when(cert.getEncoded()).thenReturn(new byte[] {1, 2, 3}); + } catch (Exception ex) { + throw new RuntimeException(ex); + } + return cert; + } + private static final class TestTokenService extends AbstractTokenService { private Client client; private OAuthServiceException exception; @@ -87,6 +168,15 @@ public class AbstractTokenServiceTest { return getClient(clientId, clientSecret, params); } + void callCheckCertificateBinding(Client boundClient, TLSSessionInfo tlsSessionInfo) { + MessageContext mc = mock(MessageContext.class); + SecurityContext sc = mock(SecurityContext.class); + when(sc.getAuthenticationScheme()).thenReturn(""); + when(mc.getSecurityContext()).thenReturn(sc); + setMessageContext(mc); + checkCertificateBinding(boundClient, tlsSessionInfo); + } + void setClient(Client client) { this.client = client; } diff --git a/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/services/DynamicRegistrationServiceTest.java b/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/services/DynamicRegistrationServiceTest.java index 72fddf6516a..fa339c5b11c 100644 --- a/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/services/DynamicRegistrationServiceTest.java +++ b/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/services/DynamicRegistrationServiceTest.java @@ -18,19 +18,28 @@ */ package org.apache.cxf.rs.security.oauth2.services; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; import java.util.Arrays; import java.util.Collections; +import javax.security.auth.x500.X500Principal; + import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.core.SecurityContext; +import org.apache.cxf.jaxrs.ext.MessageContext; import org.apache.cxf.rs.security.oauth2.common.Client; import org.apache.cxf.rs.security.oauth2.common.OAuthError; import org.apache.cxf.rs.security.oauth2.utils.OAuthConstants; +import org.apache.cxf.security.transport.TLSSessionInfo; import org.junit.Test; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class DynamicRegistrationServiceTest { @@ -80,6 +89,95 @@ public class DynamicRegistrationServiceTest { assertEquals(Collections.singletonList("openid"), client.getRegisteredScopes()); } + @Test + public void testRejectsTlsClientAuthWithoutTlsCertificate() { + TestDynamicRegistrationService service = new TestDynamicRegistrationService(); + service.setEnforceTlsClientAuthCertificateBinding(true); + service.setMessageContext(createMessageContext("", null)); + + ClientRegistration request = new ClientRegistration(); + request.setGrantTypes(Collections.singletonList(OAuthConstants.CLIENT_CREDENTIALS_GRANT)); + request.setTokenEndpointAuthMethod(OAuthConstants.TOKEN_ENDPOINT_AUTH_TLS); + request.setProperty(OAuthConstants.TLS_CLIENT_AUTH_SUBJECT_DN, + "CN=client,OU=Test,O=Apache,C=US"); + + BadRequestException ex = assertThrows(BadRequestException.class, + () -> service.createClient(request)); + + assertInvalidClientMetadata(ex); + } + + @Test + public void testRejectsTlsClientAuthWhenSubjectDnDoesNotMatchCertificate() { + TestDynamicRegistrationService service = new TestDynamicRegistrationService(); + service.setEnforceTlsClientAuthCertificateBinding(true); + X509Certificate cert = createCertificate( + "CN=actual,OU=Test,O=Apache,C=US", + "CN=issuer,OU=Test,O=Apache,C=US"); + service.setMessageContext(createMessageContext("", cert)); + + ClientRegistration request = new ClientRegistration(); + request.setGrantTypes(Collections.singletonList(OAuthConstants.CLIENT_CREDENTIALS_GRANT)); + request.setTokenEndpointAuthMethod(OAuthConstants.TOKEN_ENDPOINT_AUTH_TLS); + request.setProperty(OAuthConstants.TLS_CLIENT_AUTH_SUBJECT_DN, + "CN=expected,OU=Test,O=Apache,C=US"); + + BadRequestException ex = assertThrows(BadRequestException.class, + () -> service.createClient(request)); + + assertInvalidClientMetadata(ex); + } + + @Test + public void testAcceptsTlsClientAuthWhenSubjectDnMatchesCertificate() { + TestDynamicRegistrationService service = new TestDynamicRegistrationService(); + service.setEnforceTlsClientAuthCertificateBinding(true); + String subjectDn = "CN=client,OU=Test,O=Apache,C=US"; + X509Certificate cert = createCertificate(subjectDn, "CN=issuer,OU=Test,O=Apache,C=US"); + service.setMessageContext(createMessageContext("", cert)); + + ClientRegistration request = new ClientRegistration(); + request.setGrantTypes(Collections.singletonList(OAuthConstants.CLIENT_CREDENTIALS_GRANT)); + request.setTokenEndpointAuthMethod(OAuthConstants.TOKEN_ENDPOINT_AUTH_TLS); + request.setProperty(OAuthConstants.TLS_CLIENT_AUTH_SUBJECT_DN, subjectDn); + + Client client = service.createClient(request); + assertEquals(subjectDn, client.getProperties().get(OAuthConstants.TLS_CLIENT_AUTH_SUBJECT_DN)); + assertEquals(1, client.getApplicationCertificates().size()); + } + + private static void assertInvalidClientMetadata(BadRequestException ex) { + assertNotNull(ex.getResponse()); + OAuthError error = (OAuthError)ex.getResponse().getEntity(); + assertNotNull(error); + assertEquals("invalid_client_metadata", error.getError()); + } + + private static MessageContext createMessageContext(String authScheme, X509Certificate cert) { + SecurityContext sc = mock(SecurityContext.class); + when(sc.getAuthenticationScheme()).thenReturn(authScheme); + + MessageContext mc = mock(MessageContext.class); + when(mc.getSecurityContext()).thenReturn(sc); + if (cert != null) { + TLSSessionInfo tlsInfo = new TLSSessionInfo("TLS_FAKE", null, new Certificate[] {cert}); + when(mc.get(TLSSessionInfo.class.getName())).thenReturn(tlsInfo); + } + return mc; + } + + private static X509Certificate createCertificate(String subjectDn, String issuerDn) { + X509Certificate cert = mock(X509Certificate.class); + when(cert.getSubjectX500Principal()).thenReturn(new X500Principal(subjectDn)); + when(cert.getIssuerX500Principal()).thenReturn(new X500Principal(issuerDn)); + try { + when(cert.getEncoded()).thenReturn(new byte[] {1, 2, 3}); + } catch (Exception ex) { + throw new RuntimeException(ex); + } + return cert; + } + private static Client createClient() { Client client = new Client("client", "secret", true); client.setAllowedGrantTypes(Collections.singletonList(OAuthConstants.CLIENT_CREDENTIALS_GRANT)); @@ -90,5 +188,9 @@ public class DynamicRegistrationServiceTest { void applyClientRegistration(ClientRegistration request, Client client) { fromClientRegistrationToClient(request, client); } + + Client createClient(ClientRegistration request) { + return createNewClient(request); + } } } diff --git a/systests/rs-security/src/test/resources/org/apache/cxf/systest/jaxrs/security/oidc/client.xml b/systests/rs-security/src/test/resources/org/apache/cxf/systest/jaxrs/security/oidc/client.xml index b90f73d9835..a0fdf5f7977 100644 --- a/systests/rs-security/src/test/resources/org/apache/cxf/systest/jaxrs/security/oidc/client.xml +++ b/systests/rs-security/src/test/resources/org/apache/cxf/systest/jaxrs/security/oidc/client.xml @@ -36,6 +36,9 @@ <http:conduit name="https://localhost.*"> <http:client ConnectionTimeout="3000000" ReceiveTimeout="3000000"/> <http:tlsClientParameters disableCNCheck="true"> + <sec:keyManagers keyPassword="password"> + <sec:keyStore type="JKS" password="password" resource="keys/Morpit.jks"/> + </sec:keyManagers> <sec:trustManagers> <sec:keyStore type="JKS" password="password" resource="keys/Truststore.jks"/> </sec:trustManagers> diff --git a/systests/rs-security/src/test/resources/org/apache/cxf/systest/jaxrs/security/oidc/oidc-server-dynreg.xml b/systests/rs-security/src/test/resources/org/apache/cxf/systest/jaxrs/security/oidc/oidc-server-dynreg.xml index bc4c6b47d82..a9fd1cbc8b8 100644 --- a/systests/rs-security/src/test/resources/org/apache/cxf/systest/jaxrs/security/oidc/oidc-server-dynreg.xml +++ b/systests/rs-security/src/test/resources/org/apache/cxf/systest/jaxrs/security/oidc/oidc-server-dynreg.xml @@ -56,7 +56,7 @@ under the License. <sec:trustManagers> <sec:keyStore type="JKS" password="password" resource="keys/Truststore.jks"/> </sec:trustManagers> - <sec:clientAuthentication want="false" required="false"/> + <sec:clientAuthentication want="true" required="false"/> </httpj:tlsServerParameters> <httpj:sessionSupport>true</httpj:sessionSupport> </httpj:engine>
