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).
UPSTREAM BROKER DOWNSTREAM
-------- ------ ----------
PingFederate --+ +-- ugent-web
Entra / Okta --+--> OIDC connector -> Verified +-- tenant console
SAML (later) --+ (PKCE flow) Identity +-- future applications
|
one-time handoff codeThe 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_linkstable, never the identity of record — so federated sign-in and password login resolve to the same account.
The Login Flow
- 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).
GET /v1/authorizeredirects the browser to your identity provider, with PKCE (S256) and a login-CSRFstatebound to a stored flow.- The provider calls back on the single registered redirect URI,
GET /oidc/callback. The broker validates the id token and records a verified identity. - 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.
- The application server redeems the code at
POST /v1/handoff/redeemwith its bearer credential — server-to-server, never through the browser. - 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
| Endpoint | Auth | Purpose |
|---|---|---|
GET /v1/health | public | configuration counts, deliberately not activity |
GET /v1/apps/{app}/methods | public | login-button metadata (which methods to offer) |
GET /v1/authorize | public | 302 to the identity provider |
GET /oidc/callback | public | the one registered redirect URI |
POST /v1/handoff/redeem | app bearer | server-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, orcritare refused - signature algorithms are allowlisted to RS256/PS256/ES256 and intersected with what the provider's discovery document advertises — a provider advertising
noneorHS256gets those discarded before any token is verified - every provider endpoint is pinned to the discovery origin,
end_sessionincluded - 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/:
| File | Contents |
|---|---|
sso-providers.toml | upstream identity providers: issuer URL, client id, secret reference |
sso-apps.toml | consuming 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:
| Ref | Used by | What it is |
|---|---|---|
<provider>_client_secret | broker | the OIDC client secret at your identity provider |
<app>_credential | broker and the app | the 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:
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 SECRETConfiguring ugent-web
SSO is opt-in on the application side, via three environment variables plus the credential:
# /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.cjsWhy 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:
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 proxyproviders: 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:
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-webThe 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
xmlsec1bindings, 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
- Vault & Secrets — where broker and application credentials live
- Security & Firewall — taint tracking and per-actor session ownership
- Feature List — multi-tenancy and administration overview