Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

SAML 2.0

Identity plugin @orthacms/identity-server@orthacms/identity-admin

The protocol whose response is a form post, and what that changes.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

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

Give your IdP these values

IdP fieldValue
ACS / Reply URL (HTTP-POST)https://cms.example.com/api/auth/sso/saml/callback
SP Entity ID / AudienceSSO_SAML_ISSUER, e.g. https://cms.example.com
NameID formatpersistent (transient and email NameIDs are refused)
Attributesemail, 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=false

Differences between the two apps:

Reference serverGenerated app
SSO_SAML_ISSUERfalls back to SSO_PUBLIC_BASE_URLrequired
\n in SSO_SAML_IDP_CERTpassed as-isturned into real newlines
SSO_SAML_NAME_ATTRIBUTEnot readread

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:

OptionDefaultNotes
entryPointrequiredIdP SSO URL. HTTPS (only localhost exempt)
idpCertrequiredstring | string[] — a list trusts old and new during a rollover
issuerrequiredThis SP’s entity id. Must be non-empty
labelSAML
subjectAttributeNameID
emailAttributeemail, mail, two URN/claim forms
nameAttributedisplayName, cn, name, a claim URI
groupsAttributeunsetA single group (string) or many (array) are both read
emailVerifiedfalse
privateKey + signingCertunsetSign the AuthnRequest. Must be given together
wantAssertionsSignedtrue
wantAuthnResponseSignedtrue
clockToleranceSeconds60

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.
  • emailVerified is always your assertion. SAML carries no verification claim. Left false, a first sign-in cannot claim an existing account.
  • RelayState carries state, checked before anything is parsed. validateInResponseTo is off: the core already burns each attempt once.
  • No single logout (logoutUrl answers null) and no back-channel logout. Use SSO_SESSION_TTL_SECONDS to shorten SSO sessions.