Tuesday, August 5, 2014

OpenAM and SAML2 federation : returning a different NameID for each Service Provider

If you have an OpenAM identity provider connected to several service providers, chances are that not all providers expect the same NameID. Some, like Google Apps, make ask for the user's email while others will expect something like ActiveDirectory's sAMAccountName.

Now if you're able to map each different NameID to a NameID format in OpenAM, everything will work great for you. Here's an example of NameID mapping configuration based on the NameID format :



But what if you're forced to use the NameId called urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified for all your SPs ? This constraint can come from the SPs, but also from your OpenAM installation. In my case OpenAM has a read-only user store, so the only NameID formats I am allowed to use are the non-persistent ones. And there are only two :

  • urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified
  • urn:oasis:names:tc:SAML:2.0:nameid-format:transient
And the transient NameID format is not well managed by some SPs, so I am stuck with the unspecified format.

Luckily, OpenAM provides the ability to inject your own IDPAccountMapper implementation. You can inject your custom class directly from the interface like this :



I have decided to keep the mapping used in the administration interface, but to allow the user to specify a custom mapping for a given Service Provider. Here's how it looks :


In this example, the unspecified NameID format is by default mapped to the mail attribute, unless the SP is https://myserviceprovider.com. In that case, the attribute used will be sAMAccountName.

I will provide the code for this custom IDPAccountMapper. But this does not work with most SPs, due to a bug in OpenAM (OPENAM-4264). As far as I know, this bug exists in all versions of OpenAM at this date.

The problem is that sometimes the SP name is not provided to the IDPAccountMapper. To solve this, we need to tweak the AuthnRequest object before it is opened by OpenAM to provide the SP name to the IDPAccountMapper.

For this, we will inject a custom SAML2IdentityProviderAdapter. Again we are lucky since OpenAM allows us to inject an object at the right moment.


Now here's the source code for the IDPAccountMapper and the SAML2IdentityProviderAdapter :

/**
 * Copy-paste of the OpenAM DefaultIDPAccountMapper class, with a twist to allow SP-specific configuration.
 * To specify a custom attribute for a given SP, use the following mapping : NAMING-FORMAT:SPENTITYID=attribute
 * Example : urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified:google.com/a/mycompany.com=mail
 */
public class MyIDPAccountMapper extends DefaultAccountMapper implements IDPAccountMapper {
    private DefaultIDPAccountMapper defaultIDPAccountMapperDelegate;

    public MyIDPAccountMapper () {
        debug.message("MyIDPAccountMapper .constructor");
        this.role = "IDPRole";
        defaultIDPAccountMapperDelegate = new DefaultIDPAccountMapper();
    }


    public NameID getNameID(Object session, String hostEntityID, String remoteEntityID, String realm, String nameIDFormat)
            throws SAML2Exception {
        String userID = null;
        try {
            SessionProvider sessionProv = SessionManager.getProvider();
            userID = sessionProv.getPrincipalName(session);
        } catch (SessionException se) {
            throw new SAML2Exception(SAML2Utils.bundle.getString("invalidSSOToken"));
        }


        String nameIDValue = null;
        if (nameIDFormat.equals("urn:oasis:names:tc:SAML:2.0:nameid-format:transient")) {
            String sessionIndex = IDPSSOUtil.getSessionIndex(session);
            if (sessionIndex != null) {
                IDPSession idpSession = (IDPSession) IDPCache.idpSessionsByIndices.get(sessionIndex);

                if (idpSession != null) {
                    List list = idpSession.getNameIDandSPpairs();
                    if ((list != null) && (!list.isEmpty())) {
                        Iterator iter = list.iterator();
                        while (iter.hasNext()) {
                            NameIDandSPpair pair = (NameIDandSPpair) iter.next();

                            if (pair.getSPEntityID().equals(remoteEntityID)) {
                                nameIDValue = pair.getNameID().getValue();
                                break;
                            }
                        }
                    }
                }
            }
            if (nameIDValue == null) {
                nameIDValue = getNameIDValueFromUserProfile(realm, hostEntityID, remoteEntityID, userID, nameIDFormat);

                if (nameIDValue == null) {
                    nameIDValue = SAML2Utils.createNameIdentifier();
                }
            }
        } else {
            nameIDValue = getNameIDValueFromUserProfile(realm, hostEntityID, remoteEntityID, userID, nameIDFormat);

            if (nameIDValue == null) {
                if (nameIDFormat.equals("urn:oasis:names:tc:SAML:2.0:nameid-format:persistent")) {
                    nameIDValue = SAML2Utils.createNameIdentifier();
                } else {
                    throw new SAML2Exception(bundle.getString("unableToGenerateNameIDValue"));
                }
            }
        }


        NameID nameID = AssertionFactory.getInstance().createNameID();
        nameID.setValue(nameIDValue);
        nameID.setFormat(nameIDFormat);
        nameID.setNameQualifier(hostEntityID);
        nameID.setSPNameQualifier(remoteEntityID);
        nameID.setSPProvidedID(null);
        return nameID;
    }


    public String getIdentity(NameID nameID, String hostEntityID, String remoteEntityID, String realm)
            throws SAML2Exception {
        debug.warning("MyIDPAccountMapper -specific implementation received a call to getIdentity(). This is not supported by this implementation and will be deferred to the DefaultIDPAccountMapper delegate.");
        return defaultIDPAccountMapperDelegate.getIdentity(nameID, hostEntityID, remoteEntityID, realm);
    }


    protected String getNameIDValueFromUserProfile(String realm, String hostEntityID, String remoteEntityID, String userID, String nameIDFormat) {
        if (debug.messageEnabled()) {
            debug.message("Asking NameID for user " + userID + ", nameId format " + nameIDFormat + ", SP entity : " + remoteEntityID);
        }
        String nameIDValue = null;
        Map formatAttrMap = getFormatAttributeMap(realm, hostEntityID);

        String spSpecificNameIDFormat = nameIDFormat + ":" + remoteEntityID;
        String attrName = (String) formatAttrMap.get(spSpecificNameIDFormat);

        if (attrName == null) {
            attrName = (String) formatAttrMap.get(nameIDFormat);
            if (debug.messageEnabled()) {
                debug.message("Could not find a SP-specific attribute name, found generic attribute name : " + attrName);
            }
        } else {
            if (debug.messageEnabled()) {
                debug.message("Found SP-specific attribute name : " + attrName);
            }
        }


        if (attrName != null) {
            try {
                Set attrValues = dsProvider.getAttribute(userID, attrName);
                if ((attrValues != null) && (!attrValues.isEmpty())) {
                    nameIDValue = (String) attrValues.iterator().next();
                }
            } catch (DataStoreProviderException dspe) {
                if (debug.warningEnabled()) {
                    debug.warning("DefaultIDPAccountMapper.getNameIDValueFromUserProfile:", dspe);
                }
            }
        }


        return nameIDValue;
    }

    private Map getFormatAttributeMap(String realm, String hostEntityID) {
        String key = hostEntityID + "|" + realm;
        Map formatAttributeMap = (Map) IDPCache.formatAttributeHash.get(key);
        if (formatAttributeMap != null) {
            return formatAttributeMap;
        }

        formatAttributeMap = new HashMap();
        List values = SAML2Utils.getAllAttributeValueFromSSOConfig(realm, hostEntityID, this.role, "nameIDFormatMap");
        Iterator iter;
        if ((values != null) && (!values.isEmpty())) {
            for (iter = values.iterator(); iter.hasNext(); ) {
                String value = (String) iter.next();

                int index = value.indexOf('=');
                if (index != -1) {
                    String format = value.substring(0, index).trim();
                    String attrName = value.substring(index + 1).trim();
                    if ((format.length() != 0) && (attrName.length() != 0)) {
                        formatAttributeMap.put(format, attrName);
                    }
                }
            }
        }

        IDPCache.formatAttributeHash.put(key, formatAttributeMap);

        return formatAttributeMap;
    }



/**
 * This class is used as a fix for https://bugster.forgerock.org/jira/browse/OPENAM-4264
 * We modify the AuthnRequest parameter so that the IDPSSOUtil class can then find the SP id and provide it to the IDPAccountMapper
 */
public class MyAuthRequestUpdatingIDPAdapter extends DefaultIDPAdapter {
    private Debugger saml2UtilsDebugger = new SAML2UtilsDebugger();

    @Override
    public boolean preSendResponse(AuthnRequest authnRequest, String hostProviderID, String realm, HttpServletRequest request, HttpServletResponse response, Object session, String reqID, String relayState) throws SAML2Exception {
        SPNameQualifierEnhancer.addSPNameQualifierToAuthnRequest(authnRequest, saml2UtilsDebugger);
        return super.preSendResponse(authnRequest, hostProviderID, realm, request, response, session, reqID, relayState);
    }
}
This is the class that updates the authentication request to fix our bug :
public class SPNameQualifierEnhancer {
    private static final String DEBUG_PREFIX = "MyAuthRequestUpdatingIDPAdapter : ";

    public static void addSPNameQualifierToAuthnRequest(AuthnRequest authnRequest, Debugger debug) throws SAML2Exception {
        if (authnRequest instanceof AuthnRequestImpl) {
            MutabilityModifier.makeMutable((AuthnRequestImpl) authnRequest);
            if (authnRequest.getNameIDPolicy() == null) {
                authnRequest.setNameIDPolicy(new NameIDPolicyImpl());
                debug.message(DEBUG_PREFIX + "no NameIDPolicy found in SAML2 authn request, will create a default nameid policy");
            }

            if (!SPNameQualifierChecker.isValidSPNameQualifier(authnRequest.getNameIDPolicy().getSPNameQualifier())) {
                String replacementSpNameQualifier = null;
                if (authnRequest.getIssuer() != null) {
                    replacementSpNameQualifier = authnRequest.getIssuer().getValue();
                }
                authnRequest.setNameIDPolicy(new NameIDPolicyWithSPNameQualifierProxy(authnRequest.getNameIDPolicy(), replacementSpNameQualifier));
                debug.message(DEBUG_PREFIX + "no SPNameQualifier found in SAML2 authn request, will create one with issuer name : " + replacementSpNameQualifier);
            }
        } else {
            debug.warning("Unable to change mutability of class : " + authnRequest.getClass().getCanonicalName());
        }
    }
}


This class simply checks whether the SPNameQualifier provided in the authentication request is valid, or if we need to insert one :

public abstract class SPNameQualifierChecker {
    public static boolean isValidSPNameQualifier(String spNameQualifier) {
        return spNameQualifier != null && !(spNameQualifier.trim().isEmpty());
    }
}


Since by default the authentication request cannot be modified, we need to tweak it a little bit. Since this implies modifying a protected attribute, the package declaration is important :

package com.sun.identity.saml2.protocol.impl;

public class MutabilityModifier {
    public static void makeMutable(AuthnRequestImpl authnRequest) {
        authnRequest.isMutable = true;
    }
}


The rest is just boilerplate :

public class NameIDPolicyWithSPNameQualifierProxy implements NameIDPolicy {
    private NameIDPolicy nameIDPolicy;
    private String replacementSPNameQualifier;

    public NameIDPolicyWithSPNameQualifierProxy(NameIDPolicy nameIDPolicy, String replacementSPNameQualifier) {
        this.nameIDPolicy = nameIDPolicy;
        this.replacementSPNameQualifier = replacementSPNameQualifier;
    }

    @Override
    public String getSPNameQualifier() {
        return replacementSPNameQualifier;
    }

    @Override
    public String getFormat() {
        return nameIDPolicy.getFormat();
    }

    @Override
    public void setFormat(String s) throws SAML2Exception {
        nameIDPolicy.setFormat(s);
    }

    @Override
    public void setSPNameQualifier(String s) throws SAML2Exception {
        nameIDPolicy.setSPNameQualifier(s);
    }

    @Override
    public void setAllowCreate(boolean b) throws SAML2Exception {
        nameIDPolicy.setAllowCreate(b);
    }

    @Override
    public boolean isAllowCreate() {
        return nameIDPolicy.isAllowCreate();
    }

    @Override
    public String toXMLString() throws SAML2Exception {
        return nameIDPolicy.toXMLString();
    }

    @Override
    public String toXMLString(boolean b, boolean b2) throws SAML2Exception {
        return nameIDPolicy.toXMLString(b, b2);
    }

    @Override
    public void makeImmutable() {
        nameIDPolicy.makeImmutable();
    }

    @Override
    public boolean isMutable() {
        return nameIDPolicy.isMutable();
    }

}

public interface Debugger {
    public void message(String s);
    public void warning(String s);
}

public class SAML2UtilsDebugger implements Debugger{
    @Override
    public void message(String s) {
        SAML2Utils.debug.message(s);
    }

    @Override
    public void warning(String s) {
        SAML2Utils.debug.warning(s);
    }
}

And the test classes :

public class MyAuthRequestUpdatingIDPAdapterTest{
    private Debugger debugger = new TestDebugger();
    private static final String ISSUER = "theissuer";
    private static final String VALID_QUALIFIER = "thequalifier";

    @Test
    public void testNoNameIDPolicy() throws Exception {
        AuthnRequest authnRequest = new AuthnRequestImpl();
        Issuer issuer = new IssuerImpl();
        issuer.setValue(ISSUER);
        authnRequest.setIssuer(issuer);
        authnRequest.makeImmutable();
        SPNameQualifierEnhancer.addSPNameQualifierToAuthnRequest(authnRequest,debugger );
        assertNotNull(authnRequest.getNameIDPolicy());
        assertEquals(NameIDPolicyWithSPNameQualifierProxy.class, authnRequest.getNameIDPolicy().getClass());
        assertEquals(ISSUER, authnRequest.getNameIDPolicy().getSPNameQualifier());
    }

    @Test
      public void testNoSPNameQualifier() throws Exception {
        AuthnRequest authnRequest = new AuthnRequestImpl();
        Issuer issuer = new IssuerImpl();
        issuer.setValue(ISSUER);
        authnRequest.setIssuer(issuer);

        NameIDPolicy nameIDPolicy = new NameIDPolicyImpl();
        nameIDPolicy.makeImmutable();
        authnRequest.setNameIDPolicy(nameIDPolicy);

        authnRequest.makeImmutable();
        SPNameQualifierEnhancer.addSPNameQualifierToAuthnRequest(authnRequest, debugger);
        assertNotNull(authnRequest.getNameIDPolicy());
        assertEquals(NameIDPolicyWithSPNameQualifierProxy.class, authnRequest.getNameIDPolicy().getClass());
        assertEquals(ISSUER, authnRequest.getNameIDPolicy().getSPNameQualifier());
    }

    @Test
    public void testEmptySPNameQualifier() throws Exception {
        AuthnRequest authnRequest = new AuthnRequestImpl();
        Issuer issuer = new IssuerImpl();
        issuer.setValue(ISSUER);
        authnRequest.setIssuer(issuer);

        NameIDPolicy nameIDPolicy = new NameIDPolicyImpl();
        nameIDPolicy.setSPNameQualifier(" ");
        nameIDPolicy.makeImmutable();
        authnRequest.setNameIDPolicy(nameIDPolicy);

        authnRequest.makeImmutable();
        SPNameQualifierEnhancer.addSPNameQualifierToAuthnRequest(authnRequest, debugger);
        assertNotNull(authnRequest.getNameIDPolicy());
        assertEquals(NameIDPolicyWithSPNameQualifierProxy.class, authnRequest.getNameIDPolicy().getClass());
        assertEquals(ISSUER, authnRequest.getNameIDPolicy().getSPNameQualifier());
    }

    @Test
    public void testValidSPNameQualifier() throws Exception {
        AuthnRequest authnRequest = new AuthnRequestImpl();
        Issuer issuer = new IssuerImpl();
        issuer.setValue(ISSUER);
        authnRequest.setIssuer(issuer);

        NameIDPolicy nameIDPolicy = new NameIDPolicyImpl();
        nameIDPolicy.setSPNameQualifier(VALID_QUALIFIER);
        nameIDPolicy.makeImmutable();
        authnRequest.setNameIDPolicy(nameIDPolicy);

        authnRequest.makeImmutable();
        SPNameQualifierEnhancer.addSPNameQualifierToAuthnRequest(authnRequest, debugger);
        assertNotNull(authnRequest.getNameIDPolicy());
        assertEquals(NameIDPolicyImpl.class, authnRequest.getNameIDPolicy().getClass());
        assertEquals(VALID_QUALIFIER, authnRequest.getNameIDPolicy().getSPNameQualifier());
    }
}


public class TestDebugger implements Debugger {

    @Override
    public void message(String s) {
        System.out.println("MESSAGE : "+s);
    }

    @Override
    public void warning(String s) {
        System.out.println("WARNING : "+s);
    }
}