Documentation

Single Sign-On (SSO)

How a tenant's guests log in with the identity provider their own organization already runs — real OIDC and SAML, auto-provisioned accounts, and the security work behind the login flow. Last updated 1 October 2026.

1

What this is, and who it's for

SSO is a second way to run internal accounts — the named, permanent, non-voucher accounts a tenant normally adds by hand for staff, students, or residents. Instead of a tenant admin creating every account one at a time, a guest signs in with the login they already have at their own institution (their school's Microsoft/Entra ID, their company's Google Workspace, an Okta tenant, a Shibboleth/InCommon federation member, or any other standards-compliant identity provider), and the matching ISN Free WiFi account is created automatically the first time they do.

This is aimed squarely at organizations that already run their own identity provider — universities, schools, corporate offices, hospitals, anywhere with an existing IT department and an existing directory of who's allowed in. It is not a replacement for vouchers, data plans, or open guest access, which remain the right fit for anonymous, one-off, or walk-in guests. Nobody's password is ever stored on this platform for an SSO-provisioned account — every sign-in is verified live against the tenant's own identity provider, so offboarding someone at the source locks them out here automatically, with nothing for the tenant to remember to do on this end.

2

Supported protocols

Both protocols are implemented with real, standards-compliant libraries, not a hand-rolled approximation:

ProtocolLibraryWhat it covers in this codebase
OIDC (OpenID Connect)openid-clientFull Authorization Code flow with PKCE (S256), server-side state and nonce generation and validation, and live discovery of the identity provider's own /.well-known/openid-configuration — the issuer URL a tenant enters is never guessed at, it's resolved the same way any real OIDC relying party does.
SAML 2.0samlify (with @authenio/samlify-node-xmllint for schema validation)A real Service Provider built per tenant domain, validated against the tenant's configured Identity Provider metadata. wantAssertionsSigned is set true — the IdP's response must be cryptographically signed and is checked against the tenant's own uploaded X.509 certificate, which is what actually stops a forged or tampered assertion. authnRequestsSigned is deliberately left false, which is normal, spec-compliant SAML SP behavior once the SP is already known to the IdP via metadata exchange — it does not weaken the security property that matters here.

A tenant picks exactly one protocol at a time per tenant (a "Protocol" selector, OIDC or SAML) — there's no mixing both for the same institution.

3

Setting it up — the admin side

Configured from the tenant dashboard under Free access → Single Sign-On. The fields, exactly as they appear:

ItemDetails
Enable SSOA checkbox; SSO is otherwise off.
ProtocolOIDC or SAML.
Username attributeWhich claim/attribute to use as the account's username (placeholder suggests email for OIDC, eduPersonPrincipalName for SAML — either can be overridden).
Restrict to email domain(s)Optional, comma-separated (e.g. yourschool.ac.za, students.yourschool.ac.za). Leave blank to accept any identity the IdP itself authenticates.

OIDC settings (shown when OIDC is selected): Issuer URL (e.g. https://login.microsoftonline.com/<tenant>/v2.0), Client ID, and Client secret (a password-style field; leaving it blank on a later save keeps whatever secret is already stored, the same convention used for every other stored secret on this platform). The dashboard displays the exact Redirect URI to give the identity-provider admin, generated from the tenant's own verified domain — the tenant never has to construct this by hand.

SAML settings (shown when SAML is selected): IdP Entity ID, IdP SSO URL, and the IdP signing certificate (X.509, pasted as PEM text). The dashboard displays the SP Entity ID / metadata and the ACS (reply) URL to give back to the identity-provider admin — also published live at /sso/saml/metadata on the tenant's own domain as real SAML SP metadata XML.

Every client secret and the whole OIDC/SAML config round-trip through the same encrypted-secret pattern used elsewhere on this platform (AES-256-GCM at rest via the payment vault, decrypted only in-memory at the moment it's used) — not a plaintext column.

4

The guest login flow, end to end

Concretely, for a guest signing in at their own home institution's captive portal (OIDC shown; SAML follows the equivalent redirect-based shape):

  • The guest reaches the login page and chooses the institution sign-in option. GET /api/sso/status is what tells the login page whether to show that option at all, and which protocol to use — it's a public, unauthenticated, deliberately minimal endpoint that reveals only "SSO is on" and "it's OIDC or SAML," nothing about the IdP configuration itself.
  • GET /sso/oidc/login looks up the tenant's stored OIDC config, runs live OIDC discovery against the tenant's issuer URL, generates a PKCE code verifier/challenge, a state, and a nonce, stores them server-side keyed by that state (a 10-minute TTL, pruned on every new request), and redirects the guest's browser to the identity provider's own real login page.
  • The guest authenticates directly with their own institution — ISN Free WiFi never sees their password, because the guest never types it here.
  • The identity provider redirects back to GET /sso/oidc/callback with an authorization code and the original state. The server looks up the matching pending entry, exchanges the code for tokens (validating PKCE, state, and nonce in the process), and reads the configured username attribute (and email, for the domain check) out of the returned claims.
  • If an email-domain allowlist is configured, the claimed email/username's domain must match it or the sign-in is rejected (logged as sso_domain_rejected).
  • _ssoProvisionOrFindAccount either finds the guest's existing tenant_internal_accounts row (by tenant + lowercased username) or creates one on the spot, using the tenant's configured auto-provision defaults (see panel 3 above — data cap, duration, device limit, speed caps, FUP override). The new account is marked added_via: 'sso' and profile_completed_at is set immediately, since a real IdP sign-in already proves identity to a stronger degree than this platform's own manual profile-completion step.
  • A single-use handoff token is minted (sso_login_handoffs, 5-minute expiry) and the browser is redirected to /sso/complete?token=... on the tenant's own canonical domain — an absolute URL, never a relative one, specifically so this can't be bounced onto the platform's bare marketing domain instead of the tenant's own captive.
  • /sso/complete serves a small page that immediately POSTs that token to /api/sso/complete, which atomically consumes it, re-validates the account is still active, checks the device-concurrency limit, and calls the exact same grantInternalAccountAccessIfApplicable function a password-based internal-account login uses — SSO doesn't fork into a separate, less-tested grant path. On success it sets the same session cookie an ordinary login sets and the guest is online.

The redirect-token bridge (rather than handing back a JSON grant response directly from the IdP callback) exists because the callback is a server-to-server-style redirect chain (browser → IdP → server) with nowhere for the captive portal's own page JavaScript to directly receive a result — the one-time token is what gets the outcome back into the page without ever putting the guest's identity or grant details in a URL or browser history entry that could be replayed or screenshotted.

5

How this fits into Fair Usage Policy (FUP)

An SSO-provisioned account is a regular tenant_internal_accounts row under the hood — not a separate record type — so every internal-account mechanism applies to it unmodified, FUP included. A tenant can set its own FUP override for everything SSO auto-provisions from now on (Throttle after (GB), Throttled download (Mbps), Throttled upload (Mbps) — blank on any of these falls back to the tenant-wide fair usage policy under Free access → Owner & guest access).

The same dashboard panel also surfaces Reset throttle for internal/SSO accounts — this is the same tenant-wide setting shown on the Owner & guest access panel, not a per-SSO-account override. It matters specifically for SSO because an SSO account routinely stays valid for a full semester or year, reusing the same data allowance the whole time; without a periodic reset (Never / Rolling every N days / Calendar month), once an account crosses its FUP threshold it would otherwise stay throttled for the rest of its life, since nothing else ever signals "a new month has started" for a login that never expires.

6

Security hardening — the issuer-URL SSRF fix

What the risk was: the OIDC issuer URL is a value a tenant admin types in themselves. The server doesn't just store it — it actively fetches that URL's own /.well-known/openid-configuration document on every single unauthenticated hit to /sso/oidc/login and /sso/oidc/callback for that tenant. Left unchecked, a malicious or compromised tenant admin could point the issuer URL at an internal address — a cloud metadata endpoint, a localhost port, a private-network IP — and turn every guest's SSO attempt into a server-side request against that internal target. That class of bug is called server-side request forgery (SSRF): tricking a server into making a request on the attacker's behalf to somewhere the attacker couldn't reach directly.

What stops it now: the issuer URL is run through _isUnsafeControllerUrl — the same guard this platform already uses for tenant-supplied router-controller URLs (UniFi/Omada/Ruckus) — at the moment SSO settings are saved. It rejects anything that isn't plain http:/https:, rejects localhost/.localhost/0.0.0.0/.local hostnames outright, rejects a hostname that is itself a literal private/loopback/link-local IP address (the 127.x, 10.x, 172.16–31.x, 192.168.x, and 169.254.x ranges — the last of which specifically covers cloud metadata endpoints), and then actually resolves the hostname via DNS and rejects it if any of the resolved addresses land in one of those same private ranges. A save attempt that fails this check is bounced with "Issuer URL must be a public address, not localhost or a private/internal IP" before it's ever written to the tenant's row.

Disclosed honestly, as this platform's security page does for the same guard elsewhere: this check resolves the hostname once, at save time. It does not re-check the actual IP connected to at request time, so a classic DNS-rebinding attack — a domain that resolves to a public address when checked but a private one at the moment of connection — isn't fully closed by this alone. Closing that completely would need a custom DNS-resolving HTTP agent that pins the checked IP for the real request, which isn't implemented here.

7

Security hardening — rate limiting the grant-issuing endpoint

What the risk was: /api/sso/complete is the endpoint that actually does the work that matters — it consumes the handoff token, writes to the database, and grants real network access plus a session cookie. An internal audit found it was the one SSO route with no rate limiting applied at all. An unguessable, single-use, short-lived token is a real protection, but it isn't a substitute for a rate limit on an endpoint that performs real database writes on every single request — without one, a flood of requests (even ones carrying invalid tokens) could still drive unnecessary load, and any future bug in the token-validation logic would have had no second line of defense.

What stops it now: /api/sso/complete now runs behind ssoCallbackLimiter — the same rate limiter already protecting the /sso/oidc/callback and /sso/saml/callback routes (30 requests per 5-minute window). A caller that exceeds it gets a generic "Too many sign-in attempts. Please try again shortly." rather than being allowed to hammer the grant path indefinitely.

8

Security hardening — the token-replay race (TOCTOU) fix

What the risk was: the handoff token is meant to be single-use — read once, then deleted, so it can never be exchanged for access a second time. The original shape of that logic was a plain "look up the token row, check it, then delete it" — three separate steps. If two requests carrying the exact same valid token arrived close enough together, both could read the row as still valid before either one's delete actually executed, letting both proceed to grant access from a single token. This kind of bug is called a TOCTOU race — "time of check to time of use" — the gap between checking a condition and acting on it is wide enough for something else to sneak through.

What stops it now: the lookup and the deletion were collapsed into one atomic database statement — DELETE ... RETURNING against sso_login_handoffs, keyed on the token. Postgres's own row-level locking guarantees that of two concurrent delete attempts against the same row, only one can actually remove it and get the deleted row back; the other gets nothing, which the handler treats identically to an already-expired or unknown token. There is no longer a window between "check" and "use" for two requests to both slip through — the single SQL statement is both at once.

9

Edge cases and limits worth knowing about

ItemDetails
One protocol per tenant A tenant runs OIDC or SAML, not both at onceSwitching protocols replaces the active configuration for that tenant.
Changing auto-provision defaults is forward-onlyUpdating the data cap, duration, device limit, speed caps, or FUP override under "Auto-provision defaults" only affects accounts SSO creates from that point on. An already-provisioned account keeps whatever it was given at creation until a tenant admin edits that specific account afterward, under Internal Accounts, the same as any manually-added one.
A stale custom-expiry date never provisions a dead-on-arrival accountIf a tenant sets "Exact date & time" as the duration mode and that date has already passed by the time someone next signs in, the account falls back to the preset duration instead of being created already expired.
Handoff tokens expire in 5 minutes, intentionally wider than an initial 60-second designThe callback itself makes two real network round trips to the identity provider (discovery, then token exchange) before the token is even minted, and 60 seconds proved too tight in practice against ordinary network latency.
An existing but disabled account blocks SSO tooIf a tenant_internal_accounts row already exists for that username but its status isn't active, SSO sign-in is refused ("This account has been disabled. Contact your institution.") rather than silently re-activating it.
Device-limit accounting matches manually-added internal accounts exactlyA device only occupies a concurrency slot while it's actually still connected (freed automatically after roughly 10 minutes with no sign of it), not for the account's entire grant duration.
Works with Partner Roaming A guest whose home institution uses SSO can roam onto a partner tenant's network without ever seeing a password prompt out of contextThe visited captive bridges the login request back to the home tenant's own domain (where the IdP's registered redirect URI actually resolves), runs the exact same OIDC/SAML flow there, and only the home tenant's IdP ever sees the guest's credentials. The partnership itself is re-validated independently at three separate points along that path (entry, login kickoff, and callback) since the partnership state can change mid-flight during the round trip to the identity provider and back.