Skip to content
GantryCD

SSO Configuration

GantryCD supports three kinds of providers, all feeding the same identity and entitlement machinery:

  1. GitHub OAuth — a single platform-wide instance, configured via backend env vars. Public on the global login page.
  2. 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.
  3. 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”:

  1. Platform provider config — what IdPs GantryCD can talk to. GitHub lives in env vars; OIDC lives in org_identity_sources rows (per-org).
  2. Global user identity — after a successful login, the durable key is (provider_key, issuer, subject). Email is never the identity key.
  3. 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-256 or aws-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:

  1. Sign in once via SSO so the backend creates your users row.
  2. 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:

ProviderShapeHow to find it
GitHubgithub:<login>:<numeric-id>Numeric id at https://api.github.com/users/<login> (id field).
Googlegoogle:<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.
OIDCoidc-<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 /providers returns [] — at least one provider must be configured. For GitHub set GANTRYCD_SSO_GITHUB_CLIENT_ID/_SECRET; for Google set GANTRYCD_SSO_GOOGLE_CLIENT_ID/_SECRET and GANTRYCD_BACKEND_PUBLIC_BASE_URL. Restart after change.
  • Google sign-in succeeds but no org access — the user’s hd claim must match a configured google_hd rule. Personal gmail.com accounts have no hd and never satisfy any rule.
  • OIDC source “discovery failed” — issuer must be reachable from the backend; trailing-slash matters per IdP; {issuer}/.well-known/openid-configuration must 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_PROVIDER and its key (for aes-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 slog warnings 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 devSESSION_COOKIE_SECURE=false for plain HTTP; CORS_ORIGINS must 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 lazy OIDCBuilder. There is no in-process per-source registry.
  • OIDC discovery cache: internal/backend/sso/oidc/discovery_cache.go. Issuer-keyed *coreos/go-oidc.Provider cache with positive and negative TTLs.
  • OIDC provider: internal/backend/sso/oidc/provider.go, builder.go. Build constructs a per-source *Provider wrapping the cached discovery handle; New is the test-only direct-construction path.
  • SSO service: internal/backend/services/sso_service.go. resolveCallbackProvider reads the source row fresh and calls providers.BuildOIDC per callback.
  • Identity-source service: internal/backend/services/org_identity_source_service.go. Owns encryption, synchronous discovery validation at Create/Update, and the TestDiscovery admin probe.
  • Encryption: internal/crypto/secretbox.go (AES-256-GCM).
  • Schema: schema/tbl_org_identity_sources.hcl, schema/tbl_sso_auth_states.hcl.