Skip to content

SSO Setup

Configure Single Sign-On (SSO) authentication flow in Mantis.

Mantis implements OIDC Authorization Code flow with PKCE for secure SSO authentication.

Before configuring SSO:

  1. Identity provider configured - See OIDC Configuration
  2. Callback URL configured in IdP
  3. Mandible external URL configured

Ensure external_url is configured in Mandible:

mandible.toml
[server]
external_url = "https://api.mantis.local"

Or via environment variable:

Terminal window
MANDIBLE__SERVER__EXTERNAL_URL=https://api.mantis.local

The UI requests an authorization URL:

Terminal window
curl -X POST \
-H "Content-Type: application/json" \
-d '{"redirect_url": "https://lens.mantis.local/dashboard"}' \
"https://api.mantis.local/api/v1/auth/oidc/$IDP_SQID/login"

Response:

{
"authorization_url": "https://company.okta.com/oauth2/v1/authorize?response_type=code&client_id=xxx&redirect_uri=https://api.mantis.local/api/v1/auth/oidc/callback&scope=openid%20profile%20email&state=abc123&nonce=def456&code_challenge=xyz789&code_challenge_method=S256",
"state": "abc123"
}

The user is redirected to the IdP login page:

  1. User enters credentials
  2. IdP authenticates user
  3. IdP redirects to callback URL with authorization code

The callback endpoint processes the response:

Terminal window
# IdP redirects to:
GET /api/v1/auth/oidc/callback?code=xxx&state=abc123

The API:

  1. Validates state parameter (CSRF protection)
  2. Exchanges code for tokens using PKCE verifier
  3. Validates ID token (signature, claims)
  4. Creates or updates user
  5. Returns Mantis JWT tokens

Prevents authorization code interception:

ParameterValuePurpose
code_challenge_methodS256SHA-256 hashing
code_challengeBase64URL(SHA256(verifier))Sent with authorization request
code_verifierRandom 43-128 charsSent with token request

Prevents CSRF attacks:

Prevents replay attacks:

  • Generated for each login attempt
  • Included in ID token
  • Verified during token validation
  • Constant-time comparison prevents timing attacks
-- Auth state stored in database
CREATE TABLE oidc_auth_states (
id UUID PRIMARY KEY,
state TEXT NOT NULL UNIQUE,
nonce TEXT NOT NULL,
code_verifier TEXT NOT NULL,
provider_id UUID NOT NULL,
redirect_url TEXT,
created_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP NOT NULL,
used_at TIMESTAMP
);

Auth states:

  • Expire after 10 minutes (configurable)
  • Single-use (consumed on callback)
  • Automatically cleaned up

When auto_create_users is enabled:

  1. Extract claims from ID token:

    • sub - External user ID
    • preferred_username or configured claim - Username
    • email or configured claim - Email address
  2. Generate unique username if needed:

    alice -> alice
    alice (exists) -> alice2
    alice2 (exists) -> alice3
  3. Create user record with:

    • Random password (unused for SSO)
    • Email verified status
    • Tenant from provider (if configured)
  4. Assign default role (if configured)

  5. Create external identity link

Links Mantis users to IdP identities:

CREATE TABLE external_identities (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
provider_id UUID NOT NULL,
external_user_id TEXT NOT NULL,
external_username TEXT,
external_email TEXT,
access_token_encrypted TEXT,
refresh_token_encrypted TEXT,
token_expires_at TIMESTAMP,
last_login_at TIMESTAMP,
created_at TIMESTAMP NOT NULL,
UNIQUE (provider_id, external_user_id)
);
CheckDescription
SignatureVerify using JWKS keys
issMust match issuer URL
audMust contain client_id
azpIf multiple audiences, must equal client_id
expToken not expired
iatToken not too old (5 min max)
nonceMust match expected nonce
at_hashIf present, validates access token binding

For performance, JWKS keys are cached:

SettingValue
Cache TTL1 hour
Refresh interval1 minute minimum
Refresh triggerUnknown key ID

After successful SSO:

{
"access_token": "eyJhbGciOiJFUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"user": {
"id": "user_abc123",
"email": "alice@company.com",
"username": "alice",
"roles": ["operator"]
}
}

Refresh token is set as HTTP-only cookie:

Set-Cookie: mantis_refresh_token=xxx; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=2592000

Specify where to redirect after login:

Terminal window
curl -X POST \
-H "Content-Type: application/json" \
-d '{"redirect_url": "https://lens.mantis.local/deployments"}' \
"https://api.mantis.local/api/v1/auth/oidc/$IDP_ID/login"

Redirect URLs are validated against an allowlist:

mandible.toml
[oidc]
allowed_redirect_urls = [
"https://lens.mantis.local",
"https://lens.mantis.local/*"
]

The login page retrieves available SSO providers:

Terminal window
curl "https://api.mantis.local/api/v1/auth/oidc/providers"

Response:

[
{
"id": "idp_abc123",
"name": "Okta",
"provider_type": "oidc"
},
{
"id": "idp_def456",
"name": "Azure AD",
"provider_type": "oidc"
}
]
ErrorCauseSolution
Invalid stateCSRF attempt or expiredRestart login flow
Nonce mismatchReplay attack or bugRestart login flow
Token expiredID token too oldCheck server clock sync
Invalid audienceWrong client_idVerify IdP configuration
Provider disabledIdP not enabledEnable provider

IdP may return errors in callback:

GET /callback?error=access_denied&error_description=User%20denied%20access

These are returned to the client:

{
"error": "IdP returned error: access_denied - User denied access"
}

SSO endpoints have rate limiting:

EndpointLimitWindow
/auth/oidc/{id}/login10 requests1 minute
/auth/oidc/callback10 requests1 minute

Configuration:

mandible.toml
[rate_limit.auth]
enabled = true
login_attempts_per_minute = 10
lockout_duration_secs = 900

SSO events are logged:

{
"event": "auth.sso_login",
"category": "authentication",
"severity": "security",
"actor": {
"user_id": 42,
"username": "alice"
},
"metadata": {
"provider_id": 1,
"provider_name": "Okta",
"external_user_id": "00u1234567890",
"created": false
},
"outcome": "success"
}
  1. Check callback URL matches exactly in IdP
  2. Verify external_url is configured
  3. Check state parameter validity window
  1. Verify auto_create_users is enabled
  2. Check email claim is present in ID token
  3. Verify default role is assigned
  1. Verify groups scope is requested
  2. Check groups_claim name matches IdP
  3. Verify role mappings are configured
  1. Check server clock is synchronized
  2. Verify issuer URL matches exactly
  3. Check JWKS endpoint is accessible