SAML 2.0: HTTP-Redirect for the request, HTTP-POST for the response. The
adapter declares callbackMethod: 'POST', so the plugin serves the form-post
callback. XML signature work is @node-saml/node-saml’s.
Install
npm install @orthacms/identity-provider-samlGive your IdP these values
| IdP field | Value |
|---|---|
| ACS / Reply URL (HTTP-POST) | https://cms.example.com/api/auth/sso/saml/callback |
| SP Entity ID / Audience | SSO_SAML_ISSUER, e.g. https://cms.example.com |
| NameID format | persistent (transient and email NameIDs are refused) |
| Attributes | email, display name, groups (optional) |
Configure
# .env — registered only when ENTRY_POINT and IDP_CERT are set
# (a generated app also requires ISSUER)
SSO_SAML_ENTRY_POINT=https://idp.acme.com/app/abc123/sso/saml
# PEM or bare base64
SSO_SAML_IDP_CERT=MIIDpDCCAoygAwIBAgIGAY...
# this SP's entity id; the reference server falls back to SSO_PUBLIC_BASE_URL
SSO_SAML_ISSUER=https://cms.example.com
SSO_SAML_NAME=saml
SSO_SAML_LABEL=Acme SSO
# a stable directory id, when the NameID is not persistent
SSO_SAML_SUBJECT_ATTRIBUTE=
SSO_SAML_EMAIL_ATTRIBUTE=
SSO_SAML_GROUPS_ATTRIBUTE=
# SAML has no verification claim: set true only if the directory owns its addresses
SSO_SAML_EMAIL_VERIFIED=falseDifferences between the two apps:
| Reference server | Generated app | |
|---|---|---|
SSO_SAML_ISSUER | falls back to SSO_PUBLIC_BASE_URL | required |
\n in SSO_SAML_IDP_CERT | passed as-is | turned into real newlines |
SSO_SAML_NAME_ATTRIBUTE | not read | read |
Multi-line certificates in .env
In the reference server, paste the certificate as one line of bare base64
(no BEGIN/END lines). A generated app also accepts a PEM with \n escapes.
The reference server, apps/server/config/identity.ts:
function samlProvider(): (SamlProviderConfig & { name: string }) | undefined {
const entryPoint = readEnv('SSO_SAML_ENTRY_POINT');
const idpCert = readEnv('SSO_SAML_IDP_CERT');
return when(entryPoint && idpCert, () =>
defined({
name: readEnv('SSO_SAML_NAME') ?? 'saml',
entryPoint: entryPoint as string,
idpCert: idpCert as string,
issuer: readEnv('SSO_SAML_ISSUER') ?? readEnv('SSO_PUBLIC_BASE_URL') ?? '',
label: readEnv('SSO_SAML_LABEL'),
subjectAttribute: readEnv('SSO_SAML_SUBJECT_ATTRIBUTE'),
emailAttribute: readEnv('SSO_SAML_EMAIL_ATTRIBUTE'),
groupsAttribute: readEnv('SSO_SAML_GROUPS_ATTRIBUTE'),
emailVerified: readFlag('SSO_SAML_EMAIL_VERIFIED', false)
})
);
}All options
SamlProviderConfig:
| Option | Default | Notes |
|---|---|---|
entryPoint | required | IdP SSO URL. HTTPS (only localhost exempt) |
idpCert | required | string | string[] — a list trusts old and new during a rollover |
issuer | required | This SP’s entity id. Must be non-empty |
label | SAML | |
subjectAttribute | NameID | |
emailAttribute | email, mail, two URN/claim forms | |
nameAttribute | displayName, cn, name, a claim URI | |
groupsAttribute | unset | A single group (string) or many (array) are both read |
emailVerified | false | |
privateKey + signingCert | unset | Sign the AuthnRequest. Must be given together |
wantAssertionsSigned | true | |
wantAuthnResponseSigned | true | |
clockToleranceSeconds | 60 |
Register
// apps/server/src/plugins.ts
import { createSamlProvider } from '@orthacms/identity-provider-saml';
const { saml } = config.plugins.identity.ssoProviders;
const providers = [];
if (saml) {
const { name, ...settings } = saml;
providers.push({ name, provider: createSamlProvider(settings) });
}
IdentityPlugin(config.plugins.identity, { sso: { providers } });Certificate rollover, trusting both:
createSamlProvider({
entryPoint: 'https://idp.acme.com/sso/saml',
idpCert: [process.env.SSO_SAML_IDP_CERT!, process.env.SSO_SAML_IDP_CERT_NEXT!],
issuer: 'https://cms.example.com'
});Verify
curl -s http://localhost:3000/api/auth/sso
# [{"name":"saml","label":"Acme SSO","kind":"saml"}]
# Redirect binding: the AuthnRequest rides in the query, RelayState carries the core's state
curl -si http://localhost:3000/api/auth/sso/saml/start | grep -i '^location'
# location: https://idp.acme.com/app/abc123/sso/saml?SAMLRequest=…&RelayState=…A refused sign-in redirects to /identity/signin?error=sso and logs
SSO sign-in refused: <reason>.
Behaviour worth knowing
- Two NameIDs are refused: transient (new value every sign-in) and an email
address. The error names the fix: persistent NameID, or
SSO_SAML_SUBJECT_ATTRIBUTE. emailVerifiedis always your assertion. SAML carries no verification claim. Leftfalse, a first sign-in cannot claim an existing account.RelayStatecarriesstate, checked before anything is parsed.validateInResponseTois off: the core already burns each attempt once.- No single logout (
logoutUrlanswersnull) and no back-channel logout. UseSSO_SESSION_TTL_SECONDSto shorten SSO sessions.