Skip to content

Commit

Permalink
Support for multiple SP's with non-static metadata
Browse files Browse the repository at this point in the history
  • Loading branch information
oharsta committed Nov 6, 2023
1 parent 679d207 commit f1366dd
Show file tree
Hide file tree
Showing 14 changed files with 456 additions and 102 deletions.
6 changes: 6 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@
<version>2.7.17</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.github.tomakehurst</groupId>
<artifactId>wiremock</artifactId>
<version>2.27.2</version>
<scope>test</scope>
</dependency>
<!-- https://mvnrepository.com/artifact/org.springframework/spring-web -->
<dependency>
<groupId>org.springframework</groupId>
Expand Down
183 changes: 130 additions & 53 deletions src/main/java/saml/DefaultSAMLIdPService.java

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions src/main/java/saml/SAMLIdPService.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package saml;

import org.opensaml.saml.saml2.core.*;
import org.opensaml.saml.saml2.metadata.EntityDescriptor;
import saml.model.SAMLAttribute;
import saml.model.SAMLServiceProvider;
import saml.model.SAMLStatus;

import javax.servlet.http.HttpServletResponse;
Expand Down Expand Up @@ -43,5 +45,22 @@ void sendResponse(String destination,
HttpServletResponse servletResponse);


/**
* Construct the XML metadata (e.g. {@link EntityDescriptor}) with the provided IdP attributes
*
* @param singleSignOnService the URL for single sign on
* @param name the name of the IdP
* @param description the description of the IdP
* @param logoURI the logoURI of the IdP
* @return XML medadata
*/
String metaData(String singleSignOnService, String name, String description, String logoURI);

/**
* Resolve the metadata (e.g. {@link EntityDescriptor}) located at the provided URL
*
* @param serviceProvider the (e.g. {@link SAMLServiceProvider}) containing the metadata URL and entityID
* @return the SAMLServiceProvider that may be null
*/
SAMLServiceProvider resolveSigningCredential(SAMLServiceProvider serviceProvider);
}
3 changes: 3 additions & 0 deletions src/main/java/saml/crypto/KeyStoreLocator.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@

public class KeyStoreLocator {

private KeyStoreLocator() {
}

@SneakyThrows
public static KeyStore createKeyStore(String name, String certificate, String privateKey, String passPhrase) {
KeyStore ks = KeyStore.getInstance("JKS");
Expand Down
11 changes: 4 additions & 7 deletions src/main/java/saml/model/SAMLConfiguration.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,13 @@
import lombok.AllArgsConstructor;
import lombok.Getter;

import java.util.List;

@Getter
@AllArgsConstructor
public class SAMLConfiguration {

private String idpCertificate;
private String idpPrivateKey;
private String entityId;
private String spAudience;
private String spCertificate;
private String issuerId;
private SAMLIdentityProvider identityProvider;
private List<SAMLServiceProvider> serviceProviders;
private boolean requiresSignedAuthnRequest;

}
14 changes: 14 additions & 0 deletions src/main/java/saml/model/SAMLIdentityProvider.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package saml.model;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class SAMLIdentityProvider {

private String certificate;
private String privateKey;
private String entityId;

}
27 changes: 27 additions & 0 deletions src/main/java/saml/model/SAMLServiceProvider.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package saml.model;

import lombok.AllArgsConstructor;
import lombok.Getter;
import org.opensaml.security.credential.Credential;

@Getter
public class SAMLServiceProvider {

private String entityId;
private String metaDataUrl;
private Credential credential;
private String acsLocation;

public SAMLServiceProvider(String entityId, String metaDataUrl) {
this.entityId = entityId;
this.metaDataUrl = metaDataUrl;
}

public void setCredential(Credential credential) {
this.credential = credential;
}

public void setAcsLocation(String acsLocation) {
this.acsLocation = acsLocation;
}
}
14 changes: 2 additions & 12 deletions src/main/java/saml/parser/EncodingUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,6 @@ public class EncodingUtils {
private EncodingUtils() {
}

@SneakyThrows
private static byte[] deflate(String s) {
ByteArrayOutputStream b = new ByteArrayOutputStream();
DeflaterOutputStream deflater = new DeflaterOutputStream(b, new Deflater(DEFLATED, true));
deflater.write(s.getBytes(UTF_8));
deflater.finish();
return b.toByteArray();
}

@SneakyThrows
private static String inflate(byte[] b) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
Expand All @@ -41,9 +32,8 @@ private static String inflate(byte[] b) {
return out.toString(UTF_8);
}

public static String samlEncode(String s, boolean deflate) {
byte[] b = deflate ? EncodingUtils.deflate(s) : s.getBytes(UTF_8);
return UN_CHUNKED_ENCODER.encodeToString(b);
public static String samlEncode(String s) {
return UN_CHUNKED_ENCODER.encodeToString(s.getBytes(UTF_8));
}

public static String toISO8859_1(String text) {
Expand Down
131 changes: 102 additions & 29 deletions src/test/java/saml/DefaultSAMLIdPServiceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
import org.apache.commons.io.IOUtils;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.opensaml.core.criterion.EntityIdCriterion;
import org.opensaml.core.xml.schema.XSString;
import org.opensaml.core.xml.util.XMLObjectSupport;
Expand All @@ -18,12 +20,11 @@
import org.opensaml.security.credential.UsageType;
import org.opensaml.security.credential.impl.KeyStoreCredentialResolver;
import org.opensaml.security.criteria.UsageCriterion;
import org.opensaml.xmlsec.signature.support.SignatureException;
import org.springframework.mock.web.MockHttpServletResponse;
import org.w3c.dom.Element;
import saml.crypto.KeyStoreLocator;
import saml.model.SAMLAttribute;
import saml.model.SAMLConfiguration;
import saml.model.SAMLStatus;
import saml.model.*;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
Expand All @@ -39,46 +40,83 @@
import java.util.zip.Deflater;
import java.util.zip.DeflaterOutputStream;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static com.github.tomakehurst.wiremock.client.WireMock.*;
import static org.junit.jupiter.api.Assertions.*;

class DefaultSAMLIdPServiceTest {

private static final DefaultSAMLIdPService samlIdPService;
private static final SimpleDateFormat issueFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss");
private static final SAMLConfiguration samlConfiguration;
private static final Credential signinCredential;
private static final String spEntityId = "https://engine.test.surfconext.nl/authentication/sp/metadata";
private static final Credential signingCredential;

@RegisterExtension
WireMockExtension mockServer = new WireMockExtension(8999);
private DefaultSAMLIdPService samlIdPService;

static {
String entityId = "https://test.entity.org";
samlConfiguration = new SAMLConfiguration(
readFile("saml_idp.crt"),
readFile("saml_idp.pem"),
entityId,
"https://engine.test.surfconext.nl/authentication/sp/metadata",
readFile("saml_idp.crt"),
entityId,
false
java.security.Security.addProvider(
new org.bouncycastle.jce.provider.BouncyCastleProvider()
);
samlIdPService = new DefaultSAMLIdPService(samlConfiguration);
KeyStore keyStore = KeyStoreLocator.createKeyStore(
entityId,
samlConfiguration.getIdpCertificate(),
samlConfiguration.getIdpPrivateKey(),
spEntityId,
readFile("saml_sp.crt"),
readFile("saml_sp.pem"),
"secret"
);
KeyStoreCredentialResolver resolver = new KeyStoreCredentialResolver(keyStore, Map.of(entityId, "secret"), UsageType.SIGNING);
KeyStoreCredentialResolver resolver = new KeyStoreCredentialResolver(keyStore, Map.of(spEntityId, "secret"), UsageType.SIGNING);
try {
signinCredential = resolver.resolveSingle(new CriteriaSet(new EntityIdCriterion(entityId), new UsageCriterion(UsageType.SIGNING)));
signingCredential = resolver.resolveSingle(new CriteriaSet(new EntityIdCriterion(spEntityId), new UsageCriterion(UsageType.SIGNING)));
} catch (ResolverException e) {
throw new RuntimeException(e);
}
}

private String getSPMetaData() {
SAMLConfiguration samlConfiguration = new SAMLConfiguration(
new SAMLIdentityProvider(
readFile("saml_idp.crt"),
readFile("saml_idp.pem"),
spEntityId),
List.of(),
false
);
SAMLServiceProvider serviceProvider = new SAMLServiceProvider(spEntityId, spEntityId);
serviceProvider.setCredential(signingCredential);
serviceProvider.setAcsLocation("https://engine.test.surfconext.nl/authentication/sp/consume-assertion");
DefaultSAMLIdPService tempSamlIdPService = new DefaultSAMLIdPService(samlConfiguration);
return tempSamlIdPService.serviceProviderMetaData(serviceProvider);
}

@BeforeEach
void beforeEach() {
SAMLConfiguration samlConfiguration = getSamlConfiguration(false);
samlIdPService = new DefaultSAMLIdPService(samlConfiguration);
}

private SAMLConfiguration getSamlConfiguration(boolean requiresSignedAuthnRequest) {
String metaData = getSPMetaData();
stubFor(get(urlPathMatching("/sp-metadata.xml")).willReturn(aResponse()
.withHeader("Content-Type", "text/xml")
.withBody(metaData)));
SAMLServiceProvider serviceProvider = new SAMLServiceProvider(
spEntityId,
"http://localhost:8999/sp-metadata.xml"
);
SAMLConfiguration samlConfiguration = new SAMLConfiguration(
new SAMLIdentityProvider(
readFile("saml_idp.crt"),
readFile("saml_idp.pem"),
spEntityId),
List.of(serviceProvider),
requiresSignedAuthnRequest
);
return samlConfiguration;
}

@SneakyThrows
private String samlAuthnRequest() {
String samlRequestTemplate = readFile("authn_request.xml");
String samlRequest = String.format(samlRequestTemplate, UUID.randomUUID(), issueFormat.format(new Date()));
String samlRequest = String.format(samlRequestTemplate, UUID.randomUUID(), issueFormat.format(new Date()), spEntityId);
return deflatedBase64encoded(samlRequest);
}

Expand All @@ -87,7 +125,7 @@ private String signedSamlAuthnRequest() {
String samlRequest = samlAuthnRequest();

AuthnRequest authnRequest = samlIdPService.parseAuthnRequest(samlRequest, true, true);
samlIdPService.signObject(authnRequest, signinCredential);
samlIdPService.signObject(authnRequest, signingCredential);

Element element = XMLObjectSupport.marshall(authnRequest);
String xml = SerializeSupport.nodeToString(element);
Expand Down Expand Up @@ -120,6 +158,25 @@ void parseAuthnRequest() {
assertEquals("https://test.surfconext.nl", uri);
}

@SneakyThrows
@Test
void parseAuthnRequestSignatureMissing() {
SAMLConfiguration samlConfiguration = getSamlConfiguration(true);
DefaultSAMLIdPService idPService = new DefaultSAMLIdPService(samlConfiguration);
String samlRequest = this.samlAuthnRequest();

assertThrows(SignatureException.class, () -> idPService.parseAuthnRequest(samlRequest, true, true));
}

@SneakyThrows
@Test
void unknownServiceProvider() {
String samlRequestTemplate = readFile("authn_request.xml");
String samlRequest = String.format(samlRequestTemplate, UUID.randomUUID(), issueFormat.format(new Date()), "https://nope.nl");
String encodedSamlRequest = deflatedBase64encoded(samlRequest);
assertThrows(IllegalArgumentException.class,() -> samlIdPService.parseAuthnRequest(encodedSamlRequest, true, true)) ;
}

@SneakyThrows
@Test
void parseSignedAuthnRequest() {
Expand All @@ -136,12 +193,12 @@ void sendResponse() {
String inResponseTo = UUID.randomUUID().toString();
MockHttpServletResponse httpServletResponse = new MockHttpServletResponse();
samlIdPService.sendResponse(
"https://acs",
spEntityId,
inResponseTo,
"urn:specified",
SAMLStatus.SUCCESS,
"relayState😀",
null,
"Ok",
DefaultSAMLIdPService.authnContextClassRefPassword,
List.of(
new SAMLAttribute("group", "riders"),
Expand All @@ -156,8 +213,8 @@ void sendResponse() {
assertEquals("relayState?�", relayState);

String samlResponse = document.select("input[name=\"SAMLResponse\"]").first().attr("value");
Response response = samlIdPService.parseResponse(samlResponse, true, false);
//damn you, open-saml
//Convenient way to make simple assertions
Response response = samlIdPService.parseResponse(samlResponse);
List<String> group = response
.getAssertions().get(0)
.getAttributeStatements().get(0)
Expand All @@ -183,4 +240,20 @@ void metadata() {
assertTrue(metaData.contains(singleSignOnServiceURI));
}

@Test
void resolveSigningCredential() {
SAMLServiceProvider serviceProvider = samlIdPService.resolveSigningCredential(
new SAMLServiceProvider(spEntityId, "https://metadata.test.surfconext.nl/sp-metadata.xml")
);
assertEquals("https://engine.test.surfconext.nl/authentication/sp/metadata", serviceProvider.getEntityId());
assertNotNull(serviceProvider.getCredential());
}

@Test
void resolveSigningCredentialResilience() {
SAMLServiceProvider serviceProvider = samlIdPService.resolveSigningCredential(
new SAMLServiceProvider(spEntityId, "https://nope")
);
assertNull(serviceProvider);
}
}
27 changes: 27 additions & 0 deletions src/test/java/saml/WireMockExtension.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package saml;

import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.client.WireMock;
import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;

public class WireMockExtension extends WireMockServer implements BeforeEachCallback, AfterEachCallback {

public WireMockExtension(int port) {
super(port);
}

@Override
public void beforeEach(ExtensionContext context) {
this.start();
WireMock.configureFor("localhost", port());
}

@Override
public void afterEach(ExtensionContext context) {
this.stop();
this.resetAll();
}

}
2 changes: 1 addition & 1 deletion src/test/resources/authn_request.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Version="2.0"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol">
<saml:Issuer>https://engine.test.surfconext.nl/authentication/sp/metadata</saml:Issuer>
<saml:Issuer>%s</saml:Issuer>
<samlp:NameIDPolicy AllowCreate="true"
Format="urn:mace:dir:attribute-def:eduPersonPrincipalName"/>
<samlp:Scoping>
Expand Down
Loading

0 comments on commit f1366dd

Please sign in to comment.