# 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.



---

<!-- source: en/plugins/sso-saml.md -->
## 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

```ts
// 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.

```ts
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.

```ts
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** — `decryptionPvk` is the SP private key node-saml uses to decrypt an `EncryptedAssertion`. Encrypted and plaintext assertions both work.
- **Clock skew** — `acceptedClockSkewMs` widens the tolerated window for the assertion's `NotBefore` / `NotOnOrAfter` timestamps.
- **SP request signing** — `privateKey` + `signingCert` sign the `AuthnRequest` / `LogoutRequest`; the SP metadata advertises `AuthnRequestsSigned="true"` and publishes the signing cert.

```ts
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:

```ts
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:

```ts
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`](/docs/plugins/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.
