Node SAML
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.
SAML instance
- Start a login
- Validate the response
- The profile
- Single logout (SLO)
- Service provider metadata
- Config parameter details
- Security and signatures
- Response validation timestamps
- InResponseTo validation
- Cache provider
- Node support policy
- Contributing
- Changelog
Sponsors
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.Installation
npm install @node-saml/node-saml
TypeScript type definitions ship with the package; there is no separate @types package to install.
See the Node support policy for supported runtimes.
Usage
Create a SAML instance
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, and so is every other type the package's API uses: Profile,
CacheProvider, CacheItem, InMemoryCacheProvider and its CacheProviderOptions,
ValidateInResponseTo, RacComparison, SignatureAlgorithm, SamlSigningOptions,
SamlScopingConfig, SamlIDPListConfig, SamlIDPEntryConfig, IdpCertCallback, AuthOptions,
MandatorySamlOptions, SamlStatusError, GenerateServiceProviderMetadataParams, and the XML types
that SAML's protected methods use: XMLOutput, XmlJsObject, AudienceRestrictionXML,
XMLObject, and XMLValue.
Import from the package root only. The compiled modules under lib/ are internal: nothing there is
part of the API unless the root exports it. The next major version closes that path, so a deep
import will fail to load with ERR_PACKAGE_PATH_NOT_EXPORTED. Anything the root exports moves over
unchanged:
// deprecated: fails to load in the next major version
import { SAML } from "@node-saml/node-saml/lib/saml";
import type { Profile } from "@node-saml/node-saml/lib/types";
// use this instead
import { SAML, type Profile } from "@node-saml/node-saml";
The root doesn't export the XML helpers under lib/, such as parseDomFromString, xpath,
signXml, and signSamlPost. They wrap @xmldom/xmldom, xpath, and xml-crypto, so depend on
those packages directly instead.
Start a login
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.
The URL never carries an empty RelayState, because identity providers differ on whether a
signature covers one. A RelayState of "" in additionalParams means "send none", even when
entryPoint has one of its own. From JavaScript, where the types do not stop it, null or
undefined there means none was given, like an empty relayState argument, and one in entryPoint
is still sent. The next major version reads an empty relayState argument as "send none" too, and
takes null or undefined to mean none was given.
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 this
If 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
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).
Validate the response
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 }. One whose status is not Success, or whose Issuer is not
idpIssuer when that is set, is rejected with an Error, such as Bad status code: …, as it is
over the Redirect binding.
- A
passiverequest could not be satisfied without user interaction (aNoPassivestatus on a
{ profile: null, loggedOut: false }.
When the IdP reports a non-Success status in a Response, 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.
The profile
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 form
Attributes with no value
An 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.
Code that tests for a missing value with == null, and reads a value that may be empty with
?? "", works with both versions:
// an attribute with no AttributeValue, or one marked xsi:nil, is undefined now and null in the
// next major version, so this test stops matching it
if (profile.roles === undefined) {
/ ... /
}
// this test matches it in both
if (profile.roles == null) {
/ ... /
}
// an empty AttributeValue is undefined now and "" in the next major version; team is "" in both
const team = profile.team ?? "";
Single logout (SLO)
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 this
Over the Redirect binding. A Redirect-binding signature covers the SAMLRequest or
SAMLResponse, the RelayState if there is one, and SigAlg, byte for byte as they arrived.
Nothing else in the query string is signed. Hand the raw query string through unchanged, without
its leading ?:
const originalQuery = req.url.slice(req.url.indexOf("?") + 1);
const { profile, loggedOut, relayState } = await saml.validateRedirectAsync(originalQuery);
Node-SAML reads the SAMLRequest or SAMLResponse, RelayState, SigAlg and Signature from
that string, requires the signature to verify, and returns the RelayState that was signed. Use
that relayState, and not the one in a parsed query object such as req.query: a query parser can
read a parameter that the signature does not cover as RelayState. A query string with more than
one of any of those five parameters, or with both a SAMLRequest and a SAMLResponse, is
rejected, and so is a message with no Signature.
validateRedirectAsync also accepts a deprecated first argument, the parsed query object, before
the query string. That form is removed in the next major version, and calling it logs a warning
under NODE_DEBUG=node-saml:
await saml.validateRedirectAsync(req.query, originalQuery); // deprecated
await saml.validateRedirectAsync(originalQuery); // use this, and the relayState it returns
The deprecated form returns no relayState. A signed message is read from originalQuery there
too, and is treated as signed when either argument has a Signature, even an empty one. Because
its caller goes on reading the parsed object, it also rejects a signed query string that names one
of the five parameters in bracket notation, such as RelayState[] or [RelayState], which the
qs parser reads as RelayState. It rejects a signed message, too, when the parsed object holds a
RelayState that is not the one in originalQuery, as it does when the parser splits or nests the
query string by other rules: one that also splits on ; reads x=1;RelayState=... as a
RelayState.
Note: the deprecated form still accepts a message with no Signature parameter, because the
binding makes signing optional. Such a message is accepted with none of its contents
authenticated — the issuer and the timestamps are read from the same unsigned bytes, so
idpIssuer only checks a value the sender wrote and does not show who sent it. Run with
NODE_DEBUG=node-saml to be told when this happens. Configure your IdP to sign its logout
messages before you move to validateRedirectAsync(originalQuery), which rejects unsigned ones,
as the next major version does for every call. The POST binding is unaffected:
validatePostRequestAsync always 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.
Service provider metadata
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
decryptionPvk; pass null otherwise.
publicCerts— the public certificate matchingprivateKey. Required if the instance was
privateKey. Pass an array to support certificate rotation: the first entry must
match the current privateKey, 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,
metadataAttributeConsumingServices, and generateUniqueId.
Called directly, it signs the metadata when signMetadata is true and privateKey is set. Choose
both algorithms when it does: signatureAlgorithm has no default here, so omitting it is an error,
while an omitted digestAlgorithm selects sha1, or is an error too when signatureAlgorithm is
"sha256-mgf1". The note under signatureAlgorithm
applies to this function too, and it logs the same warnings under NODE_DEBUG=node-saml.
Config parameter details
Required
| 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. |
The next major version checks received responses against callbackUrl: an assertion has to carry a
bearer SubjectConfirmation whose Recipient is callbackUrl
(SAML profiles §4.1.4.3),
and a signed Response has to carry a Destination that is callbackUrl
(SAML bindings §3.5.5.2).
URLs are compared after parsing, so the case of the scheme and host and an explicit default port
don't count, but the path does, trailing slash included. Until then a response that fails either
check is accepted. Run with NODE_DEBUG=node-saml to be told when one arrives, and set
callbackUrl to the URL your IdP actually posts to.
Core
| 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. See Configuration option decryptionPvk. |
| signatureAlgorithm | "sha1" | "sha1", "sha256", "sha256-mgf1", 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. Required with "sha256-mgf1". |
| 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. |
Response validation
| 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. |
| rejectReplayedAssertions | false | Reject an assertion whose ID has been received before. See Replayed assertions. |
| 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.
AuthnRequest content
| 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: {
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
}
InResponseTo
| 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. |
SetvalidateInResponseToexplicitly. It defaults to"never"today, and the next major
version requires it; until then, leaving it unset logs a warning under NODE_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 with consumeAsync, 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, unless you set
rejectReplayedAssertions.
- "never" skips the check, so any captured response can be replayed until its timestamps expire,
unless you set rejectReplayedAssertions.
See InResponseTo validation below for what this protects against and how the IDs are consumed.
Logout
| 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. |
The next major version also compares the Destination of a signed LogoutRequest or
LogoutResponse with logoutCallbackUrl, in the same way as for callbackUrl above. Until then
such a message is accepted. With logoutCallbackUrl set, run with NODE_DEBUG=node-saml to be told
when a Destination differs from it or is missing.
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. |
| metadataAttributeConsumingServices | — | AttributeConsumingService entries to include in the generated metadata, each listing the attributes this service provider asks the IdP for. |
metadataContactPerson: [
{
"@contactType": "support", // "technical" | "support" | "administrative" | "billing" | "other"
GivenName: "test",
EmailAddress: ["mailto:test@node-saml"], // an array of mailto: URIs
},
],
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" }],
},
metadataAttributeConsumingServices: [
{
"@index": "0",
"@isDefault": true,
ServiceName: [{ "@xml:lang": "en", "#text": "My Service" }],
ServiceDescription: [{ "@xml:lang": "en", "#text": "Needs the user's name and email" }],
RequestedAttribute: [
{
"@Name": "urn:oid:2.5.4.42",
"@NameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri",
"@FriendlyName": "givenName",
"@isRequired": true,
},
{
"@Name": "urn:oid:0.9.2342.19200300.100.1.3",
"@NameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri",
"@FriendlyName": "mail",
"@isRequired": false,
},
],
},
],
Extensions, on a contact or on the organization, takes namespace-qualified elements as an
xmlbuilder object, like the options under Extensions below. A string there cannot
produce valid metadata. Both also take namespace-qualified attributes, each written with its
prefix next to the @xmlns: declaration of that prefix. The elements and the attributes come from
a namespace that SAML does not define: not the SAML 2.0 metadata, assertion or protocol namespace.
A REFEDS security contact is marked that way:
metadataContactPerson: [
{
"@contactType": "other",
"@xmlns:remd": "http://refeds.org/metadata",
"@remd:contactType": "http://refeds.org/metadata/contactType/security",
EmailAddress: ["mailto:security@example.com"],
},
],
The SamlOptions types do not declare such attributes until the next major version. In TypeScript,
build the entry outside the config literal, or cast it.
Each metadataAttributeConsumingServices entry needs an @index from "0" to "65535" that no
other entry uses, at least one ServiceName and at least one RequestedAttribute, and at most one
entry may set @isDefault to true. An entry that breaks these rules, carries a key not shown
above, or holds a malformed value such as an @xml:lang that is not a language tag, is rejected
with a TypeError when the SAML is constructed or the metadata is generated.
Set attributeConsumingServiceIndex to have an AuthnRequest select one of them by its @index.
The full shapes are in the SamlOptions type definitions, which your editor will complete for you.
metadataContactPerson and metadataOrganization are not rejected yet: either one in any other
shape is still written into the metadata as given, which can leave the metadata invalid. Run with
NODE_DEBUG=node-saml to be told when that happens. The next major version rejects it.
Extensions
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 take an xmlbuilder object of namespace-qualified
elements.
samlAuthnRequestExtensions: {
"req-attr:RequestedAttributes": {
"@xmlns:req-attr": "urn:oasis:names:tc:SAML:protocol:ext:req-attr",
"md:RequestedAttribute": {
"@xmlns:md": "urn:oasis:names:tc:SAML:2.0:metadata",
"@isRequired": "true",
"@Name": "LastName",
},
},
vetuma: {
"@xmlns": "urn:vetuma:SAML:2.0:extensions",
LG: { "#text": "sv" },
},
},
samlLogoutRequestExtensions: {
vetuma: {
"@xmlns": "urn:vetuma:SAML:2.0:extensions",
LG: { "#text": "sv" },
},
},
Each element directly inside Extensions comes from a namespace that SAML does not define: not the
SAML 2.0 assertion, protocol or metadata namespace. That is why the requested attributes above sit
inside the extension's own RequestedAttributes element, and not directly in Extensions. Neither
option is rejected yet: one in any other shape is still written into the request as given. Run with
NODE_DEBUG=node-saml to be told when that happens. The next major version rejects it.
Security and signatures
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,
- Ambiguity is rejected, not resolved. A response with more than one assertion, more than one
ID resolving 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.
One case is not refused yet: a signature with more than one reference is ignored, so its element
counts as unsigned. That matters only for a Response under wantAuthnResponseSigned: false,
which is then accepted on the strength of its separately signed assertion. The next major version
rejects such a message; until then it logs a warning under NODE_DEBUG=node-saml. Configure the
identity provider to sign with a single reference, to the element signed, as
SAML core §5.4.2 requires.
- Validation fails closed. Decrypted content is not trusted content: an
EncryptedAssertionis
InResponseTo are security controls rather than conveniences — an option that switches one off
(audience: false, acceptedClockSkewMs: -1) is removing a control, so make that choice
deliberately.
Subclassing SAML
Overriding a protected method of SAML that validates an incoming message is deprecated, and the
next major version makes those methods private: an override can switch off a check that the
properties above depend on. That is every protected method except signRequest and
generateAuthorizeRequestAsync. Check what the public method returns instead, and relax a check
only through its option under Response validation:
// deprecated
class TenantSaml extends SAML {
async processValidlySignedAssertionAsync(...args) {
const result = await super.processValidlySignedAssertionAsync(...args);
assertKnownTenant(result.profile);
return result;
}
}
// use this instead
const { profile } = await saml.validatePostResponseAsync(req.body);
if (profile) {
assertKnownTenant(profile);
}
To choose the certificates a signature is verified with at run time, pass a function as
idpCert rather than overriding getKeyInfosAsPem. To check the
Issuer of a logout message, set idpIssuer rather than overriding
verifyIssuer: a LogoutResponse resolves with profile: null, so its result has no issuer to
check.
Verifying a signature outside SAML
lib/xml exports getVerifiedXml(), which upholds the first property above, and the deprecated
validateSignature(), which doesn't. Like everything under lib/, neither is part of the API: the
next major version closes the path to both, and removes validateSignature().
To validate a SAML message, use the SAML methods above, which apply every check in this section.
To verify other signed XML, use xml-crypto directly, and read the signed content from
getSignedReferences() rather than from the document you passed in, as its
Verifying Xml documents section
shows. validateSignature() reports only that a signature verified, which 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. xml-crypto doesn't apply the
SAML-specific checks listed above, such as requiring a signature's reference to point at the
signature's own parent, so add any your documents need:
// 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 sig = new SignedXml({ publicCert });
sig.loadSignature(signatureElement);
if (!sig.checkSignature(xml)) {
throw new Error("Invalid signature");
}
readTheProfileFrom(new DOMParser().parseFromString(sig.getSignedReferences()[0], "text/xml"));
Configuration option signatureAlgorithm
Requests sent by Node-SAML can be signed using RSA with SHA-1, SHA-256, or SHA-512.
```javascript signatureAlgorithm: "sha256"; // preferred — your IdP should support it; if not, consider upgrading the IdP signatureAlgorithm: "sha512"; // strongest — check that your IdP supports it signatureAlgorithm: "sha256-mgf1"; // SHA-256 with RSASSA-PSS padding — only if your IdP asks for it signatureAlgorithm: "sha1"; // legacy; SHA-1 is no longer considered
... (README truncated for length)