SAML2 Servlet Filter

Posted on October 23, 2010 by Tommy McGuire
Labels: SAML, authentication, protocols, http, OpenAM, java
I have been working on various forms of authentication for Java-based web applications for quite a while. The latest iteration uses the Security Assertion Markup Language, version 2 to interact with an Identity Provider (IdP), which is supported by the authentication system we get to use. When describing the OSGi-based design I came up with to support this thingamajig I again mentioned the code to handle the interaction; the first part of that code is the topic of this post. I intend to describe the servlet filter which stands in front of application servlets; a later post will describe the Assertion Consumer Service.

Because of the way the code is structured, this post must assume that you are familiar with the basic protocol as described in SAML authentication for web applications.

Background


There are two parts to the application-side (or Service Provider, SP side) of the authentication infrastructure: a servlet filter and the Assertion Consumer Service (or ACS), an application that interprets the SAML response and directs the user's request appropriately. The servlet filter intercepts incoming user requests and either
The first and fourth options are, well, optional. The SAML2 filter only needs the second, third, and fifth branches to function correctly.

I do not intend to provide complete code for the filter, because the implementation I have is tightly bound to the OSGi container and to our framework and the idea of refactoring it for genericity leaves a bad taste in my mouth. The basic structure of the filter, though, is:
try
{
final HttpServletResponse response = (HttpServletResponse) resp;
final HttpServletRequest request =
filteredRequest(new HideClientSsoHeaderHttpRequest((HttpServletRequest) rqst), response, chain, getConfig(config));
if (request == null) { return; }
else if (handleAcsRedirect(request, response, chain)) { return; }
else if (handleAuthenticatedRequest(request, response, chain)) { return; }
else if (handleSamlDisabled(request, response, chain, getConfig(config))) { return; }
else
{
// Unknown key: send Authentication Request
response.sendRedirect(authnRequest(request));
return;
}
}
catch (...) { ... }

The possible exceptions are SecuritySystemException, a high-level exception thrown by the authentication and identity-related parts of our framework, usually indicating an operational failure of some sort, and DecoderException, from Apache Commons Codec and indicates a Base64 or other encoding error.

Pass through


Passing through requests unfiltered is relatively simple, although there are several related operations and as a result the function is larger than it would seem to be. The filteredRequest function returns either a null, indicating that the request has been handled, or a request which may be the original or may be a modified version. Pass through processing must be done first, since the whole goal is to short-circuit other more expensive processing.
private HttpServletRequest filteredRequest(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Config config) throws IOException, ServletException
{

The basic filter compares the URL from the request with a regular expression pattern; if the URL matches the request is passed on to the next element of the filter chain to be handled and null is returned.
if (config.excludedUrlPattern != null)
{
final String contextPath = request.getContextPath();
final String requestUri = request.getRequestURI();
final int contextBeg = requestUri.indexOf(contextPath);
final int contextEnd = contextBeg + contextPath.length();

String applicationUrl =
(contextBeg < 0 || contextEnd == (requestUri.length() - 1))
? requestUri : requestUri.substring(contextEnd);
if (!applicationUrl.startsWith("/"))
{
applicationUrl = "/" + applicationUrl;
}

if (config.excludedUrlPattern.matcher(applicationUrl).matches())
{
/* Request matches excluded URL pattern; do not continue with filter. */
chain.doFilter(request, response);
return null;
}
}
The second option we currently have is for AMF requests (a.k.a. BlazeDS, as in Adobe Flex), it matches against the destination of the request (which requires grubbing through the request, unfortunately). Corey describes what this does as:
When a Flex client makes a remote object call this ends making two seperate server calls. The first is an administration call that doesn't have a normal AMF payload (that can be decoded). The second is the call (with payload) to the service destination. Because I can't tell what service destination this is targeted to I'm always letting these administration request go through unfiltered. I need to add a check here when this happens to make sure they are targeting the Flex broker with the URL.
if (config.excludedAmfDestinationPattern != null
&& AMF_CONTENT_TYPE.equals(request.getHeader(CONTENT_TYPE_HEADER)))
{
final CapturedBytesHttpRequest captureBytesRequest = new CapturedBytesHttpRequest(request);
final AmfDecoder decoder = new AmfDecoder(captureBytesRequest.getCapturedBytes());
if (!decoder.decode() || config.excludedAmfDestinationPattern.matcher(decoder.getDestination()).matches())
{
/* Request matches AMF destination pattern; do not continue with filter. */
chain.doFilter(captureBytesRequest, response);
return null;
}
else
{
return captureBytesRequest;
}
}

return request;
}

ACS redirects

When a request is directed to the ACS which contains a successful authentication, it redirects the browser back to the original URL, meaning that the request is captured by this filter. However, the redirected request (which must be a GET) contains a query parameter matching the regular expression pattern "^ACSREQUESTID=(.*)$". (In-band signaling is kind of unpleasant, but there are few other ways to correctly get the requisite information shared by the ACS, this filter, and the browser.) When a request contains the ACSREQUESTID, the value is picked out and decoded. This value is a token that is used as a key for the authentication information as well as to recover the original request, which is handled by this function.
private boolean handleAcsRedirect(final HttpServletRequest request, final HttpServletResponse response, final FilterChain chain)
throws DecoderException, IOException, ServletException
{
final String queryString = request.getQueryString();
if (queryString != null)
{
final Matcher matcher = pat.matcher(queryString);
if (matcher.matches())
{
final String key = new URLCodec().decode(matcher.group(1));
final HttpServletRequestWrapper storedRequest = Storage.getRequestStore().getRequest(key);
final IdentityMap mapping = Storage.getIdentityStore().getIdentity(Arrays.asList(key));
if (storedRequest != null && mapping != null)
{
storedRequest.setRequest(request);
chain.doFilter(new AuthenticatedHttpServletRequest(storedRequest, mapping), response);
Storage.getRequestStore().removeRequest(key);
return true;
}
}
}
return false;
}
The Storage class provides access to two different storage systems: The Storage class in turn comes in two forms: An AuthenticatedHttpServletRequest recovers a stored request and adds the identity information from the IdentityMap to allow access from the application.

Authenticated requests

Authenticated requests are simple to handle. The token used as a key store the identity information is also added as a cookie to the response by the ACS, so all this method needs to do is to recover the cookie and check it against the IdentityStore (which includes checking if the authentication has timed out).
private boolean handleAuthenticatedRequest(final HttpServletRequest request, final HttpServletResponse response, final FilterChain chain)
throws DecoderException, IOException, ServletException
{
final List authCookie = Misc.getCookie(request, AUTH_COOKIE_NAME);
final IdentityMap mapping = Storage.getIdentityStore().getIdentity(authCookie);
if (mapping != null && mapping.valid())
{
chain.doFilter(new AuthenticatedHttpServletRequest(request, mapping), response);
return true;
}
return false;
}

Disabling SAML

The downside of the SAML2 single-sign-on design is that if the IdP is down, the applications are also unavailable. As an alternative, I added a administratively configurable flag to disable SAML2 processing. If SAML2 is disabled, there are three other options: I will not show any code for this method, since it is relatively straightforward and the processing for option 2 is lengthy.

Generating authentication requests

The remaining possibility requires generating an authentication request, storing the original request, and redirecting the browser via the request back to the IdP.
private String authnRequest(HttpServletRequest request) throws SecuritySystemException
{
// Generate the session identification key
final String key = Framework.getSessionKeyService().generateSessionKey();
Storage.getRequestStore().storeRequest(key, request);
return new AuthenticationRequest(key).toUriString();
}
The session key generated is used as the token for the SAML2 request as well as the value of the cookie identifying future authenticated requests. The key itself is just a cryptographically secure random number. The String generated is the URL of the IdP with the authentication request added as a query parameter, following the SAML2 specifications. This is passed back to the browser as a redirect.

An AuthenticationRequest

To interact with the IdP, I used the fmclientsdk.jar and openssoclientsdk.jar libraries from OpenAM, which was OpenSSO from Sun before the Oracle acquisition. I currently need to use both, because some classes are missing from each. (I really should report that as a bug.)
public AuthenticationRequest(String key) throws SecuritySystemException
{
try
{
final String destinationUrl = ...;
final boolean forceAuthn = ...;
final boolean isPassive = ...;
final boolean signAuthnRqst = ...;
final String acsUrl = ...;

final AuthnRequest authnRequest = ProtocolFactory.getInstance().createAuthnRequest();

authnRequest.setID(key);
authnRequest.setVersion(SAML_VERSION);
authnRequest.setIssueInstant(new Date());
authnRequest.setDestination(destinationUrl);
if (forceAuthn)
{
authnRequest.setForceAuthn(forceAuthn);
}
if (isPassive)
{
authnRequest.setIsPassive(isPassive);
}
authnRequest.setProtocolBinding(PROTOCOL_BINDING);
authnRequest.setAssertionConsumerServiceURL(acsUrl);
authnRequest.setIssuer(issuer());
authnRequest.setNameIDPolicy(nameIdPolicy());
authnRequest.setRequestedAuthnContext(requestAuthnContext());
if (signAuthnRqst)
{
sign(authnRequest);
}

authnRequestMessage = authnRequest.toXMLString(true, true);
log.debug("request: " + authnRequestMessage);
}
catch (SAML2Exception e)
{
throw new SecuritySystemException("Unable to create SAML2 AuthnRequest", e);
}
catch (...) { ... }
}
The com.sun.identity.saml2.protocol.ProtocolFactory class provides access to the components needed to generate the authentication request. The created object is a com.sun.identity.saml2.protocol.AuthnRequest, which has a large number of properties which can be set to fill in elements in the request. For precise details of which properties are needed, see the SAML2 specifications as well as something like the Federal ICAM (Identity, Credential, and Access Management) Security Assertion Markup Language (SAML) 2.0 Web Browser Single Sign-on (SSO) Profile. (Man, that thing has an unfortunate logo.) This method calls three additional methods to create other parts of the request: The one tricky feature of the authentication request is the cryptographic signature. The sign method above (invoked when the signAuthnRequest flag is set) signs the authnRequest "internally", using XML encryption. In the HTTP-Redirect binding, requests are signed after being converted to strings, which will be described shortly. Typically, signAuthnRqst should be false. Instead, the authentication request, formatted in XML, is first compressed (using DeflaterOutputStream).
public String toEncodedString() throws SecuritySystemException
{
try
{
final ByteArrayOutputStream baos = new ByteArrayOutputStream(authnRequestMessage.length());
final DeflaterOutputStream dos = new DeflaterOutputStream(baos, new Deflater(Deflater.BEST_COMPRESSION, true));
dos.write(authnRequestMessage.getBytes());
dos.close();
return encodedString = encode(baos.toByteArray());
}
catch (IOException e)
{
throw new SecuritySystemException("Unable to encode SAML2 AuthnRequest", e);
}
}
Following compression, the string is Base-64 encoded, then URL-encoded to protect non-URL-safe Base-64 characters.
private String encode(byte[] byteArray)
{
return new String( new URLCodec().encode( Base64.encodeBase64(byteArray) ) );
}
Finally, the query string is partially assembled, with the SAML request and SigAlg (indicating the signature algorithm) parameters. The resulting string is signed and the signature added as a third query parameter. The whole is assembled with the URL to be returned to the browser.
public String toUriString() throws SecuritySystemException
{
try
{
final String parameters = String.format("%s=%s&%s=%s",
SAML_REQUEST,
toEncodedString(),
"SigAlg", new URLCodec().encode("http://www.w3.org/2000/09/xmldsig#rsa-sha1"));
final String signature = signature(parameters);
return String.format("%s?%s&%s=%s", destinationUrl, parameters, "Signature", signature);
}
catch (EncoderException e)
{
throw new SecuritySystemException("Cannot enecode SigAlg", e);
}
}
To sign the string, the normal Java cryptography API's are employed. The result is encoded identically to the compressed request.
private String signature(String unsigned) throws SecuritySystemException
{
String signatureAlgorithm = ...;
try
{
final Pair pair = ...;
final Signature signature = Signature.getInstance(signatureAlgorithm);
signature.initSign(pair.getY());
signature.update(unsigned.getBytes("UTF-8"));
return encode( signature.sign() );
}
catch (...) { ... } ...
}

Conclusion


The filter is relatively complex, although it follows a simple step-by-step procedure. However, most of the complexity comes from generating the authentication request, which requires interaction with the OpenAM client SDK, Java cryptography and compression, and HTTP URL manipulation.

Best of luck, and I hope this helps someone.
active directory applied formal logic ashurbanipal authentication books c c++ comics conference continuations coq data structure digital humanities Dijkstra eclipse virgo electronics emacs goodreads haskell http java job Knuth ldap link linux lisp math naming nimrod notation OpenAM osgi parsing pony programming language protocols python quote R random REST ruby rust SAML scala scheme shell software development system administration theory tip toy problems unix vmware yeti
Member of The Internet Defense League
Site proudly generated by Hakyll.