SAML provider
WIPIdP-initiated SAML 2.0 SSO. Pairs with @node-saml/node-saml for the protocol details; duck-auth handles session minting.
Setup
SAML requires the @node-saml/node-saml peer dependency:
bun add @node-saml/node-saml
import { samlProvider } from '@gentleduck/auth/providers/saml'
import { SAML } from '@node-saml/node-saml'
const client = new SAML({
entryPoint: 'https://idp.example.com/sso',
issuer: 'https://app.example.com',
callbackUrl: 'https://app.example.com/auth/saml/callback',
cert: process.env.SAML_IDP_CERT!, // IdP signing certificate
// ... other @node-saml options
})
export const auth = new AuthEngine({
// ...
providers: [
samlProvider({
providerId: 'saml',
client,
callbackUrl: 'https://app.example.com/auth/saml/callback',
onSignIn: async ({ profile, tenantId }) => {
// Provision identity from the SAML attributes.
let identity = await auth.identities.findByEmail(profile.email)
if (!identity) {
identity = await auth.identities.create({
email: profile.email,
emailVerified: true, // IdP-attested
profile: {
displayName: profile.displayName,
department: profile.department,
},
})
await auth.identities.linkProvider(identity.id, {
providerId: 'saml',
sub: profile.nameID,
})
}
return { identityId: identity.id }
},
profileToIdentityProfile: (profile) => ({
displayName: profile.displayName,
department: profile.department,
}),
}),
],
})
How it works
- SP-initiated: the user clicks "Sign in with SSO" -> the provider
beginreturns a redirect to the IdP entry point with aSAMLRequest. - IdP-initiated: the IdP posts a signed
SAMLResponsedirectly to yourcallbackUrl- nobeginneeded. - Either way, the
completestep validates the assertion via@node-saml/node-saml, callsonSignIn, and emits astartSessionintent.
Wire shape
The IdP POSTs the SAML response as a form-encoded body:
POST /auth/saml/callback
Content-Type: application/x-www-form-urlencoded
SAMLResponse=PHNhbWxw...&RelayState=foo
The mounted server adapter parses the form, calls complete, and
issues the session.
Just-in-time provisioning
The onSignIn hook is where you provision identities - most SAML
deployments don't pre-provision users.
A typical implementation:
onSignIn: async ({ profile, tenantId }) => {
let identity = await auth.identities.findByProviderSub({
providerId: 'saml',
sub: profile.nameID,
})
if (!identity) {
identity = await auth.identities.create({
email: profile.email,
emailVerified: true,
profile: {
displayName: profile.displayName,
externalGroups: profile.groups,
},
})
await auth.identities.linkProvider(identity.id, {
providerId: 'saml',
sub: profile.nameID,
})
} else {
// Refresh attributes that may have changed in the IdP.
await auth.identities.update(identity.id, {
profile: {
displayName: profile.displayName,
externalGroups: profile.groups,
},
})
}
return { identityId: identity.id }
}
Multi-tenant SAML
For B2B SaaS where each customer has their own IdP, resolve the SAML client by tenant before construction:
function buildAuthForTenant(tenant: TenantConfig) {
const saml = new SAML({
entryPoint: tenant.idpEntryPoint,
cert: tenant.idpCert,
// ...
})
return new AuthEngine({
// ...
providers: [
samlProvider({
providerId: `saml:${tenant.id}`,
client: saml,
callbackUrl: `https://app.example.com/auth/saml/${tenant.id}/callback`,
onSignIn: async ({ profile }) => { /* ... */ },
}),
],
})
}
Or cache AuthEngine instances per tenant and pick the right one per request.
SP metadata
IdPs usually consume an SP metadata XML at a stable URL like
/sso/saml/metadata. Generate it dynamically so signing-cert rotation
is visible without a redeploy:
import { buildSpMetadata } from '@gentleduck/auth/providers/saml'
app.get('/sso/saml/metadata', (req, res) => {
const xml = buildSpMetadata({
client,
metadata: {
entityId: 'https://app.example.com',
acsUrl: 'https://app.example.com/auth/saml/callback',
sloUrl: 'https://app.example.com/auth/saml/slo',
signingCert: process.env.SP_SIGNING_CERT,
displayName: 'My App',
},
})
res.type('application/samlmetadata+xml').send(xml)
})
Falls back to a hand-rolled XML doc with proper XML escaping if
@node-saml/node-saml is not present (the client arg is optional
for this helper).
Single Logout (SLO)
Wire samlSloController next to your samlProvider to handle both
flows:
import { samlSloController } from '@gentleduck/auth/providers/saml'
const slo = samlSloController({ client })
// 1. User clicks "Sign out". App-initiated.
app.post('/auth/signout', async (req, res) => {
const session = await auth.resolveSession(req)
if (!session) return res.redirect('/')
const { redirectUrl } = await slo.beginSp({
nameID: session.identity.profile.email,
nameIDFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
sessionIndex: session.session.id,
relayState: 'app-initiated',
})
res.redirect(redirectUrl)
})
// 2. IdP's reply to our LogoutRequest.
app.get('/auth/saml/slo', async (req, res) => {
await slo.completeSp({
query: req.query as Record<string, string>,
originalQuery: req.originalUrl.split('?')[1] ?? '',
})
await auth.signOut(req)
res.redirect('/signed-out')
})
// 3. IdP-initiated logout (user signed out elsewhere).
app.post('/auth/saml/slo', async (req, res) => {
const { nameID, redirectUrl } = await slo.completeIdp({
SAMLRequest: req.body.SAMLRequest,
})
if (nameID) {
const identity = await auth.identities.findByEmail(nameID)
if (identity) await auth.sessions.revokeAllForIdentity(identity.id)
}
res.redirect(redirectUrl)
})
IdP-initiated SSO
The complete method already accepts unsolicited SAMLResponse posts
when @node-saml/node-saml is configured with allowUnsolicited: true.
No separate API call is needed; route the IdP-init endpoint to the
same callback handler you use for SP-init.
Errors
| Code | When |
|---|---|
AUTH/PROVIDER_FAILED | SAML response / request failed signature, audience, or destination checks. |
AUTH/MISCONFIGURED | SLO method called but the node-saml client doesn't expose the matching optional method (e.g. getLogoutUrlAsync). |
AUTH/OAUTH_STATE_MISMATCH | RelayState mismatch (if used). |