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

Redirect URI to register with the provider

Section titled “Redirect URI to register with the provider”

Mandible completes the authorization-code exchange itself, so the redirect URI you register with the identity provider is Mandible’s callback — derived from external_url:

<external_url>/api/v1/auth/oidc/callback

It must match exactly, including scheme and any port. The web UI does not receive the authorization code and must not be registered here.

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 (fixed — not configurable; there is no config key or env var for the auth-state TTL)
  • Single-use (consumed on callback)
  • Automatically cleaned up

Two things are required before a first-time SSO login can provision an account:

  1. auto_create_users is enabled on the provider. It is off unless you set it, so the first login against a new provider is refused until you do.
  2. The provider is bound to a tenant. A system-wide provider (no tenant_id) may link an existing user, but it will not create a brand-new one — a tenant-less account carries global scope, so minting one on every external login would hand out cross-tenant visibility. Set tenant_id on the provider to enable provisioning.

With both in place:

  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 = false — provisioned SSO users are always created unverified; the IdP’s email_verified claim is not propagated to the record
    • 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

redirect_url on the login request is validated against the allowlist and stored on the auth state. After the callback completes the exchange, Mandible redirects the browser to that URL, so it is where the user lands once logged in. If it is omitted, the browser is sent to the application root.

Because the redirect is what returns the user to the app, the URL must pass the allowlist. In release builds neither allow_any_redirect nor allow_localhost_redirect defaults on, so allowed_redirect_urls (or allowed_redirect_domains) has to include the application origin or login fails at initiation.

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]
# Entries are matched by scheme + host + port and a path PREFIX. A literal "*"
# is just a path segment and matches nothing useful; a root URL already permits
# every path on that origin, so the second line below is redundant, not a wildcard.
allowed_redirect_urls = [
"https://lens.mantis.local"
]
# For wildcard hosts, use allowed_redirect_domains (matched by domain suffix):
allowed_redirect_domains = [
"*.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"
}

The auth routes share a single rate limiter keyed by client IP. It counts failed authentication attempts only (successful calls are not counted): after 10 failed attempts per minute the IP is locked out for 900 seconds. The limit is shared across the auth routes, not a separate per-endpoint budget.

ScopeTriggerEffect
Auth routes (per client IP)10 failed attempts/min900s lockout

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 and the provider is bound to a tenant
  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