Building a SAML IDP
samlauth

Building a SAML IDP


These are some notes from adding support for integrating with Vimeo using a SAML Identity Provider (IDP). It's not all that different from the Open ID Connect (OIDC) flow, but there's a few gotchas that makes it quite a bit trickier to implement.

Do we really need to build a new library?

Unfortunately it seems to be the case.. There are some great libraries to handle SAML but they typically rely on the xml-crypto library to handle the XML signatures and xml-crypto does not run in Cloudflare Workers.

Basic login flow

Just like in OIDC the login flow typically starts at the client, which in the SAML case is the Service Provider (SP).

The SP will redirect the client to the IDP with a client side redirect passing a SAMLRequest query string parameter containing an ID that is used for Cross-site request forgery (CSRF) protection, just like the state in OIDC.

The SAML request is decoded and parsed on the IDP and the actual authentication takes place.

Once the user is authenticated the IDP creates a SAML response message and sends that back to the client. As the messages can be fairly large it is typically sending the response as a form post. In some cases this is done by rendering a form which is submitted using javascript, but it should really be possible to just send the post directly.

The SAML Response is an encoded XML document with a signature to ensure that it was created by the IDP.

Encoding

The xml is SAML requests are first base64 encoded and then compressed using deflate-encoding. It is luckily supported in javascript so once you understand how it works it isn't that tricky to do:

export async function inflate(
  compressedData: Uint8Array,
): Promise<Uint8Array> {
  const ds = new DecompressionStream("deflate-raw");
  const decompressedStream = new Blob([compressedData])
    .stream()
    .pipeThrough(ds);
  return new Uint8Array(await new Response(decompressedStream).arrayBuffer());
}

Xml Signatures

The XML Signatures is, at least to me, the biggest challenge with implementing SAML. The XML signatures are created be signing a node in the SAML, either the Response or the Attributes and then adding that signature as part of a signature element inside the document:

<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
    xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" Destination="https://scplay.skiclassics.com/saml/consume" ID="_wG9UTI1W3lGq2FB-weKhk" InResponseTo="_3b57fa6a-a8b1-4ae7-a787-4ddb0412610b" IssueInstant="2024-09-06T16:36:26.016Z" Version="2.0">
    <saml:Issuer>urn:auth2.sesamy.com</saml:Issuer>
    <samlp:Status>
        <samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
    </samlp:Status>
    <saml:Assertion xmlns="urn:oasis:names:tc:SAML:2.0:assertion" ID="__7DKwyOegnAuqodYryinA" IssueInstant="2024-09-06T16:36:26.016Z" Version="2.0">
        <saml:Issuer>urn:auth2.sesamy.com</saml:Issuer>
        <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
            <ds:SignedInfo>...

The SAML version of this works slightly different to normal XML signatures as the signature is added inside the element that is being signed, so not all tools and libraries to validate the signature will work.

Luckily there's the xml-crypto library that handles this nicely. This code will sign the assertion node and add the signature inside the same node:

    const xpath = "/*[local-name(.)='Response']/*[local-name(.)='Assertion']";
    const issuerXPath =
      '/*[local-name(.)="Issuer" and namespace-uri(.)="urn:oasis:names:tc:SAML:2.0:assertion"]';
 
    const sig = new SignedXml({
      privateKey: privateKeyPem,
    });
    sig.addReference({
      xpath,
      digestAlgorithm: "http://www.w3.org/2000/09/xmldsig#sha1",
      transforms: ["http://www.w3.org/2001/10/xml-exc-c14n#"],
    });
    sig.canonicalizationAlgorithm = "http://www.w3.org/2001/10/xml-exc-c14n#";
    sig.signatureAlgorithm = "http://www.w3.org/2000/09/xmldsig#rsa-sha1";
    sig.computeSignature(xmlContent, {
      location: { reference: xpath + issuerXPath, action: "after" },
    });

The bad news is that xml-crypto, as previously mentioned, does not work in Cloudflare Workers or with Web Crypto.

There is a another library called XMLDIGjs that handles the XML signatures that works in both environment, but it comes with the following warning Using XMLDSIG is a bit like running with scissors so use it cautiously. As one might guess it takes a bit more work to get to get this library working. I currently haven't succeeded in getting the signature to work correctly with this library and use a node process just to sign the responses.

Lets get a bit more into the detail of each step in the process and what the different options are..

SAML Request

The SAML Request is sent from the SP to the IDP to initiate the flow. It's a redirect with query parameters, so the clients browser is sent to the IDP. The request always have a SAML request query string, but can optionally have a RelayState, a Alg and a Sig querystring.

Debugging tools

There are a lot of small steps in the integration and it's really helpful to be able to validate that each step works as intended. Here are some good resources to checkout out: