Adding SAML 2.0 sign-in to a server-side web application

This walks through what a traditional, server-rendered application needs: the keys and metadata, the two routes, the cookie that carries the request id, and the session. The demo is all of it in one file with node:http; the same steps in Express are below.

1. A key pair, and the configuration

npx saml2-sp-nodejs generate-keys --config saml.json
{
  "entityId": "https://app.example.com/saml/metadata",
  "acsUrl": "https://app.example.com/saml/acs",
  "privateKey": "keys/sp.key",
  "certificate": "keys/sp.crt",
  "idpMetadata": "keys/idp-metadata.xml",
  "wantAssertionsEncrypted": true
}

Keep the private key out of the image and out of version control: mount it, or read it from your secret store and pass the PEM in privateKey.

2. Exchange metadata with the identity provider

npx saml2-sp-nodejs metadata --config saml.json --out sp-metadata.xml

Give sp-metadata.xml to the identity provider. It registers the assertion consumer service on all three bindings, the signing and encryption key, and AuthnRequestsSigned="true". Save the identity provider’s metadata where idpMetadata points, or point idpMetadata at its URL. For iya-sts, scripts/configure-iya-sts.ts does both through its management API.

3. The login route

import express from 'express';
import { ServiceProvider } from 'saml2-sp-nodejs';

const sp = await ServiceProvider.fromFile('saml.json');
const app = express();

app.get('/login', (req, res) => {
  const request = sp.login({ relayState: '/' });
  // The request id goes with THIS browser, so the Response can be matched to it. SameSite=None:
  // the HTTP-POST binding brings the browser back with a cross-site POST, which drops a Lax cookie.
  res.cookie('saml_pending', sign(request.id), {
    httpOnly: true, secure: true, sameSite: 'none', path: '/saml/acs', maxAge: 600_000,
  });
  if (request.binding === 'redirect') return res.redirect(request.url);
  res.type('html').send(request.html);
});

sign() is your application’s cookie signing (cookie-parser with a secret, or an HMAC). The id is not a secret, but a cookie the browser could replace would let it choose which request a Response answers.

4. The assertion consumer service

app.post('/saml/acs', express.urlencoded({ extended: false, limit: '512kb' }), acs);
app.get('/saml/acs', acs);   // HTTP-Artifact (and HTTP-Redirect)

async function acs(req, res) {
  try {
    const who = await sp.acs(
      { method: req.method, url: req.originalUrl, body: req.body },
      { requestId: unsign(req.cookies.saml_pending) },
    );
    res.clearCookie('saml_pending', { path: '/saml/acs' });
    req.session.regenerate(() => {          // a new session id at sign-in
      req.session.user = { nameId: who.nameId, attributes: who.attributes, sessionIndex: who.sessionIndex };
      res.redirect(303, safeLocal(who.relayState) ?? '/');
    });
  } catch (err) {
    console.warn('SAML sign-in refused', err.code, err.message);
    res.status(400).send('Sign-in failed.');
  }
}
  • Pass req.originalUrl, the URL as received: the HTTP-Redirect binding’s signature is over the query string exactly as it arrived.
  • relayState came through the browser. Only follow it to a path on your own site (safeLocal), never to an absolute URL.
  • Regenerate the session at sign-in, and set its lifetime no longer than who.sessionNotOnOrAfter.

5. Metadata at the entityID

app.get('/saml/metadata', (req, res) => res.type('application/samlmetadata+xml').send(sp.metadata()));

6. Signed out by the identity provider: Shared Signals

A session at the application outlives a sign-out at the identity provider unless something tells it. SignalsReceiver is that something: it receives the identity provider’s CAEP and RISC events and ends the matching sessions (see the reference).

const signals = new SignalsReceiver({
  transmitter: 'https://idp.example.com',
  tls: { ca: sp.config.idpCa, cert: sp.config.certificate.toString(),
         key: sp.config.privateKey.export({ type: 'pkcs8', format: 'pem' }) as string },
  onEndSessions: (ids) => ids.forEach((id) => req.sessionStore.destroy(id, () => {})),
});
await signals.start();

React to each event type with your own handler, signals.on(EVENT.credentialChange, …): Handling CAEP and RISC events has one for every CAEP and RISC type.

In the ACS, after req.session.regenerate(), call await signals.track(req.sessionID, who); at sign-out, await signals.untrack(req.sessionID). Ending the session in the store is what matters: the browser’s next request finds no session. Telling an open tab at once (Server-Sent Events, a WebSocket) is a courtesy on top; the demo does it.

7. Several people in one browser

The library is per sign-in, so several people in one browser is the application’s session design. A pattern that fits a server-rendered application, and that the demo implements:

  • One cookie names an account set held on the server: the people signed in to this browser, each with their own session.
  • The URL says which person a tab is for, as a stable per-account id (/u/<account>/…), not a position. A cookie is shared by every tab, so it can’t say that.
  • Adding a person: sp.login({ forceAuthn: true }), so the identity provider authenticates someone instead of signing the current person in again.
  • Signing a known person in again: sp.login({ forceAuthn: true, subjectHint: who.nameId, subjectHintFormat: who.nameIdFormat }). The hint becomes the AuthnRequest’s <saml:Subject>, which tells an identity provider holding several accounts which one to use.

The trap: the ACS cannot read a SameSite=Lax cookie when the Response arrives by HTTP-POST. It’s a cross-site POST, so the browser leaves such cookies off. Note the account set in the cookie that carries the request id: it’s scoped to the ACS, SameSite=None and signed. Otherwise every POST-binding sign-in starts a new set and the browser forgets everyone else.

The identity provider must also let one browser hold several sessions. Otherwise a second sign-in ends the first person’s IdP session, and its CAEP session-revoked then ends their session here too.

8. Several processes

The replay cache remembers accepted assertion IDs in one process’s memory, and the Shared Signals session index likewise. Behind a load balancer, give every process one shared replay cache (and one shared sessionIndex, with the receiver running in a single process): anything with an atomic set-if-absent and an expiry.

const sp = await ServiceProvider.fromFile('saml.json', {}, {
  replayCache: {
    async checkAndRecord(id, expiresAt) {
      const ok = await redis.set(`saml:${id}`, '1', { NX: true, PXAT: expiresAt.getTime() });
      return ok === null;   // true = seen before
    },
  },
});

9. Key rollover

Generate a new pair (generate-keys --force), publish the new metadata to the identity provider, then restart. For the identity provider’s own rollover, call sp.refreshMetadata() (or restart) after its metadata changes.