SAML 2.0 SP

SAML 2.0 (OASIS) is the federation protocol financial / government / enterprise IdPs still ship — operators can't always require an OIDC IdP. This primitive implements the SP side only:

- AuthnRequest builder (HTTP-Redirect + HTTP-POST bindings) - Response parser: * Verify Response or Assertion XMLDSig (whichever the IdP signed) using the IdP's signing certificate * Refuse signature-wrapping by enforcing single-element- match on the Reference URI (via xml-c14n) * Validate NotOnOrAfter / NotBefore / Recipient / InResponseTo on SubjectConfirmation * Validate Conditions/NotBefore/NotOnOrAfter/ AudienceRestriction - SP metadata XML emitter - MDQ (RFC 8414-style metadata-query) fetch with strict server-identity per RFC 9525

Operators wire two routes:

/saml/login → returns the AuthnRequest URL (Redirect binding) OR an HTML form (POST binding) the user-agent submits to the IdP's SSO endpoint. /saml/acs → AssertionConsumerService — receives the IdP's SAMLResponse, calls verifyResponse, hydrates the user session.

Storage of InResponseTo / RelayState pre-image / nonce is operator-side via b.cache or b.session — the framework gives the parsing + verification primitive; operators wire freshness + replay defense.

b.auth.saml.sp.create(opts) #

stable0.8.62
{
  {
    entityId:                    string,    // this SP's entityID URL
    assertionConsumerServiceUrl: string,    // SP /saml/acs endpoint
    idpEntityId:                 string,
    idpSsoUrl:                   string,    // IdP single-sign-on endpoint
    idpCertPem:                  string,    // IdP signing cert (PEM)
    audience?:                   string,    // default = entityId
    clockSkewSec?:               number,    // default 60
    nameIdFormat?:               string,    // optional NameIDPolicy/Format
    idpSloUrl?:                  string,    // the IdP single-logout endpoint both logout builders
                                            // default to. Each call may override it, and with
                                            // neither set they fall back to idpSsoUrl, so a
                                            // deployment with no SLO endpoint at all raises
                                            // auth-saml/no-idp-slo
    singleLogoutServiceUrl?:     string,    // this SP's own SLO endpoint, advertised in the
                                            // metadata document; a metadata() call may override it
  }
}

Build a SAML 2.0 SP. Operators supply: - the SP entityId (this RP's URL) - assertionConsumerServiceUrl (the /saml/acs route) - idpEntityId + idpSsoUrl + idpCertPem (the trust anchor for this SP — typically rotated quarterly via MDQ)

var sp = b.auth.saml.sp.create({
  entityId:                    "https://sp.example",
  assertionConsumerServiceUrl: "https://sp.example/saml/acs",
  idpEntityId:                 "https://idp.example",
  idpSsoUrl:                   "https://idp.example/sso",
  idpCertPem:                  process.env.IDP_CERT_PEM,
});

b.auth.saml.sp.buildAuthnRequest(opts) #

0.8.62
{
  { relayState?: string }
}

Build a SAMLRequest XML + the URL-safe deflate-base64 encoding for the HTTP-Redirect binding. Returns { id, redirectUrl, raw } where id is the AuthnRequest ID the SP must remember (binds to the response's InResponseTo).

var ar = sp.buildAuthnRequest({ relayState: "/dashboard" });
res.statusCode = 302;
res.setHeader("Location", ar.redirectUrl);
res.end();
// remember ar.id; expect it back in the Response InResponseTo

b.auth.saml.sp.verifyResponse(samlResponseB64, vopts) #

0.8.62
{
  {
    expectedInResponseTo?: string,   // the AuthnRequest ID this is responding to
    now?:                  number,   // timestamp override for tests
  }
}

Parse + verify the IdP's SAMLResponse (the base64-encoded XML the user-agent POSTs to /saml/acs). Validates the XMLDSig (Response-level OR Assertion-level signature), the assertion's SubjectConfirmation Bearer constraints, and Conditions audience + time bounds. Returns { nameId, nameIdFormat, sessionIndex, attributes, audience, inResponseTo, issuer }.

app.post("/saml/acs", function (req, res) {
  var info = sp.verifyResponse(req.body.SAMLResponse, {
    expectedInResponseTo: req.session.samlRequestId,
  });
  // → { nameId, nameIdFormat, sessionIndex, attributes, audience, inResponseTo, issuer }
});

b.auth.saml.sp.metadata(metaOpts?) #

0.8.62

Emit the SP's EntityDescriptor XML for IdP-side configuration. Operators serve this verbatim at /saml/metadata.

app.get("/saml/metadata", function (req, res) {
  res.setHeader("Content-Type", "application/samlmetadata+xml");
  res.end(sp.metadata());
});

b.auth.saml.sp.buildLogoutRequest(opts) #

stable0.10.16
{
  nameId:         string,                          // user's NameID from the original AuthnResponse
  nameIdFormat:   string,                          // optional NameID Format URI
  sessionIndex:   string,                          // SessionIndex from the original Assertion AuthnStatement
  relayState:     string,                          // optional opaque blob round-tripped to LogoutResponse
  signingKey:     Uint8Array | string | KeyObject, // PQC private key (b.pqcSoftware.ml_dsa_*.keygen()) for ML-DSA;
                                                   // PEM string or node:crypto KeyObject for RSA / ECDSA / Ed25519
  signingAlg:     "rsa-sha256" | "rsa-sha384" | "rsa-sha512" |
                  "ecdsa-sha256" | "ecdsa-sha384" | "ecdsa-sha512" |
                  "ed25519" | "ml-dsa-65" | "ml-dsa-87",   // default omitted → unsigned
  idpSloUrl:      string,                          // single-logout endpoint; falls back to
                                                   // sp.create's idpSloUrl, then its idpSsoUrl,
                                                   // and raises auth-saml/no-idp-slo with none set
}

Build a SAML 2.0 LogoutRequest XML + the URL-safe deflate-base64 encoding for the HTTP-Redirect binding. When signingKey / signingAlg are supplied, computes the binding-§3.4.4.1 canonical query-string signature so the IdP can verify the request originated from a trusted SP. The signature is computed over SAMLRequest=&[RelayState=&]SigAlg= in that exact order (no re-sorting per the spec).

var lr = sp.buildLogoutRequest({
  nameId: "alice@idp", sessionIndex: "_session-9876",
  signingKey: kp.secretKey, signingAlg: "ml-dsa-65",
});
res.statusCode = 302;
res.setHeader("Location", lr.redirectUrl);

b.auth.saml.sp.parseLogoutRequest(samlRequestB64, vopts?) #

stable0.10.16
{
  queryString:        string,             // raw query string (everything after `?` in the redirect URL)
  idpVerifyKey:       Uint8Array,         // IdP's PQC public key
  idpVerifyAlg:       "ml-dsa-65" | "ml-dsa-87" | "ed25519",
  allowUnsigned:      boolean,            // default false — the parse refuses when no idpVerifyKey is configured; set true to accept an unsigned message
}

Parse an inbound LogoutRequest (IdP-initiated SLO). Returns { id, nameId, nameIdFormat, sessionIndex, issuer, issueInstant }. When vopts.idpVerifyKey is supplied with vopts.queryString, verifies the HTTP-Redirect-binding signature against the IdP key.

var req = sp.parseLogoutRequest(req.query.SAMLRequest, {
  queryString: req.url.split("?")[1],
  idpVerifyKey: idpKp.publicKey,
  idpVerifyAlg: "ml-dsa-65",
});

b.auth.saml.sp.buildLogoutResponse(opts) #

stable0.10.16
{
  inResponseTo: string,                          // required — LogoutRequest ID being responded to
  destination:  string,                          // required — IdP SLO endpoint URL the response posts to
  statusCode:   string,                          // optional — SAML status URI; default Success
  relayState:   string,                          // optional — opaque blob from the matching LogoutRequest
  signingKey:   Uint8Array,                      // PQC private key (b.pqcSoftware.ml_dsa_*.keygen())
  signingAlg:   "ml-dsa-65" | "ml-dsa-87" | "ed25519",   // default omitted → unsigned
}

Build a SAML 2.0 LogoutResponse to an IdP-initiated LogoutRequest. Status defaults to urn:oasis:names:tc:SAML:2.0:status:Success. Same HTTP-Redirect binding + optional canonical-query signature as buildLogoutRequest.

var resp = sp.buildLogoutResponse({
  inResponseTo: incoming.id,
  destination:  "https://idp.example/slo",
  signingKey:   kp.secretKey,
  signingAlg:   "ml-dsa-65",
});
res.writeHead(302, { Location: resp.redirectUrl });

b.auth.saml.sp.parseLogoutResponse(samlResponseB64, vopts?) #

stable0.10.16
{
  queryString:   string,             // raw URL query (everything past `?`)
  idpVerifyKey:  Uint8Array,
  idpVerifyAlg:  "ml-dsa-65" | "ml-dsa-87" | "ed25519",
  allowUnsigned: boolean,            // default false — the parse refuses when no idpVerifyKey is configured; set true to accept an unsigned message
  expectedInResponseTo: string,      // optional — refuses on mismatch
}

Parse + verify an inbound SAML 2.0 LogoutResponse (the IdP's acknowledgement of a previously-issued LogoutRequest). Returns { id, inResponseTo, statusCode, issuer, success } where success is true when statusCode equals the spec's success URN. When vopts.idpVerifyKey is supplied, verifies the redirect-binding signature against the IdP key (same shape as parseLogoutRequest).

var resp = sp.parseLogoutResponse(req.query.SAMLResponse, {
  queryString: req.url.split("?")[1],
  idpVerifyKey: idpPub,
  idpVerifyAlg: "ml-dsa-65",
  expectedInResponseTo: storedLogoutRequestId,
});
resp.success;   // → true on Success status

b.auth.saml.sp.buildLogoutRequestPost(opts) #

stable0.10.16
{
  nameId:         string,                          // user's NameID from the original AuthnResponse
  nameIdFormat:   string,                          // optional NameID Format URI
  sessionIndex:   string,                          // SessionIndex from the original Assertion AuthnStatement
  relayState:     string,                          // optional; emitted as a hidden RelayState input
  signingKey:     Uint8Array | string | KeyObject, // PQC private key (b.pqcSoftware.ml_dsa_*.keygen()) for ML-DSA;
                                                   // PEM string or node:crypto KeyObject for RSA / ECDSA / Ed25519
  signingAlg:     "rsa-sha256" | "rsa-sha384" | "rsa-sha512" |
                  "ecdsa-sha256" | "ecdsa-sha384" | "ecdsa-sha512" |
                  "ed25519" | "ml-dsa-65" | "ml-dsa-87",   // default omitted → unsigned
  idpSloUrl:      string,                          // single-logout endpoint the form POSTs to; falls
                                                   // back to sp.create's idpSloUrl, then its idpSsoUrl,
                                                   // and raises auth-saml/no-idp-slo with none set
}

HTTP-POST variant of buildLogoutRequest. Returns the base64-encoded SAMLRequest body for the IdP's /slo POST endpoint along with an embedded XMLDSig-Enveloped signature (when signingKey is supplied). The signature is computed over the canonical SignedInfo element per XMLDSig §4.5 — the referenced LogoutRequest is canonicalized via exclusive-c14n, SHA3-512 digested, and that digest goes into the Reference's DigestValue before SignedInfo itself is canonicalized + signed.

var lr = sp.buildLogoutRequestPost({
  nameId: "alice@idp", signingKey: kp.secretKey, signingAlg: "ml-dsa-65",
});
res.statusCode = 200;
res.setHeader("Content-Type", "text/html");
res.end(lr.formHtml);   // auto-POSTs SAMLRequest to lr.action

b.auth.saml.sp.parseLogoutRequestPost(samlRequestB64, vopts?) #

stable0.10.16
{
  idpVerifyKey: Uint8Array,                      // verify the embedded XMLDSig signature against this IdP key
  idpVerifyAlg: "ml-dsa-65" | "ml-dsa-87" | "ed25519",   // required when idpVerifyKey is supplied
  allowUnsigned: boolean,                        // default false — the parse refuses when no idpVerifyKey is configured; set true to accept an unsigned message
}

HTTP-POST variant of parseLogoutRequest. Decodes the base64 SAMLRequest body, parses the XML, and (when idpVerifyKey / idpVerifyAlg are supplied) verifies the embedded XMLDSig- Enveloped signature against the IdP key. Refuses when the signature element is missing, the Reference URI doesn't match the document root ID, the digest doesn't match the canonicalized referenced element (signature-wrapping defense), or the SignedInfo signature doesn't verify.

var req = sp.parseLogoutRequestPost(req.body.SAMLRequest, {
  idpVerifyKey: idpPubKey, idpVerifyAlg: "ml-dsa-65",
});
// req.nameId / req.sessionIndex / req.issuer

b.auth.saml.sp.buildLogoutRequestSoap(opts) #

stable0.10.16
{
  nameId:         string,                          // user's NameID from the original AuthnResponse
  nameIdFormat:   string,                          // optional NameID Format URI
  sessionIndex:   string,                          // SessionIndex from the original Assertion AuthnStatement
  relayState:     string,                          // optional; carried through on the request
  signingKey:     Uint8Array | string | KeyObject, // PQC private key (b.pqcSoftware.ml_dsa_*.keygen()) for ML-DSA;
                                                   // PEM string or node:crypto KeyObject for RSA / ECDSA / Ed25519
  signingAlg:     "rsa-sha256" | "rsa-sha384" | "rsa-sha512" |
                  "ecdsa-sha256" | "ecdsa-sha384" | "ecdsa-sha512" |
                  "ed25519" | "ml-dsa-65" | "ml-dsa-87",   // default omitted → unsigned
  idpSloUrl:      string,                          // single-logout endpoint the envelope is sent to; falls
                                                   // back to sp.create's idpSloUrl, then its idpSsoUrl,
                                                   // and raises auth-saml/no-idp-slo with none set
}

SOAP variant of buildLogoutRequest (SAML Bindings §3.2 — synchronous back-channel binding). Wraps the LogoutRequest in ... for an HTTP POST to the IdP's SOAP endpoint. Embeds an XMLDSig-Enveloped signature on the LogoutRequest itself (not the SOAP envelope) when signingKey is supplied — matching the IdP-side parse expectation.

var lr = sp.buildLogoutRequestSoap({ nameId: "alice@idp" });
var resp = await b.httpClient.request(lr.action, {
  method:  "POST",
  body:    lr.body,
  headers: { "Content-Type": "text/xml; charset=utf-8" },
});
var result = sp.parseLogoutResponseSoap(resp.body, {
  idpVerifyKey: idpPublicKey, idpVerifyAlg: "ml-dsa-65",
});

b.auth.saml.sp.parseLogoutResponseSoap(soapXml, vopts?) #

stable0.10.16
{
  idpVerifyKey: Uint8Array,                      // verify the embedded XMLDSig signature against this IdP key
  idpVerifyAlg: "ml-dsa-65" | "ml-dsa-87" | "ed25519",   // required when idpVerifyKey is supplied
  allowUnsigned: boolean,                        // default false — the parse refuses when no idpVerifyKey is configured; set true to accept an unsigned message
}

Parse a SOAP-wrapped LogoutResponse from the IdP's synchronous back-channel reply. Unwraps the soapenv:Body, optionally verifies the XMLDSig signature, and returns the same shape as parseLogoutResponse.

var result = sp.parseLogoutResponseSoap(resp.body, {
  idpVerifyKey: idpPubKey, idpVerifyAlg: "ml-dsa-65",
});
// result.success / result.statusCode / result.inResponseTo

b.auth.saml.fetchMdq(opts) #

stable0.8.62
{
  {
    baseUrl:        string,
    entityId:       string,
    trustCertPem?:  string,    // PEM of the federation operator's signing cert
    http?:          { client?: object, allowedHosts?: string[] },  // the client the fetch is made through, and its host pin
  }
}

Fetch an entity's signed metadata from a Metadata Query (MDQ) server per SAML 2.0 MDQ. Composes b.httpClient with strict server- identity (RFC 9525) and verifies the metadata XMLDSig against the operator-supplied trust cert. Returns the raw metadata XML on success.

The MDQ URL pattern is /entities/{sha1(entityId)} per the spec — operators with a federation MDQ deployment supply the baseUrl + their pinned trust cert.

// requires: a reachable MDQ service at the baseUrl below
var xml = await b.auth.saml.fetchMdq({
  baseUrl:      "https://mdq.federation.example",
  entityId:     "https://idp.example",
  trustCertPem: process.env.FEDERATION_TRUST_CERT_PEM,
});

Last updated 2026-10-12T14:21:56.879Z by seeder.