Single Sign On - edmozley/freeitsm GitHub Wiki

Single Sign-On (SSO / OIDC)

FreeITSM supports single sign-on via OpenID Connect (OIDC), letting analysts β€” and self-service portal requesters β€” sign in through an external identity provider (IdP) instead of β€” or alongside β€” a local username and password.

It's a generic OIDC implementation: one code path works with any compliant provider, including Keycloak, Microsoft Entra ID (Azure AD), Okta, Auth0, Google Workspace and Authentik. You can configure several providers at once, which makes phased rollouts and side-by-side pilots straightforward (e.g. most users on Keycloak while a group trials Entra).

Worked example: see ↳ Setting up SSO with Keycloak (Docker) for a complete, copy-paste walkthrough using a local Keycloak instance.

Key ideas

  • Driven by discovery. Everything hangs off the provider's discovery document at <issuer>/.well-known/openid-configuration. You only supply a display name, issuer URL, client ID and client secret β€” the authorization, token, JWKS and logout endpoints are all read from discovery. That's why one form works for every IdP.
  • Local login stays. SSO sits next to local username/password login, which remains as a break-glass fallback. You can never be locked out by a misconfigured or offline IdP.
  • The analysts table is the identity store. An SSO login is mapped to an analyst row; permissions, ticket assignment, teams etc. all continue to work exactly as for local users.

How a sign-in works

  1. The login page offers an email-first box (type your email β†’ routed to your provider) plus a "Sign in with …" button per enabled provider.
  2. FreeITSM redirects the browser to the provider using the Authorization Code flow with PKCE (S256), carrying a state (CSRF protection) and nonce (replay protection).
  3. The user authenticates at the IdP (which also handles its own MFA).
  4. The provider redirects back to …/api/auth/oidc_callback.php. FreeITSM exchanges the code for tokens over a server-to-server back channel, then validates the ID token: signature against the provider's JWKS, plus issuer, audience, nonce and expiry.
  5. The identity is mapped to an analyst (see below), the session is established, and the user lands in the app.

JWT/JWKS validation uses the vendored firebase/php-jwt library (includes/vendor/firebase-jwt/) β€” the security-critical token checking is never hand-rolled.

Configuring a provider

Go to System β†’ Single Sign-On.

Global settings

  • Enable single sign-on β€” the master switch. Turning it off instantly reverts everyone to local login (break-glass kill switch).
  • Allow local login β€” keep the local username/password form available. Leave on unless you want an SSO-only experience.

Redirect URI

The page shows the exact redirect URI to register in your IdP (e.g. http://localhost/freeitsm-app/api/auth/oidc_callback.php). Copy it into the provider's valid redirect URIs list β€” it's where the IdP sends users back after they authenticate.

Sign-out addresses

Under it (since 2.10.0) are the two sign-out addresses: …/freeitsm-app/ for the analyst login and …/freeitsm-app/self-service/login.php for the portal. Register both as allowed post-logout redirect URLs (Keycloak: Valid post logout redirect URIs; Okta: Sign-out redirect URIs). Single logout sends people to the provider's sign-out page with one of these as the way back, and Keycloak and Okta refuse an address that isn't listed exactly, ending on an "Invalid redirect uri" page. The portal address was missing from the setup guides until 2.10.0.

All three addresses are built from the address you browse FreeITSM on. If you move it, say from http://localhost/freeitsm-app/ to an https name, register the new ones as well.

Adding a provider

Click Add and fill in:

Field What it is
Display name The label on the login button, e.g. Sign in with Keycloak
Issuer URL The provider's base URL (see Issuer URLs below). The Test button validates it by fetching the discovery document.
Client ID The client/application identifier created in the provider
Client secret The client's secret β€” stored encrypted (AES-256-GCM) and never returned to the browser
Scopes OIDC scopes; default openid email profile
Enabled Show this provider's button on the login page
Profile attribute sync Whether name, job title, department, office and phones are filled in from the provider - Never (default), on account creation, or Always. See Profile sync
Auto-create portal requesters (JIT) Create a self-service portal account the first time a customer signs in on the portal
Auto-create IT analysts (JIT) Create an analyst the first time someone signs in on the analyst login (see below)
Default module access for auto-created analysts Shown while analyst auto-create is on. Modules new analysts get - blank means full access, so set this for pilots
Non-analyst sign-in action Shown while analyst auto-create is off. What happens when someone with no analyst account signs in on the analyst login - see below

The two auto-create switches, the non-analyst action and profile sync were added in 2.10.0 (discussion #155), contributed by Santhosh Srinivasan. Before that one "Auto-create users" switch meant "create an analyst" on the analyst login and "create a requester" on the portal. On upgrade, Database Verification copies the old switch into both, so nothing changes until you choose. How it works underneath: SSO auto-create and profile sync - Developer Guide.

Account mapping & provisioning

On a successful sign-in, FreeITSM resolves the analyst in this order:

  1. Existing identity link β€” a stored link between the provider and the IdP's sub (subject) claim.
  2. Email match β€” an active analyst whose email matches the token's (the email must be verified at the IdP). The identity is then linked for next time.
  3. Just-in-time (JIT) provisioning β€” if the provider has Auto-create IT analysts enabled, a new analyst is created (with an unusable local password), assigned to that provider, and granted the provider's default modules.

If none apply, the provider's non-analyst sign-in action decides what happens next.

Someone with no analyst account signs in on the analyst login

With one company sign-in for everybody, customers end up on the analyst login all the time. For an OIDC provider with Auto-create IT analysts off, choose:

Action What the person sees
Warn and confirm (default for a new provider) A page saying they have no analyst account, with Continue (to the self-service portal) and Cancel. Nothing is created unless they press Continue. The offer lasts 10 minutes.
Automatically switch They go straight to the portal, exactly as if they had signed in there.
Block (what upgraded providers are set to) An error on the analyst login - the behaviour before 2.10.0.

The portal then applies its own rules: if Auto-create portal requesters is off and they have no portal account, the portal refuses them with its usual message.

LDAP providers don't have this setting - a directory sign-in is a password form, so there is no redirect to fall back from.

Profile sync

An OIDC provider can keep people's name, job title, department, office, phone and mobile in step with the identity provider:

Setting What happens
Never (default) Only the sign-in details are used. People edit their own profile.
On account creation The fields are filled in when an account is auto-created. After that, local edits stick.
Always The fields are updated at every sign-in, so they are read-only in the portal's My Account and the analyst's Preferences β†’ My details, with a note saying they come from the identity provider.
  • A portal user's own preferred name is never overwritten.
  • Nothing is written until the person has passed every sign-in check - a provider that is about to be refused cannot change anyone's details.
  • Job title and department are claims, not scopes: keep Scopes at openid email profile and add the claims in the IdP. Microsoft Entra ID needs them as optional claims on the ID token (its userinfo endpoint only returns the basic profile). The in-app help (System β†’ Authentication β†’ help, Profile sync) has the steps for Entra, Okta and Keycloak.
  • LDAP and CardDAV are unaffected - they keep details up to date through directory sync and address-book write-back.

Email verification

  • An explicit email_verified: false claim from the IdP is always refused.
  • Providers that omit the claim entirely are accepted by default, so sign-in works out of the box β€” Okta's org authorization server never sends it, while Keycloak and Entra do.
  • A per-provider Require a verified-email claim toggle (off by default) lets admins demand an explicit email_verified: true β€” useful for IdPs where users can self-register with unverified addresses.

Strict isolation

An analyst may only sign in via the provider they're assigned to (analysts.auth_provider_id). An SSO login can never silently take over a local account or an account assigned to a different provider. This keeps parallel pilots cleanly separated.

Companies for auto-created analysts (3.5.0)

On a multi-company install, analysts.can_access_all_tenants defaults to 1, so every analyst an OIDC or LDAP provider auto-created could see every company. Each provider now has Company access for auto-created analysts (auth_providers.jit_company_access):

Value A new analyst can see
all (default) Every company, as before. Upgrading changes nothing.
provider Only the provider's own company (auth_providers.tenant_id), or the Default company for a global provider.
none Nothing until an administrator grants companies.

It's applied only when the account is created (ssoApplyJitCompanies() in includes/sso_identity.php, called from oidcCreateAnalyst() and ldapCreateAnalyst()). If the write fails halfway, it fails closed: the analyst sees no company rather than every company.

Groups from the identity provider (3.5.0)

Setting it up? In the app, System β†’ Help β†’ Setting up groups and teams from single sign-on (system/help/sso-groups.php) walks through it as a checklist, with tabs for Microsoft Entra, Okta and Keycloak. It's also linked from the provider dialog's Groups and teams tab.

An OIDC sign-in carries claims - facts about the person - and most identity providers can include their groups or roles. The provider dialog's Groups and teams tab uses them at every analyst sign-in, so staff access is managed in the identity provider and FreeITSM follows. Nothing is typed as a code: groups are chosen from the ones the provider has sent, and what a group gives comes from a team.

Setting Stored in What it does
Where the groups come from auth_providers.oidc_group_claim groups, roles, realm_access.roles (Keycloak) or another claim name. A dotted path walks nested claims; a claim whose own name has dots (a URL) is matched whole first. Setting it also makes FreeITSM read the userinfo endpoint. Nothing below happens while it's unset.
Analyst group oidc_analyst_group Only members may sign in as, and be auto-created as, analysts. An existing analyst who has left it is refused and their account isn't changed. This is LDAP's ldap_analyst_group rule.
Administrator group oidc_admin_group Members are administrators; non-members are not. The last active administrator is never demoted. Empty means admins are managed by hand.
Teams sso_group_teams (provider, group, team) At each sign-in, the analyst is added to every mapped team whose group they're in and removed from mapped teams whose group they've left. Unmapped teams are never touched. The team decides modules, companies and roles as usual.
Groups seen sso_groups_seen (+ label), oidc_groups_last_seen Every group the provider has sent, each with a name the admin can give it. Microsoft Entra sends object IDs, so naming 78d21822-… "FreeITSM Staff" makes every dropdown read "FreeITSM Staff". The group boxes are dropdowns of these, plus Type a group… for one not seen yet (warned).

New analysts: when a provider has team rows and no default modules, the analysts it creates start with no modules of their own (ssoJitStartForTeams()), so the teams decide. Their companies follow Company access for auto-created analysts (below). Set it to None when teams give companies.

Group names match ignoring case in any alphabet (mb_strtolower). LDAP's ldapInGroup() got the same fix: strcasecmp() only folds A-Z.

Code: includes/sso_claims.php (ssoClaimValues, ssoInGroup, ssoSaveTeamMappings, ssoApplyGroupAccess, ssoRememberGroups, ssoJitStartForTeams), wired into api/auth/oidc_callback.php. The provider and its team rows save in one transaction (api/system/save_sso_provider.php). Tests: tests/sso-claim-mapping.php.

The provider dialog is near full-screen with tabs (Connection Β· Signing in Β· Groups and teams Β· Portal and company) for OIDC. LDAP and CardDAV sources show everything on one page.

Assigning existing users to a provider

JIT only assigns brand-new users. To move existing analysts onto SSO, open System β†’ Analysts, edit the analyst, and set Sign-in method to the provider β€” or to Follow team once their team has one (see below). On their next SSO sign-in their identity is linked automatically (matched by email).

Setting the method on a team

Added for #41. Rather than setting every analyst by hand, set Sign-in method once on the team in System β†’ Teams. Analysts whose own Sign-in method is Follow team take it β€” and Follow team is the default for every analyst added from now on, so a new starter picks up the right method just by being put in their team. Analysts who already existed keep exactly what they had until you change them.

The analyst's teams What happens
One team sets a method, or several set the same one They use it, and move automatically when the team's method changes
Their teams set different methods Nothing changes β€” they keep their current method. Their row on System β†’ Analysts shows a Sign-in conflict badge (click it for a picture of which team says what and the ways to fix it), each team shows N sign-in conflicts, and you settle it by choosing a method on the analyst
None of their teams sets one They keep what they had

Only active teams count. After any save that moves people β€” a team's method, its members, switching it off, deleting it β€” a message says how many analysts changed and how many are waiting for a choice.

Two teams never mean two ways in. Strict isolation is unchanged: an analyst still has exactly one sign-in method. A priority order between teams was considered and rejected β€” it would be invisible, and moving someone between teams would one day change how they sign in without anyone meaning it to.

How it works underneath

The team's method is copied into analysts.auth_provider_id whenever something that could change it is saved (includes/analyst_signin.php). Everything that enforces sign-in β€” the email-first router, the password form, the OIDC callback and the LDAP bind β€” reads that one column, exactly as before, and was not changed. Working the team default out at sign-in time instead would have meant teaching all four about teams, and a mistake in any one of them would be a way round strict isolation.

The price is that every place that can change the answer has to recalculate it: saving an analyst, an analyst's teams, a team's members (before and after, so people removed are included), a team's method or active flag, deleting a team, and deleting a provider. The full account, with the code, is in the developer guide.

Break-glass & SSO-only mode

  • When SSO is active the login page leads with email-first + provider buttons; the local form is tucked behind a "Sign in with a local account" link.
  • Setting Allow local login to off hides that link too (a clean SSO-only look), but local auth is never hard-disabled: the URL …/login.php?local=1 and the master kill switch always restore local login, so no one can be locked out.
  • MFA: SSO users authenticate (and do MFA) at the identity provider, so FreeITSM skips its own local TOTP step for them.

Single logout

Signing out of FreeITSM also ends the session at the identity provider via its end_session_endpoint, so the user isn't silently re-authenticated on the next visit.

Security notes

  • Authorization Code + PKCE (S256), with state and nonce validation.
  • ID-token signature verified against the provider's JWKS; issuer, audience, nonce and expiry all checked.
  • Client secrets encrypted at rest and never sent to the browser (the config UI shows only whether a secret is stored).
  • The email-resolver endpoint returns "local" for unknown emails, so it doesn't reveal whether an account exists.

Self-service portal SSO

SSO isn't just for analysts β€” the same providers can sign requesters into the self-service portal too. The portal login page gets the identical experience as the analyst login: an email-first box (type your email β†’ routed to your provider), a "Sign in with …" button per enabled provider, and a local-account break-glass. Turning Allow local login off β€” and the ?local=1 URL β€” behave exactly as on the analyst login.

Under the hood there's still one OIDC flow and one callback, made portal-aware: oidc_login.php, oidc_callback.php and resolve_login.php carry a portal=self-service flag (stashed in the session so the single IdP redirect URI serves both portals). On the way back, the callback resolves against the requester users table β€” not analysts β€” sets the self-service session, and lands the user in the portal (self-service/index.php).

Account mapping for requesters

Requester mapping mirrors the analyst flow one layer down, with one deliberate difference:

  1. Existing identity link β€” a stored link between the provider and the IdP's sub (subject) claim.
  2. Email match β€” a requester whose email matches the token's (the email must be verified at the IdP).
  3. Just-in-time (JIT) provisioning β€” if the provider has Auto-create portal requesters enabled, a brand-new requester is created (passwordless) and assigned to the provider.

The difference: because requesters self-onboard (a ticket-created contact starts passwordless and unassigned), a requester matched by verified email who is still unassigned is auto-claimed onto the provider on first sign-in. Analysts, being privileged, still require explicit enrolment β€” they're never auto-claimed. An already-assigned requester is still strictly isolated to their own provider.

Seeing and undoing a requester's link

Tickets β†’ Users shows which provider a requester is linked to (Signs in with). Since 2.10.0 an Unlink beside it, after a confirm, clears both users.auth_provider_id and their user_sso_identities rows (api/tickets/unlink_user_signin.php). They are then back to a first-time requester: the email box stops routing them, and their next provider sign-in links them again (the auto-claim above). Tickets and details are untouched. Each unlink goes to system_logs as signin_unlinked with who did it. It is refused for people a directory import keeps up to date (is_managed), because the next sync would re-link them.

Sending portal users straight to SSO by email domain

Added in 2.10.0 for #147.

On its own, the portal's email box only routes people it already knows: someone linked to a provider by an earlier sign-in, or (multi-company) someone whose company owns a provider. On a single-company install, a first-timer who types [email protected] and presses Continue gets the password box, and has to know to click the provider button instead.

Two settings on each OpenID Connect provider (System β†’ Single Sign-On β†’ edit) change that:

Setting What it does Default
Email domains One per line, e.g. company.com. On the portal, an address on one of these domains goes straight to this provider, even the first time. Everyone else gets the password box as before. Blank: nobody is routed
Show the button on the portal login Untick for a page with just the email box and Continue Ticked

Set both and one box serves your staff (sent to the provider) and your outside customers (password), and nobody has to pick a button.

The rules:

  • An assigned account always wins. Anyone already assigned to a provider goes to that provider. Anyone assigned to an LDAP directory, or to a provider that has been switched off, is never routed by domain: the callback would refuse them, because strict isolation allows one way in.
  • It includes password users. An unassigned portal user on a listed domain is sent to the provider. On their first SSO sign-in their account is linked by verified email (the portal's usual auto-claim), so their tickets stay with them. Someone with no portal account needs Auto-create portal requesters on, or the portal refuses them after sign-in.
  • The whole domain only. company.com does not cover uk.company.com; that needs its own line.
  • One provider per domain, and never a public email domain (the same list System β†’ Companies protects, including any you have added). Saving refuses both and says why.
  • The portal only. The analyst login is unchanged and still shows every global provider's button. Analysts are assigned to a provider one by one or through their team.
  • Hiding buttons never switches the email box off. It stays while any global OIDC provider, or any provider with domains, is enabled. ?local=1 still brings back the password form.

This answers the case the #156 write-up declined: a silent "fall back to the global provider" would have moved every password user to SSO the moment one was added. Here nothing moves until an administrator types a domain in.

Underneath (full account, with the traps: Portal sign-in routing - Developer Guide): resolve_login.php (portal only) checks, in order: (1) an enabled OIDC pin; (2) not assigned to any provider, and the domain is listed on an enabled OIDC provider (ssoPortalProviderForEmail() in includes/sso_identity.php); (3) multi-company routing by company domain. self-service/login.php leaves out buttons whose portal_show_button = 0. Both columns are read only after ssoPortalRoutingColumnsReady(), so an upgraded install behaves exactly as before until Database Verification adds them.

Separate identity spaces

Analysts and requesters are separate identity spaces, so the same email resolves independently per portal β€” a person can be an analyst on one provider and a requester on another (or neither). Single logout works on the portal too: signing out of self-service also ends the session at the IdP.

Database (self-service layer)

These mirror the analyst SSO tables one layer down:

  • user_sso_identities β€” links a requester (users row) to their identity at a provider (the sub claim), unique per (provider, subject).
  • users.auth_provider_id β€” which provider a requester is assigned to (NULL = local password / unassigned).

Multi-company (MSP) SSO routing

On a multi-company (MSP) install, a provider can be owned by a client company (auth_providers.tenant_id; NULL = global / MSP-internal, serving every company). This lets each client company route its own requesters to its own IdP.

Email-first only on the portal

When more than one company exists, the portal login shows no company-owned provider buttons up front, because showing every client's "Sign in with …" button would leak every client's IdP to everyone. Global providers (tenant_id IS NULL) are still shown as buttons, exactly as on a single-company install β€” they serve every company and are already public on the analyst login, so they leak nothing (until 2.9.1 they were hidden too, which left a global provider unreachable from the portal β€” see A global SSO provider was missing from the portal). For a company's own IdP you type your email and the resolver routes by email domain β†’ company β†’ that company's enabled IdP(s), reusing the existing tenant_domains / tenant_sender_addresses routing (via a resolveTenantIdForAddress() helper). The company may have:

IdPs for the company What happens
0 Local password (fall through to the local form)
1 Routed straight through to that IdP
2+ A tenant-scoped picker β€” "Choose how to sign in" β€” for a company mid-migration between, say, Entra and Okta

A per-user pin (users.auth_provider_id) routes a returning requester straight to their provider, so the picker is a once-only first-login step.

Analyst login stays global

The analyst login only ever shows global providers (tenant_id IS NULL) β€” a client's IdP never appears there.

Invisible at N=1

This whole layer is gated on isMultiTenant(): a single-company install keeps the original buttons-up-front behaviour and is completely unaffected.

Note: this routes per company using each provider's own credentials (the working handshake β€” each client registers FreeITSM as a client app in their own IdP). The credential-free "MSP multi-tenant app + consent" onboarding model (one MSP-owned app that clients consent to, keyed on the token's tenant claim) is designed but not yet built β€” the design lives in docs/design/ in the repository.

Providers list: Company column

On a multi-company install, System β†’ Single Sign-On shows an extra Company column in the providers table β€” the client company a provider is pinned to, or Global for an MSP-internal / all-companies provider. It makes it obvious at a glance why (e.g.) a client's IdP doesn't appear on the analyst login. get_sso_providers.php returns each provider's tenant_name (LEFT JOIN tenants), and the edit dialog gains a Company dropdown to set the owner (get / save_sso_provider carry tenant_id).

The column is hidden at N=1 (single company), consistent with the rest of multi-tenancy. The issuer column truncates with an ellipsis and shows the full issuer URL on hover (a title tooltip).

Issuer URLs by provider

Provider Issuer URL pattern
Keycloak https://<host>/realms/<realm>
Microsoft Entra ID https://login.microsoftonline.com/<tenant-id>/v2.0
Okta https://<org>.okta.com (or an /oauth2/<server> auth server)
Auth0 https://<tenant>.<region>.auth0.com/
Google Workspace https://accounts.google.com
Authentik https://<host>/application/o/<app-slug>/

Provider-specific concepts (Entra tenant + app registration, Keycloak realm, Okta org) all collapse into the issuer URL plus the client ID/secret β€” there's nothing else provider-specific to enter.

Database

  • auth_providers β€” one row per configured IdP (display name, issuer, client ID, encrypted secret, scopes, enabled, default modules). Since 2.10.0: auto_create_users (portal JIT), auto_create_analysts (analyst JIT), analyst_fallback_mode (confirm / redirect / block) and profile_sync_mode (never / initial / always).
  • analyst_sso_identities β€” links an analyst to their identity at a provider (the sub claim), unique per (provider, subject).
  • analysts.auth_provider_id β€” which provider an analyst is assigned to (NULL = local password user).
  • analysts.auth_follow_team β€” 1 = take the method from their teams; auth_provider_id then holds the result. See Setting the method on a team.
  • teams.auth_method / teams.auth_provider_id β€” a team's default method: NULL = not set, local, or provider with the provider's id.
  • auth_providers.portal_email_domains / portal_show_button β€” portal routing by email domain (newline-separated, lowercase, NULL = none) and whether the provider's button shows on the portal login (default 1). See Sending portal users straight to SSO.
  • auth_providers.tenant_id β€” on a multi-company install, the client company that owns a provider (NULL = global / MSP-internal). See Multi-company (MSP) SSO routing.
  • user_sso_identities / users.auth_provider_id β€” the self-service requester equivalents of the two analyst tables above. See Self-service portal SSO.

Related

⚠️ **GitHub.com Fallback** ⚠️