Authentication model
This document covers the additions TokenTimer Enterprise makes to the Core authentication model. The Core admin bootstrap, invitation flow, local password rules, and CSRF behaviour are unchanged and documented in the Core authentication reference shipped alongside the delivery.
What enterprise adds
| Capability | Status |
|---|---|
| OIDC sign-in | Configurable per deployment |
| SAML 2.0 sign-in | Configurable per deployment |
| Group to role mapping (admin UI) | Configurable per deployment |
| Stable identity across email rename | Always on once SSO is configured |
| Cross-provider account merge | Controlled by global/per-provider allow auto-link policy |
auth_method = 'sso' accounts | Always on once SSO is configured |
| License-gated SSO settings UI | Always on |
| Audit log of every login and sync | Always on |
OSS local password sign-in (LOCAL_AUTH_ENABLED=true) still works in
parallel and can be disabled per deployment.
Admin bootstrap with SSO
Two boot modes are supported.
Mode 1: Local admin first, SSO second (default)
This is the recommended mode. You keep emergency access through a locally-managed admin password even if the IdP is down.
- Set
ADMIN_EMAIL/ADMIN_PASSWORDin.env. - Start the stack. The Core bootstrap creates the admin user with
auth_method='local'. - Sign in, open System Settings → Manage SSO providers to configure SSO.
- Optionally remove
ADMIN_PASSWORDfrom.envand restart. The account stays usable; you can reset it later via DB if needed.
Mode 2: SSO only
When all administrators are managed via the IdP and you want no local fallback at all.
- Set
DISABLE_ADMIN_BOOTSTRAP=true(Helm:config.disableAdminBootstrap). - Bootstrap SSO providers via Helm
ssoProviders[], Composesso-providers.json, and/or System Settings → Manage SSO providers before the first admin signs in. Changing bootstrap manifest orSSO_<SLUG>_*env vars requires a rolling restart of all API pods (or a save under Manage SSO providers after deploy) so every replica loads the same IdP material. - Add a row under System Settings → Single Sign-On (or set
config.bootstrap_admin_groupson the provider row) for a group that the first administrator belongs to. The first SSO login from that user creates the account withis_admin=TRUE. - Once logged in, define the rest of your role mappings (workspace managers, viewers, scoped roles) via System Settings → Single Sign-On. The bootstrap shortcut from step 3 can stay for safety, or be removed if your IaC re-applies it on rebuilds.
DISABLE_ADMIN_BOOTSTRAP=true also relaxes the ADMIN_EMAIL requirement
in the Helm chart so the install does not block on it.
SSO configuration and high availability
TokenTimer Enterprise supports multiple API replicas behind a load
balancer for SSO. Saves under Manage SSO providers sync across API replicas via
sso_config_version (multi_provider_sso_v1 migration; not the removed
legacy single-provider schema). SAML InResponseTo uses Postgres
(saml_request_cache), not ingress stickiness.
| Mechanism | What it syncs |
|---|---|
| Manage SSO providers save | Bumps system_settings.sso_config_version (multi_provider_sso_v1 migration). Every API pod reloads SAML/OIDC strategies within SSO_CONFIG_SYNC_INTERVAL_MS (default 45s) or on the next /auth/oidc/<slug> / /auth/saml/<slug> request. |
| Helm / Compose bootstrap | ssoProviders[] manifest and SSO_<SLUG>_* secret env vars on each pod at process start. Changing values in GitOps requires a rolling restart of all API replicas, or one post-deploy save under Manage SSO providers. |
| Postgres session store | OIDC state, nonce, and PKCE code_verifier (shared across pods). |
Postgres saml_request_cache | SAML InResponseTo correlation (shared across pods via multi_provider_sso_v1; ingress stickiness not required; per-provider cache TTL in provider security config, default 3600). |
Sign-in readiness: the login page shows SAML/OIDC only when
/api/auth/features reports signInReady (strategy registered on that pod),
not merely when fields exist in the database. Use status badges and Test under Manage SSO providers
(or GET /api/admin/sso/oidc/diagnostics?slug=<slug> with an admin session) when troubleshooting.
Full env-var tables and IdP guides: Configuration reference, SSO identity provider guides.
Role-based access control
The Core role model separates system admin from workspace roles. Enterprise drives assignment from IdP groups through three layers:
- System admin:
users.is_admin = TRUE. Granted by anidp_group -> role='admin'mapping (workspace_id NULL) orconfig.bootstrap_admin_groupson the provider row. Sets installation-wide access only; it does not insert a workspaceadminmembership. Never revoked automatically on mapping removal. Revoke from Workspaces → Members → System admin (or DB). - Global non-admin:
idp_group -> role='workspace_manager'or'viewer'withworkspace_idNULL. Expands at login time to a membership in every workspace, including ones created after the mapping. New workspaces auto-inherit. - Workspace-scoped:
idp_group -> rolewith a specificworkspace_id. Targets one workspace.
When a user matches several workspace mappings, the highest role wins
per workspace (workspace_manager beats viewer). The SSO admin role is
not a workspace role: it never participates in that comparison.
Default workspace after SSO login
After applyResolution, the OIDC/SAML callback runs Core
ensureInitialWorkspaceForUser. For the shared Default workspace:
| Situation | users.is_admin | Default workspace membership |
|---|---|---|
SSO admin mapping (global) | TRUE | workspace_manager (join existing Default) |
| Bootstrap / first creator on empty install | TRUE (if mapped) | admin (creates Default) |
| Second system admin (toggle in Workspaces) | TRUE | unchanged (usually workspace_manager) |
So an IdP Admin mapping gives System Settings access, not workspace owner on Default. The bootstrap admin or first provisioner remains the workspace owner unless you change membership outside the product. Multiple workspace owners per workspace (co-owner promote/demote from the Members tab) are planned for a future Core release.
Manual memberships (source='manual') are never overwritten or revoked by SSO.
System-admin expansion (manual toggle or SSO admin grant) adds missing
workspace_manager rows on every workspace and may raise viewer to
workspace_manager where needed. It never downgrades an existing workspace
admin membership and never revokes manual memberships.
See Group mappings for the admin guide.
Identity model
Enterprise stores SSO users in two tables:
users: the same row Core has always had.auth_methodis set to'sso'on first SSO login (from alocalaccount) and never flips afterwards. SSO users havepassword_hash = 'SSO_NO_PASSWORD'.external_identities: one row per(provider, subject_id)pair. Storesemail_at_link(raw IdP-asserted email, original casing),display_name_at_link, andlast_seen_at. A single TokenTimer user can have many rows (one per IdP they sign in through).
This means:
- A user whose corporate email changes (e.g. marriage, company rebrand)
keeps their tokens, audit history, and workspace memberships. The
IdP rotates
email_at_linkon the next login; the underlying user is still keyed bysubject_id. - A user with both SAML (Okta) and OIDC (Azure) accounts asserting the
same email is only merged on the first cross-provider login when
allow auto-link is enabled (global System Settings → Single Sign-On or
per-provider override) AND the IdP confirmed email
ownership (OIDC
email_verified=true; SAML always counts because the assertion is cert-signed). Otherwise the second-provider login is refused withsso_account_exists_subject_mismatchand an admin has to merge the accounts manually.
See Identity model for the full lifecycle.
Login UX
Deep links
Both /auth/saml/<slug>?next=/dashboard/tokens/123 and
/auth/oidc/<slug>?next=/dashboard/tokens/123 land the user on the requested
path after a successful flow. The path is sanitised:
- Must start with
/and not//. - Must not contain a URL scheme.
- Max 512 chars.
Anything else falls back to /dashboard. SAML carries the path
through RelayState; OIDC stashes it on the session.
Failure paths
When SSO fails (signature mismatch, expired assertion, license invalid,
network error), the API redirects the browser to
/login?error=<reason> and writes a LOGIN_FAILED audit row with the
underlying reason and request path. Reasons include:
saml_failed/oidc_failed: generic strategy callback error.oidc_not_ready: OIDC fields are saved but the strategy is not registered on this API pod (check JWKS, discovery reachability, orsso_reload_warningsafter save).local_account_exists_auto_link_disabled: email matches an existingauth_method='local'user and allow auto-link is off (global System Settings → Single Sign-On, or per-provider override).sso_account_exists_subject_mismatch: email matches an existing SSO user already linked to a different(provider, subject_id)on the same provider. Resolved by an admin merge or relink.cross_provider_auto_link_disabled: email matches an SSO user linked to a different provider and allow auto-link is off (global System Settings → Single Sign-On, or per-provider override). Sign in with the original provider or enable auto-link.email_not_verified_for_link: email-fallback link refused because the OIDC IdP did not assertemail_verified=true.userinfo_sub_mismatch: the OIDC UserInfo response carried a differentsubthan the ID Token. The login is hard-refused per OIDC Core 5.3.2.license_invalid: license missing, invalid, or past grace.
License gating
The feature:oidc and feature:saml entitlements gate:
- The SSO Providers section of the admin dashboard (hidden when absent).
PUT /api/admin/sso/providersand related provider CRUD (HTTP 403 /code: "ENTITLEMENT_MISSING").- The
/api/admin/sso-mappingsCRUD endpoints (same 403). - The login UI hides the SSO buttons when no SSO entitlement is available.
When the license lacks feature:oidc or feature:saml, the login UI hides
that provider type and admin writes to matching providers return HTTP 403.
Bootstrap fields can still register a strategy if present, but operators should
align license entitlements with the providers they enable.
Audit log
Every SSO login emits a structured LOGIN_SUCCESS audit row with:
method:samloroidc.new_user,link_created,previous_auth_method: identity lifecycle deltas.subject_id: the IdP subject used as the stable identifier.idp_groups_seen: the number of groups asserted by the IdP.idp_groups_sample: up to 20 raw group values parsed from the assertion (useful when debugging Entra object IDs vs display names).matched_groups: the lowercased array of IdP groups that matched asso_group_role_mappingsrow.matched_groups_count: scalar of the above for dashboards.is_admin_resolved: whether the current login matched a globalrole='admin'mapping (resolution.isAdmin).effective_system_admin: whether the user is treated as a system admin for this login (current mapping match or durable SSO grant /is_admin_manual).is_admin_after_login:users.is_adminafter resolution and user refresh (use to confirm promotion reached the session source row).admin_mapping_matched: same boolean asis_admin_resolved;truewhen an IdP group matched a global admin mapping on this login.admin_granted: whetherusers.is_adminflipped from false to true during this login (applied.adminGranted).configured_admin_idp_groups: admin mapping values configured for this provider (fromsso_group_role_mappings, global admin rows only).workspace_grants_count/workspace_revocations_count: number of membership changes applied.
Each applied membership change additionally emits one
MEMBERSHIP_SYNCED_FROM_SSO row with change (created /
role_changed / revoked), previous_role (on revoke), and the same
matched_groups.
LOGIN_FAILED rows are written for every callback that does not
produce a session, including license failures.