diff --git a/README.md b/README.md
index 3d8f035..d09c5c8 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,9 @@
### [Usage](#usage)
The main interface of the SAML library is `SAMLService`.
-It provides the following functionality:
+It provides the following functionality for service / identity providers:
+- create an (optionally signed) `org.opensaml.saml.saml2.core.AuthnRequest`
+- construct the SP metadata
- parsing SAML to an `org.opensaml.saml.saml2.core.AuthnRequest`
- sending SAML response back to the Service Provider
- construct the IdP metadata
diff --git a/pom.xml b/pom.xml
index 6753f06..4b640fc 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,7 +5,7 @@
org.openconext
saml-idp
- 0.0.6-SNAPSHOT
+ 0.0.7-SNAPSHOT
saml-idp
diff --git a/src/main/java/saml/DefaultSAMLService.java b/src/main/java/saml/DefaultSAMLService.java
index 752e79a..de51276 100644
--- a/src/main/java/saml/DefaultSAMLService.java
+++ b/src/main/java/saml/DefaultSAMLService.java
@@ -20,6 +20,7 @@
import org.opensaml.core.xml.util.XMLObjectSupport;
import org.opensaml.saml.common.SAMLVersion;
import org.opensaml.saml.common.SignableSAMLObject;
+import org.opensaml.saml.common.xml.SAMLConstants;
import org.opensaml.saml.ext.saml2mdui.Description;
import org.opensaml.saml.ext.saml2mdui.DisplayName;
import org.opensaml.saml.ext.saml2mdui.Logo;
@@ -72,6 +73,7 @@
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.opensaml.saml.common.xml.SAMLConstants.SAML20P_NS;
import static org.opensaml.saml.common.xml.SAMLConstants.SAML2_POST_BINDING_URI;
+import static saml.parser.EncodingUtils.deflatedBase64encoded;
public class DefaultSAMLService implements SAMLService {
@@ -206,6 +208,44 @@ public AuthnRequest parseAuthnRequest(String xml, boolean encoded, boolean defla
return authnRequest;
}
+ @SneakyThrows
+ @Override
+ public String createAuthnRequest(SAMLServiceProvider serviceProvider,
+ String destination,
+ boolean signRequest,
+ boolean forceAuthn,
+ String authnContextClassRef) {
+ AuthnRequest authnRequest = buildSAMLObject(AuthnRequest.class);
+ authnRequest.setAssertionConsumerServiceURL(serviceProvider.getAcsLocation());
+ authnRequest.setDestination(destination);
+ authnRequest.setForceAuthn(forceAuthn);
+ authnRequest.setID("A" + UUID.randomUUID());
+ authnRequest.setIsPassive(false);
+ authnRequest.setIssueInstant(Instant.now());
+ authnRequest.setProtocolBinding(SAMLConstants.SAML2_POST_BINDING_URI);
+ authnRequest.setVersion(SAMLVersion.VERSION_20);
+
+ Issuer issuer = buildSAMLObject(Issuer.class);
+ issuer.setValue(serviceProvider.getEntityId());
+ authnRequest.setIssuer(issuer);
+
+ if (StringUtils.isNotEmpty(authnContextClassRef)) {
+ RequestedAuthnContext requestedAuthnContext = buildSAMLObject(RequestedAuthnContext.class);
+ AuthnContextClassRef newAuthnContextClassRef = buildSAMLObject(AuthnContextClassRef.class);
+ newAuthnContextClassRef.setURI(authnContextClassRef);
+ requestedAuthnContext.getAuthnContextClassRefs().add(newAuthnContextClassRef);
+ authnRequest.setRequestedAuthnContext(requestedAuthnContext);
+ }
+
+ if (signRequest) {
+ this.signObject(authnRequest, serviceProvider.getCredential());
+ }
+
+ Element element = XMLObjectSupport.marshall(authnRequest);
+ String samlAuthnRequest = SerializeSupport.nodeToString(element);
+ return deflatedBase64encoded(samlAuthnRequest);
+ }
+
@Override
@SneakyThrows
public Response parseResponse(String xml) {
@@ -515,7 +555,8 @@ public SAMLServiceProvider resolveSigningCredential(SAMLServiceProvider serviceP
}
@SneakyThrows
- protected String serviceProviderMetaData(SAMLServiceProvider serviceProvider) {
+ @Override
+ public String serviceProviderMetaData(SAMLServiceProvider serviceProvider) {
EntityDescriptor entityDescriptor = buildSAMLObject(EntityDescriptor.class);
entityDescriptor.setEntityID(serviceProvider.getEntityId());
entityDescriptor.setID("M" + UUID.randomUUID());
diff --git a/src/main/java/saml/SAMLService.java b/src/main/java/saml/SAMLService.java
index e5c34fd..1d60abf 100644
--- a/src/main/java/saml/SAMLService.java
+++ b/src/main/java/saml/SAMLService.java
@@ -1,7 +1,9 @@
package saml;
+import lombok.SneakyThrows;
import org.opensaml.saml.saml2.core.*;
import org.opensaml.saml.saml2.metadata.EntityDescriptor;
+import org.opensaml.security.credential.Credential;
import saml.model.SAMLAttribute;
import saml.model.SAMLServiceProvider;
import saml.model.SAMLStatus;
@@ -11,8 +13,17 @@
public interface SAMLService {
-
- AuthnRequest createAuthnRequest(String authnContextClassRef);
+ /**
+ * Create an {@link AuthnRequest} and return the XML representation
+ *
+ * @param serviceProvider the (e.g. {@link SAMLServiceProvider}) containing the entityID
+ * @param destination the destination (e.g. singleSignService URL of the IdP)
+ * @param signRequest will the request be signed. If so, then the {@link Credential} must be present in the SP
+ * @param forceAuthn do we force a new authentication
+ * @param authnContextClassRef an optional value for the authnContextClassRef element
+ * @return deflated and Base64 encoded SAML AuthnRequest
+ */
+ String createAuthnRequest(SAMLServiceProvider serviceProvider, String destination, boolean signRequest, boolean forceAuthn, String authnContextClassRef);
/**
* Parse XML String to {@link Response}
@@ -73,4 +84,13 @@ void sendResponse(String spEntityID,
* @return the SAMLServiceProvider that may be null
*/
SAMLServiceProvider resolveSigningCredential(SAMLServiceProvider serviceProvider);
+
+ /**
+ * Create SP metaData
+ *
+ * @param serviceProvider the (e.g. {@link SAMLServiceProvider}) containing the entityID and certificate
+ * @return SAML metadata
+ */
+ @SneakyThrows
+ String serviceProviderMetaData(SAMLServiceProvider serviceProvider);
}
diff --git a/src/main/java/saml/parser/EncodingUtils.java b/src/main/java/saml/parser/EncodingUtils.java
index fa6e6fb..2ecce77 100644
--- a/src/main/java/saml/parser/EncodingUtils.java
+++ b/src/main/java/saml/parser/EncodingUtils.java
@@ -5,8 +5,10 @@
import org.apache.commons.codec.binary.Base64;
import java.io.ByteArrayOutputStream;
+import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
+import java.nio.charset.Charset;
import java.util.zip.Deflater;
import java.util.zip.DeflaterOutputStream;
import java.util.zip.Inflater;
@@ -14,7 +16,6 @@
import static java.nio.charset.StandardCharsets.ISO_8859_1;
import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.zip.Deflater.DEFLATED;
public class EncodingUtils {
@@ -32,6 +33,15 @@ private static String inflate(byte[] b) {
return out.toString(UTF_8);
}
+ public static String deflatedBase64encoded(String input) throws IOException {
+ ByteArrayOutputStream bytesOut = new ByteArrayOutputStream();
+ Deflater deflater = new Deflater(Deflater.DEFLATED, true);
+ DeflaterOutputStream deflaterStream = new DeflaterOutputStream(bytesOut, deflater);
+ deflaterStream.write(input.getBytes(Charset.defaultCharset()));
+ deflaterStream.finish();
+ return new String(Base64.encodeBase64(bytesOut.toByteArray()));
+ }
+
public static String samlEncode(String s) {
return UN_CHUNKED_ENCODER.encodeToString(s.getBytes(UTF_8));
}
diff --git a/src/test/java/saml/DefaultSAMLServiceTest.java b/src/test/java/saml/DefaultSAMLServiceTest.java
index 28e1335..d611dc4 100644
--- a/src/test/java/saml/DefaultSAMLServiceTest.java
+++ b/src/test/java/saml/DefaultSAMLServiceTest.java
@@ -4,7 +4,6 @@
import net.shibboleth.utilities.java.support.resolver.CriteriaSet;
import net.shibboleth.utilities.java.support.resolver.ResolverException;
import net.shibboleth.utilities.java.support.xml.SerializeSupport;
-import org.apache.commons.codec.binary.Base64;
import org.apache.commons.io.IOUtils;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
@@ -28,8 +27,6 @@
import saml.crypto.KeyStoreLocator;
import saml.model.*;
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.security.KeyStore;
@@ -40,11 +37,10 @@
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
-import java.util.zip.Deflater;
-import java.util.zip.DeflaterOutputStream;
import static com.github.tomakehurst.wiremock.client.WireMock.*;
import static org.junit.jupiter.api.Assertions.*;
+import static saml.parser.EncodingUtils.deflatedBase64encoded;
class DefaultSAMLServiceTest {
@@ -54,7 +50,7 @@ class DefaultSAMLServiceTest {
@RegisterExtension
WireMockExtension mockServer = new WireMockExtension(8999);
- private DefaultSAMLService samlIdPService;
+ private DefaultSAMLService defaultSAMLService;
static {
java.security.Security.addProvider(
@@ -77,7 +73,7 @@ class DefaultSAMLServiceTest {
@BeforeEach
void beforeEach() {
SAMLConfiguration samlConfiguration = getSamlConfiguration(false);
- samlIdPService = new DefaultSAMLService(samlConfiguration);
+ defaultSAMLService = new DefaultSAMLService(samlConfiguration);
}
private String getSPMetaData() {
@@ -127,8 +123,8 @@ private String samlAuthnRequest() {
private String signedSamlAuthnRequest() {
String samlRequest = samlAuthnRequest();
- AuthnRequest authnRequest = samlIdPService.parseAuthnRequest(samlRequest, true, true);
- samlIdPService.signObject(authnRequest, signingCredential);
+ AuthnRequest authnRequest = defaultSAMLService.parseAuthnRequest(samlRequest, true, true);
+ defaultSAMLService.signObject(authnRequest, signingCredential);
Element element = XMLObjectSupport.marshall(authnRequest);
String xml = SerializeSupport.nodeToString(element);
@@ -143,20 +139,11 @@ private static String readFile(String path) {
return IOUtils.toString(inputStream, Charset.defaultCharset());
}
- private String deflatedBase64encoded(String input) throws IOException {
- ByteArrayOutputStream bytesOut = new ByteArrayOutputStream();
- Deflater deflater = new Deflater(Deflater.DEFLATED, true);
- DeflaterOutputStream deflaterStream = new DeflaterOutputStream(bytesOut, deflater);
- deflaterStream.write(input.getBytes(Charset.defaultCharset()));
- deflaterStream.finish();
- return new String(Base64.encodeBase64(bytesOut.toByteArray()));
- }
-
@SneakyThrows
@Test
void parseAuthnRequest() {
String samlRequest = this.samlAuthnRequest();
- AuthnRequest authnRequest = samlIdPService.parseAuthnRequest(samlRequest, true, true);
+ AuthnRequest authnRequest = defaultSAMLService.parseAuthnRequest(samlRequest, true, true);
String uri = authnRequest.getScoping().getRequesterIDs().get(0).getURI();
assertEquals("https://test.surfconext.nl", uri);
}
@@ -177,7 +164,7 @@ 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));
+ assertThrows(IllegalArgumentException.class, () -> defaultSAMLService.parseAuthnRequest(encodedSamlRequest, true, true));
}
@SneakyThrows
@@ -187,14 +174,14 @@ void acsLocationInvalid() {
String samlRequest = String.format(samlRequestTemplate, UUID.randomUUID(), issueFormat.format(new Date()), spEntityId);
samlRequest = samlRequest.replace("https://engine.test.surfconext.nl/authentication/sp/consume-assertion", "https://nope");
String encodedSamlRequest = deflatedBase64encoded(samlRequest);
- assertThrows(IllegalArgumentException.class, () -> samlIdPService.parseAuthnRequest(encodedSamlRequest, true, true));
+ assertThrows(IllegalArgumentException.class, () -> defaultSAMLService.parseAuthnRequest(encodedSamlRequest, true, true));
}
@SneakyThrows
@Test
void parseSignedAuthnRequest() {
String authnRequestXML = this.signedSamlAuthnRequest();
- AuthnRequest authnRequest = samlIdPService.parseAuthnRequest(authnRequestXML, true, true);
+ AuthnRequest authnRequest = defaultSAMLService.parseAuthnRequest(authnRequestXML, true, true);
String uri = authnRequest.getScoping().getRequesterIDs().get(0).getURI();
assertEquals("https://test.surfconext.nl", uri);
@@ -205,7 +192,7 @@ void parseSignedAuthnRequest() {
void sendResponse() {
String inResponseTo = UUID.randomUUID().toString();
MockHttpServletResponse httpServletResponse = new MockHttpServletResponse();
- samlIdPService.sendResponse(
+ defaultSAMLService.sendResponse(
spEntityId,
inResponseTo,
"urn:specified",
@@ -227,7 +214,7 @@ void sendResponse() {
String samlResponse = document.select("input[name=\"SAMLResponse\"]").first().attr("value");
//Convenient way to make simple assertions
- Response response = samlIdPService.parseResponse(samlResponse);
+ Response response = defaultSAMLService.parseResponse(samlResponse);
String statusCode = response.getStatus().getStatusCode().getValue();
assertEquals(statusCode, "urn:oasis:names:tc:SAML:2.0:status:Success");
@@ -258,7 +245,7 @@ void sendResponse() {
void sendResponseNoAuthnContext() {
String inResponseTo = UUID.randomUUID().toString();
MockHttpServletResponse httpServletResponse = new MockHttpServletResponse();
- samlIdPService.sendResponse(
+ defaultSAMLService.sendResponse(
spEntityId,
inResponseTo,
"urn:specified",
@@ -273,7 +260,7 @@ void sendResponseNoAuthnContext() {
Document document = Jsoup.parse(html);
String samlResponse = document.select("input[name=\"SAMLResponse\"]").first().attr("value");
//Convenient way to make simple assertions
- Response response = samlIdPService.parseResponse(samlResponse);
+ Response response = defaultSAMLService.parseResponse(samlResponse);
StatusCode statusCode = response.getStatus().getStatusCode();
StatusCode innerStatusCode = statusCode.getStatusCode();
@@ -290,7 +277,7 @@ void sendResponseNoAuthnContext() {
@Test
void metadata() {
String singleSignOnServiceURI = "https://single.sign.on";
- String metaData = samlIdPService.metaData(
+ String metaData = defaultSAMLService.metaData(
singleSignOnServiceURI,
"Test",
"Test description",
@@ -300,7 +287,7 @@ void metadata() {
@Test
void resolveSigningCredential() {
- SAMLServiceProvider serviceProvider = samlIdPService.resolveSigningCredential(
+ SAMLServiceProvider serviceProvider = defaultSAMLService.resolveSigningCredential(
new SAMLServiceProvider(spEntityId, "https://metadata.test.surfconext.nl/sp-metadata.xml")
);
assertEquals("https://engine.test.surfconext.nl/authentication/sp/metadata", serviceProvider.getEntityId());
@@ -309,9 +296,24 @@ void resolveSigningCredential() {
@Test
void resolveSigningCredentialResilience() {
- SAMLServiceProvider serviceProvider = samlIdPService.resolveSigningCredential(
+ SAMLServiceProvider serviceProvider = defaultSAMLService.resolveSigningCredential(
new SAMLServiceProvider(spEntityId, "https://nope")
);
assertNull(serviceProvider);
}
+
+ @Test
+ void createAuthnRequest() {
+ SAMLServiceProvider serviceProvider = new SAMLServiceProvider(spEntityId, spEntityId);
+ serviceProvider.setCredential(signingCredential);
+ serviceProvider.setAcsLocation("https://engine.test.surfconext.nl/authentication/sp/consume-assertion");
+
+ String authnRequestXML = this.defaultSAMLService.createAuthnRequest(serviceProvider,
+ "https://mujina-idp.test.surfconext.nl/SingleSignOnService",
+ true, true, "https://refeds.org/profile/mfa");
+
+ AuthnRequest authnRequest = this.defaultSAMLService.parseAuthnRequest(authnRequestXML, true, true);
+ assertEquals(serviceProvider.getEntityId(), authnRequest.getIssuer().getValue());
+ assertEquals(serviceProvider.getAcsLocation(), authnRequest.getAssertionConsumerServiceURL());
+ }
}
\ No newline at end of file