Skip to content

SSO & Federated Identity ​

UGENT ships a centralized SSO broker: an OIDC relying party upstream, an identity issuer downstream. It authenticates a person against your corporate identity provider (PingFederate, Entra, Okta, or any OIDC provider) and hands the verified result to UGENT applications through a one-time code — so every application in the estate gets enterprise sign-in while only one service ever talks to the identity provider.

Document Author: Uni Zhu

Why a Broker ​

The typical trigger: your identity provider admin provisions one OIDC client with exactly one registered redirect URI for the whole estate — not one per application. Applications cannot each be a relying party, because there is only one callback to go around.

So the broker is the relying party, once, on everyone's behalf. This Backend-for-Frontend shape is the IETF's recommended practice (RFC 10017 / BCP 212).

text
  UPSTREAM                 BROKER                      DOWNSTREAM
  --------                 ------                      ----------
  PingFederate  --+                                 +-- ugent-web
  Entra / Okta  --+--> OIDC connector  -> Verified +-- tenant console
  SAML (later)  --+     (PKCE flow)       Identity  +-- future applications
                                              |
                                        one-time handoff code

The broker is a broker, not an identity provider. It verifies who someone is. It stores no passwords, owns no user table, and issues no roles:

  • No role assignment. Consuming applications own their authorization models. Provider groups pass through verbatim; each application maps them to its own roles and permissions.
  • No user table. The SSO subject is a lookup key into an identity_links table, never the identity of record — so federated sign-in and password login resolve to the same account.

The Login Flow ​

  1. The user clicks Continue with followed by the provider's name on the ugent-web login screen, one button for each identity provider the broker offers (the buttons appear only when SSO is configured and the broker answered when ugent-web last asked it; ugent-web asks at most once a minute while the broker answers, and once every 10 seconds while it does not, so a broker that comes back shows its button within 10 seconds, and one that goes away loses it within a minute).
  2. GET /v1/authorize redirects the browser to your identity provider, with PKCE (S256) and a login-CSRF state bound to a stored flow.
  3. The provider calls back on the single registered redirect URI, GET /oidc/callback. The broker validates the id token and records a verified identity.
  4. The broker redirects back to the application with a one-time handoff code: 32 bytes from the OS CSPRNG, base64url, valid 30 seconds, single-use, bound to the redeeming application, and consumed on a failed redemption as well as a successful one.
  5. The application server redeems the code at POST /v1/handoff/redeem with its bearer credential — server-to-server, never through the browser.
  6. ugent-web resolves the subject to a local user and opens a normal session. Memory, per-actor access control, and transcripts are keyed on the same actor id a password login produces.

Handoff goes by one-time code rather than a shared parent-domain cookie: every application terminates on one host, so a .example.com cookie would be readable by all of them, and subdomain takeover into cookie theft is a documented SSO bypass. The return_to parameter is validated as a relative path only and appended to the application's registered origin — the open-redirect class is eliminated rather than filtered, which matters here more than anywhere else, since the broker owns the estate's only registered redirect URI.

Endpoints ​

EndpointAuthPurpose
GET /v1/healthpublicconfiguration counts, deliberately not activity
GET /v1/apps/{app}/methodspubliclogin-button metadata (which methods to offer)
GET /v1/authorizepublic302 to the identity provider
GET /oidc/callbackpublicthe one registered redirect URI
POST /v1/handoff/redeemapp bearerserver-to-server identity read

Security Properties ​

Ported from a controller that has run in production, each covered by a test that fails when the guard is removed:

  • id-token headers carrying jwk, jku, x5u, or crit are refused
  • signature algorithms are allowlisted to RS256/PS256/ES256 and intersected with what the provider's discovery document advertises — a provider advertising none or HS256 gets those discarded before any token is verified
  • every provider endpoint is pinned to the discovery origin, end_session included
  • untrusted JSON is parsed with duplicate-member rejection on all four paths: discovery, token response, JWT segments, JWKS
  • secrets are compared in constant time
  • a full authorization store evicts the oldest pending flow, never a completed one — an unauthenticated flood cannot deny logins
  • the CSRF control is the state-to-flow lookup when a callback is consumed; a forged state fails as "flow not found"

Configuring the Broker ​

Two configuration files, mode 0600, installed at /etc/ugent/:

FileContents
sso-providers.tomlupstream identity providers: issuer URL, client id, secret reference
sso-apps.tomlconsuming applications: app id, callback origin, credential reference

Secrets are never written to the configuration files. They live in the UGENT vault and are referenced by name; the service unit resolves them with ugent secret exec, which resolves and audits each ref, then replaces itself with the service, so the service runs with its secrets and without the vault passphrase. A unit can instead receive each secret as a systemd credential. Two secrets in a typical deployment:

RefUsed byWhat it is
<provider>_client_secretbrokerthe OIDC client secret at your identity provider
<app>_credentialbroker and the appthe shared bearer credential used to redeem handoff codes

Rotating a shared credential needs both sides

The broker verifies the app credential and the application presents it. While they disagree, every redemption returns 401 and logins fail. There is no dual-credential window, so restart both services back to back and expect a few seconds of failures. ugent secret exec resolves a ref once at process start, so a rotated value does not reach a running service until it restarts.

Store secrets on stdin, never on a command line — argv is world-readable via ps aux and survives in shell history:

sh
read -rs SECRET                     # paste, press Enter. Nothing is echoed.
printf '%s' "$SECRET" | ugent secret --backend file-vault \
  add myprovider_client_secret --provider myprovider --kind api-key --stdin
unset SECRET

Configuring ugent-web ​

SSO is opt-in on the application side, via three environment variables plus the credential:

ini
# /etc/systemd/system/ugent-web.service.d/sso.conf
[Service]
Environment=UGENT_SSO_BROKER_URL=https://sso.example.com
Environment=UGENT_SSO_APP_ID=ugent-web
# Server-to-server calls go straight to the loopback listener.
Environment=UGENT_SSO_BROKER_INTERNAL_URL=http://127.0.0.1:8790
EnvironmentFile=/etc/ugent/secret-broker.env
ExecStart=
ExecStart=/usr/local/bin/ugent secret --backend file-vault exec \
  --env UGENT_SSO_APP_CREDENTIAL=@sso_ugent_web_credential \
  -- /opt/node24/bin/node /opt/ugent-web/dist/server.cjs

Why the broker URL is two settings: UGENT_SSO_BROKER_URL is where the browser goes (public DNS and TLS); UGENT_SSO_BROKER_INTERNAL_URL is where ugent-web itself calls. Pointing server-to-server traffic at loopback removes a proxy hop and a TLS handshake that can only fail — with a self-signed or mismatched certificate, Node refuses the fetch and SSO reports itself unavailable even though the broker is healthy. The symptom is specific: /api/auth/sso/config returns {"enabled":false,"methods":[]} while localhost:8790/v1/health returns {"status":"ok",...}. Both together mean the fetch failed, not that configuration is missing. ugent-web keeps a good answer from the broker for a minute and a failed one for 10 seconds, so the symptom can outlast the fault by up to 10 seconds, and a broker that has just come back reads as disabled until it is asked again; a restart of ugent-web does not wait.

The empty ExecStart= is required — without it systemd appends rather than replaces, and the unit fails to load with two start commands.

Deploying the Broker ​

The broker is a small standalone Rust service (~8 MB binary, rustls — no OpenSSL). Run it on loopback behind your reverse proxy:

  • systemd unit, listening on 127.0.0.1:8790, with graceful shutdown
  • one Caddy (or nginx) block reusing your existing wildcard certificate — no new certificate is issued for the SSO hostname

Verify, in order:

sh
systemctl status ugent-sso-broker
curl -s localhost:8790/v1/health | jq
# -> {"status":"ok","providers":1,"applications":1,...}
curl -s localhost:8790/v1/apps/ugent-web/methods | jq
curl -s https://sso.example.com/v1/health | jq   # through the reverse proxy

providers: 0 means discovery failed against the identity provider; the journal names both sides of an issuer mismatch, which is the usual cause and is almost always a trailing slash. The issuer comparison is exact.

Then the end-to-end check, which is the only one that proves the thing that matters: sign in via SSO as a pre-created user and confirm the session reaches the engine with the same actor id a password login produces. If SSO resolved to a different identity than password login, a user would authenticate successfully and then see none of their own documents.

Rollback ​

Backing SSO out does not touch password login:

sh
systemctl stop ugent-sso-broker && systemctl disable ugent-sso-broker
rm /etc/systemd/system/ugent-web.service.d/sso.conf
systemctl daemon-reload && systemctl restart ugent-web

The login screen stops offering the button as soon as the config endpoint reports enabled: false: at once after the restart above, and within a minute if only the broker goes away, since ugent-web keeps a good answer from the broker that long. That is also what it reports when the broker is simply unreachable. Leftover identity_links rows are harmless.

Federated Actors for External Applications ​

Beyond browser sign-in, the engine accepts federated identity from external applications calling the web channel. An application that has authenticated a user through its own SSO passes federation_issuer and federation_subject in the request; UGENT derives a canonical actor as federation:{issuer}:{subject} and auto-links the application's key to it.

  • The same person across CRM, portal, and helpdesk shares one memory scope instead of one per application.
  • Validation rejects partial, empty, and reserved claims; conflict detection (a provider already linked to a different actor) fails closed.
  • Conversation namespacing ({client_id}:{conversation_id}) prevents id collisions when multiple applications share one web plugin endpoint.
  • An application's key may name only users of the issuers its [[web.auth.keys]] entry lists, and a request from it that names nobody acts as that application's own anonymous user, never as the operator. See Keys and callers.

Session ownership still applies: a session records the actor whose turn created it, and knowing a session id is not enough to read another actor's transcript.

Scope Notes ​

  • SAML. The connector seam is built; there is no implementation yet. Adding one is additive rather than a rewrite. The mature Rust option is pre-1.0 and wraps modified xmlsec1 bindings, and XML signature wrapping is actively exploited — so this is deliberate, not neglect.
  • The broker never decides what someone may do. Applications map the passed-through groups to their own roles and permissions.

See Also ​

Released under the Private Beta License.