Appearance
SSO & SAML Authentication
Overview
ODP supports three Single Sign-On (SSO) driver types:
- oauth2 — Generic OAuth 2.0 authorization code flow
- openid — OpenID Connect (OAuth 2.0 + ID token + userinfo endpoint)
- saml — SAML 2.0 SP-initiated and IdP-initiated flows
Provider configurations are stored in odp_extension_settings where extension_key = 'system_auth_provider'. Sensitive fields (clientSecret, idpCert) are encrypted at rest using the server SECRET.
Provider Configuration
OAuth2 / OpenID Config Fields
| Field | Type | Required | Description |
|---|---|---|---|
driver | 'oauth2' | 'openid' | Yes | Auth driver type |
clientId | string | Yes | OAuth2 client ID |
clientSecret | string | Yes | OAuth2 client secret (encrypted) |
authorizeUrl | string | Yes | Provider authorization endpoint |
tokenUrl | string | Yes | Provider token exchange endpoint |
userinfoUrl | string | OpenID | Userinfo endpoint (OpenID only) |
issuerUrl | string | OpenID | OIDC discovery URL |
scope | string | Yes | OAuth2 scopes (space-separated) |
identifierKey | string | No | User identifier claim (default: sub for OpenID, email for OAuth2) |
redirectUrls | string/array | Yes | Allowed redirect URIs (comma-separated or array) |
profileMapping | object | No | Maps provider claims to ODP fields (email, firstName, lastName, avatarUrl) |
emailVerifiedKey | string | No | Claim for email verification status (default: email_verified) |
allowUnverifiedEmail | boolean | No | Allow login with unverified emails |
defaultRoleId | string (UUID) | No | Role assigned to auto-created users |
label | string | No | Display name for the provider |
iconUrl | string | No | URL to provider icon image |
SAML Config Fields
| Field | Type | Required | Description |
|---|---|---|---|
driver | 'saml' | Yes | Must be saml |
idpSsoUrl | string | Yes | IdP Single Sign-On URL |
idpCert | string | Yes | IdP X.509 signing certificate (PEM or raw base64) |
spEntityId | string | Yes | SP Entity ID (must match IdP configuration) |
spAcsUrl | string | Yes | SP Assertion Consumer Service URL |
idpEntityId | string | No | IdP Entity ID (defaults to idpSsoUrl) |
idpSloUrl | string | No | IdP Single Logout URL |
nameIdFormat | string | No | NameID format (default: persistent) |
wantAssertionsSigned | boolean | No | Require signed assertions (default: true) |
attributeMapping | object | No | Maps SAML attributes to { email, firstName, lastName } |
redirectUrls | string/array | No | Allowed frontend redirect URLs |
allowUnverifiedEmail | boolean | No | Not applicable for SAML (always trusted) |
defaultRoleId | string (UUID) | No | Role assigned to auto-created users |
Endpoints
GET /auth/sso/providers
List all configured SSO providers (public info only — no secrets exposed).
Auth required: No
Response:
json
{
"data": [
{
"name": "google",
"label": "Google",
"icon": "https://example.com/google-icon.png",
"driver": "openid",
"authorizeUrl": "https://accounts.google.com/o/oauth2/v2/auth",
"clientId": "...",
"scope": "openid email profile"
}
]
}POST /auth/sso/verify
Exchange an OAuth2 authorization code for ODP access/refresh tokens.
Auth required: No
Request Body:
json
{
"provider": "google",
"code": "4/0AX4XfWh...",
"redirect_uri": "https://app.example.com/auth/callback",
"code_verifier": "optional-pkce-verifier",
"mode": "json"
}| Field | Type | Required | Description |
|---|---|---|---|
provider | string | Yes | Provider name (must match configured name) |
code | string | Yes | Authorization code from provider |
redirect_uri | string | Yes | Must match one of the configured redirectUrls |
code_verifier | string | No | PKCE verifier (for OpenID PKCE flow) |
mode | 'json' | 'session' | No | Token delivery mode. session sets HttpOnly cookie |
Response:
json
{
"data": {
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "a1b2c3d4e5f6...",
"expires": 900000
}
}Business Logic:
- Validates provider exists and redirect_uri is whitelisted
- Exchanges code for tokens with the provider (single token exchange)
- Resolves
SSOUserInfo(identifier, email, firstName, lastName, emailVerified) - Checks
allowUnverifiedEmail— rejects if false and email is unverified - Calls
findOrCreateUser: looks up inodp_user_providersby(provider, external_identifier), creates new user if not found - If email collision with different user → throws
AccountExistsLinkRequiredError - Verifies user status is
active - Creates session, issues access token with
sidclaim
POST /auth/saml/:provider/login (GET)
Generate SAML SP-initiated login URL with SAMLRequest.
Auth required: No
URL Parameters:
provider— SAML provider name
Query Parameters:
redirect_url— Optional frontend URL to redirect back after authentication. Must match configured redirect URL origins.
Response:
json
{
"data": {
"url": "https://idp.example.com/sso?SAMLRequest=...&RelayState=myprovider%7Chttps%3A%2F%2Fapp.example.com"
}
}RelayState Format: providerName or providerName|frontendUrl
POST /auth/saml/acs
Assertion Consumer Service — receives SAMLResponse from IdP (HTTP POST binding).
Auth required: No Content-Type: application/x-www-form-urlencoded (IdP form POST) or application/json (API call)
Form Parameters:
SAMLResponse— Base64-encoded SAMLResponse XMLRelayState— Provider identifier, optionally with frontend URL (provider|frontendUrl)
Business Logic:
- Parses
RelayStateto determine provider name and optional frontend URL - For IdP-initiated flows (no RelayState), extracts
<Issuer>from SAMLResponse XML and matches to configured providers - Validates SAMLResponse signature using IdP certificate via
samlify - Extracts user attributes according to
attributeMappingconfig - Stores
SSOUserInfo+ provider inodp_ephemeral_codestable (5-minute TTL, one-time use) - Returns one-time code
Response (API/JSON):
json
{
"data": {
"code": "a3f8b2c1d4e5...",
"provider": "mysaml"
}
}Response (Browser): HTTP 302 redirect to {frontendUrl}/auth/saml/callback?code=...&provider=...
POST /auth/saml/verify
Exchange SAML one-time code for ODP auth tokens.
Auth required: No
Request Body:
json
{
"code": "a3f8b2c1d4e5...",
"provider": "mysaml",
"mode": "json"
}Response:
json
{
"data": {
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "b2c3d4e5f6a1...",
"expires": 900000
}
}Business Logic:
- Consumes code from
odp_ephemeral_codes(transactional — deleted on read to prevent replay) - Validates provider matches the code's stored provider
- Calls
findOrCreateUserwith storedSSOUserInfo - Verifies user status is
active - Creates session and issues access token
GET /auth/saml/:provider/metadata
Returns SP metadata XML for import into the IdP.
Auth required: No Content-Type Response: application/xml
xml
<?xml version="1.0"?>
<EntityDescriptor entityID="https://api.example.com/saml/sp" ...>
<SPSSODescriptor>
<AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://api.example.com/auth/saml/acs"/>
</SPSSODescriptor>
</EntityDescriptor>SAML Flow Diagram
OAuth2 / OpenID Flow Diagram
Provider Linking
An already-authenticated user can link additional SSO providers without creating a new session.
POST /users/me/providers/link
json
{
"provider": "github",
"code": "...",
"redirect_uri": "https://app.example.com/settings/providers"
}Business Logic:
- Exchanges code, resolves user info
- Checks email collision — SSO identity email must not belong to another user
- Inserts link into
odp_user_providers
For SAML linking, POST the SAMLResponse to /auth/saml/link (see sessions docs).
Data Model
odp_user_providers
| Column | Type | Description |
|---|---|---|
id | UUID PK | |
user_id | UUID FK → odp_users | |
provider | string(128) | Provider name |
external_identifier | string(255) | Provider's user identifier |
provider_email | string(255) | Email from provider (backfilled) |
auth_data | text (JSON) | Raw provider auth data |
created_at | timestamp |
Unique constraint: (provider, external_identifier)
odp_ephemeral_codes
| Column | Type | Description |
|---|---|---|
code | string(64) PK | Random hex code |
type | string(32) | Code type (e.g. saml) |
data | jsonb | Stored payload (userInfo + provider) |
expires_at | timestamp | 5 minutes from creation |
created_at | timestamp |
Configuration
| Variable | Default | Description |
|---|---|---|
SECRET | — | Required. Used for JWT signing and field encryption |
ACCESS_TOKEN_TTL | 15m | Access token lifetime |
REFRESH_TOKEN_TTL | 7d | Session (refresh token) lifetime |
SESSION_COOKIE_ENABLED | true | Allow session cookie mode |
SESSION_COOKIE_NAME | odp_session_token | Cookie name |
PUBLIC_URL | http://localhost:8055 | Used as fallback for SAML redirect |
TFA Not Applicable for SSO Users
SSO users cannot enable two-factor authentication (TFA/TOTP) within ODP. This is by design:
- Authentication via an external IdP (OAuth2, OpenID Connect, or SAML) means the user's identity is already verified by that provider.
- The IdP is responsible for enforcing any additional authentication factors (e.g. MFA configured at the Google, Azure AD, or Okta level).
- Adding a local TOTP layer inside ODP would be redundant and is explicitly blocked.
What this means in practice:
| Action | Local user | SSO user |
|---|---|---|
Enable TFA (POST /users/me/tfa/enable) | Allowed (password required) | Blocked — returns 400 INVALID_PAYLOAD |
Login with OTP (POST /auth/login) | Required if tfa_secret is set | N/A — SSO login never checks OTP |
SSO verify (POST /auth/sso/verify) | — | No OTP prompt, no TFA check |
SAML verify (POST /auth/saml/verify) | — | No OTP prompt, no TFA check |
If a user has both local credentials and a linked SSO provider, the SSO provider link takes precedence — they will be blocked from enabling TFA until the provider link is removed.
Error Codes
| Error | Condition |
|---|---|
INVALID_PAYLOAD | Unknown provider, redirect_uri mismatch, missing fields, or TFA enable attempted by SSO user |
INVALID_CREDENTIALS | User not found, not active, email unverified |
ACCOUNT_EXISTS_LINK_REQUIRED | Email exists for different user — must link account instead |