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 Sign in with SSO on the ugent-web login screen (the button appears only when SSO is configured and the broker is reachable).
  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 projects each ref into one child process's environment and audits the projection. 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.

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 — which 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.

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.