SAML SSO

Enterprise SAML 2.0 SSO — SP-initiated login, ACS assertion consumer, SP metadata. Signature verification via @node-saml/node-saml; framework session minting built in.

@voltro/plugin-sso-saml adds SAML 2.0 single sign-on with Single Logout (SLO) — the login protocol large enterprises mandate (alongside, or instead of, OIDC). The framework's six IdP adapters cover JWT/OIDC; this covers SAML. Signature verification is delegated to the maintained @node-saml/node-saml (an optional, lazily-loaded dependency — install it to use the plugin); the framework integration (routes, session minting, attribute mapping, metadata) is built in. Beyond the base flow it supports IdP-metadata-URL config (auto cert rotation), encrypted assertions, a clock-skew tolerance, and SP request signing.

Wiring

// app.config.ts
import { samlSsoPlugin } from '@voltro/plugin-sso-saml'

export default {
  type: 'api' as const, name: 'api',
  plugins: [
    samlSsoPlugin({
      idp: { entryPoint: process.env.SAML_IDP_SSO_URL!, idpCert: process.env.SAML_IDP_CERT! },
      sp:  { entityId: 'https://app.example.com/saml/metadata', acsUrl: 'https://app.example.com/saml/acs' },
      sessionSecret: process.env.VOLTRO_SESSION_SECRET!,
      // Map the validated SAML identity → an app Subject (look up / JIT-create a user).
      onLogin: async (profile) => {
        const user = await findOrCreateUser(profile.email ?? profile.nameId)
        return { type: 'user', id: user.id, tenantId: user.orgId }
      },
      successRedirect: '/',
    }),
  ],
}
// install once: pnpm add @node-saml/node-saml

Endpoints (mounted under /saml)

  • GET /saml/metadata — SP metadata XML to paste into your IdP (tells it your ACS URL + entity id, the SP SLO endpoint, and — with signingCert — the SP signing cert). Works without the optional dep.
  • GET /saml/login?returnTo=/dashboard — builds an AuthnRequest and redirects the browser to the IdP. Unsigned by default; set privateKey to sign it (the SP metadata then declares AuthnRequestsSigned="true").
  • POST /saml/acs — the Assertion Consumer Service: validates the IdP's signed assertion, maps it via onLogin, mints an HttpOnly framework session cookie (signSession), persists the NameID/SessionIndex for logout, and redirects to returnTo / successRedirect.
  • GET /saml/logout — SP-initiated Single Logout: builds a LogoutRequest (signed when privateKey is set) referencing the stored NameID + SessionIndex, clears the session, and redirects to the IdP SLO endpoint.
  • GET|POST /saml/slo — the IdP-facing SLO endpoint: an IdP-initiated LogoutRequest (signature verified) or the LogoutResponse to our own request lands here.

The flow: user hits /saml/login → IdP authenticates → IdP POSTs the assertion to /saml/acs → verified → session cookie set → user is logged in (the next WS connection resolves the authenticated Subject).

Single Logout (SLO)

SLO works in both directions. A LogoutRequest must reference the IdP NameID + SessionIndex from the login, so those are captured at the ACS and stored keyed by an opaque HttpOnly companion cookie.

  • SP-initiated (GET /saml/logout) — loads the stored NameID/SessionIndex, builds the (signed) LogoutRequest, clears the session cookie, and redirects to the IdP SLO endpoint.
  • IdP-initiated (GET|POST /saml/slo) — the IdP's signed LogoutRequest clears the SP session and is answered with a LogoutResponse; a bad signature is rejected (401) and the session is left intact.

The SLO state store mirrors the replay cache — in-process by default (single replica), shared via the DataStore with sloStore: { store: true } (a _voltro_saml_logout table) so a login on one replica can log out on another.

samlSsoPlugin({
  // …idp / sp / sessionSecret / onLogin…
  sloStore: { store: true },   // shared across replicas
  logoutRedirect: '/goodbye',
})

IdP-metadata-URL config (auto cert rotation)

Instead of pinning idp.entryPoint + idp.idpCert, point the plugin at the IdP metadata document. It fetches the XML (via @effect/platform's HttpClient), reads the SSO entryPoint, the signing certificate, and the SingleLogoutService, and re-fetches on the metadataRefreshMs schedule — so when the IdP rotates its cert you change nothing.

samlSsoPlugin({
  idp: {
    metadataUrl: process.env.SAML_IDP_METADATA_URL!,   // entryPoint + cert loaded from here
    metadataRefreshMs: 6 * 60 * 60 * 1000,             // re-fetch every 6h (0 disables)
  },
  sp: { entityId: 'https://app.example.com/saml/metadata', acsUrl: 'https://app.example.com/saml/acs' },
  sessionSecret: process.env.VOLTRO_SESSION_SECRET!,
  onLogin: (profile) => lookupOrCreateUser(profile),
})

An explicit entryPoint / idpCert still act as a fallback if a fetch fails, so a transient metadata outage never takes down login.

Encrypted assertions, clock skew, SP signing

  • Encrypted assertionsdecryptionPvk is the SP private key node-saml uses to decrypt an EncryptedAssertion. Encrypted and plaintext assertions both work.
  • Clock skewacceptedClockSkewMs widens the tolerated window for the assertion's NotBefore / NotOnOrAfter timestamps.
  • SP request signingprivateKey + signingCert sign the AuthnRequest / LogoutRequest; the SP metadata advertises AuthnRequestsSigned="true" and publishes the signing cert.
samlSsoPlugin({
  idp: { entryPoint: process.env.SAML_IDP_SSO_URL!, idpCert: process.env.SAML_IDP_CERT! },
  sp: { entityId: 'https://app.example.com/saml/metadata', acsUrl: 'https://app.example.com/saml/acs' },
  sessionSecret: process.env.VOLTRO_SESSION_SECRET!,
  onLogin: (profile) => lookupOrCreateUser(profile),
  decryptionPvk: process.env.SAML_SP_DECRYPTION_KEY!,   // decrypt EncryptedAssertion
  privateKey: process.env.SAML_SP_SIGNING_KEY!,          // sign AuthnRequest / LogoutRequest
  signingCert: process.env.SAML_SP_SIGNING_CERT!,        // published in SP metadata
  acceptedClockSkewMs: 5000,                             // 5s clock-skew tolerance
})

All three secrets come from env / a secrets backend — never a literal, never logged.

Replay / InResponseTo protection

By default node-saml runs with validateInResponseTo: 'never', so a captured SAMLResponse can be replayed to the ACS within its validity window (and unsolicited IdP-initiated posts are accepted). Turn on the one-time request-id cache with replayProtection:

samlSsoPlugin({
  // …idp / sp / sessionSecret / onLogin…
  replayProtection: { store: true },   // shared across replicas — the production choice
})
  • false (default) — off. Single-replica dev / PoC only.
  • true — on with an in-process cache. Correct for one replica only; a login and its ACS POST that land on different processes fail the check (boot warns).
  • { store: true, ttlMs? } — on, backed by the framework DataStore (a contributed _voltro_saml_replay table). Shared across replicas. Declares store:write. ttlMs bounds an outstanding request's validity (default 10 min).

The AuthnRequest id is stored at /saml/login and consumed at /saml/acs, so the same assertion can't be replayed and a response referencing no request this SP issued is rejected.

Notes

  • WantAssertionsSigned is on — the plugin rejects unsigned assertions.
  • The session cookie is the same one @voltro/plugin-auth / verifySession read, so the rest of your app authenticates identically.
  • idpCert is the IdP's signing certificate (PEM body) — get it from the IdP's metadata.

Permissions

None by default (it serves the SAML routes + mints a session). replayProtection: { store: true } and sloStore: { store: true } each declare store:write for their shared table (_voltro_saml_replay / _voltro_saml_logout).