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.pemnext 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=nameandgroups=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
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.0Bun installs the packages; Node.js runs the app.
Code
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, '<')}</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.jsVerify
- From the app: open
http://localhost:3000and select Sign in with IdRa. Sign in on IdRa's page. You come back to Signed in, showing your NameID, email and groups. - 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 gotInvalid document signature. Posting a real response twice gotInResponseTo is not valid. - 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.
- 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.
- 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 setAPP_URLand both URLs in SAML settings to match. - Use a shared session store.
express-session's default memory store is for development. - Give
passport-samla sharedcacheProviderif you run more than one instance. Its default cache lives in one process, so a response landing on another instance getsInResponseTo is not valid. - Keep the ACS route free of session reads. The response is a cross-site POST, so a
SameSite=Laxcookie 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 withInResponseTo is missing from response.- When IdRa's signing certificate rotates (Settings → Keys), update
idra-cert.pembefore 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.