filter_list

OpenSAML and Feide integration

Thursday 26.06.2014

Recently I worked on implementing an integration towards Feide, a Norwegian Single Sign-On login service. This service uses SAML 2.0 for communication between a Service Provider (SP) and an Identity Provider (IdP). There exists a library tailored for Feide, but this project has not been updated since 2011. I've used this library for an earlier Feide integration, but I wanted a more stable solution. The most well-known library out there for communication with a SAML server are currently OpenSAML.

Although OpenSAML comes highly recommended, there are close to no documentation on its usage. Their Wiki has no full example on its usage, and all you have are some tidbits here and there. Thankfully I found a guy that wrote a lot of blog posts on the topic, and I managed to scrape together some working code from these posts and various other sources online.

This is the main login class I ended up with:


import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.lang.reflect.Field;
import java.security.InvalidParameterException;
import java.util.List;
import java.util.Map;
import java.util.Timer;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.xml.namespace.QName;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.joda.time.DateTime;
import org.joda.time.DateTimeZone;
import org.opensaml.Configuration;
import org.opensaml.DefaultBootstrap;
import org.opensaml.common.SAMLObject;
import org.opensaml.common.binding.BasicSAMLMessageContext;
import org.opensaml.common.xml.SAMLConstants;
import org.opensaml.saml2.binding.encoding.HTTPRedirectDeflateEncoder;
import org.opensaml.saml2.core.Assertion;
import org.opensaml.saml2.core.Attribute;
import org.opensaml.saml2.core.AttributeStatement;
import org.opensaml.saml2.core.AuthnContextClassRef;
import org.opensaml.saml2.core.AuthnContextComparisonTypeEnumeration;
import org.opensaml.saml2.core.AuthnRequest;
import org.opensaml.saml2.core.Issuer;
import org.opensaml.saml2.core.NameIDPolicy;
import org.opensaml.saml2.core.RequestedAuthnContext;
import org.opensaml.saml2.core.Response;
import org.opensaml.saml2.core.Statement;
import org.opensaml.saml2.metadata.EntityDescriptor;
import org.opensaml.saml2.metadata.IDPSSODescriptor;
import org.opensaml.saml2.metadata.SingleSignOnService;
import org.opensaml.saml2.metadata.provider.MetadataProvider;
import org.opensaml.saml2.metadata.provider.MetadataProviderException;
import org.opensaml.saml2.metadata.provider.ResourceBackedMetadataProvider;
import org.opensaml.security.MetadataCredentialResolver;
import org.opensaml.security.MetadataCredentialResolverFactory;
import org.opensaml.security.MetadataCriteria;
import org.opensaml.util.resource.ClasspathResource;
import org.opensaml.util.resource.ResourceException;
import org.opensaml.ws.message.encoder.MessageEncodingException;
import org.opensaml.ws.transport.http.HttpServletResponseAdapter;
import org.opensaml.xml.ConfigurationException;
import org.opensaml.xml.XMLObject;
import org.opensaml.xml.XMLObjectBuilder;
import org.opensaml.xml.io.Unmarshaller;
import org.opensaml.xml.io.UnmarshallingException;
import org.opensaml.xml.parse.BasicParserPool;
import org.opensaml.xml.schema.XSString;
import org.opensaml.xml.security.CriteriaSet;
import org.opensaml.xml.security.credential.Credential;
import org.opensaml.xml.security.criteria.EntityIDCriteria;
import org.opensaml.xml.util.Base64;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.xml.sax.SAXException;

public class FeideLogin implements ExternalLogin {

    private static final Log LOGGER = LogFactory.getLog(FeideLogin.class);
    private static final Map<Class<?>, QName> ELEMENT_CACHE = new ConcurrentHashMap<Class<?>, QName>();
    private static final String CALLBACK = "https://***?provider=feide";
    private static final String SAML2_ISSUER = "***";

    public FeideLogin() {
        try {
            DefaultBootstrap.bootstrap();
        } catch (final ConfigurationException e) {
            throw new RuntimeException("Bootstrapping failed");
        }
    }

    @Override
    public final void doLogin(final HttpServletResponse response) {
        final MetadataProvider metadataProvider = this.getMetadataProvider();
        final EntityDescriptor entityDescriptor = this.getEntityDescriptor(metadataProvider);
        if (entityDescriptor == null) {
            try {
                response.sendRedirect("/login");
            } catch (final IOException ex) {
                LOGGER.warn("Failed to send user back to login page", ex);
            }
            return;
        }

        final AuthnRequest authnRequest = this.generateAuthnRequest(entityDescriptor);

        final HttpServletResponseAdapter responseAdapter = new HttpServletResponseAdapter(response, true);
        final BasicSAMLMessageContext<SAMLObject, AuthnRequest, SAMLObject> context = new BasicSAMLMessageContext<>();
        context.setPeerEntityEndpoint(this.getSingleSignOnService(entityDescriptor));
        context.setOutboundSAMLMessage(authnRequest);
        context.setOutboundSAMLMessageSigningCredential(this.generateCredential(metadataProvider));
        context.setOutboundMessageTransport(responseAdapter);
        context.setRelayState(CALLBACK);

        final HTTPRedirectDeflateEncoder encoder = new HTTPRedirectDeflateEncoder();

        try {
            encoder.encode(context);
        } catch (final MessageEncodingException ex) {
            LOGGER.warn("Error while performing Feide login", ex);
        }
    }

    @Override
    public final ExternalUserMetadata getUserMetadata(final HttpServletRequest request) {
        final String samlResponse = request.getParameter("SAMLResponse");
        if (samlResponse == null) {
            // throw new IllegalStateException("SAMLResponse parameter cannot be null");
            return null;
        }

        try {
            final String xml = new String(Base64.decode(samlResponse), "UTF-8");
            final XMLObject object = this.unmarshallElementFromString(xml);
            if (!(object instanceof Response)) {
                throw new IllegalArgumentException("SAMLResponse must be of type Response. Was " + object);
            }

            final ExternalUserMetadata metadata = new ExternalUserMetadata();
            metadata.setProvider(LoginProvider.FEIDE);
            final Response response = (Response) object;

            for (final Assertion assertion : response.getAssertions()) {
                for (final Statement statement : assertion.getStatements()) {
                    if (statement instanceof AttributeStatement) {
                        final AttributeStatement attributeStatement = (AttributeStatement) statement;
                        for (final Attribute attribute : attributeStatement.getAttributes()) {
                            if ("displayName".equals(attribute.getName())) {
                                metadata.setName(this.getValue(attribute));
                            }
                            if ("eduPersonTargetedID".equals(attribute.getName())) {
                                metadata.setId(this.getValue(attribute));
                            }
                        }
                    }
                }
            }

            return metadata;
        } catch (final UnsupportedEncodingException ex) {
            throw new RuntimeException(ex);
        }
    }

    private String getValue(final Attribute attribute) {
        for (final XMLObject object : attribute.getAttributeValues()) {
            if (object instanceof XSString) {
                return ((XSString) object).getValue();
            }
        }
        return null;
    }

    private MetadataProvider getMetadataProvider() {
        final String basePath = FeideLogin.class.getPackage().getName().replaceAll("\\.", "/");
        final String path = "/" + basePath + "/feide-idp-metadata.xml";

        try {
            final ClasspathResource resource = new ClasspathResource(path);
            final ResourceBackedMetadataProvider metadataProvider =
                    new ResourceBackedMetadataProvider(new Timer(true), resource);
            metadataProvider.setRequireValidMetadata(true);
            metadataProvider.setParserPool(new BasicParserPool());
            metadataProvider.initialize();
            return metadataProvider;
        } catch (final ResourceException | MetadataProviderException ex) {
            LOGGER.warn("Failed to read service metadata", ex);
        }

        return null;
    }

    private EntityDescriptor getEntityDescriptor(final MetadataProvider metadataProvider) {
        if (metadataProvider != null) {
            try {
                return metadataProvider.getEntityDescriptor("https://idp.feide.no");
            } catch (final MetadataProviderException ex) {
                LOGGER.warn("Failed to read service metadata", ex);
            }
        }

        return null;
    }

    private AuthnRequest generateAuthnRequest(final EntityDescriptor entityDescriptor) {
        final AuthnRequest authnRequest = this.buildXMLObject(AuthnRequest.class);
        authnRequest.setForceAuthn(true);
        authnRequest.setIsPassive(false);
        authnRequest.setIssueInstant(new DateTime(DateTimeZone.UTC));
        authnRequest.setDestination(this.getSingleSignOnLocation(entityDescriptor));
        authnRequest.setProtocolBinding(SAMLConstants.SAML2_POST_BINDING_URI);
        authnRequest.setAssertionConsumerServiceURL(CALLBACK);
        authnRequest.setID("_" + UUID.randomUUID().toString());
        // authnRequest.setProviderName("https://idp.feide.no");

        final Issuer issuer = this.buildXMLObject(Issuer.class);
        issuer.setValue(SAML2_ISSUER);
        authnRequest.setIssuer(issuer);

        final NameIDPolicy nameIDPolicy = this.buildXMLObject(NameIDPolicy.class);
        nameIDPolicy.setSPNameQualifier(SAML2_ISSUER);
        nameIDPolicy.setAllowCreate(true);
        nameIDPolicy.setFormat("urn:oasis:names:tc:SAML:2.0:nameid-format:transient");
        authnRequest.setNameIDPolicy(nameIDPolicy);

        final RequestedAuthnContext requestedAuthnContext = this.buildXMLObject(RequestedAuthnContext.class);
        requestedAuthnContext.setComparison(AuthnContextComparisonTypeEnumeration.MINIMUM);
        final AuthnContextClassRef authnContextClassRef = this.buildXMLObject(AuthnContextClassRef.class);
        authnContextClassRef.setAuthnContextClassRef(
                "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport");
        requestedAuthnContext.getAuthnContextClassRefs().add(authnContextClassRef);
        authnRequest.setRequestedAuthnContext(requestedAuthnContext);

        return authnRequest;
    }

    private Credential generateCredential(final MetadataProvider metadataProvider) {
        final MetadataCredentialResolver resolver = MetadataCredentialResolverFactory.getFactory()
                .getInstance(metadataProvider);

        final CriteriaSet criteriaSet = new CriteriaSet();
        criteriaSet.add(new MetadataCriteria(IDPSSODescriptor.DEFAULT_ELEMENT_NAME, SAMLConstants.SAML20P_NS));
        criteriaSet.add(new EntityIDCriteria("IPDEntityId"));

        try {
            return resolver.resolveSingle(criteriaSet);
        } catch (final org.opensaml.xml.security.SecurityException ex) {
            LOGGER.warn("Failed to lookup credentials", ex);
        }

        return null;
    }

    private SingleSignOnService getSingleSignOnService(final EntityDescriptor entityDescriptor) {
        final List<SingleSignOnService> singleSignOnServices =
                entityDescriptor.getIDPSSODescriptor(SAMLConstants.SAML20P_NS).getSingleSignOnServices();
        for (final SingleSignOnService singleSignOnService : singleSignOnServices) {
            if (singleSignOnService.getBinding().equals(SAMLConstants.SAML2_REDIRECT_BINDING_URI)) {
                return singleSignOnService;
            }
        }

        return null;
    }

    private String getSingleSignOnLocation(final EntityDescriptor entityDescriptor) {
        final SingleSignOnService singleSignOnService = this.getSingleSignOnService(entityDescriptor);
        if (singleSignOnService != null) {
            return singleSignOnService.getLocation();
        } else {
            return null;
        }
    }

    @SuppressWarnings("unchecked")
    private <T extends XMLObject> T buildXMLObject(final Class<T> type) {
        try {
            final QName objectQName = this.getElementQName(type);
            final XMLObjectBuilder<T> builder = Configuration.getBuilderFactory().getBuilder(objectQName);
            if (builder == null) {
                throw new InvalidParameterException("No builder exists for object: " + objectQName.getLocalPart());
            }
            return builder.buildObject(objectQName.getNamespaceURI(), objectQName.getLocalPart(),
                    objectQName.getPrefix());
        } catch (final SecurityException e) {
            throw new RuntimeException(e);
        }
    }

    private <T> QName getElementQName(final Class<T> type) {
        if (ELEMENT_CACHE.containsKey(type)) {
            return ELEMENT_CACHE.get(type);
        }

        try {
            Field typeField;
            try {
                typeField = type.getDeclaredField("DEFAULT_ELEMENT_NAME");
            } catch (final NoSuchFieldException ex) {
                typeField = type.getDeclaredField("ELEMENT_NAME");
            }

            final QName objectQName = (QName) typeField.get(null);
            ELEMENT_CACHE.put(type, objectQName);
            return objectQName;
        } catch (final NoSuchFieldException e) {
            throw new RuntimeException(e);
        } catch (final IllegalAccessException e) {
            throw new RuntimeException(e);
        }
    }

    private XMLObject unmarshallElementFromString(final String elementString) {
        try {
            final Element samlElement = this.loadElementFromString(elementString);

            final Unmarshaller unmarshaller = Configuration.getUnmarshallerFactory().getUnmarshaller(samlElement);
            if (unmarshaller == null) {
                LOGGER.error("Unable to retrieve unmarshaller by DOM Element");
                throw new IllegalArgumentException("No unmarshaller for " + elementString);
            }

            return unmarshaller.unmarshall(samlElement);
        } catch (final UnmarshallingException ex) {
            LOGGER.error("Unmarshalling failed when parsing element string " + elementString, ex);
            throw new RuntimeException(ex);
        }
    }

    private Element loadElementFromString(final String elementString) {
        try {
            final DocumentBuilderFactory newFactory = this.getDocumentBuilderFactory();
            newFactory.setNamespaceAware(true);

            final DocumentBuilder builder = newFactory.newDocumentBuilder();

            final Document doc = builder.parse(new ByteArrayInputStream(elementString.getBytes("UTF-8")));
            final Element samlElement = doc.getDocumentElement();

            return samlElement;
        } catch (final ParserConfigurationException ex) {
            LOGGER.error("Unable to parse element string " + elementString, ex);
            throw new RuntimeException(ex);
        } catch (final SAXException ex) {
            LOGGER.error("Unable to parse element string " + elementString, ex);
            throw new RuntimeException(ex);
        } catch (final IOException ex) {
            LOGGER.error("Unable to parse element string " + elementString, ex);
            throw new RuntimeException(ex);
        }
    }

    private DocumentBuilderFactory getDocumentBuilderFactory() throws ParserConfigurationException {
        final DocumentBuilderFactory newFactory = DocumentBuilderFactory.newInstance();
        newFactory.setNamespaceAware(true);

        // External entities has been disabled in order to prevent XML External Entity (XXE) attacks.
        newFactory.setFeature("http://xml.org/sax/features/external-general-entities", false);
        return newFactory;
    }
}

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public interface ExternalLogin {

    void doLogin(HttpServletRequest request, HttpServletResponse response);

    ExternalUserMetadata getUserMetadata(HttpServletRequest request);
}

import java.io.Serializable;

import org.codehaus.jackson.annotate.JsonIgnoreProperties;
import org.codehaus.jackson.annotate.JsonProperty;

@SuppressWarnings("serial")
@JsonIgnoreProperties(ignoreUnknown = true)
public class ExternalUserMetadata implements Serializable {

    private LoginProvider provider;
    private String id;
    private String name;
    private String firstName;
    private String lastName;
    private String link;
    private String username;
    private String gender;
    private String locale;
    private String hostedDomain;

    public final LoginProvider getProvider() {
        return this.provider;
    }

    public final void setProvider(final LoginProvider provider) {
        this.provider = provider;
    }

    public final String getId() {
        return this.id;
    }

    public final void setId(final String id) {
        this.id = id;
    }

    @JsonProperty("sub")
    public final void setSub(final String sub) {
        this.id = sub;
    }

    public final String getName() {
        return this.name;
    }

    public final void setName(final String name) {
        this.name = name;
    }

    public final String getFirstName() {
        return this.firstName;
    }

    @JsonProperty("first_name")
    public final void setFirstName(final String firstName) {
        this.firstName = firstName;
    }

    @JsonProperty("given_name")
    public final void setGivenName(final String givenName) {
        this.firstName = givenName;
    }

    public final String getLastName() {
        return this.lastName;
    }

    @JsonProperty("last_name")
    public final void setLastName(final String lastName) {
        this.lastName = lastName;
    }

    @JsonProperty("family_name")
    public final void setFamilyName(final String familyName) {
        this.lastName = familyName;
    }

    public final String getLink() {
        return this.link;
    }

    public final void setLink(final String link) {
        this.link = link;
    }

    public final String getUsername() {
        return this.username;
    }

    public final void setUsername(final String username) {
        this.username = username;
    }

    @JsonProperty("screen_name")
    public final void setScreenName(final String screenName) {
        this.username = screenName;
    }

    public final String getGender() {
        return this.gender;
    }

    public final void setGender(final String gender) {
        this.gender = gender;
    }

    public final String getLocale() {
        return this.locale;
    }

    public final void setLocale(final String locale) {
        this.locale = locale;
    }

    public final String getHostedDomain() {
        return this.hostedDomain;
    }

    public final void setHostedDomain(final String hostedDomain) {
        this.hostedDomain = hostedDomain;
    }

    @JsonProperty("hd")
    public final void setHD(final String hd) {
        this.hostedDomain = hd;
    }
}

public enum LoginProvider {

    FACEBOOK, FEIDE, GOOGLE, MICROSOFT, TWITTER
}

As one might see from this code, a lot of manual work goes into the SAML communication. Hopefully this example will help someone else in the same situation.

First published at:
https://infposs.blogspot.no/2014/06/opensaml-and-feide-integration.html

2017

2016

2014

2013

2012

2011

2010

2009

2007

2005

2004

2003

2002

2001