Profile
Back to NewsBack
GitHub Trending 27 min
Reader Mode
node-saml/node-saml: A SAML library not dependent on any frameworks that runs in Node.

node-saml/node-saml: A SAML library not dependent on any frameworks that runs in Node.

16 hours ago

Node SAML

Build Status</a> npm version</a> code style: prettier</a> codecov</a> DeepScan grade</a>

NPM</a>

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.

- Create a SAML instance - Start a login - Validate the response - The profile - Single logout (SLO) - Service provider metadata

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

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 LogoutResponse rather 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 passive request could not be satisfied without user interaction (a NoPassive status on a
validly signed response): { 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's Issuer, 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 matching decryptionPvk. Required if the instance was
configured with decryptionPvk; pass null otherwise.
  • publicCerts — the public certificate matching privateKey. Required if the instance was
configured with 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. |

Set validateInResponseTo explicitly. 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" validates InResponseTo when 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,
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 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 EncryptedAssertion is
decrypted and then still has to have its signature verified. Timestamps, audience, issuer, and 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)

Chat with me