Skip to content

Commit 40bb05d

Browse files
Add support for SSL bundles in SAML2 signing auto-configuration
1 parent f47ff31 commit 40bb05d

File tree

7 files changed

+431
-18
lines changed

7 files changed

+431
-18
lines changed

spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/saml2/Saml2RelyingPartyProperties.java

+72
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
* @author Phillip Webb
3333
* @author Moritz Halbritter
3434
* @author Lasse Wulff
35+
* @author Scott Frederick
3536
* @since 2.2.0
3637
*/
3738
@ConfigurationProperties("spring.security.saml2.relyingparty")
@@ -173,6 +174,12 @@ public static class Credential {
173174
*/
174175
private Resource certificateLocation;
175176

177+
/**
178+
* SSL bundle providing a private key used for signing and a Relying Party
179+
* X509Certificate shared with the identity provider.
180+
*/
181+
private Bundle bundle;
182+
176183
public Resource getPrivateKeyLocation() {
177184
return this.privateKeyLocation;
178185
}
@@ -189,6 +196,14 @@ public void setCertificateLocation(Resource certificate) {
189196
this.certificateLocation = certificate;
190197
}
191198

199+
public Bundle getBundle() {
200+
return this.bundle;
201+
}
202+
203+
public void setBundle(Bundle bundle) {
204+
this.bundle = bundle;
205+
}
206+
192207
}
193208

194209
}
@@ -222,6 +237,12 @@ public static class Credential {
222237
*/
223238
private Resource certificateLocation;
224239

240+
/**
241+
* SSL bundle providing a private key used for decrypting and a Relying Party
242+
* X509Certificate shared with the identity provider.
243+
*/
244+
private Bundle bundle;
245+
225246
public Resource getPrivateKeyLocation() {
226247
return this.privateKeyLocation;
227248
}
@@ -238,6 +259,14 @@ public void setCertificateLocation(Resource certificate) {
238259
this.certificateLocation = certificate;
239260
}
240261

262+
public Bundle getBundle() {
263+
return this.bundle;
264+
}
265+
266+
public void setBundle(Bundle bundle) {
267+
this.bundle = bundle;
268+
}
269+
241270
}
242271

243272
}
@@ -367,6 +396,12 @@ public static class Credential {
367396
*/
368397
private Resource certificate;
369398

399+
/**
400+
* SSL bundle providing the X.509 certificate used for verification of
401+
* incoming SAML messages.
402+
*/
403+
private Bundle bundle;
404+
370405
public Resource getCertificateLocation() {
371406
return this.certificate;
372407
}
@@ -375,6 +410,14 @@ public void setCertificateLocation(Resource certificate) {
375410
this.certificate = certificate;
376411
}
377412

413+
public Bundle getBundle() {
414+
return this.bundle;
415+
}
416+
417+
public void setBundle(Bundle bundle) {
418+
this.bundle = bundle;
419+
}
420+
378421
}
379422

380423
}
@@ -427,4 +470,33 @@ public void setBinding(Saml2MessageBinding binding) {
427470

428471
}
429472

473+
public static class Bundle {
474+
475+
/**
476+
* Name of the SSL bundle.
477+
*/
478+
private String name;
479+
480+
/**
481+
* Alias for the certificate to use from the SSL bundle.
482+
*/
483+
private String alias;
484+
485+
public String getName() {
486+
return this.name;
487+
}
488+
489+
public void setName(String name) {
490+
this.name = name;
491+
}
492+
493+
public String getAlias() {
494+
return this.alias;
495+
}
496+
497+
public void setAlias(String alias) {
498+
this.alias = alias;
499+
}
500+
}
501+
430502
}

spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/saml2/Saml2RelyingPartyRegistrationConfiguration.java

+73-12
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@
1717
package org.springframework.boot.autoconfigure.security.saml2;
1818

1919
import java.io.InputStream;
20+
import java.security.GeneralSecurityException;
21+
import java.security.Key;
22+
import java.security.KeyStore;
23+
import java.security.PrivateKey;
24+
import java.security.cert.Certificate;
2025
import java.security.cert.CertificateFactory;
2126
import java.security.cert.X509Certificate;
2227
import java.security.interfaces.RSAPrivateKey;
@@ -25,13 +30,18 @@
2530
import java.util.Map;
2631
import java.util.function.Consumer;
2732

33+
import org.springframework.beans.factory.ObjectProvider;
2834
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
2935
import org.springframework.boot.autoconfigure.security.saml2.Saml2RelyingPartyProperties.AssertingParty;
3036
import org.springframework.boot.autoconfigure.security.saml2.Saml2RelyingPartyProperties.AssertingParty.Verification;
37+
import org.springframework.boot.autoconfigure.security.saml2.Saml2RelyingPartyProperties.Bundle;
3138
import org.springframework.boot.autoconfigure.security.saml2.Saml2RelyingPartyProperties.Decryption;
3239
import org.springframework.boot.autoconfigure.security.saml2.Saml2RelyingPartyProperties.Registration;
33-
import org.springframework.boot.autoconfigure.security.saml2.Saml2RelyingPartyProperties.Registration.Signing;
40+
import org.springframework.boot.autoconfigure.security.saml2.Saml2RelyingPartyProperties.Registration.Signing.Credential;
3441
import org.springframework.boot.context.properties.PropertyMapper;
42+
import org.springframework.boot.ssl.SslBundle;
43+
import org.springframework.boot.ssl.SslBundleKey;
44+
import org.springframework.boot.ssl.SslBundles;
3545
import org.springframework.context.annotation.Bean;
3646
import org.springframework.context.annotation.Conditional;
3747
import org.springframework.context.annotation.Configuration;
@@ -57,27 +67,29 @@
5767
* @author Moritz Halbritter
5868
* @author Lasse Lindqvist
5969
* @author Lasse Wulff
70+
* @author Scott Frederick
6071
*/
6172
@Configuration(proxyBeanMethods = false)
6273
@Conditional(RegistrationConfiguredCondition.class)
6374
@ConditionalOnMissingBean(RelyingPartyRegistrationRepository.class)
6475
class Saml2RelyingPartyRegistrationConfiguration {
6576

6677
@Bean
67-
RelyingPartyRegistrationRepository relyingPartyRegistrationRepository(Saml2RelyingPartyProperties properties) {
78+
RelyingPartyRegistrationRepository relyingPartyRegistrationRepository(Saml2RelyingPartyProperties properties,
79+
ObjectProvider<SslBundles> sslBundles) {
6880
List<RelyingPartyRegistration> registrations = properties.getRegistration()
6981
.entrySet()
7082
.stream()
71-
.map(this::asRegistration)
83+
.map((entry) -> asRegistration(entry, sslBundles.getIfAvailable()))
7284
.toList();
7385
return new InMemoryRelyingPartyRegistrationRepository(registrations);
7486
}
7587

76-
private RelyingPartyRegistration asRegistration(Map.Entry<String, Registration> entry) {
77-
return asRegistration(entry.getKey(), entry.getValue());
88+
private RelyingPartyRegistration asRegistration(Map.Entry<String, Registration> entry, SslBundles sslBundles) {
89+
return asRegistration(entry.getKey(), entry.getValue(), sslBundles);
7890
}
7991

80-
private RelyingPartyRegistration asRegistration(String id, Registration properties) {
92+
private RelyingPartyRegistration asRegistration(String id, Registration properties, SslBundles sslBundles) {
8193
boolean usingMetadata = StringUtils.hasText(properties.getAssertingparty().getMetadataUri());
8294
Builder builder = (!usingMetadata) ? RelyingPartyRegistration.withRegistrationId(id)
8395
: createBuilderUsingMetadata(properties.getAssertingparty()).registrationId(id);
@@ -87,19 +99,19 @@ private RelyingPartyRegistration asRegistration(String id, Registration properti
8799
builder.signingX509Credentials((credentials) -> properties.getSigning()
88100
.getCredentials()
89101
.stream()
90-
.map(this::asSigningCredential)
102+
.map((signing) -> asSigningCredential(signing, sslBundles))
91103
.forEach(credentials::add));
92104
builder.decryptionX509Credentials((credentials) -> properties.getDecryption()
93105
.getCredentials()
94106
.stream()
95-
.map(this::asDecryptionCredential)
107+
.map((decryption) -> asDecryptionCredential(decryption, sslBundles))
96108
.forEach(credentials::add));
97109
builder.assertingPartyDetails(
98110
(details) -> details.verificationX509Credentials((credentials) -> properties.getAssertingparty()
99111
.getVerification()
100112
.getCredentials()
101113
.stream()
102-
.map(this::asVerificationCredential)
114+
.map((verification) -> asVerificationCredential(verification, sslBundles))
103115
.forEach(credentials::add)));
104116
builder.singleLogoutServiceLocation(properties.getSinglelogout().getUrl());
105117
builder.singleLogoutServiceResponseLocation(properties.getSinglelogout().getResponseUrl());
@@ -150,19 +162,37 @@ private void validateSigningCredentials(Registration properties, boolean signReq
150162
}
151163
}
152164

153-
private Saml2X509Credential asSigningCredential(Signing.Credential properties) {
165+
private Saml2X509Credential asSigningCredential(Credential properties, SslBundles sslBundles) {
166+
Bundle sslBundle = properties.getBundle();
167+
if (sslBundle != null) {
168+
PrivateKey privateKey = getPrivateKey(sslBundle.getName(), sslBundles);
169+
X509Certificate certificate = getCertificate(sslBundle, sslBundles);
170+
return new Saml2X509Credential(privateKey, certificate, Saml2X509CredentialType.SIGNING);
171+
}
154172
RSAPrivateKey privateKey = readPrivateKey(properties.getPrivateKeyLocation());
155173
X509Certificate certificate = readCertificate(properties.getCertificateLocation());
156174
return new Saml2X509Credential(privateKey, certificate, Saml2X509CredentialType.SIGNING);
157175
}
158176

159-
private Saml2X509Credential asDecryptionCredential(Decryption.Credential properties) {
177+
private Saml2X509Credential asDecryptionCredential(Decryption.Credential properties, SslBundles sslBundles) {
178+
Bundle sslBundle = properties.getBundle();
179+
if (sslBundle != null) {
180+
PrivateKey privateKey = getPrivateKey(sslBundle.getName(), sslBundles);
181+
X509Certificate certificate = getCertificate(sslBundle, sslBundles);
182+
return new Saml2X509Credential(privateKey, certificate, Saml2X509CredentialType.DECRYPTION);
183+
}
160184
RSAPrivateKey privateKey = readPrivateKey(properties.getPrivateKeyLocation());
161185
X509Certificate certificate = readCertificate(properties.getCertificateLocation());
162186
return new Saml2X509Credential(privateKey, certificate, Saml2X509CredentialType.DECRYPTION);
163187
}
164188

165-
private Saml2X509Credential asVerificationCredential(Verification.Credential properties) {
189+
private Saml2X509Credential asVerificationCredential(Verification.Credential properties, SslBundles sslBundles) {
190+
Bundle sslBundle = properties.getBundle();
191+
if (sslBundle != null) {
192+
X509Certificate certificate = getCertificate(sslBundle, sslBundles);
193+
return new Saml2X509Credential(certificate, Saml2X509Credential.Saml2X509CredentialType.ENCRYPTION,
194+
Saml2X509Credential.Saml2X509CredentialType.VERIFICATION);
195+
}
166196
X509Certificate certificate = readCertificate(properties.getCertificateLocation());
167197
return new Saml2X509Credential(certificate, Saml2X509Credential.Saml2X509CredentialType.ENCRYPTION,
168198
Saml2X509Credential.Saml2X509CredentialType.VERIFICATION);
@@ -190,4 +220,35 @@ private X509Certificate readCertificate(Resource location) {
190220
}
191221
}
192222

223+
private PrivateKey getPrivateKey(String sslBundle, SslBundles sslBundles) {
224+
try {
225+
SslBundle bundle = sslBundles.getBundle(sslBundle);
226+
SslBundleKey key = bundle.getKey();
227+
KeyStore keyStore = bundle.getStores().getKeyStore();
228+
Key privateKey = keyStore.getKey(key.getAlias(), key.getPassword().toCharArray());
229+
Assert.notNull(privateKey,
230+
"Private key with alias '" + key.getAlias() + "' was not found in SSL bundle '" + sslBundle + "'");
231+
Assert.isInstanceOf(PrivateKey.class, privateKey);
232+
return (PrivateKey) privateKey;
233+
}
234+
catch (GeneralSecurityException ex) {
235+
throw new IllegalStateException("Error getting private key from SSL bundle '" + sslBundle + "'", ex);
236+
}
237+
}
238+
239+
private X509Certificate getCertificate(Bundle sslBundle, SslBundles sslBundles) {
240+
try {
241+
SslBundle bundle = sslBundles.getBundle(sslBundle.getName());
242+
KeyStore keyStore = bundle.getStores().getKeyStore();
243+
Certificate certificate = keyStore.getCertificate(sslBundle.getAlias());
244+
Assert.notNull(certificate, "Certificate with alias '" + sslBundle.getAlias()
245+
+ "' was not found in SSL bundle '" + sslBundle + "'");
246+
Assert.isInstanceOf(X509Certificate.class, certificate);
247+
return (X509Certificate) certificate;
248+
}
249+
catch (GeneralSecurityException ex) {
250+
throw new IllegalStateException("Error getting certificate from SSL bundle '" + sslBundle + "'", ex);
251+
}
252+
}
253+
193254
}

spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/saml2/Saml2RelyingPartyAutoConfigurationTests.java

+64-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2023 the original author or authors.
2+
* Copyright 2012-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -27,6 +27,7 @@
2727

2828
import org.springframework.boot.autoconfigure.AutoConfigurations;
2929
import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration;
30+
import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration;
3031
import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration;
3132
import org.springframework.boot.test.context.FilteredClassLoader;
3233
import org.springframework.boot.test.context.assertj.AssertableWebApplicationContext;
@@ -58,13 +59,14 @@
5859
* @author Madhura Bhave
5960
* @author Moritz Halbritter
6061
* @author Lasse Lindqvist
62+
* @author Scott Frederick
6163
*/
6264
class Saml2RelyingPartyAutoConfigurationTests {
6365

6466
private static final String PREFIX = "spring.security.saml2.relyingparty.registration";
6567

6668
private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner().withConfiguration(
67-
AutoConfigurations.of(Saml2RelyingPartyAutoConfiguration.class, SecurityAutoConfiguration.class));
69+
AutoConfigurations.of(Saml2RelyingPartyAutoConfiguration.class, SecurityAutoConfiguration.class, SslAutoConfiguration.class));
6870

6971
@Test
7072
void autoConfigurationShouldBeConditionalOnRelyingPartyRegistrationRepositoryClass() {
@@ -122,6 +124,40 @@ void relyingPartyRegistrationRepositoryBeanShouldBeCreatedWhenPropertiesPresent(
122124
});
123125
}
124126

127+
@Test
128+
void relyingPartyRegistrationRepositoryBeanShouldBeCreatedWhenPropertiesPresentWithSslBundles() {
129+
this.contextRunner.withPropertyValues(getPropertyValuesWithSslBundles()).run((context) -> {
130+
RelyingPartyRegistrationRepository repository = context.getBean(RelyingPartyRegistrationRepository.class);
131+
RelyingPartyRegistration registration = repository.findByRegistrationId("foo");
132+
133+
assertThat(registration.getAssertingPartyDetails().getSingleSignOnServiceLocation())
134+
.isEqualTo("https://simplesaml-for-spring-saml.cfapps.io/saml2/idp/SSOService.php");
135+
assertThat(registration.getAssertingPartyDetails().getEntityId())
136+
.isEqualTo("https://simplesaml-for-spring-saml.cfapps.io/saml2/idp/metadata.php");
137+
assertThat(registration.getAssertionConsumerServiceLocation())
138+
.isEqualTo("{baseUrl}/login/saml2/foo-entity-id");
139+
assertThat(registration.getAssertionConsumerServiceBinding()).isEqualTo(Saml2MessageBinding.REDIRECT);
140+
assertThat(registration.getAssertingPartyDetails().getSingleSignOnServiceBinding())
141+
.isEqualTo(Saml2MessageBinding.POST);
142+
assertThat(registration.getAssertingPartyDetails().getWantAuthnRequestsSigned()).isFalse();
143+
assertThat(registration.getSigningX509Credentials()).hasSize(1);
144+
assertThat(registration.getDecryptionX509Credentials()).hasSize(1);
145+
assertThat(registration.getAssertingPartyDetails().getVerificationX509Credentials()).isNotNull();
146+
assertThat(registration.getEntityId()).isEqualTo("{baseUrl}/saml2/foo-entity-id");
147+
assertThat(registration.getSingleLogoutServiceLocation())
148+
.isEqualTo("https://simplesaml-for-spring-saml.cfapps.io/saml2/idp/SLOService.php");
149+
assertThat(registration.getSingleLogoutServiceResponseLocation())
150+
.isEqualTo("https://simplesaml-for-spring-saml.cfapps.io/");
151+
assertThat(registration.getSingleLogoutServiceBinding()).isEqualTo(Saml2MessageBinding.POST);
152+
assertThat(registration.getAssertingPartyDetails().getSingleLogoutServiceLocation())
153+
.isEqualTo("https://simplesaml-for-spring-saml.cfapps.io/saml2/idp/SLOService.php");
154+
assertThat(registration.getAssertingPartyDetails().getSingleLogoutServiceResponseLocation())
155+
.isEqualTo("https://simplesaml-for-spring-saml.cfapps.io/");
156+
assertThat(registration.getAssertingPartyDetails().getSingleLogoutServiceBinding())
157+
.isEqualTo(Saml2MessageBinding.POST);
158+
});
159+
}
160+
125161
@Test
126162
void autoConfigurationWhenSignRequestsTrueAndNoSigningCredentialsShouldThrowException() {
127163
this.contextRunner.withPropertyValues(getPropertyValuesWithoutSigningCredentials(true)).run((context) -> {
@@ -325,6 +361,32 @@ private String[] getPropertyValues() {
325361
PREFIX + ".foo.acs.binding=redirect" };
326362
}
327363

364+
private String[] getPropertyValuesWithSslBundles() {
365+
return new String[] { "spring.ssl.bundle.pem.saml.key.alias=key-alias",
366+
"spring.ssl.bundle.pem.saml.key.password=secret1",
367+
"spring.ssl.bundle.pem.saml.keystore.certificate=classpath:saml/certificate-location",
368+
"spring.ssl.bundle.pem.saml.keystore.private-key=classpath:saml/private-key-location",
369+
PREFIX + ".foo.signing.credentials[0].bundle.name=saml",
370+
PREFIX + ".foo.signing.credentials[0].bundle.alias=key-alias",
371+
PREFIX + ".foo.decryption.credentials[0].bundle.name=saml",
372+
PREFIX + ".foo.decryption.credentials[0].bundle.alias=key-alias",
373+
PREFIX + ".foo.singlelogout.url=https://simplesaml-for-spring-saml.cfapps.io/saml2/idp/SLOService.php",
374+
PREFIX + ".foo.singlelogout.response-url=https://simplesaml-for-spring-saml.cfapps.io/",
375+
PREFIX + ".foo.singlelogout.binding=post",
376+
PREFIX + ".foo.assertingparty.singlesignon.url=https://simplesaml-for-spring-saml.cfapps.io/saml2/idp/SSOService.php",
377+
PREFIX + ".foo.assertingparty.singlesignon.binding=post",
378+
PREFIX + ".foo.assertingparty.singlesignon.sign-request=false",
379+
PREFIX + ".foo.assertingparty.entity-id=https://simplesaml-for-spring-saml.cfapps.io/saml2/idp/metadata.php",
380+
PREFIX + ".foo.assertingparty.verification.credentials[0].bundle.name=saml",
381+
PREFIX + ".foo.assertingparty.verification.credentials[0].bundle.alias=key-alias",
382+
PREFIX + ".foo.asserting-party.singlelogout.url=https://simplesaml-for-spring-saml.cfapps.io/saml2/idp/SLOService.php",
383+
PREFIX + ".foo.asserting-party.singlelogout.response-url=https://simplesaml-for-spring-saml.cfapps.io/",
384+
PREFIX + ".foo.asserting-party.singlelogout.binding=post",
385+
PREFIX + ".foo.entity-id={baseUrl}/saml2/foo-entity-id",
386+
PREFIX + ".foo.acs.location={baseUrl}/login/saml2/foo-entity-id",
387+
PREFIX + ".foo.acs.binding=redirect" };
388+
}
389+
328390
private boolean hasSecurityFilter(AssertableWebApplicationContext context, Class<? extends Filter> filter) {
329391
return getSecurityFilterChain(context).getFilters().stream().anyMatch(filter::isInstance);
330392
}

0 commit comments

Comments
 (0)