Single Sign On - edmozley/freeitsm GitHub Wiki
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.
-
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
analyststable 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.
- The login page offers an email-first box (type your email β routed to your provider) plus a "Sign in with β¦" button per enabled provider.
- FreeITSM redirects the browser to the provider using the Authorization Code flow with PKCE (S256), carrying a
state(CSRF protection) andnonce(replay protection). - The user authenticates at the IdP (which also handles its own MFA).
- 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. - 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.
Go to System β Single Sign-On.
- 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.
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.
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.
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.
On a successful sign-in, FreeITSM resolves the analyst in this order:
-
Existing identity link β a stored link between the provider and the IdP's
sub(subject) claim. - 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.
- 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.
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.
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 profileand 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.
- An explicit
email_verified: falseclaim 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.
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.
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.
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.
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).
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.
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.
- 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=1and 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.
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.
- Authorization Code + PKCE (S256), with
stateandnoncevalidation. - 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.
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).
Requester mapping mirrors the analyst flow one layer down, with one deliberate difference:
-
Existing identity link β a stored link between the provider and the IdP's
sub(subject) claim. - Email match β a requester whose email matches the token's (the email must be verified at the IdP).
- 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.
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.
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.comdoes not coveruk.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=1still 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.
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.
These mirror the analyst SSO tables one layer down:
-
user_sso_identitiesβ links a requester (usersrow) to their identity at a provider (thesubclaim), unique per(provider, subject). -
users.auth_provider_idβ which provider a requester is assigned to (NULL= local password / unassigned).
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.
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.
The analyst login only ever shows global providers (tenant_id IS NULL) β a client's IdP never appears there.
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.
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).
| 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.
-
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) andprofile_sync_mode(never/initial/always). -
analyst_sso_identitiesβ links an analyst to their identity at a provider (thesubclaim), 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_idthen 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, orproviderwith 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 (default1). 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.
- β³ Setting up SSO with Keycloak (Docker) β full worked example
- Security β the wider authentication, encryption and brute-force defences
- System β other system-administration settings