Skip to content

Single sign-on (OIDC & GitHub)

Single sign-on lets your team log in through the identity providers you already run. You can configure more than one provider at the same time — several OIDC providers (Keycloak, Authentik, Zitadel, Google, GitLab, Microsoft Entra ID, Okta, Auth0, or any standards-compliant OIDC provider) alongside GitHub. For OIDC providers TruePPM acts as an OpenID Connect relying party using the Authorization Code flow with PKCE; GitHub uses OAuth 2.0. In both cases TruePPM mints its normal session on a successful sign-in — there is no separate token to manage.

Under the hood, login federation is backed by django-allauth’s provider registry, while TruePPM keeps its own hardened callback flow (SSRF-guarded outbound requests, encrypted client secrets, and a single unchanged redirect URI). Basic SSO remains part of the open-source core.

What is in the open-source core (and what is not)

Section titled “What is in the open-source core (and what is not)”

The line is one sentence: log in via your own IdP → open-source core; provision, deprovision, and govern accounts from a directory → Enterprise.

CapabilityEdition
Log in with your own OIDC provider(s) and GitHub✅ Open-source core
Multiple providers configured at once✅ Open-source core
Per-provider admin config, Test connection, redirect-URI display✅ Open-source core
Auto-create a member on first sign-in (single default role, domain-gated)✅ Open-source core
Group → role mapping (groups scope / custom claims)Enterprise
Enforced SSO (disable password sign-in / disable local accounts)Enterprise
SCIM provisioning, LDAP/AD directory sync, SAML federationEnterprise
Immutable, retained auth-event trail with evidence exportEnterprise
Audit rows for provider config changes, secret rotation, and account linking✅ Open-source core

For OIDC, the open-source core requests only the openid email profile scopes — never a groups scope. For GitHub it requests only read:user user:email. On the sign-in policy, password and SSO sign-in are both allowed; turning password sign-in off (enforced SSO) is an Enterprise capability.

When you add a provider you first pick its type. Each type already knows its endpoints, so you supply only credentials plus a small provider-specific field. The issuer is composed for you and shown before you save.

ProviderTypeWhat you supply
Generic OIDCOIDCIssuer URL
GoogleOIDC(nothing — issuer is auto-configured)
Microsoft Entra IDOIDCTenant ID
GitLabOIDCInstance URL
KeycloakOIDCBase URL + Realm
AuthentikOIDCBase URL + Application slug
ZitadelOIDCInstance URL
OktaOIDCOrg domain
Auth0OIDCTenant domain
GitHubOAuth 2.0(optional) Organization

Every provider also takes a Client ID and Client secret from the OAuth/OIDC application you register in that provider for TruePPM.

Open Workspace → Settings → Single sign-on as a workspace admin, then choose Add provider. (When no provider is configured yet, the same button appears in the empty state.)

  1. Provider type — pick the provider from the list. For an OIDC provider, fill in the type-specific field(s) and TruePPM composes the issuer for you, showing the resolved value as you type. For Generic OIDC, enter the issuer directly (its discovery base, e.g. https://id.example.com — TruePPM appends /.well-known/openid-configuration itself, so paste the issuer, not the discovery URL). For GitHub, no issuer is needed.
  2. Display name — the label shown on the sign-in button (e.g. “Acme SSO”).
  3. Client ID and Client secret — the credentials of the application you register in your provider for TruePPM. The secret is write-only: it is encrypted at rest and never displayed again. Leave the field blank on later edits to keep the stored secret; enter a new value to rotate it.
  4. Redirect URI — copy the read-only value shown here into your provider’s list of allowed redirect (callback) URIs. It is derived from your public API origin and is the same for every provider ({origin}/api/v1/auth/oidc/callback/), so you never have to change the allow-list when you add another provider.
  5. Allowed email domains — gates new account linking and auto-created members only: a user whose email is not on this list cannot link a fresh account to this provider or be auto-created by it. It has no effect on an account already linked — narrowing or clearing this list does not revoke access for anyone who has signed in before. To cut off an existing member, deactivate them (see How users sign in) or remove the provider. Leaving the list blank blocks everyone from signing in via this provider; TruePPM also refuses to let you enable a provider with an empty list.
  6. Auto-create members (optional) — when on, a user signing in for the first time from an allowed domain is created as a member at the default role you choose (Member or Admin; SSO can never grant Owner). Leave it off to require that accounts be invited first. You can only set the default role to a role below your own: an Admin can set it to Member but not Admin, and only an Owner can set it to Admin — the same ceiling that applies when changing an existing member’s role or sending an invite, so SSO auto-provisioning cannot be used to create a peer administrator.
  7. Enable this provider — turn it on once the configuration is complete, then save. A provider cannot be enabled until it is fully configured.

Once a provider is saved, use Test connection on it (via Edit) to verify that — for OIDC — the issuer’s discovery document and signing keys are reachable, or — for GitHub — that the GitHub API is reachable. When the failure is specifically the outbound egress guard blocking a private address — the common case for an identity provider running inside your own cluster — the card shows an inline hint pointing at TRUEPPM_EGRESS_ALLOWLISTED_HOSTS instead of only the raw diagnosis; see Running the identity provider inside your cluster below.

The Scopes shown on the form are fixed by the open-source core (openid email profile for OIDC, read:user user:email for GitHub) and cannot be widened.

Testing SSO against the local development stack

Section titled “Testing SSO against the local development stack”

The bundled dev stack (docker-compose.yml, started with make up) does not serve the SPA and the API from one origin — that single-origin behavior is a production/Docker-Compose-single-server property (see One origin, four variables), not a property of local development itself. In dev the SPA runs under Vite on :5173 and the API runs separately on :8000, and Vite’s dev proxy forwards /api/* to the API container with changeOrigin: true — which rewrites the Host header the API sees to the container’s internal name before Django ever gets the request. Two settings therefore cannot be left at their empty defaults for SSO to work locally, and docker-compose.yml sets both for you:

# api service:
TRUEPPM_PUBLIC_API_BASE_URL: http://localhost:8000 # the OIDC redirect_uri origin
TRUEPPM_FRONTEND_BASE_URL: http://localhost:5173 # where the callback sends you back

Without the first, the redirect_uri your provider is handed is derived from the proxy-rewritten request and resolves to the API container’s internal Docker hostname (e.g. http://api:8000/...) — reachable inside Docker’s network, not from your browser, so the IdP redirects you to a page that cannot load (DNS_PROBE_FINISHED_NXDOMAIN). Without the second, the post-login redirect Django issues is a bare relative URL, which the browser resolves against whatever origin it is currently on (the API, port 8000) instead of the SPA (port 5173) — a 404 from Django’s own URL configuration, since that route only exists in the SPA’s router.

When registering an application at your IdP for local testing, use the redirect URI the admin panel shows you (it should read http://localhost:8000/api/v1/auth/oidc/callback/ once the stack is up with the defaults above) — never the Docker-internal http://api:8000/... form. If you change the published ports in docker-compose.yml, update both variables to match.

The same redirect-URI derivation the dev-stack section above works around also applies behind a Helm-managed Ingress, and it is easy to miss because the failure surfaces at your identity provider, not in TruePPM: the admin panel shows a redirect URI that looks plausible, gets registered at the IdP in good faith, and the IdP then redirects signed-in users somewhere their browser cannot reach.

  • env.TRUEPPM_PUBLIC_API_BASE_URL — set this whenever SSO is enabled. Until it is set, the redirect URI shown in step 4 above is derived from the request’s Host header, which behind an Ingress is frequently an internal cluster address rather than your public origin. values.yaml’s own comment next to the key states this requirement; see the Helm Values Reference and TRUEPPM_PUBLIC_API_BASE_URL for the full description.
  • env.TRUEPPM_FRONTEND_BASE_URL — the origin the OIDC callback sends the browser back to once sign-in completes. Leave it empty only on this chart’s default single-origin Ingress, where the SPA and the API share one host; set it explicitly for a split-origin deployment (the SPA and the API on different hostnames).
  • CSRF_TRUSTED_ORIGINS — required on a split-origin deployment, or the post-login token refresh fails with a 403 immediately after the IdP handshake has already succeeded. The chart does not yet expose this as a named values.yaml key — #3945 tracks adding it alongside TRUEPPM_PUBLIC_API_BASE_URL. Until that lands, set it yourself through the chart’s generic env: map, which accepts any key the API reads:
    env:
    CSRF_TRUSTED_ORIGINS: "https://app.example.com"
    See CSRF_TRUSTED_ORIGINS for what the setting does and when a single-origin deployment needs no value.
  • In-cluster identity provider — if the IdP itself runs inside your cluster (e.g. Keycloak at a private service address), the SSRF guard blocks discovery to it until you allow-list it via env.TRUEPPM_EGRESS_ALLOWLISTED_HOSTS, documented in values.yaml next to TRUEPPM_INTEGRATION_ALLOWED_HOSTS. See Running the identity provider inside your cluster below.

For the full set of chart values and how they map to the API’s environment variables, see the Helm Values Reference and Configuration.

GitHub uses OAuth 2.0 rather than OIDC, so there is no issuer or discovery document. A few things differ:

  • No issuer URL — GitHub’s endpoints are configured automatically.
  • Verified email only — TruePPM reads the user’s verified primary email from the GitHub user API. A user with no verified primary email cannot sign in.
  • Organization restriction (optional) — set an Organization to allow only members of that GitHub organization to sign in. When set, membership is checked on every sign-in and the check fails closed.

GitLab is a regular OIDC provider (see Provider types), so the general flow above applies — a few things are specific to it:

  • Instance URL — for GitLab.com use https://gitlab.com; for a self-managed instance use its own base URL (e.g. https://gitlab.example.com). TruePPM appends the standard OIDC discovery path itself.
  • Register the application in GitLab first. In GitLab, go to your user, group, or Admin Area Applications page and create an application with the Redirect URI shown in step 4 above and the openid, profile, and email scopes checked — GitLab only returns an ID token (required for OIDC sign-in) when openid is selected. GitLab then issues the Client ID and Client secret you paste into steps 3–4. The redirect URI is known before you save anything here (it does not depend on a saved provider), so you can register the GitLab application first and come back to finish this form afterward.
  • Self-managed GitLab on a private network — if your GitLab instance is not publicly reachable (e.g. an internal gitlab.internal host), TruePPM’s outbound SSRF guard blocks discovery to it by default; see Running the identity provider inside your cluster to allow-list it.

On the sign-in screen, each enabled provider shows its own Continue with {provider} button. A user chooses their provider, authenticates there, and is returned to TruePPM and dropped into their workspace. If no provider is configured, the sign-in screen shows only the email-and-password form.

A user’s identity is matched to a TruePPM account by email address (case-insensitive), the same key an invite is sent to — not by username or display name, and not by the provider’s internal user ID. If a user authenticates successfully at their provider but no account with that email exists yet (and auto-create is off, or their domain is not on the Allowed email domains list), they see a clear “verified, but not a member yet” message with the code SSO_NO_MEMBER. To resolve it, an admin sends an invite to the exact email address the user signs in with at that provider — mismatched capitalization is fine (matching is case-insensitive), but a different address (e.g. a personal address instead of the corporate one the IdP returns) will not link, and the user will see SSO_NO_MEMBER again on the next attempt.

The invited user still has to complete the invite-accept step before their first SSO sign-in works. No User row exists until the invite is accepted — sending an invite alone does not provision one, so the email match at sign-in has nothing to match against yet. The accept page asks for a username and a password even for someone who intends to sign in only via SSO going forward, because accept_invite requires one when no matching account already exists. Have the invitee follow the emailed link and complete that one-time step first; only then will the same email’s SSO sign-in succeed. (With auto-create members enabled instead of an invite, this step is skipped entirely — the first successful SSO sign-in provisions the account itself.)

A deactivated member is refused at the callback with SSO_ACCOUNT_DISABLED and no session is created, even though their identity provider signed them in successfully. Deactivating a member in Workspace → Settings → Members therefore closes the SSO path as well as password login; reactivating them restores it with no extra step.

The first time an existing account is linked to a provider, TruePPM will email that account to say so. This is the case where a user’s verified IdP email matches a local account that already existed before they ever signed in with SSO — including an account with the Owner role. Because that link happens automatically, with no re-authentication step, the account owner will get a message naming the provider and roughly when it happened, so they can tell whether it was them. If it was not, the message asks them to contact their workspace administrator right away. This notice is sent only once, at the moment the identity is first linked — a later sign-in through the same provider resolves by the durable identity already on file (see above), not by email, so it will not send the notice again. Sending the email is best-effort: a mail delivery problem will never block or fail the sign-in itself. A member provisioned by auto-create (no pre-existing account) will not get this email — there is no existing owner to notify, and that path is covered by the workspace audit log instead.

Use Remove on a provider to delete its configuration. This also unlinks everyone who signed in through it. Removing one provider does not affect the others.

  • Provider configuration is session-only. Every SSO admin endpoint — list, add, edit, delete, and Test connection — requires a signed-in session or a JWT, and refuses any personal access token regardless of its scope or its owner’s role. Reads are refused along with writes. The reason is containment: provider configuration decides who may become a member and at what role, so a token that could widen Allowed email domains and switch on Auto-create members at the Admin default role would turn one leaked token into a permanent admin account that survives revoking the token. Keeping this surface behind a session means a leaked token cannot rewrite who may sign in. Read that as a statement about this surface only — it is not a claim that revoking a token contains everything; see Revoking a token for what a token can still reach.
  • Client secrets are encrypted at rest and are never returned to any client.
  • Every change to a provider is recorded in the audit log: adding one, deleting one, and rotating its client secret each write a row naming the admin, and an edit records a before and after for each field that actually changed — the allowed email domains, auto-create setting, default role, enabled state, issuer URL, GitHub org, OAuth client id, and display name. The client secret never appears in any of them, in any form: a rotation records only that it happened. A save that changes nothing writes nothing.
  • Rows also record whether the change came from a browser session or from a Personal Access Token (actor_kind), so a scripted change is not indistinguishable from an admin sitting at the page.
  • When a single sign-on identity binds to an existing local account, that link is recorded as sso_account_linked — with the provider, issuer, subject, and the role the account holds. This is the moment an account that already had a password gains a second way in, and it is now visible.
  • Setting Auto-create members’ default role to Admin requires an Owner — an Admin saving a provider is refused if they try to set the default role to Admin (or anything at or above their own role), the same ceiling enforced when changing an existing member’s role or sending an invite. Without it, an Admin could set the default role to Admin and sign in through a second identity at that provider to provision a peer administrator — a self-duplication the member-role and invite paths already refuse.
  • The provider create, update, and delete endpoints are rate-limited to 20 requests per minute. Reading the provider list is not.
  • The OIDC login flow is protected against login-CSRF / session fixation with a single-use, browser-bound state value; the ID token’s signature (an asymmetric-algorithm allow-list), issuer, audience, and nonce are all validated before a session is minted.
  • Sign-in is bound to the durable (issuer, subject) pair, never to a mutable email; a subject already bound to a different issuer fails closed.
  • The callback never places a token in the URL — it sets the same hardened, httpOnly refresh cookie used by password login. The callback path is identical for every provider.
  • Discovery, token, and JWKS requests (OIDC) and all GitHub API requests are subject to TruePPM’s outbound SSRF guard.
  • Your provider’s signing keys (JWKS) are cached briefly rather than fetched on every sign-in, and a key rotation is picked up immediately: a token signed with a key TruePPM has not seen triggers a fresh fetch rather than a failure.
  • ID-token time claims are checked with one minute of clock-skew tolerance, so a provider whose clock runs slightly ahead of TruePPM’s does not produce unexplained sign-in failures. Beyond that, keep both clocks on NTP.

Running the identity provider inside your cluster

Section titled “Running the identity provider inside your cluster”

TruePPM’s outbound SSRF guard blocks requests to private, loopback, and link-local addresses by default. That is the right posture for a public issuer, but it also blocks an identity provider you run inside the same cluster — for example Keycloak reachable only at a private service address such as keycloak.sso.svc.cluster.local. Left unset, discovery, token exchange, and JWKS fetches to that issuer fail with a “provider unreachable” error and the Test connection button reports the issuer as unreachable.

To allow it, name the exact hostnames you trust in the TRUEPPM_EGRESS_ALLOWLISTED_HOSTS environment variable (comma-separated). Those hosts — and only those — bypass the private-address check:

Terminal window
# Permit an in-cluster Keycloak; every other private host stays blocked.
TRUEPPM_EGRESS_ALLOWLISTED_HOSTS=keycloak.sso.svc.cluster.local

Matching is an exact, case-insensitive hostname compare — no wildcards and no suffix matching, so allow-listing keycloak.sso.svc.cluster.local never admits a lookalike host. Leave the variable unset (the default) unless you actually run an IdP on a private address; a public issuer needs no allow-list. This setting is operator configuration only and is never influenced by user input.