Skip to content

External Identities

Manage links between Mantis users and external identity provider accounts.

External identities link Mantis user accounts to identity provider accounts, enabling SSO authentication and user lifecycle management.

FieldTypeDescription
idUUIDUnique identifier
user_idUUIDLinked Mantis user
provider_idUUIDIdentity provider reference
external_user_idStringUser ID at IdP (sub claim)
external_usernameString?Username at IdP
external_emailString?Email at IdP
access_token_encryptedString?Encrypted access token
refresh_token_encryptedString?Encrypted refresh token
token_expires_atDateTime?Token expiration
last_login_atDateTime?Last SSO login via this link
created_atDateTimeLink creation time

A single Mantis user can have multiple external identities:

This allows users to:

  • Login via different IdPs
  • Migrate between IdPs
  • Use personal and work accounts

User details include linked external identities:

Terminal window
curl -H "Authorization: Bearer $TOKEN" \
"https://api.mantis.local/api/v1/admin/users/$USER_ID"

Response:

{
"id": "019b937d-f30f-8946-ab14-f85563b8c7e4",
"email": "alice@company.com",
"username": "alice",
"external_identities": [
{
"provider_id": "019b937d-e394-85ca-a36e-f75e92a53aff",
"provider_name": "Okta",
"external_username": "alice.smith",
"external_email": "alice.smith@company.com",
"last_login_at": "2024-01-15T10:30:00Z"
},
{
"provider_id": "019b937d-1d8f-8d66-a6cc-5f5f84ac1d10",
"provider_name": "Azure AD",
"external_username": "asmith",
"external_email": "asmith@company.onmicrosoft.com",
"last_login_at": "2024-01-10T08:15:00Z"
}
]
}
SELECT
ei.id,
u.email AS mantis_email,
ip.name AS provider_name,
ei.external_username,
ei.external_email,
ei.last_login_at
FROM external_identities ei
JOIN users u ON u.id = ei.user_id
JOIN identity_providers ip ON ip.id = ei.provider_id
WHERE u.email = 'alice@company.com';

When a user logs in via SSO, Mantis checks for an existing user by email — but linking to that existing account is not automatic. It requires all four of the following, and otherwise fails closed with a 403. They are checked in this order, and the first one to fail is the one you are told about:

  1. the provider has auto_create_users enabled — otherwise 403 User auto-creation is disabled for this provider, before the address is looked up at all,
  2. the existing account is in the provider’s own tenant — an address held outside it is refused with 403 Email is already associated with an account outside this provider's tenant,
  3. the provider has allow_email_linking = true, and
  4. the ID token asserts email_verified: true.

Conditions 3 and 4 failing gives 403 Email is already associated with another account, naming which of the two was the blocker.

:::caution System-wide providers cannot provision A system-wide (NULL-tenant) provider never creates a new user. Provisioning a NULL-tenant account on every external login would silently grant cross-tenant visibility, so only a tenant-scoped provider can create one. A system-wide IdP can still authenticate an already-linked user, and can link to an existing account by email — the user’s tenant is already fixed in that case. :::

Currently, external identity links are created automatically during SSO. To manually create a link:

INSERT INTO external_identities (
id,
user_id,
provider_id,
external_user_id,
external_username,
external_email,
created_at
) VALUES (
gen_random_uuid(),
(SELECT id FROM users WHERE email = 'alice@company.com'),
(SELECT id FROM identity_providers WHERE name = 'Okta'),
'okta_external_sub_id',
'alice.smith',
'alice.smith@company.com',
NOW()
);
  • User leaving organization
  • Migrating to different IdP
  • Security incident response
  • Account cleanup

The account’s links, with each provider_id, are in GET /api/v1/admin/users/$USER_ID under external_identities. Removing one needs users:update and is audited as user.external_identity_unlinked. It also revokes the roles the provider’s group mappings granted, in either role sync mode, and leaves roles granted by hand; that needs roles:manage and the authority to confer each of them, and is audited as user.roles_removed:

Terminal window
curl -X DELETE \
-H "Authorization: Bearer $TOKEN" \
"$API_URL/api/v1/admin/users/$USER_ID/external-identities/$PROVIDER_ID"

IdP tokens are stored encrypted for potential future use:

TokenPurpose
access_tokenAPI calls to IdP (user info, etc.)
refresh_tokenObtaining new access tokens

Tokens are encrypted using AES-256-GCM with provider-specific AAD:

let aad = format!("external_identity:{}:{}", provider_id, external_user_id);
let encrypted = encryptor.encrypt_string(token, &aad)?;

On each SSO login, tokens are updated:

UPDATE external_identities
SET
access_token_encrypted = $new_access_token,
refresh_token_encrypted = $new_refresh_token,
token_expires_at = $expiration,
last_login_at = NOW(),
updated_at = NOW()
WHERE id = $identity_id;
External ID "ext123" → User "alice"
New login with "ext123" → Always maps to "alice"

External identity links are unique per provider:

UNIQUE (provider_id, external_user_id)
User "alice@company.com" ← External ID "ext123" (Okta)
User "alice@company.com" ← External ID "ext456" (Azure AD)

Both link to the same Mantis user only where that provider permits it: linking an incoming identity to an existing account by email needs auto_create_users and allow_email_linking on the provider and email_verified: true in the ID token. Without all three the second provider’s login fails closed with 403 Email is already associated with another account — see Automatic Linking by Email.

ScenarioBehavior
Existing external_id + same userUpdate tokens
Existing external_id + different userUse original link
New external_id + existing emailLink only if auto_create_users + allow_email_linking + token email_verified; else 403
New external_id + new emailCreate new user (if auto_create and tenant-scoped provider)

last_login_at tracks the most recent SSO login via each provider:

SELECT
ip.name,
ei.last_login_at,
NOW() - ei.last_login_at AS time_since_login
FROM external_identities ei
JOIN identity_providers ip ON ip.id = ei.provider_id
WHERE ei.user_id = $user_id
ORDER BY ei.last_login_at DESC;

Find identities not used recently:

SELECT
u.email,
ip.name AS provider,
ei.last_login_at
FROM external_identities ei
JOIN users u ON u.id = ei.user_id
JOIN identity_providers ip ON ip.id = ei.provider_id
WHERE ei.last_login_at < NOW() - INTERVAL '90 days'
ORDER BY ei.last_login_at;
  1. User assigned to IdP groups
  2. First SSO login creates:
    • Mantis user (if auto_create enabled)
    • External identity link
    • Role assignments (from mappings)
  1. Remove user from IdP groups
  2. Disable IdP account
  3. (Optional) Disable Mantis account
  4. (Optional) Remove external identity links
  1. Configure new IdP
  2. Users login via new IdP
  3. New external identity links created automatically
  4. Old links remain (can use both IdPs)
  5. (Optional) Remove old links after migration complete

External identity events are logged:

When a new external identity link is created during SSO, Mantis emits a single event covering both the new user/link creation and the resulting login:

{
"event": "auth.sso_user_created",
"category": "authentication",
"severity": "security",
"actor": {
"user_id": "...",
"username": "alice"
},
"resource": {
"type": "user",
"id": "..."
},
"metadata": {
"provider_id": "...",
"provider_name": "Okta",
"external_user_id": "00u1234567890",
"created": true
},
"outcome": "success"
}

Subsequent logins via an existing external identity link emit:

{
"event": "auth.sso_login",
"category": "authentication",
"severity": "security",
"actor": {
"user_id": "...",
"username": "alice"
},
"resource": {
"type": "user",
"id": "..."
},
"metadata": {
"provider_id": "019b937d-4862-8001-a001-000000000001",
"provider_name": "Okta",
"external_user_id": "00u1234567890",
"created": false
},
"outcome": "success"
}
CREATE TABLE external_identities (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider_id UUID NOT NULL REFERENCES identity_providers(id) ON DELETE CASCADE,
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 DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP,
UNIQUE (provider_id, external_user_id)
);
CREATE INDEX idx_external_identities_user_id ON external_identities(user_id);
CREATE INDEX idx_external_identities_provider_id ON external_identities(provider_id);
CREATE INDEX idx_external_identities_external_user_id ON external_identities(external_user_id);
  1. Check external identity exists:

    SELECT * FROM external_identities
    WHERE external_user_id = 'sub_from_idp'
    AND provider_id = $provider_id;
  2. Verify linked user is active:

    SELECT u.status FROM users u
    JOIN external_identities ei ON ei.user_id = u.id
    WHERE ei.external_user_id = 'sub_from_idp';
  3. Check provider is enabled:

    SELECT is_enabled FROM identity_providers WHERE id = $provider_id;

This occurs when:

  • External ID was already linked to different user
  • Email changed at IdP

Resolution:

Terminal window
curl -X DELETE \
-H "Authorization: Bearer $TOKEN" \
"$API_URL/api/v1/admin/users/$USER_ID/external-identities/$PROVIDER_ID"

The next login through that provider links the account again only when the provider has auto_create_users and allow_email_linking set and the IdP asserts the email as verified. Otherwise that login is refused, or, where no account has the asserted email, provisions a new one. Unlinking also revokes the roles the provider’s group mappings granted, in either role sync mode.

Occurs when:

  • Email not returned in ID token
  • Different email at IdP than Mantis

Prevention:

  • Ensure email scope is requested
  • Configure correct email claim

Resolution:

  • Merge users manually
  • Update external identity links
Error: Failed to decrypt client secret
  1. Verify encryption key hasn’t changed
  2. Check AAD format matches
  3. Re-link identity if key rotated
  1. Audit links regularly - Review inactive links
  2. Monitor login patterns - Detect anomalies
  3. Prompt token rotation - On security events
  4. Limit link age - Consider expiring old links
  1. Document linking behavior for support team
  2. Test migrations before switching IdPs
  3. Monitor link creation for unexpected patterns
  4. Clean up unused external identities
  1. Clear error messages for link issues
  2. Support multiple IdPs per user when needed
  3. Communicate changes when migrating IdPs