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.
| Capability | Edition |
|---|---|
| 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 federation | Enterprise |
| Immutable, retained auth-event trail with evidence export | Enterprise |
| 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.
Provider types
Section titled “Provider types”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.
| Provider | Type | What you supply |
|---|---|---|
| Generic OIDC | OIDC | Issuer URL |
| OIDC | (nothing — issuer is auto-configured) | |
| Microsoft Entra ID | OIDC | Tenant ID |
| GitLab | OIDC | Instance URL |
| Keycloak | OIDC | Base URL + Realm |
| Authentik | OIDC | Base URL + Application slug |
| Zitadel | OIDC | Instance URL |
| Okta | OIDC | Org domain |
| Auth0 | OIDC | Tenant domain |
| GitHub | OAuth 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.
Configuring a provider
Section titled “Configuring a provider”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.)
- 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-configurationitself, so paste the issuer, not the discovery URL). For GitHub, no issuer is needed. - Display name — the label shown on the sign-in button (e.g. “Acme SSO”).
- 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.
- 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. - 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.
- 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.
- 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 originTRUEPPM_FRONTEND_BASE_URL: http://localhost:5173 # where the callback sends you backWithout 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.
Kubernetes / Helm deployments
Section titled “Kubernetes / Helm deployments”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’sHostheader, 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 andTRUEPPM_PUBLIC_API_BASE_URLfor 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 namedvalues.yamlkey — #3945 tracks adding it alongsideTRUEPPM_PUBLIC_API_BASE_URL. Until that lands, set it yourself through the chart’s genericenv:map, which accepts any key the API reads:Seeenv:CSRF_TRUSTED_ORIGINS: "https://app.example.com"CSRF_TRUSTED_ORIGINSfor 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 invalues.yamlnext toTRUEPPM_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 specifics
Section titled “GitHub specifics”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 specifics
Section titled “GitLab specifics”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, andemailscopes checked — GitLab only returns an ID token (required for OIDC sign-in) whenopenidis 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.internalhost), TruePPM’s outbound SSRF guard blocks discovery to it by default; see Running the identity provider inside your cluster to allow-list it.
How users sign in
Section titled “How users sign in”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.
Removing a provider
Section titled “Removing a provider”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.
Security notes
Section titled “Security notes”- 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
statevalue; 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:
# Permit an in-cluster Keycloak; every other private host stays blocked.TRUEPPM_EGRESS_ALLOWLISTED_HOSTS=keycloak.sso.svc.cluster.localMatching 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.