SSO Configuration
GantryCD supports three kinds of providers, all feeding the same identity and entitlement machinery:
- GitHub OAuth — a single platform-wide instance, configured via backend env vars. Public on the global login page.
- Google OAuth/OIDC — a single platform-wide instance, configured via backend env vars. Public on the global login page; org binding uses the Workspace
hd(hosted domain) claim. - Generic OIDC — one or more per-organization instances, configured by org admins through Settings → Identity Sources. Org-bound; never appears on global discovery.
Mental Model
GantryCD separates three concerns that often get bundled under “SSO”:
- Platform provider config — what IdPs GantryCD can talk to. GitHub lives in env vars; OIDC lives in
org_identity_sourcesrows (per-org). - Global user identity — after a successful login, the durable key is
(provider_key, issuer, subject). Email is never the identity key. - Org access policy — standard orgs decide which provider identities are allowed in. GitHub rules check org/team membership; OIDC rules check group claim membership.
Routes
GET /api/v1/auth/sso/providers
GET /api/v1/auth/sso/{provider_key}/start
GET /api/v1/auth/sso/callback
GET /api/v1/auth/sso/orgs/{handle}/providers
GET /api/v1/auth/sso/orgs/{handle}/sources/{slug}/start
All public; rate-limited by client IP.
Provider dispatch at callback is driven by the auth-state row keyed on the state hash, not by the URL path. The auth-state row carries org_identity_source_id so OIDC callbacks resolve the right per-source config; static-singleton callbacks resolve through the auth-state’s provider_key. The redirect URI registered with each IdP is ${PUBLIC_BASE_URL}/api/v1/auth/sso/callback.
Org admins can probe a configured source’s IdP without performing a login:
POST /api/v1/orgs/{org_id}/identity-sources/{source_id}/test
Session-authenticated, gated by the manage-identity-sources permission. Returns 204 on success; for OIDC sources this runs an uncached well-known fetch against the configured issuer (the per-process discovery cache is bypassed so an admin who just fixed an outage gets a fresh result, not a stale negative). Static singletons (GitHub) no-op — health is fully determined by environment at boot, not per-source state.
GitHub OAuth (Platform-Wide)
Set GANTRYCD_SSO_GITHUB_CLIENT_ID and GANTRYCD_SSO_GITHUB_CLIENT_SECRET (see Configuration). The OAuth callback URL to register at GitHub is {public-base-url}/api/v1/auth/sso/callback (the unified SSO callback; provider dispatch is driven by the auth-state row, not the path). For GitHub Enterprise, also set GANTRYCD_SSO_GITHUB_BASE_URL and GITHUB_API_BASE_URL. The base URL becomes the issuer stored on identities — changing it after users sign in creates a new identity namespace.
Verify with curl http://localhost:8080/api/v1/auth/sso/providers — expect at least {"key":"github","display_name":"GitHub"} in the array.
Google OAuth/OIDC (Platform-Wide)
Set GANTRYCD_SSO_GOOGLE_CLIENT_ID and GANTRYCD_SSO_GOOGLE_CLIENT_SECRET, plus GANTRYCD_BACKEND_PUBLIC_BASE_URL so the backend can derive the redirect URL. Optionally set GANTRYCD_SSO_GOOGLE_HOSTED_DOMAIN to pre-filter the account chooser to a single Workspace tenant (UX hint only — never trusted for access decisions). The OAuth redirect URI to register at console.cloud.google.com is {public-base-url}/api/v1/auth/sso/callback — the same unified callback every other provider uses.
The provider performs OIDC discovery against https://accounts.google.com once at boot. Discovery failures degrade to “skip registration” rather than crashing the backend; restart once Google is reachable to register. The issuer is fixed and never overridden.
Org binding for Google uses the Workspace hosted-domain (hd) claim from the verified ID token. Personal gmail.com accounts have no hd and therefore satisfy no rules — those users can sign in but receive no automatic org membership. The hd claim is single-valued per session (a user belongs to one Workspace tenant at a time), but a single google_hd rule may enumerate multiple verified domains owned by that tenant via the domains array.
Verify with curl http://localhost:8080/api/v1/auth/sso/providers — expect {"key":"google","display_name":"Google"} in the array.
OIDC (Per-Organization)
Backend prerequisites (set once, not per org):
GANTRYCD_BACKEND_PUBLIC_BASE_URL— used to compute the unified SSO callback URL:{base}/api/v1/auth/sso/callback(the same path every provider uses).GANTRYCD_DATA_ENCRYPTION_PROVIDER(+ its key) — selects the at-rest secret encryption provider (aes-256oraws-kms-aes-256) used for e.g.oidc_client_secret_ciphertext. Long-lived; changing the key/CMK without re-encrypting existing rows breaks decryption. See data_encryption.md.
Data encryption is mandatory: an unset or malformed GANTRYCD_DATA_ENCRYPTION_* configuration aborts boot (a startup self-test round-trips a probe to fail fast).
Org admins configure each source through Settings → Identity Sources. The single OIDC redirect URI registered at the IdP is the same for every org and every source. GantryCD fetches {issuer}/.well-known/openid-configuration synchronously at admin Create/Update time — misconfigured issuers fail validation immediately, before the row is committed.
At callback time providers are built lazily per request from the source row, sharing one *coreos/go-oidc.Provider per issuer through a process-wide discovery cache (positive TTL ~30 min, negative TTL ~30 s). This replaces the boot-time “warm registry” that previously pre-built every OIDC provider at startup. Two consequences:
- Backend boots are independent of OIDC issuer reachability — a slow or unreachable IdP no longer slows or fails boot.
- Admin updates on one replica are visible immediately on every replica without a restart or registry-coherence dance, because the source row is re-read fresh per callback.
Cache health is observable via the gantrycd_sso_oidc_discovery_requests{outcome} counter and gantrycd_sso_oidc_discovery_duration_seconds histogram, plus a structured log line per cache miss carrying the issuer URL.
Sources Per Org
Schema: schema/tbl_org_identity_sources.hcl. Static providers (GitHub) are unique on (org_id, provider_key) via a partial index WHERE provider_key <> 'oidc'. OIDC sources can appear multiple times per org and are deduplicated by (org_id, source_slug) — this is intentional, e.g. one IdP for staff and another for contractors.
Per-Tenant Isolation
Different orgs configuring different OIDC tenants are isolated by issuer URL. Group claims from tenant A do not grant access to a source configured for tenant B even if both are oidc and the user happens to have the same group name. The runtime check in SSOService.refreshEntitlementsAndMemberships evaluates an OIDC source’s rules only when the source’s oidc_issuer matches the issuer that signed the user’s ID token.
Bootstrap And Recovery
User accounts are not creatable from the CLI or any admin UI — every user is provisioned at SSO login time, with the GantryCD user_id derived deterministically from the IdP-supplied (provider, login, subject) triple. See pkg/domain/userid.go and the User Identity section of architecture.md.
Bootstrap a fresh install in two steps:
- Sign in once via SSO so the backend creates your
usersrow. - From a host with database access, promote yourself:
gantrycli operator users promote-super-admin --user-id=<your-user-id>
Subsequent super-admins are managed from the backstage UI. The backend never auto-promotes from any env var or login event.
user_id shapes:
| Provider | Shape | How to find it |
|---|---|---|
| GitHub | github:<login>:<numeric-id> | Numeric id at https://api.github.com/users/<login> (id field). |
google:<email>:<sub> | The Google account sub is the opaque numeric ID returned in the ID token. Easiest path: leave the env unset, sign in once, read user_id from the users table, then set the var and restart. | |
| OIDC | oidc-<12-hex-of-issuer>:<preferred_username>:<sub> | Hash is domain.OIDCProviderKey(issuer) (SHA-256, 12 hex chars). |
If the OIDC hash isn’t known up front: leave the env var unset, log in once to materialize the user, read user_id from the users table, then set the var and restart. To recover from “all super-admins demoted” without DB access, set the env var to that user’s id; with DB access, UPDATE users SET is_super_admin = true WHERE id = '...'.
Rules
OAuth scopes are provider API permissions granted to GantryCD, not GantryCD org-access rules. Rule shapes:
{"type": "github_org", "org": "acme"}
{"type": "github_team", "org": "acme", "team": "platform"}
{"type": "google_hd", "domains": ["acme.com", "acme-eu.com"]}
{"type": "oidc_group", "group": "engineers"}
OIDC groups are flat strings; nested or hierarchical group structures aren’t supported. The Google hd claim is single-valued per ID token (one user belongs to at most one Workspace tenant per session), but a single google_hd rule may list multiple verified domains owned by the same tenant; a user matches if any one of them equals their hd. Public providers (GitHub, Google) are safe for global discovery (GET /api/v1/auth/sso/providers); per-org OIDC instances are private by construction and are reachable only through the org’s login start handle.
Identity Collision
External identities are unique on (provider_key, issuer, subject). The same human existing in both GitHub and an OIDC tenant is treated as two distinct external identities until the user explicitly links them via GET /api/v1/auth/sso/{provider_key}/link/start (authenticated). No automatic email-based merging.
Troubleshooting
GET /providersreturns[]— at least one provider must be configured. For GitHub setGANTRYCD_SSO_GITHUB_CLIENT_ID/_SECRET; for Google setGANTRYCD_SSO_GOOGLE_CLIENT_ID/_SECRETandGANTRYCD_BACKEND_PUBLIC_BASE_URL. Restart after change.- Google sign-in succeeds but no org access — the user’s
hdclaim must match a configuredgoogle_hdrule. Personalgmail.comaccounts have nohdand never satisfy any rule. - OIDC source “discovery failed” — issuer must be reachable from the backend; trailing-slash matters per IdP;
{issuer}/.well-known/openid-configurationmust return valid JSON. - OIDC source “OIDC is not configured on this GantryCD instance” — set
GANTRYCD_BACKEND_PUBLIC_BASE_URL. - OIDC source encryption-key error — set
GANTRYCD_DATA_ENCRYPTION_PROVIDERand its key (foraes-256,GANTRYCD_DATA_ENCRYPTION_AES_256_KEY_B64, base64 32 bytes). - Sign-in succeeds at IdP but GantryCD rejects the callback — state expired or already used (10-minute TTL); ID-token nonce/audience/issuer/expiry didn’t validate; or the callback is hitting a different environment than the one that started the login.
- OIDC sign-in succeeds but no access — user must have the configured group; backend
slogwarnings show whether the userinfo endpoint returned the claim. For multi-tenant deployments, confirm the source’s stored issuer matches the issuer signing the user’s ID token. - Session cookie not retained in local dev —
SESSION_COOKIE_SECURE=falsefor plain HTTP;CORS_ORIGINSmust include the web origin when web and backend run on different origins.
Implementation Pointers
- Providers:
internal/backend/sso/providers.go. Holds static singletons (GitHub, Google) wired once from env, plus a lazyOIDCBuilder. There is no in-process per-source registry. - OIDC discovery cache:
internal/backend/sso/oidc/discovery_cache.go. Issuer-keyed*coreos/go-oidc.Providercache with positive and negative TTLs. - OIDC provider:
internal/backend/sso/oidc/provider.go,builder.go.Buildconstructs a per-source*Providerwrapping the cached discovery handle;Newis the test-only direct-construction path. - SSO service:
internal/backend/services/sso_service.go.resolveCallbackProviderreads the source row fresh and callsproviders.BuildOIDCper callback. - Identity-source service:
internal/backend/services/org_identity_source_service.go. Owns encryption, synchronous discovery validation at Create/Update, and theTestDiscoveryadmin probe. - Encryption:
internal/crypto/secretbox.go(AES-256-GCM). - Schema:
schema/tbl_org_identity_sources.hcl,schema/tbl_sso_auth_states.hcl.