
Building a SAML IDP
Some notes from adding SAML support for a Vimeo integration. The flow isn't all that different from OpenID Connect, but there are a handful of gotchas that make it meaningfully more annoying to implement.
Do we really need to build our own?
It seems so, unfortunately. The established SAML libraries all rely on xml-crypto for signing, and xml-crypto doesn't run in Cloudflare Workers. Which is where we need to run this.
The basic login flow
As in OIDC, the flow starts at the client — the Service Provider (SP) in SAML terms.
The SP redirects the browser to the IDP with a SAMLRequest query parameter containing an ID that plays the same role as state in OIDC: CSRF protection.
The IDP decodes and parses the request and authenticates the user.
Once the user is authenticated the IDP builds a SAML response and sends it back to the SP. The responses are large enough that they go as form POSTs rather than as redirects, typically as a rendered HTML form that auto-submits with JavaScript. In principle a direct POST would work too.
The SAML response is an encoded XML document with a digital signature that proves the IDP produced it.
Encoding
SAML requests are deflate-compressed and then base64-encoded — the deflate-raw variant. That's in the Compression Streams API these days, so once you know what you're looking for it's short:
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 are the hardest part of SAML, at least for me. You sign a node inside the document — either the whole Response or just the Assertion — and then you add the signature element back inside that same node:
<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>...SAML nests the signature inside the signed element, which is a bit unusual and means plenty of generic XML-signature tooling won't validate it.
xml-crypto handles this nicely. This snippet signs the assertion node and drops the signature inside it:
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" },
});Back to the problem from the top of the post: xml-crypto doesn't run on Cloudflare Workers or on top of Web Crypto.
There's another library — xmldsigjs — that does run in both, but it ships with the warning "Using XMLDSIG is a bit like running with scissors so use it cautiously", which is not what you want to read at 11pm. I haven't yet got it producing signatures that verify cleanly, so for now I'm cheating: responses are signed in a small Node process called from the Worker.
The request
The SAML request is a redirect with query parameters. SAMLRequest is always there; RelayState, SigAlg and Signature are optional depending on whether the binding is signed.
Debugging tools
There are a lot of steps and it's hard to tell where the breakage is. These saved me:
- Chilkat's XMLDsig verifier — verifies signatures on their own, independent of your code.
- OneLogin's SAML tools — encode/decode and inspect requests and responses.
- SAML Tester — end-to-end test SP to run your IDP against.