Skip to content

About

SAML 2.0 authentication with Passport

Resources

Code of conduct

Stars

880 stars

Watchers

28 watching

Forks

Repository files navigation

Passport-SAML

Build Status CodeQL npm version Node.js version License: MIT code style: prettier codecov DeepScan grade GitHub Sponsors

NPM

This is a SAML 2.0 authentication provider for Passport, the Node.js authentication library.

Passport-SAML has been tested to work with OneLogin, Okta, Shibboleth, SimpleSAMLphp based Identity Providers, and with Active Directory Federation Services.

Sponsors

workos workos

Installation

npm install @node-saml/passport-saml

Usage

Passport-SAML is the service provider (SP) side of a SAML login: it sends users to your identity provider (IdP) to log in, and validates the response the IdP sends back. Register your site with the IdP first; most IdPs accept the metadata that Passport-SAML generates. From the IdP you need its single sign-on URL, for entryPoint, and its signing certificate, for idpCert.

The examples use ES modules: save them as .mjs files, or set "type": "module" in package.json. With CommonJS, require() the same names. The examples use https://sp.example.com for your site and https://idp.example.com for the IdP.

Configure strategy

Most of the configuration options for the Strategy constructor are passed through to the underlying node-saml library. For more details on the configuration options and how the underlying SAML flows work, see the node-saml documentation

Config parameter details

These are the Strategy parameters related directly to passport-saml. For the full list of parameters, see the node-saml documentation

  • additionalParams: dictionary of additional query params to add to all requests; if an object with this key is passed to authenticate, the dictionary of additional query params will be appended to those present on the returned URL, overriding any specified by initialization options' additional parameters (additionalParams, additionalAuthorizeParams, and additionalLogoutParams)
  • passReqToCallback: if truthy, req will be passed as the first argument to the verify callback (default: false)
  • name: Optionally, provide a custom name. (default: saml). Useful if you want to instantiate the strategy multiple times with different configurations, allowing users to authenticate against multiple different SAML targets from the same site. You'll need to use a unique set of URLs for each target, and use this custom name when calling passport.authenticate() as well.

Examples

The IdP sends its response to callbackUrl; Provide the authentication callback adds the route for it.

import { readFileSync } from "node:fs";
import passport from "passport";
import { Strategy as SamlStrategy } from "@node-saml/passport-saml";

const samlStrategy = new SamlStrategy(
  {
    callbackUrl: "https://sp.example.com/login/callback",
    entryPoint: "https://idp.example.com/sso",
    issuer: "https://sp.example.com/metadata",
    idpCert: readFileSync("./idp-signing-cert.pem", "utf-8"),
  },
  // Sign-on: return the user the IdP authenticated
  async (profile, done) => {
    try {
      done(null, await findUserByEmail(profile.email));
    } catch (err) {
      done(err);
    }
  },
  // Logout: return the user the IdP is logging out
  async (profile, done) => {
    try {
      done(null, await findUserByNameID(profile.nameID));
    } catch (err) {
      done(err);
    }
  },
);

passport.use(samlStrategy);

findUserByEmail() and findUserByNameID() stand in for your own user lookups. profile holds the SAML assertion's nameID and attributes; profile.email comes from an email or mail attribute, if the IdP sends one.

  • Sign-on: call done(null, user) to log the user in. A missing user (null, undefined or false) fails the login, and done(err) reports an error.
  • Logout: runs when the IdP sends a LogoutRequest. Passport-SAML compares the user you return with req.user, using assert.deepStrictEqual, and tells the IdP the logout succeeded only if they match. It logs out the current session either way.

Configure strategy for multiple providers

To choose the SAML configuration per request, use MultiSamlStrategy and pass it a getSamlOptions function:

import passport from "passport";
import { MultiSamlStrategy } from "@node-saml/passport-saml";

passport.use(
  new MultiSamlStrategy(
    {
      passReqToCallback: true, // pass `req` to the sign-on and logout functions below
      getSamlOptions: async (req, done) => {
        try {
          const provider = await findProvider(req);
          done(null, provider.samlOptions);
        } catch (err) {
          done(err);
        }
      },
    },
    // Sign-on
    async (req, profile, done) => {
      try {
        done(null, await findUserByEmail(profile.email));
      } catch (err) {
        done(err);
      }
    },
    // Logout
    async (req, profile, done) => {
      try {
        done(null, await findUserByNameID(profile.nameID));
      } catch (err) {
        done(err);
      }
    },
  ),
);

getSamlOptions runs on every request that reaches the strategy. findProvider() stands in for your own lookup. Call done(null, options) with that provider's configuration, or done(err). The options are merged over the ones passed to MultiSamlStrategy, so settings shared by every provider can go in the constructor. callbackUrl, issuer and idpCert must be set after the merge.

MultiSamlStrategy builds a new node-saml SAML instance from the merged options for each request. If those options have no cacheProvider, each instance starts with its own empty in-memory cache, and the callback never finds the request ID recorded when the login started. With validateInResponseTo: "always", every login is then rejected, and with "ifPresent", every response whose Response element carries InResponseTo. Return a cacheProvider from getSamlOptions, the same one for a provider on every call, so a response finds the cache its request was recorded in. One cacheProvider passed to the MultiSamlStrategy constructor also works, but all providers share it, so a response to one provider's request passes this check at another provider's callback.

A cacheProvider is an object with saveAsync(key, value), getAsync(key) and removeAsync(key) methods, described by the CacheProvider TypeScript type. Back it with a store that every process handling your logins can reach, such as Redis or your database. node-saml removes a request ID only when a response to it arrives, so have the store expire entries after requestIdExpirationPeriodMs (8 hours by default); otherwise the IDs of abandoned logins stay forever.

Provide the authentication callback

The IdP posts its response to callbackUrl as a form. Add a route for that path that parses the form body before passport.authenticate(). express.urlencoded() is built into Express 4.16 and later, including Express 5.

app.post(
  "/login/callback",
  express.urlencoded({ extended: false }),
  passport.authenticate("saml", { failureRedirect: "/" }),
  (req, res) => {
    res.redirect("/");
  },
);

Authenticate requests

Use passport.authenticate(), specifying saml as the strategy, on the route that starts a login. It sends the browser to the IdP's entryPoint:

app.get("/login", passport.authenticate("saml"));

...or, if you wish to add or override query string parameters:

app.get(
  "/login",
  passport.authenticate("saml", { additionalParams: { username: "user@example.com" } }),
);

These parameters go on the redirect URL, so a login with authnRequestBinding: "HTTP-POST" does not send them.

In addition to passing the additionalParams option to passport.authenticate, you can also pass samlFallback, either as "login-request" or "logout-request". By default, this is set to "login-request". However, in the case of the req.query and the req.body not containing a SAMLRequest or SAMLResponse, this can be used to dictate which request handler is used in cases where it can not be determined by these standard properties.

Return users to where they started with RelayState

RelayState is a short value that goes to the IdP with a SAML request and comes back, unchanged, with the IdP's response. Use it to send users back to the page they asked for before they had to log in.

To send one, put RelayState in the query string or form body of the request that starts the login. Passport-SAML sends it to the IdP alongside the AuthnRequest: as a query parameter with the HTTP-Redirect binding, or as a hidden form field with HTTP-POST. The /login route above needs no changes:

// When a page needs a logged-in user, send them to log in and remember where they were going
res.redirect(`/login?RelayState=${encodeURIComponent(req.originalUrl)}`);

The IdP posts it back to your callback URL along with the SAMLResponse, where it is available as req.body.RelayState. Passport-SAML passes it through without acting on it.

RelayState is not part of the signed SAML response: the IdP posts it as a separate form field, so anyone can change it on its way back to you. Treat it as untrusted input. Redirecting to it without checking it turns your callback into an open redirect. For example, follow it only when it is a path on your own site:

app.post(
  "/login/callback",
  express.urlencoded({ extended: false }),
  passport.authenticate("saml", { failureRedirect: "/" }),
  (req, res) => {
    res.redirect(localPathOr(req.body.RelayState, "/"));
  },
);

// Returns `value` if it is a path on this site; otherwise `fallback`.
function localPathOr(value, fallback) {
  if (typeof value !== "string") return fallback;
  const base = "https://sp.invalid"; // placeholder origin, used only to parse `value`
  try {
    const url = new URL(value, base);
    // A browser reads a path that starts with "//" as the address of another site
    if (url.origin === base && !url.pathname.startsWith("//")) {
      return url.pathname + url.search + url.hash;
    }
  } catch {
    // not a valid URL
  }
  return fallback;
}

The SAML 2.0 bindings specification (sections 3.4.3 and 3.5.3) limits RelayState to 80 bytes, so an IdP may reject a longer value. It also recommends protecting the value from tampering "by using a checksum, a pseudo-random value, or similar means". If your paths can be longer than 80 bytes, or you want that protection, store the destination on the server under a short random key and send the key as RelayState instead.

Logout works the same way. RelayState on the request that starts a logout, through strategy.logout(req, callback) or samlFallback: "logout-request", is sent with the LogoutRequest and comes back with the IdP's LogoutResponse, in req.query.RelayState or req.body.RelayState depending on the binding. When the IdP starts the logout, Passport-SAML returns the IdP's RelayState in its LogoutResponse, as the specification requires.

RelayState only comes back if the IdP received your request. If entryPoint is a link that starts an IdP-initiated login, rather than the IdP's single sign-on service URL (the SingleSignOnService location in its metadata), the IdP ignores the AuthnRequest and the RelayState along with it. In an IdP-initiated login, req.body.RelayState holds whatever the IdP is configured to send, if anything.

generateServiceProviderMetadata( decryptionCert, signingCert )

Generates your SP's metadata, which you can give to the IdP when you register your site. Serve it from a route:

app.get("/metadata", (req, res) => {
  res.type("application/xml").send(samlStrategy.generateServiceProviderMetadata(null));
});

Pass decryptionCert when you configure decryptionPvk, and signingCert when you configure privateKey; otherwise pass null for decryptionCert and leave out signingCert. For details, see Service provider metadata in the node-saml documentation.

The generateServiceProviderMetadata method is also available on the MultiSamlStrategy, but needs an extra request and a callback argument (generateServiceProviderMetadata(req, decryptionCert, signingCert, callback)). It passes req to getSamlOptions to retrieve the correct configuration, then calls callback(err, metadata).

Usage with Active Directory Federation Services

Here is a configuration for ADFS:

const adfsOptions = {
  entryPoint: "https://adfs.example.com/adfs/ls/",
  issuer: "https://sp.example.com/login/callback",
  callbackUrl: "https://sp.example.com/login/callback",
  idpCert: "MIICizCCAfQCCQCY8tKaMc0BMjANBgkqh ... W==",
  authnContext: ["http://schemas.microsoft.com/ws/2008/06/identity/authenticationmethod/windows"],
  identifierFormat: null,
};

ADFS needs a relying party trust for your service, with issuer as its identifier.

By default, ADFS signs only the assertion in its response, and Passport-SAML rejects a response whose outer Response element is not signed. Have ADFS sign both:

Set-AdfsRelyingPartyTrust -TargetName "<relying party name>" -SamlResponseSignature MessageAndAssertion

If you can't change the relying party trust, set wantAuthnResponseSigned: false instead. The assertion's signature is still required.

For more detailed instructions, see ADFS documentation.

SLO (single logout)

Passport-SAML has built in support for SLO from Node-SAML.

Note: Fully functional IdP initiated SLO support is not provided out of the box. You have to inspect your use cases / implementation / deployment scenarios (location of IdP in respect to SP) and consider things / cases listed e.g. at issue(s) #221 and #419. This library provides you a mechanism to veto "Success" result but it does not provide hooks/interfaces to implement support for IdP initiated SLO which would work under all circumstances. You have to do it yourself.

ChangeLog

See Releases to find the changes that go into each release. Additionally, see the CHANGELOG.

FAQ

Is there an example I can look at?

Gerard Braad has provided an example app at https://github.com/gbraad/passport-saml-example/. It was written for passport-saml 1.x, so some of its option names have changed since; for example, cert is now idpCert. Use this README for the current options.

Node Support Policy

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.

As each Node LTS version reaches its end-of-life we will remove that version from the node engines property of our package's package.json file. 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.

Project History

The project was started by @bergie in 2012 based on Michael Bosworth's express-saml library. From 2014 - 2016, @ploer served as primary maintainer. @markstos served as the primary maintainer from 2017 till 2020 when he created the node-saml organization. With a goal to create a team of maintainers, invitations were sent to major contributors and fork authors to work together to maintain all the improvements in one place.

Since 2020, @cjbarth emerged as the primary maintainer, with major contributions from @gugu and @zoellner. Major updates from the team included rewriting the project in TypeScript and splitting off a node-saml module which can be used without Passport. Almost 100 other developers have contributed improvements to the project.

The project continues to be maintained by volunteers. Contributions small and large are welcome.

Past sponsors

stytchauth stytchauth

Copyright Notices

“OASIS”, “SAML”, and “Security Assertion Markup Language” are trademarks of OASIS, the open standards consortium where the SAML specification is owned and developed. SAML is a copyrighted © work of OASIS Open. All rights reserved.

About

SAML 2.0 authentication with Passport

Resources

Code of conduct

Stars

880 stars

Watchers

28 watching

Forks

Releases

Packages

Used by

Contributors

Languages