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.

    An SLO message with no signature at all is rejected 401 before the SAML library is consulted. This is not a formality: the underlying verifier treats an absent redirect-binding signature as "nothing to verify" and returns valid, and /saml/slo is a GET that is deliberately CSRF-exempt — so an unsigned message would let any page log a visitor out with an <img> tag. If your IdP does not sign SLO, it now gets a 401; enable message signing on the IdP side.

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.

Which signatures are required

Two separate requirements, and only one of them is a choice:

Required? Option
Assertion signature Always. Not configurable.
Response envelope signature No, by default wantAuthnResponseSigned

The assertion is the part that matters: only signature-covered XML is ever read, and the assertion is what carries the NameID, the attributes, the audience restriction, the validity window and the SubjectConfirmationData. An envelope signature additionally covers the response-level Status, Destination and InResponseTo.

The default accepts an assertion-signed, envelope-unsigned response — Okta's default application profile ("Sign assertion", response unsigned), and Azure AD's. Set wantAuthnResponseSigned: true if your IdP signs the response; it is one checkbox on both, and it is the stronger posture:

samlSsoPlugin({
  // …idp / sp / sessionSecret / onLogin…
  wantAuthnResponseSigned: true,   // require the envelope signature too
})

One edge worth knowing at the default: an envelope signature that does not verify is treated the same as no envelope signature — it is discarded and the assertion signature decides. That is not a bypass (an attacker holding a validly signed assertion would simply send no envelope signature, and an attacker-signed assertion is refused at every setting), but it does mean an IdP misconfigured to sign responses with the wrong key goes unnoticed. wantAuthnResponseSigned: true surfaces it.

Replay / InResponseTo protection

On by default, backed by the DataStore. 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.

  • { store: true, ttlMs? } (default) — 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).
  • true — 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). The default is store-backed rather than this precisely because the in-process mode is not a milder version of the same protection — under more than one replica it is a total login outage.
  • false — off, and a captured SAMLResponse is replayable within its validity window.

It refuses IdP-initiated SSO — and false is the only way back

Replay protection sets validateInResponseTo: 'always', which means a SAMLResponse carrying no InResponseTo is rejected. That is exactly an IdP-initiated login: the Okta / Azure dashboard app tile, rather than a user arriving at /saml/login.

You cannot keep both, and the reason is structural rather than a missing feature: the protection is the requirement that the response answer a request this SP issued, and an unsolicited response answers none. ('ifPresent' looks like the compromise and is not one — an attacker replaying a captured response just deletes the attribute and the check declines to run.)

If you need the app tile, opt out deliberately:

samlSsoPlugin({
  // …idp / sp / sessionSecret / onLogin…
  replayProtection: false,          // accepts unsolicited responses — and replays
  wantAuthnResponseSigned: true,    // recommended if you must run unsolicited
})

Boot warns when replay protection is off, because nothing about it is visible at runtime.

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

store:write by default — replay protection is on and store-backed, so the plugin contributes _voltro_saml_replay. sloStore: { store: true } adds _voltro_saml_logout under the same permission. Setting replayProtection: false (and leaving sloStore at its in-process default) drops the permission entirely: the plugin then only serves the SAML routes + mints a session.