# SAML (Express) (https://docs.idra.zone/docs/quickstarts/saml)

Add IdRa sign-in to an app that speaks SAML, with Express and passport-saml: register, configure, run, and fix what goes wrong.

A server-side service provider (SP) using [`@node-saml/passport-saml`](https://github.com/node-saml/passport-saml) and `express-session`. Node.js 20.12 or later. The steps carry over to any SAML library: you need the same four values and the same checks.

Sample repository (being opened soon): [IdRaHQ/idra-sample-saml](https://github.com/IdRaHQ/idra-sample-saml). It has this quickstart's code.

**Tested locally over http only**

  This page was run with a real browser against a local IdRa on `http://localhost`, with the versions below. It hasn't been run over `https`, on the hosted service or behind a proxy. See [Before production](#before-production).

## Register the app

Open your organization's admin console at `https://<your-org>.idra.team/admin` (or `/admin` on your own IdRa). Go to **Applications → Create App Integration**, and set:

- **Sign-in method:** **SAML 2.0**
- **App integration name:** any name

Leave **Assign this app to me so I can test it** on, then select **Create**. The app's **Sign On** tab opens.

Under **Identity provider**, copy these for the next step:

- **Sign-on URL**, such as `https://<your-org>.idra.team/sso/saml/<appId>/sso`
- **Issuer (IdP entity ID)**, such as `https://<your-org>.idra.team/sso/saml/<appId>`
- **Copy certificate**: save it as `idra-cert.pem` next to your code

Libraries that read metadata can use the **Metadata URL** instead. `passport-saml` doesn't.

Under **SAML settings**, set:

- **Single sign-on URL (ACS):** `http://localhost:3000/saml/acs`
- **SP entity ID:** `http://localhost:3000/saml/metadata`
- **Attribute statements:** select **Add attribute** and add `email` = `email`, `name` = `name` and `groups` = `groups`. Without them, the app only receives the NameID.

Leave **Sign the whole Response too** on, because this library requires it, then select **Save**.

To let other people sign in, assign them or their groups on **Assignments**.

**Already running the app?**

  **Import SP metadata** fills in the ACS URL, entity ID and NameID format from the XML at `http://localhost:3000/saml/metadata`. Attribute statements are still yours to add.

## Configure

```sh title=".env"
IDRA_SSO_URL=https://<your-org>.idra.team/sso/saml/<appId>/sso
IDRA_ENTITY_ID=https://<your-org>.idra.team/sso/saml/<appId>
IDRA_CERT_FILE=idra-cert.pem
APP_URL=http://localhost:3000
SESSION_SECRET=<output of: openssl rand -hex 32>
```

## Install

```sh
mkdir idra-saml && cd idra-saml
echo '{"private":true,"type":"module"}' > package.json
bun add --exact @node-saml/passport-saml@5.1.1 express@5.3.0 express-session@1.19.0 passport@0.7.0
```

Bun installs the packages; Node.js runs the app.

## Code

```js title="server.js"
import { readFileSync } from 'node:fs';
import { Strategy as SamlStrategy } from '@node-saml/passport-saml';
import express from 'express';
import session from 'express-session';
import passport from 'passport';

const { IDRA_SSO_URL, IDRA_ENTITY_ID, IDRA_CERT_FILE, APP_URL, SESSION_SECRET } = process.env;

const saml = new SamlStrategy(
  {
    // Where the browser goes, and who must have signed what comes back.
    entryPoint: IDRA_SSO_URL,
    idpIssuer: IDRA_ENTITY_ID,
    idpCert: readFileSync(IDRA_CERT_FILE, 'utf8'),
    // This app: its entity ID (the expected audience too) and its ACS URL.
    issuer: `${APP_URL}/saml/metadata`,
    callbackUrl: `${APP_URL}/saml/acs`,
    identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
    // Both signatures are required, which are also this library's defaults. Never turn them off.
    wantAssertionsSigned: true,
    wantAuthnResponseSigned: true,
    // Answers to requests must match one sent; no InResponseTo means a My Apps launch.
    validateInResponseTo: 'ifPresent',
  },
  // One group arrives as a string, several as an array.
  (profile, done) => done(null, { nameID: profile.nameID, email: profile.email, groups: [profile.groups ?? []].flat() }),
  // Required by the library. Only called for Single Logout, which IdRa doesn't send.
  (profile, done) => done(null, { nameID: profile.nameID }),
);
passport.use('saml', saml);
passport.serializeUser((user, done) => done(null, user));
passport.deserializeUser((user, done) => done(null, user));

const app = express();
app.set('trust proxy', 1);
app.use(
  session({
    name: 'app.sid',
    secret: SESSION_SECRET,
    resave: false,
    saveUninitialized: false,
    cookie: { httpOnly: true, sameSite: 'lax', secure: APP_URL.startsWith('https://') },
  }),
);
app.use(passport.session());

app.get('/', (req, res) => {
  if (!req.user) return res.send('<h1>Signed out</h1><a href="/login">Sign in with IdRa</a>');
  res.send(`<h1>Signed in</h1><pre>${JSON.stringify(req.user, null, 2).replace(/</g, '&lt;')}</pre>
    <a href="/logout">Sign out</a>`);
});

app.get('/login', passport.authenticate('saml'));

// IdRa posts the signed response here. A cross-site POST carries no Lax cookie, so nothing here reads the session.
app.post(
  '/saml/acs',
  express.urlencoded({ extended: false }),
  (req, res, next) =>
    passport.authenticate('saml', (err, user, info) => {
      if (err || !user) {
        console.error(err ?? info);
        return res.status(401).send(`Sign-in failed: ${err?.message ?? info?.message ?? 'no user'}`);
      }
      req.login(user, (e) => (e ? next(e) : res.redirect('/')));
    })(req, res, next),
);

app.get('/saml/metadata', (req, res) => {
  res.type('application/samlmetadata+xml').send(saml.generateServiceProviderMetadata(null));
});

// Ends this app's session only: IdRa doesn't support SAML Single Logout, so its session stays.
app.get('/logout', (req, res, next) => {
  req.logout((e) => (e ? next(e) : res.redirect('/')));
});

app.listen(process.env.PORT ?? 3000, () => console.log(`Listening on ${APP_URL}`));
```

## Run

```sh
node --env-file=.env server.js
```

## Verify

1. **From the app:** open `http://localhost:3000` and select **Sign in with IdRa**. Sign in on IdRa's page. You come back to **Signed in**, showing your NameID, email and groups.
2. **Signature checks:** change anything in a response and the app refuses it. We posted a changed NameID, a removed signature, a changed signature value and a changed audience to `/saml/acs`: each got `Invalid document signature`. Posting a real response twice got `InResponseTo is not valid`.
3. **MFA on the way:** if a policy asks for MFA, IdRa asks for it, or has the person set up a method, then continues the same sign-in and sends them back to the app.
4. **From My Apps:** select **Sign out**, open your IdRa dashboard and select the app's tile. A new tab opens, signed in to the app.
5. **Someone not assigned:** IdRa shows **Access to … denied** and the app receives nothing.

**Reports → System Log** in the admin console records each sign-in as `app.saml.sso`, and refused access as `app.access` with the reason.

## Signing out

IdRa doesn't support SAML Single Logout (SLO), for this or any SAML app, and its metadata lists no logout endpoint. **Sign out** here ends the app's session only. The IdRa session stays, so the next **Sign in with IdRa** returns without asking for a password. To end the IdRa session, sign out of IdRa as well.

## What the app receives

| Field | Where from |
| --- | --- |
| `nameID` | **Application username (NameID)**, the email by default |
| `email`, `name`, `groups` | **Attribute statements**, named as you named them |

The `groups` attribute lists group names, always including `Everyone`. `passport-saml` gives a string for one value and an array for several, so this code always makes it an array.

## Before production

- Serve the app over `https`, and set `APP_URL` and both URLs in **SAML settings** to match.
- Use a shared session store. `express-session`'s default memory store is for development.
- Give `passport-saml` a shared `cacheProvider` if you run more than one instance. Its default cache lives in one process, so a response landing on another instance gets `InResponseTo is not valid`.
- Keep the ACS route free of session reads. The response is a cross-site POST, so a `SameSite=Lax` cookie doesn't come with it.
- `validateInResponseTo: 'ifPresent'` is what lets My Apps launches through. This library keeps no record of those responses, so a captured one can be replayed until it expires, five minutes at most. If nobody needs the tile, use `'always'`; the app then refuses My Apps launches with `InResponseTo is missing from response`.
- When IdRa's signing certificate rotates (**Settings → Keys**), update `idra-cert.pem` before retiring the old one.

## Troubleshooting

| What you see | Cause | Fix |
| --- | --- | --- |
| `SAML request rejected: issuer is not this application's SP entity ID` | The app's `issuer` differs from **SP entity ID**. | Make them match exactly. |
| `SAML request rejected: AssertionConsumerServiceURL or AssertionConsumerServiceIndex does not match a configured ACS URL` | The app's `callbackUrl` isn't a configured ACS URL. Matching is exact. | Make it match **Single sign-on URL (ACS)**. |
| `SAML request rejected: this application has no ACS URL yet; finish its Sign On settings` | The tile was opened before the settings were saved. | Fill in **SAML settings** and select **Save**. |
| `Unknown SAML application` | The app ID in the URL is wrong, or the app isn't a SAML app. | Copy **Sign-on URL** again. |
| `Sign-in failed: Invalid document signature` | **Sign the whole Response too** is off, or the certificate is wrong or was rotated, or the response was changed. | Turn the setting on, or copy the certificate again. Never turn off the app's signature checks. |
| `Sign-in failed: Unknown SAML issuer. Expected: … Received: …` | `IDRA_ENTITY_ID` isn't this app's issuer. Each IdRa app has its own. | Copy **Issuer (IdP entity ID)**. |
| `Sign-in failed: SAML assertion audience mismatch. Expected: … Received: …` | **Audience restriction** differs from the app's entity ID. | Clear it, or set it to the **SP entity ID**. |
| `Sign-in failed: InResponseTo is not valid` | The response was already used, or the app restarted during the sign-in. | Sign in again from the app. |
| **Access to … denied**: `You are not assigned to this application. Contact your administrator to request access.` | The person isn't assigned. | Assign them on **Assignments**. |
| Signed in, but `email` and `groups` are missing | No **Attribute statements**. | Add them, as above. |

For every setting, see [SAML 2.0 single sign-on](https://docs.idra.zone/docs/admin/saml).