A SAML 2.0 implementation for Node.js. It acts as the
service provider (SP) half of a SAML exchange: it builds the AuthnRequest and logout messages you
send to an identity provider (IdP), and it decides whether the responses that come back are
trustworthy.
This package is transport-agnostic and framework-agnostic — it takes strings in and hands strings
out, and you wire it into whatever HTTP layer you already have. If your application uses
Passport, reach for
@node-saml/passport-saml instead; it wraps this
library in a Passport strategy.
- Sponsors
- Installation
- Usage
- Config parameter details
- Security and signatures
- Response validation timestamps
- InResponseTo validation
- Cache provider
- Node support policy
- Contributing
- Changelog
We gratefully acknowledge support from our sponsors:
If your company benefits from node-saml being secure and up-to-date, consider asking them to sponsor the project at $25/month. See the Github Sponsors page for more sponsorship levels. It's easy to do, appearing as another line-item on the Github bill they already have.
npm install @node-saml/node-samlTypeScript type definitions ship with the package; there is no separate @types package to install.
See the Node support policy for supported runtimes.
const fs = require("node:fs");
const { SAML } = require("@node-saml/node-saml");
const saml = new SAML({
// Required
callbackUrl: "https://sp.example.com/login/callback",
issuer: "https://sp.example.com/metadata",
idpCert: fs.readFileSync("./idp-signing-cert.pem", "utf-8"),
// Where to send the user to authenticate
entryPoint: "https://idp.example.com/sso",
// Accept only responses to requests we made; see "InResponseTo".
validateInResponseTo: "always",
// Sign our own requests. Set the algorithms explicitly; see "Security and signatures".
privateKey: fs.readFileSync("./sp-private-key.pem", "utf-8"),
publicCert: fs.readFileSync("./sp-public-cert.pem", "utf-8"),
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
});callbackUrl, issuer, and idpCert are required. Omitting one throws a TypeError naming the
option, and so does passing a non-boolean to an option that gates behavior — for example the string
"false". All of this happens in the constructor, so a misconfiguration surfaces at startup rather
than in the middle of someone's login.
In TypeScript, the constructor takes a SamlConfig and saml.options is a SamlOptions; both are
exported from the package root, along with Profile, CacheProvider, CacheItem,
InMemoryCacheProvider, ValidateInResponseTo, RacComparison, SignatureAlgorithm,
SamlScopingConfig, SamlIDPListConfig, SamlIDPEntryConfig, IdpCertCallback, AuthOptions,
MandatorySamlOptions, and SamlStatusError.
All three of these require entryPoint to be set.
HTTP-Redirect binding — build a URL and redirect to it:
const url = await saml.getAuthorizeUrlAsync(relayState, options);
res.redirect(url);relayState is echoed back by the IdP and is omitted from the request when it is an empty string.
options is an AuthOptions, whose additionalParams override anything set by
additionalParams/additionalAuthorizeParams in the constructor.
All three of these methods also accept a deprecated host argument between relayState and
options. It is ignored, and it is removed in the next major version, so pass options directly:
await saml.getAuthorizeUrlAsync(relayState, host, options); // deprecated
await saml.getAuthorizeUrlAsync(relayState, options); // use thisIf you subclass SAML and override one of these, migrate the override at the same time: a
two-argument call reaches it directly, so an override written for the old signature receives
options as host.
HTTP-POST binding — return a self-submitting form:
const html = await saml.getAuthorizeFormAsync(relayState, options);
res.send(html);This returns a complete HTML document that posts to entryPoint on load, with a <noscript>
fallback button for browsers without JavaScript.
If you would rather build the form yourself, getAuthorizeMessageAsync(relayState, options)
returns the message as a plain object of form fields (SAMLRequest plus any additional parameters).
The IdP posts the response back to your callbackUrl as a form-encoded SAMLResponse field, so the
route needs a body parser.
app.post("/login/callback", express.urlencoded({ extended: false }), async (req, res, next) => {
try {
const { profile, loggedOut } = await saml.validatePostResponseAsync(req.body);
// profile is the authenticated user; see "The profile" below
} catch (err) {
next(err);
}
});validatePostResponseAsync rejects with an Error on anything it cannot vouch for, and the message
says what failed — an invalid signature, a mismatched audience, an expired assertion, and a missing
decryption key are all distinguishable. Nothing is returned for a document that did not verify. If
something between Node-SAML and you hides that message, run with NODE_DEBUG=node-saml to have it
logged; see Troubleshooting.
Two cases resolve without a profile:
- The IdP returned a
LogoutResponserather than an authentication response:{ profile: null, loggedOut: true }. - A
passiverequest could not be satisfied without user interaction (aNoPassivestatus on a validly signed response):{ profile: null, loggedOut: false }.
When the IdP reports a non-Success status, the rejection is a SamlStatusError whose xmlStatus
property carries the Status element as XML, so you can surface the IdP's own reason to the user.
Be aware that the signature requirement is applied first: many identity providers do not sign their
error responses, and under the default wantAuthnResponseSigned: true such a response is rejected
for the missing signature before its status is read.
profile is a Profile: the fields the library understands, plus every AttributeValue in the
assertion keyed by its Name. What is populated depends on the message, so check for the fields you
rely on rather than assuming they are all present.
| Field | Description |
|---|---|
issuer |
The assertion's Issuer. |
nameID, nameIDFormat |
The subject's name identifier and its format. |
nameQualifier, spNameQualifier |
Name qualifiers, when the assertion carries them. |
sessionIndex |
The AuthnStatement's SessionIndex; you need it to build a logout request. |
inResponseTo |
The InResponseTo a signature covers: the Response's if signed, else the SubjectConfirmationData's. |
mail, email |
Convenience aliases. mail falls back to urn:oid:0.9.2342.19200300.100.1.3, and email falls back to mail. |
attributes |
Every attribute as a Name → value map. Single-valued attributes are strings; repeated ones are arrays. |
getAssertionXml() |
The assertion XML that the signature covers. This is the trustworthy copy. |
getAssertion() |
The same assertion, parsed into a JavaScript object. |
getSamlResponseXml() |
Deprecated. The response XML as received, not as verified. See the warning below. |
Attributes are also copied onto profile at the top level for convenience, but an attribute never
overwrites a field the library set itself.
The profile returned for a LogoutRequest by validatePostRequestAsync and validateRedirectAsync
is a smaller thing: ID (the logout request's own ID), issuer, nameID, nameIDFormat, and
sessionIndex. It carries no attributes and none of the getters, since there is no assertion.
Warning:
getSamlResponseXml()returns the response document as it arrived. When the IdP signs only the assertion, nothing around it — the response'sIssuer,Status, and timestamps — is covered by any signature, and the method does not tell you whether that was the case. Never treat what you read from it as authenticated.
getSamlResponseXml() is deprecated and is removed in the next major version; calling it logs a
warning under NODE_DEBUG=node-saml. Read the verified assertion instead:
profile.getSamlResponseXml(); // deprecated
profile.getAssertionXml(); // use this, or getAssertion() for the parsed formAn attribute sent without a usable value reaches the profile in one of two ways:
<Attribute Name="roles"/> <!-- left out -->
<Attribute Name="team"><AttributeValue/></Attribute> <!-- undefined -->
<Attribute Name="team"><AttributeValue xsi:nil="true"/></Attribute> <!-- undefined -->The first is left out, so it looks the same as an attribute the identity provider did not send. The
others are present with the value undefined, which JSON.stringify drops and code commonly treats
as absent.
The next major version keeps the first with a null value, and represents an empty AttributeValue
as the empty string and one marked xsi:nil as null, which is what they mean in
SAML core §2.7.3.1.1. Run
with NODE_DEBUG=node-saml to be told which attributes in a response you received are affected.
Node-SAML supports SP-initiated and IdP-initiated logout, over both the Redirect and POST
bindings, including signature validation and decryption of encrypted name identifiers.
SP-initiated. Build a LogoutRequest URL for a user you previously authenticated. The profile
you pass needs at least nameID, nameIDFormat, and — if the IdP expects it — sessionIndex:
const url = await saml.getLogoutUrlAsync(profile, relayState, options);
res.redirect(url);The request goes to logoutUrl, which defaults to entryPoint.
IdP-initiated over POST. Validate the incoming LogoutRequest, then answer it:
const { profile } = await saml.validatePostRequestAsync(req.body);
const url = await saml.getLogoutResponseUrlAsync(profile, relayState, options, true);
res.redirect(url);getLogoutResponseUrl(profile, relayState, options, success, callback) is the callback-style
equivalent of getLogoutResponseUrlAsync.
validatePostRequestAsync also accepts a deprecated second argument, an object of injected
dependencies — _parseDomFromString, _parseXml2JsFromString and _validateSignature. It is
ignored: nothing passed there can substitute signature verification, which the last of those used to
do. The argument is removed in the next major version, and calling it that way logs a warning under
NODE_DEBUG=node-saml:
await saml.validatePostRequestAsync(req.body, { _validateSignature }); // deprecated, and ignored
await saml.validatePostRequestAsync(req.body); // use thisOver the Redirect binding. Redirect-binding signatures are computed over the exact bytes of the query string, so you must hand the raw query string through unchanged — not a re-serialized copy of the parsed object:
const originalQuery = req.url.slice(req.url.indexOf("?") + 1);
const { profile, loggedOut } = await saml.validateRedirectAsync(req.query, originalQuery);Note: on the Redirect binding, a signature is only checked when the message carries a
Signaturequery parameter, because the binding makes signing optional. A message arriving without one is accepted with none of its contents authenticated — the issuer and the timestamps are read from the same unsigned bytes, soidpIssuerdoes not constrain it either. Run withNODE_DEBUG=node-samlto be told when this happens. Configure your IdP to sign its logout messages; a future major version will reject unsigned ones. The POST binding is unaffected:validatePostRequestAsyncalways requires a valid signature.
On the POST binding the profile is built only from the bytes that signature covers. The signature
has to envelope the message the way
SAML core §5.4 and the
protocol schema require — a ds:Signature that is a child of the LogoutRequest element it
references — so a request whose signature sits elsewhere in the document is rejected even though
that signature verifies.
Most identity providers will take a metadata document instead of asking you to type the same values into a form.
const metadata = saml.generateServiceProviderMetadata(decryptionCert, publicCerts);decryptionCert— the public certificate matchingdecryptionPvk. Required if the instance was configured withdecryptionPvk; passnullotherwise.publicCerts— the public certificate matchingprivateKey. Required if the instance was configured withprivateKey. Pass an array to support certificate rotation: the first entry must match the currentprivateKey, and later entries publish upcoming certificates to the IdP before you switch over.
Both are read by the rules described under privateKey, as PEM or
as Base64, and published as a single line of Base64. decryptionCert, and each entry of
publicCerts, must hold exactly one certificate; a value holding several, or holding a public key
rather than a certificate, is refused with an error naming it.
The underlying function is also exported directly, for generating metadata without constructing a
SAML instance:
const { generateServiceProviderMetadata } = require("@node-saml/node-saml");
const metadata = generateServiceProviderMetadata({
issuer: "https://sp.example.com/metadata",
callbackUrl: "https://sp.example.com/login/callback",
});It accepts issuer and callbackUrl plus the metadata-relevant options from the configuration tables below:
logoutCallbackUrl, identifierFormat, wantAssertionsSigned, decryptionPvk, decryptionCert,
privateKey, publicCerts, signatureAlgorithm, digestAlgorithm, xmlSignatureTransforms,
signMetadata, metadataContactPerson, metadataOrganization, and generateUniqueId.
| Option | Type | Description |
|---|---|---|
callbackUrl |
string |
The SP endpoint the IdP posts the response back to; becomes the AssertionConsumerServiceURL. |
issuer |
string |
The issuer string identifying this service provider to the IdP. |
idpCert |
string | string[] | IdpCertCallback |
The IdP's signing certificate(s) or public key(s), used to validate incoming signatures. See Security and signatures. |
| Option | Default | Description |
|---|---|---|
entryPoint |
— | The IdP's SSO endpoint. Required to generate any authentication request, and required by the specification when the request is signed. |
audience |
issuer |
Expected Audience in the response. Set to false to skip the check — which removes a security control; see the note under Security and signatures. |
privateKey |
— | SP private key in PEM format, used to sign outgoing messages. See Security and signatures. |
publicCert |
— | SP public signing certificate, embedded in the AuthnRequest so the IdP can verify it. Must match privateKey. |
decryptionPvk |
— | Private key used to decrypt encrypted assertions and encrypted name identifiers. |
signatureAlgorithm |
"sha1" |
"sha1", "sha256", or "sha512". Set this explicitly if you set privateKey; see Configuration option signatureAlgorithm. |
digestAlgorithm |
"sha1" |
Digest algorithm for the signed data object: "sha1", "sha256", or "sha512". Same advice as above. |
xmlSignatureTransforms |
enveloped-signature + exc-c14n | Signature transforms used in HTTP-POST signatures. The default is ["http://www.w3.org/2000/09/xmldsig#enveloped-signature", "http://www.w3.org/2001/10/xml-exc-c14n#"]. |
generateUniqueId |
built-in | Function returning the unique IDs used for outgoing SAML messages. |
| Option | Default | Description |
|---|---|---|
wantAssertionsSigned |
true |
Require the assertion itself to be signed, and advertise WantAssertionsSigned="true" in the metadata. |
wantAuthnResponseSigned |
true |
Require the response to be signed at the top level, not only at the assertion. |
acceptedClockSkewMs |
0 |
Tolerance in milliseconds when checking NotBefore and NotOnOrAfter. -1 disables those checks entirely. |
maxAssertionAgeMs |
0 |
Reject an assertion older than this, measured from its IssueInstant. 0 means no limit beyond NotOnOrAfter. When set and stricter than NotOnOrAfter, this wins. |
idpIssuer |
— | If set, the Issuer on incoming logout requests and responses must match it. For ADFS this looks like https://acme_tools.windows.net/deadbeef. |
Turning both wantAssertionsSigned and wantAuthnResponseSigned off does not turn signature
checking off: either the response or the assertion still has to carry a valid signature, or the
document is rejected.
| Option | Default | Description |
|---|---|---|
identifierFormat |
urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress |
NameID format to request. Set to null to leave the Format attribute off the NameIDPolicy and the NameIDFormat element out of the metadata. |
allowCreate |
true |
Let the IdP create a new subject identifier. |
spNameQualifier |
— | Request that the subject identifier be returned or created in another SP's namespace, or in that of an affiliation of service providers. |
authnContext |
["urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"] |
Requested authentication context classes. Must be an array, even for a single value. |
racComparison |
"exact" |
How the IdP should compare the requested context: "exact", "minimum", "maximum", or "better". |
disableRequestedAuthnContext |
false |
Omit RequestedAuthnContext entirely. |
forceAuthn |
false |
Ask the IdP to re-authenticate the user even if they hold a valid session. |
passive |
false |
Ask the IdP not to take visible control of the user interface. See the NoPassive case in Validate the response. |
providerName |
— | Human-readable name of the requester, for the presenter's user agent or the IdP. |
attributeConsumingServiceIndex |
— | Tells the IdP which attribute set to attach to the response (background). |
disableRequestAcsUrl |
false |
Omit the optional AssertionConsumerServiceURL from the request. |
skipRequestCompression |
false |
Send the request uncompressed instead of DEFLATE-compressed. |
authnRequestBinding |
"HTTP-Redirect" |
Recorded on the instance for consumers such as passport-saml to act on. Within this library the binding follows from the method you call — getAuthorizeUrlAsync for Redirect, getAuthorizeFormAsync for POST. |
additionalParams |
{} |
Query parameters added to every outgoing request. |
additionalAuthorizeParams |
{} |
Query parameters added to authorize requests only. |
scoping |
— | Scoping element contents; see below. |
scoping implements SAML core §3.4.1.2, <Scoping>:
scoping: {
idpList: [ // optional
{
entries: [ // required
{
providerId: "yourProviderId", // required for each entry
name: "yourName", // optional
loc: "yourLoc", // optional
},
],
getComplete: "URI to your complete IDP list", // optional
},
],
proxyCount: 2, // optional
requesterId: "requesterId", // optional; a string or an array of strings
}| Option | Default | Description |
|---|---|---|
validateInResponseTo |
"never" |
"always", "ifPresent", or "never". Set this explicitly; the trade-offs are below. The ValidateInResponseTo enum is exported for this. |
requestIdExpirationPeriodMs |
28800000 (8h) |
How long a generated request ID stays valid for matching against an incoming InResponseTo. |
cacheProvider |
in-memory | Where request IDs are stored. See Cache provider. |
Set
validateInResponseToexplicitly. It defaults to"never"today, and the next major version requires it; until then, leaving it unset logs a warning underNODE_DEBUG=node-saml. Which value fits depends on how logins reach you:
"always"accepts only a response that answers a request Node-SAML recorded, and removes that request ID as it accepts the response, so a response cannot be delivered unsolicited or presented again later. When the cache provider consumes IDs atomically withconsumeAsync, as the built-in one does, two copies arriving at the same moment cannot both be accepted either. It rejects IdP-initiated logins, and on more than one server or process it needs a shared cache provider."ifPresent"validatesInResponseTowhen a response carries one and accepts a response that does not. IdP-initiated login keeps working, and an unsolicited response is accepted and can be replayed until its timestamps expire."never"skips the check, so any captured response can be replayed until its timestamps expire.
See InResponseTo validation below for what this protects against and how the IDs are consumed.
| Option | Default | Description |
|---|---|---|
logoutUrl |
entryPoint |
Address to send logout requests to. |
additionalLogoutParams |
{} |
Query parameters added to logout requests only. |
logoutCallbackUrl |
— | The Location for the SingleLogoutService elements in the generated service provider metadata. |
| Option | Default | Description |
|---|---|---|
signMetadata |
false |
Sign the generated service provider metadata. Requires privateKey. |
metadataContactPerson |
— | ContactPerson entries to include in the generated metadata. An array, since metadata may carry several. |
metadataOrganization |
— | Organization details to include in the generated metadata. |
metadataContactPerson: [
{
"@contactType": "support", // "technical" | "support" | "administrative" | "billing" | "other"
GivenName: "test",
EmailAddress: ["test@node-saml"], // note: an array
},
],
metadataOrganization: {
OrganizationName: [{ "@xml:lang": "en", "#text": "node-saml" }],
OrganizationDisplayName: [{ "@xml:lang": "en", "#text": "node-saml" }],
OrganizationURL: [{ "@xml:lang": "en", "#text": "https://github.com/node-saml/node-saml" }],
},The full shapes are in the SamlOptions type definitions, which your editor will complete for you.
samlAuthnRequestExtensions and samlLogoutRequestExtensions add an Extensions element to the
generated AuthnRequest and LogoutRequest. They are useful for things like the
requested attributes protocol extension,
and accept any xmlbuilder object, so any element is
expressible.
samlAuthnRequestExtensions: {
"md:RequestedAttribute": {
"@isRequired": "true",
"@Name": "LastName",
"@xmlns:md": "urn:oasis:names:tc:SAML:2.0:metadata",
},
vetuma: {
"@xmlns": "urn:vetuma:SAML:2.0:extensions",
LG: { "#text": "sv" },
},
},
samlLogoutRequestExtensions: {
vetuma: {
"@xmlns": "urn:vetuma:SAML:2.0:extensions",
LG: { "#text": "sv" },
},
},Node-SAML uses the HTTP-Redirect binding for its AuthnRequests (unless you call
getAuthorizeFormAsync for HTTP-POST) and expects the messages back over the HTTP-POST binding.
Three properties hold throughout response validation, and they are worth knowing because they explain rejections that might otherwise look overly strict:
- Only signed bytes are trusted. Verification returns the content the signature actually covers, and that is the content the library goes on to process. The original document is never re-read after verification, because an attacker controls the difference between the two — that is the whole of an XML signature wrapping attack.
- Ambiguity is rejected, not resolved. A response with more than one assertion, more than one
signature on an element, an
IDresolving to more than one element, a reference pointing anywhere other than its own parent, or more than two transforms is refused. The library does not pick a reading, and it does not pick the reading that happens to verify. - Validation fails closed. Decrypted content is not trusted content: an
EncryptedAssertionis decrypted and then still has to have its signature verified. Timestamps, audience, issuer, andInResponseToare security controls rather than conveniences — an option that switches one off (audience: false,acceptedClockSkewMs: -1) is removing a control, so make that choice deliberately.
Most integrations need only what the package exports at the top level: SAML,
generateServiceProviderMetadata, and the types. The compiled modules under lib/ are reachable
too, and three of their exports bear on the first property above: getVerifiedXml() is what upholds
it, validateSignature() is the shape it replaces, and parseDomFromString() is how you read what
either one was given. All three come from lib/xml:
| Export | Behavior |
|---|---|
getVerifiedXml(fullXml, currentNode, pemFiles) |
Returns the bytes the signature over currentNode covers, or null if none of pemFiles verify. |
parseDomFromString(xml) |
Parses xml into a Document, rejecting anything that is not a well-formed XML document. |
validateSignature(fullXml, currentNode, pemFiles) |
Deprecated. Returns whether that signature verified, and nothing about what it covered. |
validateSignature() is removed in the next major version. Reporting only that a signature verified
leaves you to find the signed content somewhere else, and an attacker controls the difference between
what verified and what you then read — that is an XML signature wrapping attack.
getVerifiedXml() returns the verified bytes, so there is nothing left to go looking for:
// deprecated: `dom` is the document as received, not the part the signature covered
if (validateSignature(xml, dom.documentElement, pemFiles)) {
readTheProfileFrom(dom);
}
// use this instead
const verifiedXml = getVerifiedXml(xml, dom.documentElement, pemFiles);
if (verifiedXml == null) {
throw new Error("Invalid signature");
}
readTheProfileFrom(await parseDomFromString(verifiedXml));Requests sent by Node-SAML can be signed using RSA with SHA-1, SHA-256, or SHA-512.
signatureAlgorithm: "sha256"; // preferred — your IdP should support it; if not, consider upgrading the IdP
signatureAlgorithm: "sha512"; // strongest — check that your IdP supports it
signatureAlgorithm: "sha1"; // legacy; SHA-1 is no longer considered collision-resistantdigestAlgorithm takes the same three values and controls the digest over the signed data object.
Set both explicitly if you sign, and check the spelling. With
privateKeyset, leavingsignatureAlgorithmordigestAlgorithmunset selectssha1, which is no longer considered safe for signatures; the next major version requires both wheneverprivateKeyis set. A value that is not one of the three above — including a casing difference such as"SHA256"— is not an error today either: it falls through to SHA-1, so a typo silently downgrades the signature you asked for. The next major version rejects it instead.digestAlgorithmis typed as a plain string, so TypeScript does not catch a typo in it. Run withNODE_DEBUG=node-samlto be told when any of this happens.
To sign authentication requests, provide the private key in PEM format via privateKey. Node-SAML
reads it with xml-crypto's toPem(), and uses what that returns: canonical
RFC 7468 PEM, with \n line endings, lines of 64
characters, and one message after another.
What it accepts is more liberal than RFC 7468's stricttextualmsg:
- whitespace surrounding the value, and a leading UTF-8 byte order mark, whether the value arrives
as a string or a
Buffer; - any of the three line-ending conventions;
- encoded data wrapped at any width, or not wrapped at all, with spaces or tabs anywhere in it;
- a blank line after the
-----BEGIN ...-----boundary; - several PEM messages concatenated in one value, optionally separated by blank lines. Signing and
verification each take only one key from such a value, so it suits a private key stored alongside
its certificate, but not a set of keys: to trust several IdP certificates, give
idpCertan array.
It rejects, with an error naming the option and giving the reason:
- encoded data that is not valid Base64 as RFC 4648
section 4 defines it —
=padding away from the end, or a last group that is incomplete — rather than decoding as much of it as it can; - a message whose
-----BEGINand-----ENDlabels disagree; - a
CERTIFICATEwhose data is not exactly one X.509 certificate; - text before, after or between the messages, and a boundary sharing its line with other text.
xml-crypto documents the complete rules under
What the parser accepts. A
Buffer is read as the text of a PEM or Base64 file, just as a string is; DER is not accepted, so
convert it to PEM first as shown under idpCert.
privateKey: fs.readFileSync("./privateKey.pem", "latin1");Accepted formats:
-
RFC 7468 PEM, with either label:
-----BEGIN PRIVATE KEY----- <private key contents here delimited at 64 characters per row> -----END PRIVATE KEY----------BEGIN RSA PRIVATE KEY----- <private key contents here delimited at 64 characters per row> -----END RSA PRIVATE KEY----- -
A single-line or multi-line private key in Base64, without the delimiter lines. See the single-line private key used in the tests.
Validating the signatures on incoming responses is the point of this library, and idpCert is what
it validates them against. Provide the IdP's public X.509 signing certificate(s) or public key(s). The
same normalization, tolerances and rejections described under
privateKey apply here.
idpCert: "MIICizCCAfQCCQCY8tKaMc0BMjANBgkqh ... W==";If the IdP has several valid signing certificates or public keys — during a rollover, for instance, when responses signed with either key are valid — pass an array:
idpCert: ["MIICizCCAfQCCQCY8tKaMc0BMjANBgkqh ... W==", "MIIEOTCCAyGgAwIBAgIJAKZgJdKdCdL6M ... g="];idpCert can also be a function taking a node-style callback, which lets you poll the IdP for its
current keys so a rotation is picked up without a restart. The result is not cached, so the function
is called on every validation:
idpCert: (callback) => {
callback(null, polledCertificates);
};Accepted formats:
-
RFC 7468 PEM, as a certificate or a bare public key:
-----BEGIN CERTIFICATE----- <certificate contents here delimited at 64 characters per row> -----END CERTIFICATE----------BEGIN PUBLIC KEY----- <public key contents here delimited at 64 characters per row> -----END PUBLIC KEY----- -
A single-line or multi-line certificate in Base64, without the delimiter lines.
Convert it to PEM:
openssl x509 -inform der -in my_certificate.cer -out my_certificate.pemSome identity providers require the SP's public signing certificate to be embedded in the
AuthnRequest, so they can verify the request, match the subject DN, and confirm the certificate was
signed. Pass it as publicCert; it must match privateKey, and it must hold at least one certificate:
a public key alone is refused, because it would leave KeyInfo out of the signature. The same two
formats are accepted:
-----BEGIN CERTIFICATE-----
<X.509 certificate contents here delimited at 64 characters per row>
-----END CERTIFICATE-----
or
publicCert: "MIICizCCAfQCCQCY8tKaMc0BMjANBgkqh ... W==";When a response carries NotBefore or NotOnOrAfter, Node-SAML validates them against the current
time plus or minus acceptedClockSkewMs, which accounts for drift between your server's clock and
the IdP's. The default skew is 0.
Both attributes are honored on the SubjectConfirmation element and within
Assertion/Conditions. maxAssertionAgeMs adds an independent limit measured from the assertion's
IssueInstant, and applies when it is stricter than NotOnOrAfter. An assertion whose
SubjectConfirmation elements are all outside their window is rejected whatever
validateInResponseTo is set to.
InResponseTo ties a response back to a request you actually made, which is what stops a response
captured elsewhere from being replayed at your callback. Turn it on with
validateInResponseTo: "always".
Node-SAML then records the ID of every request it generates, and a response validates only if its
InResponseTo matches one of them. It is checked as an attribute of the top-level Response or
LogoutResponse element, and within SubjectConfirmation.
Only a signature makes the top-level attribute trustworthy, so when the IdP signs the assertion but
not the Response, "always" requires one of the assertion's SubjectConfirmationData elements,
within its validity window, to carry InResponseTo, and rejects the response otherwise. A
conforming IdP already puts it there
(SAML profiles §4.1.4.2).
If yours does not, have it sign the Response.
Recorded IDs expire after requestIdExpirationPeriodMs (8 hours by default). A response arriving
with an expired — or unrecognized — InResponseTo is rejected. Accepting a response removes its
request ID, so presenting the same response again later fails. Two copies arriving at the same
moment can both be accepted unless the cache provider removes the ID atomically, which the built-in
one does and a custom one does if it implements consumeAsync, described below.
While InResponseTo is validated — always under "always", and under "ifPresent" when the
Response or LogoutResponse element carries one — a request ID is removed when a verified
signature covers an InResponseTo naming it, on that element or on a SubjectConfirmationData in
the assertion, whether the response is then accepted or rejected. Nothing else removes one. Anyone
who learns a pending request ID can put it in a response, so an unsigned InResponseTo is still
checked against the recorded IDs but cannot by itself retire one, and the IdP's genuine response
validates after it. That covers a rejected response in which nothing signed names the request, an
unsigned Response accepted under "ifPresent" on the strength of its assertion alone, and an
unsigned Redirect-binding LogoutResponse. None of these can be presented again with more effect
than before: an unsigned InResponseTo can be deleted, making the response unsolicited, and an
unsigned logout message can be forged outright.
A signature shows that the IdP answered a request, not that whoever presents the response started
it. Someone with an account at your IdP who gets it to answer a request carrying another user's
pending ID receives a genuinely signed response, and presenting it retires that user's request. When
AuthnRequests are unsigned, knowing the ID is enough to build such a request. Signing them (set
privateKey, and have the IdP require signed requests) leaves only a replay of that user's own
AuthnRequest, which some IdPs reject.
With InResponseTo validation on, the generated request IDs have to be stored somewhere. That is
the cacheProvider's job.
The default is an InMemoryCacheProvider, which keeps request IDs in one process's memory. It is
not sufficient across multiple servers or processes: the instance that generated the request ID may
not be the one that handles the response, and validation then fails for legitimate logins. For
those deployments, back the cache with something shared — Redis, a database, your session store —
by implementing:
interface CacheProvider {
/** Store an item in the cache, using the specified key and value. */
saveAsync(key: string, value: string): Promise<CacheItem | null>;
/** Returns the value of the specified key in the cache. */
getAsync(key: string): Promise<string | null>;
/** Removes an item from the cache if the key exists. */
removeAsync(key: string | null): Promise<string | null>;
/** Optional. Removes the key and returns its value, or null if it was absent, atomically. */
consumeAsync?(key: string): Promise<string | null>;
}Implement consumeAsync with your store's single-step remove-and-return, such as Redis GETDEL or
SQL DELETE … RETURNING. Node-SAML consumes request IDs with it, so of two copies of one response
validated at the same moment, only one is accepted. Without it, Node-SAML reads the ID and removes
it in separate calls, both copies can be accepted, and a warning is logged under
NODE_DEBUG=node-saml whenever InResponseTo is validated. The next major version requires it. The
built-in provider implements it.
Give each identity provider its own cache. In a shared store, that means a provider for each identity provider that prefixes every key with its name. Request IDs are looked up by ID alone, so when several identity providers share one cache, a response signed by one of them can answer, and retire, a request sent to another.
Within one process InMemoryCacheProvider is enough, but each SAML instance given no
cacheProvider creates its own, which lasts only as long as that instance. Code that constructs a
SAML for each request, as passport-saml's MultiSamlStrategy does, would record a login's request
ID in one cache and look for it in another, rejecting the response with InResponseTo is not valid.
Create one InMemoryCacheProvider for each identity provider, once, and pass it every time:
const { InMemoryCacheProvider, SAML } = require("@node-saml/node-saml");
// Once, at startup, for each identity provider:
const cacheProvider = new InMemoryCacheProvider();
// On each request to that identity provider:
const saml = new SAML({ ...idpOptions, cacheProvider });It keeps a request ID for keyExpirationPeriodMs, 8 hours by default. If you change
requestIdExpirationPeriodMs, pass the same value as
new InMemoryCacheProvider({ keyExpirationPeriodMs }). The SAML constructor does that for the
provider it creates, but a cache you supply expires IDs on its own schedule.
CacheProvider, CacheItem, and InMemoryCacheProvider are exported from the package root.
Node-SAML writes debug output through Node's
util.debuglog, and is silent
until you turn it on. Set NODE_DEBUG=node-saml in the environment the process starts with:
NODE_DEBUG=node-saml node server.jsNode reads NODE_DEBUG once, at startup, so assigning process.env.NODE_DEBUG inside your
application has no effect. If you already set other sections, add node-saml to the
comma-separated list. Each line goes to stderr, prefixed with NODE-SAML and the process ID.
The output covers two things:
- Why a response was rejected. When
validatePostResponseAsyncrejects, it logs the error too. That helps when the code between Node-SAML and you, such as a PassportfailureRedirect, reports only that the login failed. - What the next major version changes. Configuration or input that works today, but that the next major version rejects or handles differently, logs a warning when it is used. Examples are a security-relevant option left at its default, a deprecated argument or accessor, an unsigned logout message on the Redirect binding, and an attribute with no usable value. Each warning says what to change, and the sections above describe each one next to the option it concerns.
Version 5.1.0 and earlier used the debug package, turned on with DEBUG=node-saml. That variable
no longer does anything; use NODE_DEBUG=node-saml instead.
We only support Long-Term Support versions of Node.
We specifically limit our support to LTS versions of Node, not because this package won't work on other versions, but because we have a limited amount of time, and supporting LTS offers the greatest return on that investment.
It's possible this package will work correctly on newer versions of Node. It may even be possible to use this package on older versions of Node, though that's more unlikely as we'll make every effort to take advantage of features available in the oldest LTS version we support.
The engines field in package.json is the authoritative statement of what we support. As each Node LTS version reaches its end-of-life we will remove that version from it. Removing a Node version is considered a breaking change and will entail the publishing of a new major version of this package. We will not accept any requests to support an end-of-life version of Node. Any merge requests or issues supporting an end-of-life version of Node will be closed.
We will accept code that allows this package to run on newer, non-LTS, versions of Node.
Issues and pull requests are welcome. A change that touches how a document is accepted, rejected, or
trusted needs a test that fails without it; AGENTS.md documents the standards this
repository holds itself to, and the pull request template lists
what a review looks for. For questions rather than bugs, start in
Discussions.
When a change follows the SAML specification, link the relevant part. Start from the OASIS SAML 2.0 standards.
See CHANGELOG.md.
