Skip to main content
Version: 0.9

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

CapabilityStatus
OIDC sign-inConfigurable per deployment
SAML 2.0 sign-inConfigurable per deployment
Group to role mapping (admin UI)Configurable per deployment
Stable identity across email renameAlways on once SSO is configured
Cross-provider account mergeControlled by global/per-provider allow auto-link policy
auth_method = 'sso' accountsAlways on once SSO is configured
License-gated SSO settings UIAlways on
Audit log of every login and syncAlways 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.

  1. Set ADMIN_EMAIL / ADMIN_PASSWORD in .env.
  2. Start the stack. The Core bootstrap creates the admin user with auth_method='local'.
  3. Sign in, open System Settings → Manage SSO providers to configure SSO.
  4. Optionally remove ADMIN_PASSWORD from .env and 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.

  1. Set DISABLE_ADMIN_BOOTSTRAP=true (Helm: config.disableAdminBootstrap).
  2. Bootstrap SSO providers via Helm ssoProviders[], Compose sso-providers.json, and/or System Settings → Manage SSO providers before the first admin signs in. Changing bootstrap manifest or SSO_<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.
  3. Add a row under System Settings → Single Sign-On (or set config.bootstrap_admin_groups on the provider row) for a group that the first administrator belongs to. The first SSO login from that user creates the account with is_admin=TRUE.
  4. 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.

MechanismWhat it syncs
Manage SSO providers saveBumps 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 bootstrapssoProviders[] 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 storeOIDC state, nonce, and PKCE code_verifier (shared across pods).
Postgres saml_request_cacheSAML 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:

  1. System admin: users.is_admin = TRUE. Granted by an idp_group -> role='admin' mapping (workspace_id NULL) or config.bootstrap_admin_groups on the provider row. Sets installation-wide access only; it does not insert a workspace admin membership. Never revoked automatically on mapping removal. Revoke from Workspaces → Members → System admin (or DB).
  2. Global non-admin: idp_group -> role='workspace_manager' or 'viewer' with workspace_id NULL. Expands at login time to a membership in every workspace, including ones created after the mapping. New workspaces auto-inherit.
  3. Workspace-scoped: idp_group -> role with a specific workspace_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:

Situationusers.is_adminDefault workspace membership
SSO admin mapping (global)TRUEworkspace_manager (join existing Default)
Bootstrap / first creator on empty installTRUE (if mapped)admin (creates Default)
Second system admin (toggle in Workspaces)TRUEunchanged (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_method is set to 'sso' on first SSO login (from a local account) and never flips afterwards. SSO users have password_hash = 'SSO_NO_PASSWORD'.
  • external_identities: one row per (provider, subject_id) pair. Stores email_at_link (raw IdP-asserted email, original casing), display_name_at_link, and last_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_link on the next login; the underlying user is still keyed by subject_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 with sso_account_exists_subject_mismatch and an admin has to merge the accounts manually.

See Identity model for the full lifecycle.

Login UX

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, or sso_reload_warnings after save).
  • local_account_exists_auto_link_disabled: email matches an existing auth_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 assert email_verified=true.
  • userinfo_sub_mismatch: the OIDC UserInfo response carried a different sub than 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/providers and related provider CRUD (HTTP 403 / code: "ENTITLEMENT_MISSING").
  • The /api/admin/sso-mappings CRUD 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: saml or oidc.
  • 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 a sso_group_role_mappings row.
  • matched_groups_count: scalar of the above for dashboards.
  • is_admin_resolved: whether the current login matched a global role='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_admin after resolution and user refresh (use to confirm promotion reached the session source row).
  • admin_mapping_matched: same boolean as is_admin_resolved; true when an IdP group matched a global admin mapping on this login.
  • admin_granted: whether users.is_admin flipped from false to true during this login (applied.adminGranted).
  • configured_admin_idp_groups: admin mapping values configured for this provider (from sso_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.