Skip to main content

SAML provider

WIP

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

  1. SP-initiated: the user clicks "Sign in with SSO" -> the provider begin returns a redirect to the IdP entry point with a SAMLRequest.
  2. IdP-initiated: the IdP posts a signed SAMLResponse directly to your callbackUrl - no begin needed.
  3. Either way, the complete step validates the assertion via @node-saml/node-saml, calls onSignIn, and emits a startSession intent.

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

CodeWhen
AUTH/PROVIDER_FAILEDSAML response / request failed signature, audience, or destination checks.
AUTH/MISCONFIGUREDSLO method called but the node-saml client doesn't expose the matching optional method (e.g. getLogoutUrlAsync).
AUTH/OAUTH_STATE_MISMATCHRelayState mismatch (if used).