Enterprise SSO (SAML 2.0 + OIDC) and SCIM v2 Provisioning
May 28, 2026 · View on GitHub
AIG-646. This document describes how to configure aistack for enterprise Single Sign-On and automated user/group provisioning, and the threat model the implementation defends against.
Table of Contents
- Overview
- Configuration
- Setup: Okta SAML
- Setup: Azure AD SAML
- Setup: Google Workspace SAML
- Setup: Auth0 OIDC
- SCIM v2 Provisioning (Okta)
- Group to Role Mapping
- Threat Model
- Operational Notes
Overview
The SSO module (src/auth/sso/) is additive. It does not modify the existing
JWT + bcrypt + RBAC AuthService; instead it sits alongside it and:
- accepts authenticated subjects from an external IdP (SAML 2.0 or OIDC),
- creates or links a local
usersrow, - maps IdP groups to one of the aistack
UserRolevalues via a strict whitelist (groupRoleMap), - issues the same access + refresh JWTs the password login flow produces, and
- (optionally) exposes a SCIM v2 endpoint so the IdP can provision and deprovision users without manual intervention.
Endpoints (mounted under the web server's /api prefix):
| Method | Path | Purpose |
|---|---|---|
| GET | /api/auth/sso/saml/login | Redirect to IdP SSO |
| POST | /api/auth/sso/saml/acs | Assertion Consumer Service |
| GET | /api/auth/sso/saml/logout | Redirect to IdP SLO (if any) |
| GET | /api/auth/sso/oidc/login | Redirect to IdP authorize URL |
| GET | /api/auth/sso/oidc/callback | OIDC code exchange |
| ANY | /api/scim/v2/{Users,Groups,...} | SCIM v2 server |
Configuration
SSO is configured under auth.sso in aistack.config.json. All three
sub-sections (saml, oidc, scim) are independent — enable only what you
need.
{
"auth": {
"sso": {
"defaultRole": "viewer",
"strictIdentityBinding": true,
"saml": {
"providerName": "okta",
"entityId": "urn:aistack:sp",
"idpSsoUrl": "https://${OKTA_DOMAIN}/app/aistack/sso/saml",
"idpSloUrl": "https://${OKTA_DOMAIN}/app/aistack/slo/saml",
"idpCert": "${OKTA_SAML_CERT_PEM}",
"callbackUrl": "https://aistack.example.com/api/auth/sso/saml/acs",
"signatureAlgorithm": "sha256",
"acceptedClockSkewSec": 30,
"groupAttributeName": "groups",
"emailAttributeName": "email",
"groupRoleMap": {
"aistack-admins": "admin",
"aistack-developers": "developer",
"aistack-viewers": "viewer"
}
},
"oidc": {
"providerName": "auth0",
"issuerUrl": "https://${AUTH0_DOMAIN}/",
"clientId": "${AUTH0_CLIENT_ID}",
"clientSecret": "${AUTH0_CLIENT_SECRET}",
"redirectUri": "https://aistack.example.com/api/auth/sso/oidc/callback",
"scopes": ["openid", "profile", "email", "groups"],
"groupsClaim": "https://aistack.example.com/groups",
"groupRoleMap": {
"aistack-admins": "admin"
}
},
"scim": {
"enabled": true,
"bearerToken": "${SCIM_BEARER_TOKEN}",
"mutationsPerMinute": 60,
"groupRoleMap": {
"aistack-admins": "admin",
"aistack-developers": "developer"
}
}
}
}
}
Values such as ${OKTA_SAML_CERT_PEM} are resolved from environment variables
at startup. Never hardcode certificates, client secrets, or bearer tokens
in the config file checked into source control.
The following env vars are required when SSO is enabled:
JWT_SECRETandREFRESH_SECRET— same as the existing password login flow; SSO reuses them so token verification is consistent.SCIM_BEARER_TOKEN— at least 32 random bytes (base64 or hex); generated per IdP integration and rotated annually.- IdP-specific secrets (e.g.
AUTH0_CLIENT_SECRET,OKTA_SAML_CERT_PEM).
Setup: Okta SAML
- In Okta admin, Applications → Create App Integration → SAML 2.0.
- Set Single sign-on URL =
https://aistack.example.com/api/auth/sso/saml/acs. - Set Audience URI (SP Entity ID) =
urn:aistack:sp(matchentityId). - Attribute Statements:
email→user.emaildisplayName→user.displayName
- Group Attribute Statements:
- Name:
groups - Filter:
Matches regex→aistack-.*
- Name:
- Save and assign Okta users/groups to the app.
- View SAML Setup Instructions → copy the X.509 certificate (PEM) and
the SSO URL into
OKTA_SAML_CERT_PEM/OKTA_DOMAIN.
Setup: Azure AD SAML
- In Azure AD, Enterprise applications → New application → Non-gallery.
- Single sign-on → SAML.
- Basic SAML Configuration:
- Identifier (Entity ID):
urn:aistack:sp - Reply URL:
https://aistack.example.com/api/auth/sso/saml/acs
- Identifier (Entity ID):
- User Attributes & Claims:
- Add a group claim with Source attribute =
cloudOnly, Name =groups.
- Add a group claim with Source attribute =
- SAML Signing Certificate → download
Certificate (Base64)and setOKTA_SAML_CERT_PEM(variable name kept generic; rename to suit). - Copy the Login URL into
idpSsoUrl.
Setup: Google Workspace SAML
- Admin Console → Apps → Web and mobile apps → Add custom SAML app.
- Note the SSO URL, Entity ID, and download the certificate.
- Service Provider Details:
- ACS URL:
https://aistack.example.com/api/auth/sso/saml/acs - Entity ID:
urn:aistack:sp - Name ID format:
EMAIL
- ACS URL:
- Attribute mapping:
Primary email→email, custom group attribute if you operate one. - Assign to OUs as needed.
Setup: Auth0 OIDC
-
Applications → Create Application → Regular Web Application.
-
Allowed Callback URLs:
https://aistack.example.com/api/auth/sso/oidc/callback -
Allowed Logout URLs:
https://aistack.example.com/ -
Advanced Settings → Grant Types: enable
Authorization Code. -
Add a Rule or Action that injects a groups claim into the id_token:
exports.onExecutePostLogin = async (event, api) => { const groups = event.authorization?.roles ?? []; api.idToken.setCustomClaim( 'https://aistack.example.com/groups', groups ); }; -
Populate
AUTH0_CLIENT_ID,AUTH0_CLIENT_SECRET,AUTH0_DOMAIN.
SCIM v2 Provisioning (Okta)
- In your Okta SAML app, Provisioning → Configure API Integration.
- Base URL:
https://aistack.example.com/api/scim/v2. - API Token: paste the
SCIM_BEARER_TOKENvalue. - Test API Credentials. Okta will probe
/ServiceProviderConfig. - Enable Create Users, Update User Attributes, Deactivate Users.
- Map Okta groups to SCIM groups; aistack stores them in
scim_groupsand applies thegroupRoleMapwhitelist.
The supported subset of RFC 7644 covers:
UsersGET/POST/GET/PUT/PATCH/DELETE (filter byuserName eq/email eq).GroupsGET/POST/GET/PUT/PATCH/DELETE (members add/remove via PATCH).ServiceProviderConfig,Schemas,ResourceTypesdiscovery endpoints.
Group to Role Mapping
groupRoleMap is a strict whitelist:
- Keys are the exact IdP group names (case-sensitive).
- Values must be one of
admin,developer,viewer(theUserRoleenum). Unknown values are rejected at config load. - When a user has multiple mapped groups, the highest privilege wins
(
admin>developer>viewer). This is intentional and audited. - When a user has no mapped groups,
defaultRole(orviewerif omitted) is assigned.
There is no facility to inject arbitrary roles, evaluate expressions, or use group names as SQL/path fragments. The mapping is a pure data lookup.
Threat Model
Identity is the most critical security surface in the system; the following threat classes are explicitly addressed.
1. Forged or unsigned SAML assertions (SEC #1)
wantAssertionsSigned: trueis hard-coded; the parser refuses any assertion without a verified signature over the canonicalised XML.signatureAlgorithmis restricted tosha256orsha512at the constructor; SHA1 / MD5 are rejected.- The XML preflight (
preflightSamlResponse) refuses any SAMLResponse whose<ds:SignatureMethod>or<ds:DigestMethod>references SHA1 or MD5 before the parser is even invoked. NotBeforeandNotOnOrAfterare enforced with a configurable clock skew (default 30s, max 5 min).
2. XML External Entity / XXE injection (SEC #2)
preflightSamlResponserejects any SAMLResponse containing<!DOCTYPE,<!ENTITY,SYSTEMorPUBLICexternal references prior to parsing.- The underlying
@node-saml/node-samllibrary usesxpath+xml-cryptowhich do not resolve external entities by default; the preflight is defence in depth.
3. SAML replay attacks (SEC #3)
- Two-layer replay protection via
ReplayCache(SQLite + in-memory):- the SAML
AssertionIDis recorded with a 5-minute TTL; second use → rejected. - the SAML
InResponseTo(if present) is recorded identically, so a captured response cannot be replayed against a fresh AuthnRequest.
- the SAML
validateInResponseTo: 'always'is enabled in the underlying parser.- Missing
AssertionID→ request is refused (fail closed).
4. OIDC CSRF, code interception, replay (SEC #4)
stateis a 16-byte random value, persisted in anHttpOnly; Secure; SameSite=Laxcookie and checked on callback. Mismatch → request rejected.nonceis a separate 16-byte random value bound into the id_token by the IdP. TheReplayCachealso records the nonce with a 10-minute TTL so a reused nonce is rejected even if the IdP issues a duplicate id_token.- PKCE is mandatory: a 32-byte random verifier and SHA-256 challenge are
always sent. Constructing an OIDC provider with
pkceRequired: falseand noclientSecretis refused. - The stored auth state expires after 10 minutes.
5. OIDC id_token / JWKS validation (SEC #5)
- Signature,
iss,aud,exp,nbf,iatandnoncechecks are delegated toopenid-client(panva), which uses the issuer-published JWKS with automatic key rotation. - Missing
suboremailclaims → request rejected.
6. SCIM authorization and DoS (SEC #6)
- All SCIM requests require a bearer token of at least 16 characters;
comparison uses
crypto.timingSafeEqualto prevent timing leaks. - Cross-tenant operations are not possible: aistack has a single tenant per deployment, and the SCIM server only operates on its own SQLite store.
- Mutation rate limit (default 60/minute per token) protects against POST flood DoS; further limiting can be applied at the reverse proxy layer.
7. SCIM account takeover via silent merge (SEC #7)
- On
POST /Users, a duplicateemailoruserNamereturns 409 Conflict. There is no silent merge. - During SSO login, if
strictIdentityBindingistrue, an SSO-asserted email that already exists locally without a matchingsso_identityrow is refused. WithstrictIdentityBinding: false(default) the existing user is linked, the action is logged at WARN level, but their role is not silently elevated — the previous role is preserved until the next SSO login completes group resolution. - All SCIM mutations are logged at INFO level with user IDs for audit.
8. Group to Role mapping injection (SEC #8)
- Role mapping is a pure dictionary lookup. There is no
eval, no SQL templating, no string interpolation into queries. - The set of valid roles is hard-coded to the
UserRoleenum; any other value in the config is dropped at startup with a clear log message. - SCIM
PATCH /Users/:idintentionally ignores rawrolemutations: privilege changes can only come through group membership, which is audited and rate-limited.
9. Session fixation after SSO login (SEC #9)
- Every successful SSO login mints a brand new access + refresh token pair
via the existing
AuthServicesemantics. The new refresh token is stored inrefresh_tokens; previous refresh tokens for the user are not silently revoked, but the legacylogoutendpoint can be used by the client to revoke them. - SSO requests run on the standard web Router which sets no server-side HTTP session; there is no session id to fix.
Operational Notes
- Rotation: rotate
SCIM_BEARER_TOKEN, OIDCclient_secret, and SAML certificate before they expire. The replay cache is keyed by provider name so multiple SAML IdPs can coexist without collision. - Logging: every SSO/SCIM mutation logs at INFO level via the standard logger. Pipe to your SIEM for retention and alerting.
- Local dev: SSO is disabled when
auth.ssois absent. Tests do not require@node-saml/node-samloropenid-clientto be installed because both libraries are loaded viarequireonly when a provider is wired up; unit tests use stub factories. - Migrations: the new tables (
sso_identities,sso_sessions,sso_replay_cache,scim_groups,scim_group_members) live inmigrations/006_sso_provisioning.sql. They are additive and do not modify existing tables.