Skip to content

Portal Sign In Routing Developer Guide

Ed Mozley edited this page Oct 1, 2026 · 1 revision

Portal sign-in routing - Developer Guide

Shipped in 2.10.0 Β· Built for #147 Β· User-facing pages: Single Sign-On and LDAP & Active Directory

How the self-service portal decides where to send someone who types their email, and the three 2.10.0 changes that touched it:

  • routing by email domain, with the option to hide a provider's button;
  • the sign-out addresses shown on the settings page;
  • Unlink in Tickets β†’ Users.

They are one subject: each one either feeds the router or undoes what it remembers. If you are about to change any of the files below, read The traps first.


Why this exists

Benjamin's request in #147 was simple: on the portal, show only an email box, and send @hiscompany.com straight to Microsoft 365.

Most of that was already there. The portal had an email-first box, and it routed people it already knew:

  • someone linked to a provider by an earlier sign-in;
  • on a multi-company install, someone whose company owns a provider.

The gap was the first visit on a single-company install. A new member of staff typed their email, pressed Continue, and got a password box for an account that has no password. They had to know to click the "Sign in with Microsoft" button underneath instead.

The #156 write-up had already rejected one fix for this: "if nothing matches, fall back to the global provider". That would silently move every password user to SSO the moment someone added a provider. So the design rule here is: nothing changes until an administrator types a domain in.


The decision, in order

api/auth/resolve_login.php answers one question: this email typed into this login page - where should it go? It returns one of three answers: sso (with a provider), choose (a short list), or local (show the password box).

For the portal it now checks four things, in this order:

# Check Answer
1 Is the account already linked to an enabled OIDC provider? sso to that provider
2 New: is the account linked to nothing, and is the email's domain listed in a provider's Email domains? sso to that provider
3 Multi-company only: does the domain belong to a company that owns providers? sso (1 provider) or choose (2+)
4 None of the above local

The analyst login only ever runs check 1. Analysts are enrolled one by one or through their team (Team sign-in guide), never by domain.

Step 2 in the code

// api/auth/resolve_login.php
} elseif ($portal === 'self-service') {
    require_once '../../includes/sso_identity.php';
    $pinned = $conn->prepare("SELECT 1 FROM users WHERE LOWER(email) = ? AND auth_provider_id IS NOT NULL LIMIT 1");
    $pinned->execute([$email]);
    $byDomain = $pinned->fetchColumn() ? null : ssoPortalProviderForEmail($conn, $email);
    if ($byDomain) {
        $resp = ['mode' => 'sso', 'provider_id' => $byDomain['id'], 'provider_name' => $byDomain['display_name']];
    }

    // (3) Otherwise, on a multi-tenant install, route by company
    require_once '../../includes/tenancy.php';
    if (!$byDomain && isMultiTenant($conn)) {
        ...

In plain English: if this person is already tied to any provider, the domain list is not consulted at all. Only someone tied to nothing gets routed by domain. The first trap explains why "any" matters.

The lookup

// includes/sso_identity.php
function ssoPortalProviderForEmail(PDO $conn, string $email): ?array {
    if (!ssoPortalRoutingColumnsReady($conn)) return null;
    $at = strrpos($email, '@');
    if ($at === false) return null;               // a bare username has no domain
    $domain = strtolower(trim(substr($email, $at + 1)));
    ...
    $rows = $conn->query(
        "SELECT id, display_name, portal_email_domains FROM auth_providers
          WHERE enabled = 1 AND protocol = 'oidc'
            AND portal_email_domains IS NOT NULL AND portal_email_domains <> ''
          ORDER BY sort_order, display_name"
    )->fetchAll(PDO::FETCH_ASSOC);
    foreach ($rows as $r) {
        if (in_array($domain, explode("\n", $r['portal_email_domains']), true)) {
            return ['id' => (int)$r['id'], 'display_name' => $r['display_name']];
        }
    }
    return null;
}

Three things this deliberately does:

  • Whole domains only. company.com does not match uk.company.com. A suffix match would quietly route addresses nobody listed, so a subdomain needs its own line.
  • OIDC only. An LDAP directory has nowhere to redirect to. Directory users type their password into the ordinary box (LDAP guide).
  • A bare username skips it. The portal accepts usernames for directory staff with no mailbox, and a username has no domain.

A provider has at most a handful of domains and an install a handful of providers, so it reads them and matches in PHP rather than building a LIKE pattern. ORDER BY is only a tie-break that can't happen in practice, because the save refuses a domain claimed twice.


The data

Two columns on auth_providers, in both database/freeitsm.sql and includes/db_verify_schema.php:

`portal_email_domains`   TEXT NULL,                        -- one domain per line, lowercase; NULL = no routing
`portal_show_button`     TINYINT(1) NOT NULL DEFAULT 1,    -- show "Sign in with ..." on the PORTAL login

DEFAULT 1 is the upgrade promise: every button stays where it was until someone unticks it.

TEXT and newline-separated, the same convention as sync_ou_includes and carddav_scope_value. It is a setting belonging to one provider, never joined to, so a child table would buy nothing.


Saving the settings

api/system/save_sso_provider.php takes portal_email_domains and portal_show_button from the provider dialog.

Parsing what the admin typed

// includes/sso_identity.php
function ssoParseEmailDomains($value): array {
    // Lines, commas and semicolons - not spaces, so a typo such as "not a domain"
    // is quoted back whole in the error rather than as three fragments.
    $parts = is_array($value) ? $value : preg_split('/[\r\n,;]+/', (string)$value);
    ...
        $d = strtolower(trim((string)$part));
        $d = ltrim($d, '@');
        if (strlen($d) > 253 || !preg_match('/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9-]{2,63}$/', $d)) {
            $invalid[] = trim((string)$part);
    ...
    return ['domains' => array_keys($domains), 'invalid' => $invalid];
}

@Company.com, company.com becomes one entry, company.com. Anything that isn't a domain is refused with what was typed, rather than dropped. A silently dropped line is a domain the admin believes is routed and isn't.

Two refusals, checked on the final value

// api/system/save_sso_provider.php - after the "keep what is stored" step
if (ssoPortalRoutingColumnsReady($conn)) {
    $finalDomains = (string)($vals[array_search('portal_email_domains', $cols, true)] ?? '');
    ...
        foreach ($finalDomains as $d) {
            if (isFreemailDomain($conn, $d)) {
                bail("$d is a public email domain, so it cannot be sent to one provider - everyone who uses it would be.");
            }
        }
        // One provider per domain
        $others = $conn->prepare("SELECT display_name, portal_email_domains FROM auth_providers
                                   WHERE id <> ? AND portal_email_domains IS NOT NULL AND portal_email_domains <> ''");
        ...
                bail(implode(', ', $clash) . ' is already sent to ' . $o['display_name'] . '. A domain can only go to one provider.');
  • Public email domains use the same list System β†’ Companies uses (isFreemailDomain()), including any an admin has added. Mapping gmail.com to one provider would send every Gmail user on the portal to it.
  • One provider per domain. Otherwise which one wins would be decided by sort order, and nobody looking at the screen could see why.

Why "the final value"? This endpoint keeps a stored setting when a screen doesn't send it, so renaming a provider doesn't wipe its other settings (see the long comment in the file). The checks run after that step, so a save that never mentions the domains is still held to the same rules.

LDAP and CardDAV saves always store NULL and 1. They have no redirect and no button.


The portal page

self-service/login.php decides two things before it draws anything: which buttons to show, and whether to show the email-first box at all ($ssoActive).

$portalRouting = ssoPortalRoutingColumnsReady($ssoConn);
$ssoProviders = $ssoConn->query("SELECT id, display_name FROM auth_providers WHERE enabled = 1 AND tenant_id IS NULL AND protocol = 'oidc'"
    . ($portalRouting ? ' AND portal_show_button = 1' : '')
    . " ORDER BY sort_order, display_name")->fetchAll(PDO::FETCH_ASSOC);

$routerNeeded = $portalRouting && (int)$ssoConn->query(
    "SELECT COUNT(*) FROM auth_providers WHERE enabled = 1 AND protocol = 'oidc'
        AND (tenant_id IS NULL OR (portal_email_domains IS NOT NULL AND portal_email_domains <> ''))"
)->fetchColumn() > 0;
...
$ssoActive = $ssoOn && ($multiTenant || !empty($ssoProviders) || $routerNeeded);

Before 2.10.0, $ssoActive was $ssoOn && ($multiTenant || !empty($ssoProviders)): the box appeared because there were buttons. With buttons now optional that link had to be cut, which is the second trap.

Company-owned providers still never get a button on a multi-company portal. That is the privacy rule from #156 and is unchanged.

The analyst login (auth/login.php) does not read portal_show_button. It still shows every enabled global OIDC provider.


Sign-out addresses

Single logout sends the browser to the provider's end_session_endpoint with a post_logout_redirect_uri, so that signing out of FreeITSM also signs out of the provider. Keycloak and Okta refuse that address unless it is registered exactly.

The two sign-outs ask for different addresses:

// auth/analyst_logout.php
$postLogout = $scheme . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . BASE_URL;

// self-service/logout.php
$postLogout = $scheme . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . BASE_URL . 'self-service/login.php';

Until 2.10.0 the Keycloak guide listed only the first, and the settings page showed neither. So anyone who followed the guide got Keycloak's "Invalid redirect uri" page when they signed out of the portal. System β†’ Single Sign-On now shows both under the redirect URI, each with a Copy button, built the same way:

// system/sso/index.php (and again in system/help/sso.php)
$signoutAnalystUri = $scheme . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . BASE_URL;
$signoutPortalUri  = $signoutAnalystUri . 'self-service/login.php';

All three addresses (sign-in, and the two sign-outs) are built from the host the browser used. Moving an install from http://localhost/freeitsm-app/ to an https name changes all three, and the provider has to be told the new ones. That is exactly how this gap was found.


Unlink

What the router remembers lives in two places for a portal user:

Where Means
users.auth_provider_id "this account signs in through provider N". Check 1 above reads it, and strict isolation enforces it.
user_sso_identities the provider's own id for them (the sub claim, or a directory GUID)

api/tickets/unlink_user_signin.php clears both, in one transaction, and logs it:

$conn->beginTransaction();
$del = $conn->prepare("DELETE FROM user_sso_identities WHERE user_id = ?");
$del->execute([$id]);
$conn->prepare("UPDATE users SET auth_provider_id = NULL WHERE id = ?")->execute([$id]);
$conn->prepare("INSERT INTO system_logs (log_type, analyst_id, details, created_datetime) VALUES ('signin_unlinked', ?, ?, UTC_TIMESTAMP())")
     ->execute([(int)$_SESSION['analyst_id'], json_encode([... 'provider_name' => ..., 'identities_removed' => $del->rowCount()])]);
$conn->commit();

Afterwards the person is a first-time requester again. The email box no longer sends them to that provider. If they sign in through a provider anyway, the portal's auto-claim links them again by verified email. Their tickets and details are never touched.

The guards, in order:

  1. requireModuleAccessJson('tickets');
  2. analystCanAccessUser(), answering "User not found" for someone in a company the analyst can't reach, the same wording as delete_user.php. A scoped analyst must not be able to tell "not yours" from "not there";
  3. refused for is_managed people (see the fifth trap);
  4. refused with "not linked" when there is nothing to remove.

On screen, tickets/users.php adds the link only to the Signs in with line, which is shown when a person is linked but not managed. A managed person's line says Details from and has no Unlink.


Files

πŸ—„οΈ schema Β· πŸ”€ routing Β· ✏️ write Β· πŸ–₯️ UI Β· ❓ help Β· πŸ§ͺ Feature Bingo

🎨 File What it does here
πŸ—„οΈ database/freeitsm.sql portal_email_domains, portal_show_button for fresh installs
πŸ—„οΈ includes/db_verify_schema.php The same two for Database Verification
πŸ”€ includes/sso_identity.php ssoPortalRoutingColumnsReady(), ssoParseEmailDomains(), ssoPortalProviderForEmail()
πŸ”€ api/auth/resolve_login.php Step 2, portal only, skipped for anyone already linked
πŸ–₯️ self-service/login.php Hides unticked buttons; $routerNeeded keeps the email box
✏️ api/system/save_sso_provider.php Parses, refuses public and duplicate domains, keeps stored values
πŸ–₯️ api/system/get_sso_providers.php Returns both columns, or their defaults before verification
πŸ–₯️ system/sso/index.php Email domains + Show button in the provider dialog (OIDC only); the two sign-out addresses
✏️ api/tickets/unlink_user_signin.php New. Clears the link, logs signin_unlinked
πŸ–₯️ tickets/users.php Unlink beside Signs in with, with a confirm
❓ system/help/sso.php, system/help/_registry.php Portal: route by email domain section; troubleshooting for sign-out and unlinking
πŸ§ͺ includes/feature_bingo/cards/system-core.php system.sso_portal_email_domains
🌐 lang/en/system.php, lang/en/tickets.php New English strings

Not touched: api/auth/oidc_callback.php. Strict isolation, the auto-claim and JIT all work exactly as before. This change only decides where the browser is sent; what happens when it comes back is unchanged.


The traps

1. Anyone already linked skips the domain check

Check 1 only matches an account linked to an enabled OIDC provider. Two kinds of linked account fall straight through it:

  • someone linked to an LDAP directory (OIDC only, so no match);
  • someone linked to a provider that has since been switched off (enabled = 1 fails).

If step 2 then routed them by domain, they would land at Microsoft, sign in successfully, and be refused on the way back: the callback allows an account in only through the provider it is linked to. The person would be stranded, through no fault of their own. Hence auth_provider_id IS NOT NULL - any link - and not "an OIDC link".

2. Hiding buttons must never switch the email box off

The email box is the only way the router can help returning users. If the last visible button is hidden and $ssoActive turns false, the page falls back to the plain password form. Everyone linked to SSO is then asked for a password they don't have. $routerNeeded keeps the box whenever any global OIDC provider, or any provider with domains, is enabled.

3. New columns must not break an install that hasn't run Database Verification

Every upgraded install spends some time running new code against the old schema. A query that names a missing column fails, and several of these screens read a failed query as "nothing there". The provider list would say No providers yet, and the portal would lose its buttons. So everything asks ssoPortalRoutingColumnsReady() first:

  • the resolver skips step 2;
  • the portal page leaves portal_show_button out of its query;
  • the provider list selects NULL AS portal_email_domains, 1 AS portal_show_button;
  • the save leaves both columns out of its INSERT/UPDATE.

Before verification the portal behaves exactly as 2.9.0. Same pattern and same reason as ssoJitColumnsReady() in the auto-create guide.

4. The sign-out address is built in four places

auth/analyst_logout.php, self-service/logout.php, system/sso/index.php and system/help/sso.php each build it. Change where a sign-out lands, and change all four. If the pages stop matching, admins register an address the provider is never asked for, and sign-out breaks again with nothing in FreeITSM to show why.

5. No Unlink for people a directory keeps up to date

is_managed = 1 means a directory import or address book owns the record. Directory sync attaches its identity again on the next run (sync_on_conflict = 'adopt'), so unlinking here would appear to work and quietly undo itself. The endpoint refuses, and the page never offers it.

6. One provider per domain, and never a public one

Both are enforced on save, not at sign-in. If you add another way to write portal_email_domains (the REST API, an import), it must make the same two checks, or the resolver's "first match wins" becomes a guess.


How it was tested

Ed's development database holds real data, so the routing was tested on a throwaway copy:

  • a git worktree of the change, served by WAMP at its own address;
  • a new database loaded from database/freeitsm.sql;
  • a copy of db_config.php pointing the worktree at it.

All three were removed afterwards. Requests went through the real endpoints with curl and a forged admin session.

Area Cases Result
Resolver baseline (no domains) matches 2.9.0; first-time user routed; mixed case; unlinked password user routed; listed subdomain routed, unlisted subdomain not; LDAP-linked user not routed; user linked to another provider keeps it; other domain gets local; bare username local; analyst login never routed 10/10
Save malformed domain quoted back; gmail.com refused; @Company.com, company.com normalised; domain claimed by a second provider refused; rename without sending domains keeps them 5/5
Portal page one button hidden; both hidden with domains; both hidden, no domains (box stays); defaults identical to today; SSO off; local login off and ?local=1 6/6
Analyst login still shows both buttons with both hidden on the portal βœ…
Before Database Verification columns dropped: resolver, portal page, provider list, save and settings page all as 2.9.0, no warnings; verification then adds both columns with buttons defaulting to shown βœ…
Dialog (headless Chrome) fields filled from the provider; shown for OIDC, hidden for LDAP and CardDAV; defaults for a new provider; saved through the page's own Save βœ…
Sign-out Keycloak accepts the portal address once registered; an unregistered address is still refused (the control) βœ…
Unlink through the endpoint: linked β†’ unlinked and logged; again β†’ "not linked"; directory-managed β†’ refused, nothing written; unknown id; no session. Through the page: confirm wording, success message, row gone; a managed person shows no Unlink. On a temporary user, since deleted. βœ…
Existing suites sso-dangling-link 12/12, oidc-discovery 27/27, web-exposure-guard 12/12, config-not-load-bearing 13/13; Feature Bingo 589 cards, 0 malformed pass

Not tested here: a complete round trip through Microsoft Entra's own sign-in screen. On Ed's install, a real Keycloak sign-in from the Choose how to sign in list linked the account (auth_provider_id and a user_sso_identities row both written). The resolver then returned sso to Keycloak for that address instead of the list.

⚠️ Two test-rig lessons worth keeping:

  • When checking whether a page scrolls, don't call window.scrollTo(). A script can scroll a page that has overflow: hidden, even though a mouse wheel can't, so the test passes on a broken page.
  • On a multi-company install get_users.php follows the session's company. A forged session without active_tenant_id sees nobody in another company, and the page looks empty for a reason that has nothing to do with the code under test.

Related

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally