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-samlEndpoints (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 — withsigningCert— 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; setprivateKeyto sign it (the SP metadata then declaresAuthnRequestsSigned="true").POST /saml/acs— the Assertion Consumer Service: validates the IdP's signed assertion, maps it viaonLogin, mints an HttpOnly framework session cookie (signSession), persists the NameID/SessionIndex for logout, and redirects toreturnTo/successRedirect.GET /saml/logout— SP-initiated Single Logout: builds aLogoutRequest(signed whenprivateKeyis 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-initiatedLogoutRequest(signature verified) or theLogoutResponseto 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 signedLogoutRequestclears the SP session and is answered with aLogoutResponse; 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 assertions —
decryptionPvkis the SP private key node-saml uses to decrypt anEncryptedAssertion. Encrypted and plaintext assertions both work. - Clock skew —
acceptedClockSkewMswidens the tolerated window for the assertion'sNotBefore/NotOnOrAftertimestamps. - SP request signing —
privateKey+signingCertsign theAuthnRequest/LogoutRequest; the SP metadata advertisesAuthnRequestsSigned="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_replaytable). Shared across replicas. Declaresstore:write.ttlMsbounds 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
WantAssertionsSignedis on — the plugin rejects unsigned assertions.- The session cookie is the same one
@voltro/plugin-auth/verifySessionread, so the rest of your app authenticates identically. idpCertis 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).