Showing posts with label openam. Show all posts
Showing posts with label openam. Show all posts

Friday, December 12, 2014

OpenAM and Kerberos authentication : how to provide a fallback for devices who do not have Kerberos enabled ?

OpenAM is very commonly used with the Kerberos and SPNEGO protocols to provide seamless authentication inside an company's network.

Those are the protocols used by OpenAM's Windows Desktop SSO module. It is extremely convenient : no need to input a password, you are automatically logged in.

When we deployed Kerberos, we usually face an issue with devices not configured for Kerberos authentication : phones, tablets, macbooks or Windows computers that were not configured by the company's administrators. Those users would see an "HTTP 401" error when attempting to authenticate to Kerberos.

If you did not change your default error page, it would look like this on Apache Tomcat :


The usual workaround is to edit the default 401 error page to redirect the user to a different authentication solution.

The problem with this method is that the user's original request is lost : OpenAM will not know anymore what application the user wanted to access. The usual solution is either to redirect the user arbitrarily to the most commonly used application, or to display a list of applications for the user to choose from.

We came around a better way to do this, without losing the user's original request. Here is how it works :
  • When the user fails the Kerberos authentication, a custom 401 page is displayed to the user. This 401 page sends the user back to the page he was trying to access (the login form), but with an additional request in the query string.
  • When the user hits this page, he is redirected to https://sso.company.com/UI/Login?.....&ignoreHttpCallback=true . The last parameter is added by the custom 401 page.
  • A custom filter added in OpenAM's web.xml detected the ignoreHttpCallback parameter and injects a fake Authorization header into the request, to make OpenAM believe that the client is trying to use Kerberos
  • With this, the Windows Desktop SSO module is started, and its authentication fails. If you have another module in the authentication chain (typically an Active Directory module), it will be used instead of the Windows Desktop SSO module.
Note that for this workaround to work, you must use an authentication chain containing the Windows Desktop SSO module (in level SUFFICIENT) and another fallback module, such as Active Directory, also in level SUFFICIENT.



Here is the code of a simple custom 401 error page :

<%@ page language="java" isErrorPage="true" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%
    String redirectURL = null;
    try {
        redirectURL = request.getAttribute("javax.servlet.forward.request_uri") + "?" + request.getAttribute("javax.servlet.forward.query_string") +
                "&ignoreHttpCallback=true";
    } catch (Exception e) {
        throw new RuntimeException("Unable to generate target URL", e);
    }
    if(!response.containsHeader("WWW-Authenticate")){
        response.addHeader("WWW-Authenticate", "Negotiate");
    }
%>
<html>
<head>
    <meta http-equiv="refresh" content="1; <%=redirectURL%>"/>
</head>
<body>
<h1>Error during transparent authentication.</h1>
<p>You will be automatically redirected to the login/password fallback.</p>
<p>If the redirection does not happen, please <a href="<%=redirectURL%>">click here for manual redirection</a>.</p>
</body>
</html>

And here is the code of the HttpFilter we use to simulate a Kerberos ticket, based on the ignoreHttpCallback parameter :


public class KerberosFallbackFilter implements Filter {
 private static final String IGNORE_PARAMETER = "ignoreHttpCallback";
 
 @Override
        public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
          if (request.getParameter(IGNORE_PARAMETER) != null) {
            request = new AuthorisationHeaderEnhancedRequest((HttpServletRequest) request);
          }
  chain.doFilter(request, response);
 }
 
 public class AuthorisationHeaderEnhancedRequest extends HttpServletRequestWrapper {
  private static final String AUTHORIZATION_HEADER = "Authorization";
  private static final String FAKE_HEADER = "Negotiate FAKE_HEADER";

  public AuthorisationHeaderEnhancedRequest(HttpServletRequest req) {
   super(req);
  }

  @Override
  public String getHeader(String key) {
   if (key != null && key.trim().equalsIgnoreCase(AUTHORIZATION_HEADER)) {
    return FAKE_HEADER;
   } else {
    return super.getHeader(key);
   }
  }
 }
}

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);
    }
}

Friday, June 20, 2014

Advanced authentication strategies on OpenAM : choosing the authentication strategy based on client's IP address and the target application

OpenAM is a great open source Single Sign On and federation application, with a lot of features accumulated over its years of existence, first as Sun Access Manager, then as Sun and Oracle OpenSSO, before Forgerock finally takes over and names it OpenAM.

Finding the exact feature you want, and working around its limitations, can be quite time consuming.

In our case, we wanted to be able to choose the right authentication method (say, Kerberos, Active Directory, several types of strong authentication) based on two factors :

  • IP Address : Is the user coming from the internal network or from the internet ?
  • Targeted Service Provider : Our OpenAM instance is used as SAML2 IDP, and we want to provide different levels of security depending on which SAML2 Service Provider the user is trying to access. In other words, accessing your Google Apps account should require a stronger authentication than a not-so-secret company-wide intrante.

Initially, we only managed IP address-based authentication, for which we used policies as is described in several blogs. However, we did not find any easy way to extend this feature to switch the authentication based on the Service Provider.

Here is the solution we eventually implemented :

In this architecture, a reverse proxy (we used Apache HTTPd with mod_proxy) redirects the user to one of two realms based on his IP address. Inside each realm, we developped a custom implementation of the IDPAuthnContextMapper class to choose the authentication based on the requested Service Provider.

IP address-based authentication switching

The IDP has two realms "internal" and "external". A reverse proxy is in charge of switching from one realm to the other based on the user's IP address. This way the user only sees one URL, whereas there actually are two :
  • The SAML endpoint that the user sees : https://sso.example.com/auth/SSORedirect/metaAlias/idp
  • The actual SAML endpoints that the proxy queries :
    • https://sso.example.com/SSORedirect/metaAlias/internal/idp
    • https://sso.example.com/SSORedirect/metaAlias/external/idp
With this, we can configure completely different authentication modules and chainings depending on where the user comes from.

This requires a bit of configuration overhead, since me must set the IDP's and SP's configuration twice (once for each realm), but it works well overall.

If you follow OpenAM releases, you may have noticed that there is an Adaptative Risk authentication module that can fail or succeed based on a user's IP address. Coupled with authentication chainings, this is another way to perform IP-based authentication switching. However, in our case we wanted to always have Kerberos as the internal authentication method, and with the Adaptative Risk module we could not find a way to avoid showing the Kerberos popup for external users.



Service Provider-based authentication switching

In OpenAM, there is an interface dedicated to choosing the authentication chaining based on the SAML Request, the IDPAuthnContextMapper. The default implentation chooses the authentication chaining or module based on the SAML authentication context provided in the SP's SAML request.

In our case, we simply reimplemented the interface to decide based on the issuer provided in the SAML request instead of the authentication context.

We are lucky since OpenAM allows to easily inject your own IPDAuthnContextMapper implementation in replacement of the existing one. Here is what we can see in the IDP configuration screen :