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.An SLO message with no signature at all is rejected
401before 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/slois aGETthat 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 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.
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_replaytable). Shared across replicas. Declaresstore:write.ttlMsbounds 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 capturedSAMLResponseis 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
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
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.