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