sdxc

[ Identity & security ]

@sdxc/saml

SAML 2.0 service provider: verify signed assertions, build requests and metadata

npm add @sdxc/saml
pnpm add @sdxc/saml
yarn add @sdxc/saml
bun add @sdxc/saml
Used by
auth-saas

SAML 2.0 service provider: verify signed assertions, build requests and metadata.

An identity provider posts a signed XML document to your assertion consumer service; this package verifies it, builds the request that asked for it, and reads and writes the metadata the two sides exchange. It runs on Web Crypto alone, so the same code works in workerd, Node, Bun and Deno.

Verification hands back an Assertion read from the exact element the signature covered, and that is the only way to read claims: the document tree is never exposed, so a signature-wrapping attack (a signed assertion hidden next to a forged one) has nothing to slip through.

Installation

npm add @sdxc/saml

Every call answers a Result from @sdxc/result. Clock skew and replay TTLs are duration strings from @sdxc/duration.

Usage

Verify a response

import * as SAML from "@sdxc/saml";
import { isFailure } from "@sdxc/result";

let result = await SAML.verifyResponse(samlResponseXml, {
	certificates, // the provider's signing certificates
	audience: "https://sp.example.com/metadata",
	destination: "https://sp.example.com/acs",
	recipient: "https://sp.example.com/acs",
	inResponseTo: requestId, // or null for a sign-in the provider started
	decryptionKey: null, // or the private key(s) for encrypted assertions
	replay, // a ReplayStore
	clock: { now: new Date(), skew: "60 seconds" },
});

if (isFailure(result)) return new Response(null, { status: 403 });

let assertion = result.data;
assertion.nameId?.value;
assertion.attribute("email");

Every option is required, so a call that forgets the audience or the replay store does not typecheck. One call checks the signature, the wrapping, Destination, Audience, Recipient, NotBefore, NotOnOrAfter, InResponseTo and the replay store before it answers.

Start a sign-in

import * as SAML from "@sdxc/saml";
import { isSuccess } from "@sdxc/result";

let request = await SAML.createAuthnRequest({
	binding: "redirect",
	destination: "https://idp.example.com/sso",
	issuer: "https://sp.example.com/metadata",
	assertionConsumerService: "https://sp.example.com/acs",
	nameIdFormat: null,
	forceAuthn: false,
	relayState: "/dashboard",
	signingKey: null,
	now: new Date(),
});

if (isSuccess(request)) {
	await saveRequestId(request.data.id);
	return Response.redirect(request.data.url!, 302);
}

Keep id: passing it back as inResponseTo binds the response to the browser that asked for it.

Read and write metadata

import * as SAML from "@sdxc/saml";
import { isSuccess } from "@sdxc/result";

let idp = await SAML.parseIdPMetadata(idpMetadataXml);
if (isSuccess(idp)) {
	idp.data.entityId;
	idp.data.singleSignOn; // [{ binding, location }]
	idp.data.signing; // Certificate[]
}

let metadata = SAML.buildServiceProviderMetadata({
	entityId: "https://sp.example.com/metadata",
	assertionConsumerService: "https://sp.example.com/acs",
	certificate: null,
	nameIdFormat: null,
	wantAssertionsSigned: true,
	authnRequestsSigned: false,
	validUntil: null,
});

if (isSuccess(metadata)) {
	return new Response(metadata.data, {
		headers: { "Content-Type": "application/samlmetadata+xml" },
	});
}

API

verifyResponse(source: string, options: VerifyResponseOptions): Promise<Result<Assertion, SAMLError>>

Verifies a response and answers with its assertion. source is the base64-decoded SAMLResponse XML. A signature verifying under any one of certificates is accepted. An accepted assertion id is remembered in replay for the rest of the assertion's own validity window.

  • certificates: the provider's signing certificates

  • audience: your entity id

  • destination / recipient: your assertion consumer service URL

  • inResponseTo: the id of the request this answers, or null for a provider-initiated sign-in

  • decryptionKey: an RSA-OAEP private key, several, or null to accept cleartext assertions only

  • replay: a ReplayStore

  • clock: { now, skew }, the moment every window is judged against and the drift allowed

createAuthnRequest(options: CreateAuthnRequestOptions): Promise<Result<AuthnRequestResult, SAMLError>>

Builds an authentication request for the "redirect" or "post" binding. The result carries the request id, plus a url to redirect to (redirect binding) or a form of { action, samlRequest, relayState } to post (POST binding); the other is null. signingKey is an RSASSA-PKCS1-v1_5 or ECDSA private key, or null to send the request unsigned.

parseIdPMetadata(source: string): Promise<Result<IdPMetadata, SAMLError>>

Reads an identity provider's metadata into its entity id, sign-on endpoints, parsed signing and encryption certificates, and wantAuthnRequestsSigned.

buildServiceProviderMetadata(options: ServiceProviderMetadataOptions): Result<string, SAMLError>

Writes the metadata XML you hand an identity provider. A certificate is published for both signing and encryption; nameIdFormat and validUntil are written when not null.

Certificate

One X.509 certificate. Instances carry fingerprint, subject, issuer, serial, notBefore, notAfter, algorithm, spki and der, with validAt(date), toPem() and toBase64().

Certificate.parse(source: string): Promise<Result<Certificate, CertificateError>>

Reads PEM, or the bare base64 a metadata document carries.

Certificate.selfSigned(options: Certificate.SelfSignedOptions): Promise<Result<Certificate, CertificateError>>

Generates a self-signed certificate from an RSASSA-PKCS1-v1_5 key pair, with commonName, notBefore and notAfter.

Assertion

A verified assertion; instances come only from verifyResponse. Carries id, issuer, nameId ({ value, format } or null), sessionIndex, authnInstant and notOnOrAfter. attribute(name) returns the first value or null, values(name) every value, names() every attribute name, and claims() one record where a repeated attribute is an array. Attributes resolve by both Name and FriendlyName.

Errors

Every failure extends SAMLError; read name for the kind. Messages never carry key material or document-supplied text, so they are safe to log.

ErrorMeaning
UnsupportedFeatureErrorThe document uses a refused feature, named in feature
MalformedDocumentErrorNot readable XML, or a required element is absent
UnsignedDocumentErrorNot exactly one signature (count)
UnresolvedReferenceErrorThe reference names no element or several (count)
SignatureMismatchErrorThe signature did not verify under any certificate
WrappedAssertionErrorThe signed element and the assertion element differ
DecryptionFailedErrorAn encrypted assertion could not be opened, for any reason
ResponseStatusErrorThe provider reported a non-success status and substatus
AssertionConditionErrorAuthentic but not for here; condition names which check failed
ReplayedAssertionErrorThe assertion id was already accepted
ReplayStoreErrorThe replay store threw; no verdict was reached, so the user may retry
CertificateErrorA certificate could not be read or generated

Supported algorithms

Signatures use RSA or ECDSA with SHA-256 or stronger and exclusive canonicalization, over one same-document reference. Encrypted assertions use RSA-OAEP key transport (MGF1 with SHA-1 or SHA-256) and AES-128 or AES-256 in GCM or CBC. Everything else answers UnsupportedFeatureError, including SHA-1, DSA, RSA-PSS, inclusive canonicalization, #WithComments, XPath and XSLT transforms, URI="" references, document type declarations, and the identity-provider role, Single Logout, HTTP-Artifact, SOAP, ECP and SAML 1.1.

Canonicalization costs CPU over the whole document, so cap the posted body size before verifying.

Types

VerifyResponseOptions, CreateAuthnRequestOptions, ServiceProviderMetadataOptions

The options objects above. Every field is required.

AuthnRequestResult and AuthnRequest.Binding

The built request (id, url, form) and "redirect" | "post".

IdPMetadata and IdPMetadata.Endpoint

Parsed provider metadata; an endpoint is { binding, location }.

ReplayStore

seen(id): Promise<boolean> and remember(id, ttl): Promise<void>, where ttl is a duration.

Assertion.NameId and Certificate.SelfSignedOptions

{ value, format }, and { keys, commonName, notBefore, notAfter }.

Pattern: An Assertion Consumer Service

Decode the posted form, verify against the request you started, and tell a configuration problem from an infrastructure one.

import * as SAML from "@sdxc/saml";
import { isFailure } from "@sdxc/result";

async function handleAcs(request: Request) {
	let form = await request.formData();
	let encoded = String(form.get("SAMLResponse") ?? "");
	let xml = new TextDecoder().decode(Uint8Array.from(atob(encoded), (c) => c.charCodeAt(0)));
	let relayState = form.get("RelayState");

	let result = await SAML.verifyResponse(xml, {
		certificates: await loadCertificates(),
		audience: "https://sp.example.com/metadata",
		destination: "https://sp.example.com/acs",
		recipient: "https://sp.example.com/acs",
		inResponseTo: await takeRequestId(request),
		decryptionKey: null,
		replay,
		clock: { now: new Date(), skew: "60 seconds" },
	});

	if (isFailure(result)) {
		let error = result.error;
		if (error instanceof SAML.ReplayStoreError) return new Response("Try again", { status: 503 });
		if (error instanceof SAML.AssertionConditionError) {
			logger.warn("assertion not for us", { condition: error.condition });
		} else {
			logger.warn("assertion rejected", { kind: error.name });
		}
		return new Response("Sign-in failed", { status: 403 });
	}

	await signIn(result.data.nameId?.value, result.data.sessionIndex);
	return Response.redirect(typeof relayState === "string" ? relayState : "/", 303);
}

Pattern: A Replay Store On A Table

import type { ReplayStore } from "@sdxc/saml";
import { toMs } from "@sdxc/duration";

let replay: ReplayStore = {
	async seen(id) {
		return (await db.findOne(assertionIds, { where: { id } })) !== null;
	},
	async remember(id, ttl) {
		await db.create(assertionIds, { id, expires_at: Date.now() + toMs(ttl) });
	},
};

An id is remembered only after every other check passed, and only for as long as the assertion stays valid, so a periodic delete of expired rows keeps the table small.

Pattern: Certificates That Rotate

Trust every certificate the provider's metadata lists, and refresh them on a schedule. A signature verifying under any one of them is accepted, so a rotation needs no coordination, and notAfter tells you when to warn an administrator.

import * as SAML from "@sdxc/saml";
import { isFailure } from "@sdxc/result";

let response = await fetch("https://idp.example.com/metadata");
let idp = await SAML.parseIdPMetadata(await response.text());
if (isFailure(idp)) throw idp.error;

for (let certificate of idp.data.signing) {
	await saveCertificate({
		fingerprint: certificate.fingerprint,
		notAfter: certificate.notAfter,
		pem: certificate.toPem(),
	});
}

let certificates: SAML.Certificate[] = [];
for (let pem of await loadCertificatePems()) {
	let parsed = await SAML.Certificate.parse(pem);
	if (!isFailure(parsed) && parsed.data.validAt(new Date())) certificates.push(parsed.data);
}
// pass `certificates` to verifyResponse

Pattern: Signed Requests And Encrypted Assertions

Generate one key pair, publish a self-signed certificate for it, sign requests with it, and open encrypted assertions with the same private key imported for RSA-OAEP. Providers use either OAEP digest, so pass the key imported under both.

import * as SAML from "@sdxc/saml";
import { isFailure } from "@sdxc/result";

let keys = await crypto.subtle.generateKey(
	{
		name: "RSASSA-PKCS1-v1_5",
		modulusLength: 2048,
		publicExponent: Uint8Array.of(1, 0, 1),
		hash: "SHA-256",
	},
	true,
	["sign", "verify"],
);

let certificate = await SAML.Certificate.selfSigned({
	keys,
	commonName: "sp.example.com",
	notBefore: new Date(),
	notAfter: new Date(Date.now() + 5 * 365 * 24 * 60 * 60 * 1000),
});
if (isFailure(certificate)) throw certificate.error;

let metadata = SAML.buildServiceProviderMetadata({
	entityId: "https://sp.example.com/metadata",
	assertionConsumerService: "https://sp.example.com/acs",
	certificate: certificate.data,
	nameIdFormat: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
	wantAssertionsSigned: true,
	authnRequestsSigned: true,
	validUntil: null,
});

let pkcs8 = await crypto.subtle.exportKey("pkcs8", keys.privateKey);
let decryptionKey = await Promise.all(
	["SHA-1", "SHA-256"].map((hash) =>
		crypto.subtle.importKey("pkcs8", pkcs8, { name: "RSA-OAEP", hash }, false, ["decrypt"]),
	),
);

let request = await SAML.createAuthnRequest({
	binding: "post",
	destination: "https://idp.example.com/sso",
	issuer: "https://sp.example.com/metadata",
	assertionConsumerService: "https://sp.example.com/acs",
	nameIdFormat: null,
	forceAuthn: true,
	relayState: null,
	signingKey: keys.privateKey,
	now: new Date(),
});
// request.data.form → { action, samlRequest, relayState } to render as an auto-submitting form
// later: SAML.verifyResponse(xml, { ...options, decryptionKey })

Pattern: Mapping Attributes To An Account

Providers name the same attribute differently; Assertion answers to both Name and FriendlyName, and claims() gives the whole set for a configurable mapping.

import type { Assertion } from "@sdxc/saml";

let TRANSIENT = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient";

function toAccount(assertion: Assertion) {
	let email =
		assertion.attribute("email") ??
		assertion.attribute("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress");

	return {
		externalId: assertion.nameId?.format === TRANSIENT ? null : (assertion.nameId?.value ?? null),
		email,
		groups: assertion.values("groups"),
		raw: assertion.claims(),
	};
}

A transient NameID names one session, not a person, so link accounts by a persistent identifier or an attribute instead.

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.