IdRa

SAML (Express)

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

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

.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

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

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

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

FieldWhere from
nameIDApplication username (NameID), the email by default
email, name, groupsAttribute 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 seeCauseFix
SAML request rejected: issuer is not this application's SP entity IDThe app's issuer differs from SP entity ID.Make them match exactly.
SAML request rejected: AssertionConsumerServiceURL or AssertionConsumerServiceIndex does not match a configured ACS URLThe 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 settingsThe tile was opened before the settings were saved.Fill in SAML settings and select Save.
Unknown SAML applicationThe 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 signatureSign 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 validThe 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 missingNo Attribute statements.Add them, as above.

For every setting, see SAML 2.0 single sign-on.

On this page