SSO identity model
Audience: TokenTimer Enterprise system administrators and operators who need to understand how SSO identity is tracked across providers and over time.
This document explains how SSO users are linked to the internal
users table, what happens when emails change in the IdP, and how
local-auth and SSO accounts coexist.
Tables
users external_identities
+------+----------+----------+ +---------+----------------+--------+--------------+
| id | email | auth_method <--- | user_id | provider_slug | subject_id |
+------+----------+----------+ +---------+----------------+--------+--------------+
UNIQUE (provider_slug, subject_id)
ON DELETE CASCADE -> users
external_identities.(provider_slug, subject_id)is the canonical identity key.users.idis the internal account.users.emailis profile data, not identity. It can change.users.auth_methodstarts aslocalorssoand becomesssowhen a local account first links successfully to SSO. It is not updated on every login and does not flip between providers; the source of truth for which IdPs a user has linked is theexternal_identitiestable.
A single user can hold multiple external_identities rows: one per
provider slug they have linked. We never duplicate (provider_slug, subject_id) (UNIQUE), so the same IdP subject can only point at one
TokenTimer user.
users.auth_method semantics
| Scenario | auth_method after the event |
|---|---|
| Local account created via admin bootstrap / invite. | local |
First SSO login from a local account (linked). | sso (set once, then sticky) |
| First SSO login creating a brand-new account. | sso |
| Second SSO login via a different provider. | sso (no change) |
We deliberately do not store the protocol enum (saml /
oidc) on users.auth_method because a user with both providers
linked would otherwise flip the column on every login and lose
historical signal. Read external_identities.provider_slug and
external_identities.protocol instead.
Login resolution algorithm
For every SSO callback:
1. Extract subject_id from the configured claim path. OIDC binds the
subject to the ID Token `sub` (UserInfo claims are only merged in
when the UserInfo `sub` matches the ID Token `sub`).
2. SELECT FROM external_identities
WHERE provider_slug = $1 AND subject_id = $2.
HIT -> return that user_id. Update last_seen_at.
MISS -> step 3.
3. Fall back to email match:
SELECT FROM users WHERE LOWER(email) = LOWER($1).
HIT -> the email-fallback link is only written when ALL of:
- allowAutoLink (SSO_ALLOW_AUTO_LINK_LOCAL_ACCOUNTS) is true, AND
- the IdP confirmed mailbox ownership (SAML: always, the
assertion is cert-signed; OIDC: email_verified=true), AND
- the matched user is not already linked to a DIFFERENT subject
under the same provider.
Otherwise the resolver refuses with one of:
- local_account_exists_auto_link_disabled
(existing local-password account, opt-in missing)
- cross_provider_auto_link_disabled
(account linked to another provider, opt-in missing)
- sso_account_exists_subject_mismatch
(different subject already linked under the same provider)
- email_not_verified_for_link
(OIDC IdP did not assert email_verified=true)
MISS -> step 4.
4. Create a new user + new external_identities row in the same TX.
last_seen_at is bumped on every successful resolution so admins can see
the freshness of each link.
Email rename
Bob's IdP email changes from bob@old.example to bob@new.example.
| Time | What happens |
|---|---|
| T0 | Bob has users.id = 42, email = bob@old.example, external_identities (keycloak, sub-42). |
| T1 | Admin renames Bob's email in the IdP. The IdP keeps emitting sub = sub-42. |
| T2 | Bob logs in. The resolver looks up (keycloak, sub-42), hits, resolves user_id=42. Login succeeds. |
| T3 | TokenTimer updates users.email = bob@new.example from the new email claim. |
No new account is created. Tokens, workspace memberships, and audit history follow Bob. Keying on a stable subject (rather than email) is what makes this transparent.
Two providers, same user (cross-provider merge)
Alice has Okta SAML and Azure OIDC, both emitting alice@example.com.
Cross-provider merge runs through the email-fallback path, so it
requires the admin to opt in via SSO_ALLOW_AUTO_LINK_LOCAL_ACCOUNTS=true.
| Time | What happens |
|---|---|
| T0 | Alice has no TokenTimer account. |
| T1 | Alice logs in via Okta SAML. Resolver: (okta, sub-okta-A) miss, email miss, create user_id=10, link (okta, sub-okta-A) -> 10. |
| T2 | Alice logs in via Entra OIDC. Resolver: (entra, sub-entra-A) miss, email hit on user_id=10. With auto-link enabled AND email_verified=true the resolver links (entra, sub-entra-A) -> 10. With auto-link disabled the login is refused with cross_provider_auto_link_disabled; an administrator must review and enable the linking policy before retrying. |
| T3 | When the link succeeds, Alice has one TokenTimer account with two external_identities rows. |
Both providers can then be used interchangeably. Logout invalidates only the TokenTimer session, not the IdP session.
Local account collision
Alice already has a local-password TokenTimer account
alice@example.com. The org now rolls out Okta SAML.
Default behaviour (SSO_ALLOW_AUTO_LINK_LOCAL_ACCOUNTS=false):
- Alice clicks "Sign in with SAML".
- Resolver: subject miss, then email hit with
auth_method = 'local'; auditlocal_account_exists_auto_link_disabled; refuse login. - Alice sees the login error directing her to the admin.
Why default-deny? Because if Alice's local password is compromised, a malicious IdP login would silently merge into her account. Default-deny forces an admin decision.
Resolution paths:
- Enable allow auto-link globally under System Settings → Single Sign-On, or override per provider under Manage SSO providers → Advanced. Env equivalent:
SSO_ALLOW_AUTO_LINK_LOCAL_ACCOUNTS=true. Alice's next SSO login attaches her IdP subject to the existing local account. - Preserve the existing account and review the linking policy with a system administrator; deleting it is not a migration procedure.
Audit trail
| Action | When |
|---|---|
LOGIN_SUCCESS | After a successful resolution. Metadata includes method (saml or oidc), subject_id, new_user, link_created, previous_auth_method, idp_groups_seen, matched_groups (array), matched_groups_count, admin_granted, workspace_grants_count, workspace_revocations_count. |
LOGIN_FAILED | Failure with structured reason: callback_error, no_user, session_login_error, local_account_exists_auto_link_disabled, cross_provider_auto_link_disabled, sso_account_exists_subject_mismatch, email_not_verified_for_link, userinfo_sub_mismatch, missing_email. |
MEMBERSHIP_SYNCED_FROM_SSO | When group resolution actually changes membership (see Group mappings). |
The external_identities row itself records the raw IdP-asserted
email in email_at_link (original casing preserved for forensic
correlation with IdP logs), display_name_at_link, and last_seen_at
(bumped on every login, regardless of whether the match came from the
subject or the email fallback path).
FAQ
Can I manually link two existing accounts into one?
Not today. The auto-link path handles cross-provider matching for new
sign-ins only when SSO_ALLOW_AUTO_LINK_LOCAL_ACCOUNTS=true and the
IdP confirmed email ownership. There is no manual "merge accounts" UI for administrators.
Can I disable subject-based identity entirely?
A blank subject path selects defaults. OIDC always prefers the validated ID Token sub; it cannot be changed into email-only identity by clearing that field. For SAML, use a stable NameID and review claim paths before a provider migration.
Can I have multiple SAML IdPs in one deployment?
Yes. Add one provider row per IdP with a unique slug. Each slug gets its own sign-in button, callback URL, and group mappings.