Single sign-on (SSO)

verge-asm can let people sign in through your own identity provider instead of a per-account password. Single sign-on here is OpenID Connect (OIDC), and only OIDC: the app completes the authorization-code flow and verifies the provider's signed id_token against its published keys. Reverse-proxy header trust is not an option — a misconfigured trusting proxy is a bypass class the signature check does not have, so it stays refused (ADR-0112).

Two rules shape everything below, and both are deliberate:

  • SSO authenticates an existing account; it never creates one. A verified identity that maps to no local account is refused, not provisioned — so turning on a broad IdP can never silently mint accounts. Create the account first (see accounts.md); the user links their identity to it afterward.
  • A binding is keyed on the verified (provider, sub), never a username. The sub is the provider's stable, non-reassignable subject id. Matching on it — rather than on a mutable, recyclable username or email claim — closes the account-takeover surface a reassigned username would open (ADR-0113).

The config lives in cmd/web/settings_sso.go; the flow in cmd/web/sso.go.


Which providers ship

None ship pre-configured, and none are hard-coded. verge-asm ships one generic OIDC connector, not an Okta plug-in or a Google plug-in. Any provider that speaks standard OIDC discovery — Okta, Google Workspace, Microsoft Entra ID, Keycloak, Auth0, and the rest — works through the same path: you declare it by its issuer URL, and the app discovers its endpoints and signing keys from <issuer>/.well-known/openid-configuration. "Okta" and "google" appear only as example labels and slugs in the forms.

Both confidential clients (client id and secret) and public, PKCE-only clients (client id, no secret) are supported. PKCE (S256) is used on every login regardless; the secret is what distinguishes the two client types.

Setting up a specific provider

The mechanics below are the same for every provider; the fiddly part is finding the right values in each IdP's own console. These worked examples map a provider's admin console to the five fields and two callback URLs this guide describes:

ProviderGuide
Microsoft Entra ID (formerly Azure AD)sso-entra-id.md
Google Workspacesso-google.md
Oktasso-okta.md
Keycloaksso-keycloak.md

Any other standards-compliant OIDC provider works through the same generic path — declare it by its issuer URL and register the two callback URLs.


Configure a provider — Settings → SSO

Everything here is admin-only — each route is admin-gated, and a non-admin POST is refused. Open Settings → Single sign-on (/settings?tab=sso).

Add a provider

The Add an OpenID Connect provider form (POST /settings/sso) takes five fields:

FieldNotes
Display nameThe label rendered on the sign-in button and the Settings row (e.g. Okta).
SlugA short URL-safe id that rides the flow routes (/login/sso/<slug>). Lowercase letters, digits and internal hyphens only; unique per install.
Issuer URLThe OIDC issuer. Must be https — discovery over plaintext is refused at validation time.
Client IDThe OAuth2 client id the IdP assigned this deployment. Not a secret.
Client secretOptional. Set it for a confidential client; leave it blank for a public PKCE-only client.

A new provider is created enabled. A duplicate slug is reported plainly rather than as a raw error.

Edit, disable, re-key, remove

Each row carries an Edit disclosure with three independent forms:

  • Save provider (POST /settings/sso/update) edits the display name, slug, issuer, client id, and the enabled checkbox. Disabling a provider keeps its config but renders no sign-in button and refuses its flow — the clean way to turn SSO off without deleting it, and without stranding password login.
  • Update secret (POST /settings/sso/secret) is the secret's own write path. Leaving the field blank keeps the stored secret; typing a value replaces it; ticking clear the secret removes it (making the client public/PKCE-only). The clear box wins over any typed value. This mirrors the notification-channel secret exactly.
  • Remove provider (POST /settings/sso/delete) deletes the provider. Its identity bindings cascade away with it.

Where the client secret is stored

The client secret is held in the database, in the sso_provider.client_secret column, and it is write-only at the interface. No list or detail read ever selects it — those queries expose only whether a secret is set (a set / none badge), never the value. Exactly one server-side read path hands the secret to the OIDC token exchange. So the Settings UI can display a provider without ever being able to leak its secret, and a placeholder in the form reads "set — leave blank to keep."

This is the same write-only treatment the notification-channel secret gets under ADR-0053. Note the distinction from the session-signing and prober SSH keys, which live on per-service state volumes and never touch Postgres (see running.md → Where secrets live): the OIDC client secret does live in the database, so a database dump carries it. Treat pgdata accordingly.


The callback URL to register with your IdP

Every OIDC provider needs the exact redirect URI(s) registered on its side, or the IdP refuses the round-trip. verge-asm uses two callback paths per provider — one for sign-in, one for the Profile self-link — and both must be registered:

PurposePath (register <base> + this)
Sign-in callback/login/sso/<slug>/callback
Profile self-link callback/profile/sso/<slug>/link/callback

For a provider slugged okta at https://verge.example.com, register https://verge.example.com/login/sso/okta/callback and https://verge.example.com/profile/sso/okta/link/callback.

<base> comes from VERGE_EXTERNAL_URL

The app builds the redirect_uri it sends the IdP from VERGE_EXTERNAL_URL — the trusted origin the deployment is reached at (e.g. https://verge.example.com), set on the web service. Deriving it from a fixed, configured origin — rather than the incoming Host header — keeps an attacker-influenceable header out of the redirect_uri.

Set VERGE_EXTERNAL_URL before you register anything. When it is unset the app falls back to the request's own host and scheme (https when TLS terminates in front, matching the VERGE_SECURE_COOKIES signal), but the value it produces must still match what you registered at the IdP — so pin it explicitly for any real deployment. Configure it the same way as the other environment variables in running.md → Environment variables.

Note: VERGE_EXTERNAL_URL (web, the SSO redirect origin) is a different variable from VERGE_PUBLIC_URL (worker, the base for links in notification bodies). SSO uses the former; setting only the latter does not configure the callback origin.


Signing in with SSO

Each enabled provider renders its own button on the sign-in screen (GET /login/sso/<slug>). The flow is two hops, both unauthenticated by construction:

  1. GET /login/sso/<slug> mints a state / nonce / PKCE transaction into a short-lived signed cookie and redirects to the IdP.
  2. GET /login/sso/<slug>/callback verifies the state, exchanges the code, verifies the id_token (signature, issuer, audience, and the per-login nonce), resolves the (provider, sub) binding, and issues the session.

If the verified identity is bound to no account, the login is refused with an honest message — "That identity is not linked to an account here. Sign in with your password, then link it on your Profile."

SSO does not replace your second factor

SSO proves the IdP identity, but it never downgrades a local second factor. An account that has enrolled TOTP still lands on the same two-factor step after the SSO round-trip and owes its code, exactly as a password login would — only an account without TOTP completes the login outright. The session's role is always read from the local account row, not from any IdP claim. And because SSO authenticates existing accounts, password + TOTP login always remains available alongside it; disabling or removing a provider never strands anyone out of their account. See authentication.md for the password and TOTP mechanics.


Linking and unlinking an identity — Profile

A binding is established by the account holder themselves, from Profile → Linked identities — never trust-on-first-use, which would re-open a first-claimant window.

  • Link (GET /profile/sso/<slug>/link…/link/callback) runs the same OIDC round-trip inside the caller's session, then records (provider, sub) → this account. Re-linking your own identity is a no-op; an identity already bound to a different account is refused cleanly. An account may hold one identity per provider.
  • Unlink (POST /profile/sso/unlink) removes one of your own bindings. It is scoped to your account, so you can only ever unlink your own — a stale or foreign id simply no-ops.

Admin removal of a binding — offboarding

Under Settings → SSO → Linked identities, an admin sees every verified identity bound to any local account (provider, account, display label, and when it was linked). Remove (POST /settings/sso/identity/remove) revokes a binding outright — the offboarding / seat-reassignment case: a departed user's linked identity, or a recycled one, must stop authenticating as that account. Removal is idempotent, and it does not delete the local account — only the identity's ability to sign in as it. To take away the account itself, see accounts.md.


Route reference

RouteWhoWhat
GET /login/sso/{slug}anyoneStart SSO sign-in.
GET /login/sso/{slug}/callbackanyoneComplete sign-in; issue the session.
GET /profile/sso/{slug}/linklogged inStart a self-link.
GET /profile/sso/{slug}/link/callbacklogged inComplete the self-link binding.
POST /profile/sso/unlinklogged inUnlink one of your own identities.
POST /settings/ssoadminAdd a provider.
POST /settings/sso/updateadminEdit a provider / toggle enabled.
POST /settings/sso/secretadminSet, replace or clear the client secret.
POST /settings/sso/deleteadminRemove a provider.
POST /settings/sso/identity/removeadminRemove any identity binding.