Skip to content

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

FieldTypeRequiredDescription
driver'oauth2' | 'openid'YesAuth driver type
clientIdstringYesOAuth2 client ID
clientSecretstringYesOAuth2 client secret (encrypted)
authorizeUrlstringYesProvider authorization endpoint
tokenUrlstringYesProvider token exchange endpoint
userinfoUrlstringOpenIDUserinfo endpoint (OpenID only)
issuerUrlstringOpenIDOIDC discovery URL
scopestringYesOAuth2 scopes (space-separated)
identifierKeystringNoUser identifier claim (default: sub for OpenID, email for OAuth2)
redirectUrlsstring/arrayYesAllowed redirect URIs (comma-separated or array)
profileMappingobjectNoMaps provider claims to ODP fields (email, firstName, lastName, avatarUrl)
emailVerifiedKeystringNoClaim for email verification status (default: email_verified)
allowUnverifiedEmailbooleanNoAllow login with unverified emails
defaultRoleIdstring (UUID)NoRole assigned to auto-created users
labelstringNoDisplay name for the provider
iconUrlstringNoURL to provider icon image

SAML Config Fields

FieldTypeRequiredDescription
driver'saml'YesMust be saml
idpSsoUrlstringYesIdP Single Sign-On URL
idpCertstringYesIdP X.509 signing certificate (PEM or raw base64)
spEntityIdstringYesSP Entity ID (must match IdP configuration)
spAcsUrlstringYesSP Assertion Consumer Service URL
idpEntityIdstringNoIdP Entity ID (defaults to idpSsoUrl)
idpSloUrlstringNoIdP Single Logout URL
nameIdFormatstringNoNameID format (default: persistent)
wantAssertionsSignedbooleanNoRequire signed assertions (default: true)
attributeMappingobjectNoMaps SAML attributes to { email, firstName, lastName }
redirectUrlsstring/arrayNoAllowed frontend redirect URLs
allowUnverifiedEmailbooleanNoNot applicable for SAML (always trusted)
defaultRoleIdstring (UUID)NoRole 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"
}
FieldTypeRequiredDescription
providerstringYesProvider name (must match configured name)
codestringYesAuthorization code from provider
redirect_uristringYesMust match one of the configured redirectUrls
code_verifierstringNoPKCE verifier (for OpenID PKCE flow)
mode'json' | 'session'NoToken delivery mode. session sets HttpOnly cookie

Response:

json
{
  "data": {
    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "refresh_token": "a1b2c3d4e5f6...",
    "expires": 900000
  }
}

Business Logic:

  1. Validates provider exists and redirect_uri is whitelisted
  2. Exchanges code for tokens with the provider (single token exchange)
  3. Resolves SSOUserInfo (identifier, email, firstName, lastName, emailVerified)
  4. Checks allowUnverifiedEmail — rejects if false and email is unverified
  5. Calls findOrCreateUser: looks up in odp_user_providers by (provider, external_identifier), creates new user if not found
  6. If email collision with different user → throws AccountExistsLinkRequiredError
  7. Verifies user status is active
  8. Creates session, issues access token with sid claim

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 XML
  • RelayState — Provider identifier, optionally with frontend URL (provider|frontendUrl)

Business Logic:

  1. Parses RelayState to determine provider name and optional frontend URL
  2. For IdP-initiated flows (no RelayState), extracts <Issuer> from SAMLResponse XML and matches to configured providers
  3. Validates SAMLResponse signature using IdP certificate via samlify
  4. Extracts user attributes according to attributeMapping config
  5. Stores SSOUserInfo + provider in odp_ephemeral_codes table (5-minute TTL, one-time use)
  6. 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:

  1. Consumes code from odp_ephemeral_codes (transactional — deleted on read to prevent replay)
  2. Validates provider matches the code's stored provider
  3. Calls findOrCreateUser with stored SSOUserInfo
  4. Verifies user status is active
  5. 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

ColumnTypeDescription
idUUID PK
user_idUUID FK → odp_users
providerstring(128)Provider name
external_identifierstring(255)Provider's user identifier
provider_emailstring(255)Email from provider (backfilled)
auth_datatext (JSON)Raw provider auth data
created_attimestamp

Unique constraint: (provider, external_identifier)

odp_ephemeral_codes

ColumnTypeDescription
codestring(64) PKRandom hex code
typestring(32)Code type (e.g. saml)
datajsonbStored payload (userInfo + provider)
expires_attimestamp5 minutes from creation
created_attimestamp

Configuration

VariableDefaultDescription
SECRETRequired. Used for JWT signing and field encryption
ACCESS_TOKEN_TTL15mAccess token lifetime
REFRESH_TOKEN_TTL7dSession (refresh token) lifetime
SESSION_COOKIE_ENABLEDtrueAllow session cookie mode
SESSION_COOKIE_NAMEodp_session_tokenCookie name
PUBLIC_URLhttp://localhost:8055Used 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:

ActionLocal userSSO 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 setN/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

ErrorCondition
INVALID_PAYLOADUnknown provider, redirect_uri mismatch, missing fields, or TFA enable attempted by SSO user
INVALID_CREDENTIALSUser not found, not active, email unverified
ACCOUNT_EXISTS_LINK_REQUIREDEmail exists for different user — must link account instead

ODP Internal API Documentation