Skip to content

Auth architecture

Single sign-on is the setup guide for configuring an identity provider. This page is the architecture behind it: why the auth stack is shaped the way it is, what django-allauth is and is not responsible for, and how a browser session, a mobile client, and a WebSocket connection each end up authenticated from the same underlying credential.

Section titled “The credential: a JWT pair behind an httpOnly cookie”

Every session, regardless of how it began (password, OIDC, GitHub), ends the same way: TruePPM mints a simplejwt access/refresh pair and hands the browser only the access token in the response body. The refresh token never appears in JSON at all — it is set directly as a cookie:

  • httpOnly — unreadable by JavaScript, so an XSS payload can ride the current access token but cannot exfiltrate the long-lived refresh credential.
  • SameSite=Strict, Secure by default.
  • Path-scoped to the refresh endpoint only (/api/v1/auth/token/refresh/), so it is never attached to an ordinary API call — only to the one request that needs it.

This is the design choice that makes “log in via password” and “log in via your own IdP” converge onto one session mechanism: an OIDC or GitHub login does not mint a separate kind of token. It runs RefreshToken.for_user(user) and sets the identical cookie a password login would, then redirects the browser to a frontend completion route that calls the ordinary refresh endpoint. From the moment a session exists, the rest of the application cannot tell how it began.

A short-lived access token (minutes, not hours) limits the blast radius of a token that does leak somewhere the refresh cookie doesn’t reach — a server log line, a support screenshot, a browser extension with page-script access. The refresh token is rotated on every use and the prior one blacklisted, so a stolen refresh token is a single-use window, not a standing credential.

Rotation has a consequence for anyone running several tabs: every tab of the same browser shares the one refresh cookie, so a tab that sends the value a sibling tab has just rotated is refused with the same 401 a replayed, stolen token gets. The server cannot tell the two apart, and is deliberately not asked to — a grace period for a just-used token would also be a grace period for a thief. The browser client absorbs it instead. Refreshes are serialized across tabs with a Web Lock, so a reloading tab waits for a sibling’s in-flight rotation and then presents the rotated cookie. A refresh refused with 401 is retried once after a short pause, which covers a browser without the Locks API. A session that has really ended (signed out, revoked, or past its lifetime) is refused again on the retry and the client shows the session-expired dialog.

The sign-in form asks for an email address, and Django’s ModelBackend matches on the username column. Those are the same string for some accounts and not for others — an invited user chooses a username when they accept — so from 0.4 the login view resolves the submitted identifier itself: it tries the username first, unchanged, and only if that fails looks for the one account whose email matches. Before 0.4, only the username was matched.

Three properties of that fallback are load-bearing rather than incidental, and each exists because the email column carries no uniqueness constraint:

  • Username first, always. The email branch can only ever add a way in. An account whose username happens to be email-shaped keeps its own login, and is never displaced by a different account holding that string as its email.
  • Ambiguity fails closed. An address on two accounts refuses both, mirroring the same decision the OIDC account-linking path already makes: picking one of two accounts would sign someone into an identity they did not name, which is worse than asking them to use their username.
  • The refusal carries no information. “No account with this email” and “wrong password” return the same status and the same body, and the miss path spends the same password-hash work as the hit path, so the endpoint cannot be used to test which addresses have accounts.

One account also keeps one guess budget. The per-account login throttle runs before the view and can only key on the identifier as submitted, so an account answering to two identifiers would otherwise have two independent budgets; the view therefore checks and charges the resolved account’s own bucket before it spends a second password comparison. Recording without checking would have capped only the email-first order, which is the worse failure — a protection that looks complete and holds in one direction.

The resolution lives in the login view, not in AUTHENTICATION_BACKENDS. A backend would widen every authenticate() caller in the process — the Django admin login among them — and would sit upstream of the view’s own post-authentication policy seam, which is where an Enterprise deployment can refuse password login for an account governed by enforced SSO.

Why django-allauth is a library here, not the login flow

Section titled “Why django-allauth is a library here, not the login flow”

TruePPM’s SSO design went through two generations, and the reasoning for the second is worth carrying forward because it is easy to accidentally undo in a future change.

Generation 1 was a hand-rolled OpenID Connect relying party: TruePPM’s own code did discovery, PKCE, token exchange, and ID-token validation, with no allauth involvement. It worked, and it established every security property the design still has today — but it was OIDC-only. GitHub has no OIDC discovery document, no JWKS, no ID token; representing it meant either building a second, GitHub-shaped relying-party path by hand, or adopting a library that already understands GitHub’s OAuth2 shape.

Generation 2 adopts allauth.socialaccount — but strictly as a provider registry and metadata source, not as the flow itself. TruePPM never mounts allauth.urls; there is no /accounts/... route, and no request is ever handled by allauth’s own views. Every OIDC identity provider (generic, Google, Microsoft Entra ID, GitLab, Keycloak, Authentik, Zitadel, Okta, Auth0) registers as a named openid_connect provider app; GitHub is the one entry using allauth’s dedicated github module, since it has no OIDC surface to speak of. TruePPM’s own views own the callback path, and that path is identical for every provider — /api/v1/auth/oidc/callback/ — because the provider is disambiguated by a slug carried in server-side login state, not by a URL segment. A stable callback path matters beyond tidiness: it is what keeps an operator’s provider allow-list, the redirect-URI instructions in Single sign-on, and OpenTelemetry’s query-param redaction rule all correct without per-provider special-casing.

The reason plain allauth was not adopted wholesale is that five security properties the bespoke relying party carried would otherwise be lost:

  1. Egress control. Every outbound request the flow makes — discovery document, token exchange, JWKS fetch — passes through the same SSRF guard described below. Allauth’s own provider code makes these requests directly and does not know about that guard.
  2. Secret-at-rest. Allauth’s SocialApp.secret field is plaintext. TruePPM stores the client secret Fernet-encrypted on a side-row (SsoProviderPolicy) instead and leaves SocialApp.secret empty.
  3. Stable, redactable callback path — covered above.
  4. ID-token validation discipline — an explicit algorithm allow-list (asymmetric algorithms only; none and HS* are rejected outright, closing the classic alg-confusion attack), a fail-closed allowed-domain gate, and a browser-binding cookie (below) — none of which allauth’s generic OIDC provider enforces on its own.
  5. The JWT bridge — the SPA needs the simplejwt refresh cookie described above, not a Django session, which is what allauth mints unassisted.

So the rule going forward is: allauth supplies provider metadata; it never opens a socket. Any change that routes a request through allauth’s own client code instead of TruePPM’s egress module would silently reintroduce the SSRF gap generation 1 closed.

Authorization Code + PKCE, and the durable identity binding

Section titled “Authorization Code + PKCE, and the durable identity binding”

For an OIDC provider, TruePPM acts as a relying party using the Authorization Code flow with PKCE — mandatory, not optional, on every configured provider. The flow that matters architecturally:

  1. Login start mints a single-use state, a PKCE verifier/challenge pair (S256), and a nonce; stores them server-side keyed by state (5-minute TTL); and additionally sets a browser-binding cookie carrying the same state value, httpOnly, SameSite=Lax.
  2. Callback compares the state query parameter against that cookie (constant-time) before consuming the server-side state. This is a distinct check from state-replay protection: server-side state proves the server issued this authorization response; the cookie proves the same browser that started the flow is the one completing it. Without it, an attacker could complete their own login at the identity provider and hand the resulting callback URL to a victim, silently signing the victim into the attacker’s account — a login-CSRF / session-fixation class of attack. SameSite=Lax (not Strict) is required here because the callback is a top-level navigation arriving from the identity provider’s origin, which Strict would otherwise strip.
  3. ID-token validation checks signature (against the provider’s JWKS, fetched through the SSRF guard — deliberately not using PyJWT’s own PyJWKClient, which opens its own socket and follows redirects, bypassing the guard entirely), issuer, audience, expiry, and the nonce bound at login.

The identity that then resolves to a TruePPM account is bound to the durable (issuer, subject) pair, never to the mutable claimed email. A subject already linked under one issuer that reappears under a different issuer fails closed rather than silently re-linking — this is what makes re-pointing a provider to a new issuer a “remove and re-add” operation (Single sign-on) rather than an in-place edit: an in-place issuer change would otherwise let an unrelated account at the new issuer, whose subject happens to collide with an old binding, take over that binding.

Silent account linking is notified, not blocked (2026-09-14, #3554)

Section titled “Silent account linking is notified, not blocked (2026-09-14, #3554)”

The one case above where an OIDC/OAuth identity binds to a TruePPM account the IdP did not create — the durable-identity resolution failing to find a match by (issuer, subject), then a verified email matching an existing local user — grants a federated credential to that account with no re-authentication step. ADR-0517 (see Architecture Decision Records) already named this as a risk it deliberately re-verified rather than closed (its Consequences section: “Account-linking-by-verified-email takeover is the same risk as ADR-0187 and must be re-verified on the new models”). Issue #3554 is that re-verification, run as a threat-model + ai-review gate pass against this surface, and it forced an explicit choice among three responses: notify the account, block the link for admin/owner roles pending a one-time re-auth, or accept the risk and only document it.

Decision: notify, not block. The bar for this link is control of a verified mailbox at an allowed domain (or an IdP that mis-asserts email_verified) — the same bar password reset already accepts, with one difference: a password reset is visible to the account it targets (an email, revoked sessions), and this link was not. Closing that gap only needed a notification, not a new authentication step, so TruePPM emails the linked account — naming the provider and the approximate time — the first time this branch runs, deferred until the database transaction that creates the binding commits (so a link that is rolled back never sends a notice for a binding that was never persisted), and sent best-effort (a mail-transport failure never fails the sign-in). It applies to every workspace role the linked account may hold, Owner included — the risk does not vary by role, only its blast radius does. See Single sign-on for what the account owner sees.

Blocking the link outright for Admin/Owner roles — requiring a one-time password re-auth on the completion page before the identity binds — was considered and deliberately deferred rather than rejected: it is a real additional control for the highest-privilege accounts, just not one the owner asked for in this pass. It remains open as a follow-up if the team decides the notification alone is not enough.

A browser cannot attach an Authorization header to a WebSocket upgrade request, so some form of credential has to travel in the connection URL itself — and a URL is exactly the part of a request that access logs, load balancers, and proxies routinely persist. A 15-minute access token sitting in a query string is therefore a 15-minute window of credential exposure in every log that captured it, which is a materially worse trade than the same token in a header nothing logs by default.

The fix is a purpose-built, single-use ticket, not the access token itself:

  1. POST /api/v1/ws/ticket/ (any authenticated caller) mints a 256-bit random ticket, stores it directly in Redis — not Django’s cache framework, because the Channels consumer runs in a separate process from the request that would populate an in-process cache — keyed ws:ticket:<ticket> → the caller’s user id, with a 30-second TTL.
  2. The client opens the socket with ?ticket=<id> in place of a token.
  3. The consumer consumes the ticket with an atomic Redis GETDEL — read-and-delete in one operation, so the ticket is single-use by construction. A replayed or raced second attempt to consume the same ticket resolves to nothing, even if a log somewhere captured the URL: by the time anyone reads that log, the ticket has already been spent.

The ticket authenticates the connection; it does not authorize it. Once the consumer has resolved a user from the ticket, it still runs the project-role gate described in RBAC and permission architecture before admitting the connection — minting a ticket proves who you are, not that you may join this particular project’s channel.

A raw ?token=<jwt> query parameter is still accepted as a deliberately temporary, off-by-default fallback (behind an operator flag), logged as a deprecation warning whenever it’s used, so operators can find any client still depending on it before it is removed.

Outbound requests are never trusted by default

Section titled “Outbound requests are never trusted by default”

Every outbound call the auth stack makes on an operator- or user-influenced URL — an OIDC provider’s discovery document, its JWKS, its token endpoint, GitHub’s API — passes through the same egress guard the rest of the platform uses for webhook delivery, personal-access-token verification, and git-link status checks. It resolves the target hostname and refuses to proceed if any resolved address is not globally routable — covering private (RFC1918), loopback, link-local, and cloud-metadata (169.254.169.254) address space — and disables redirect-following so an already-validated URL cannot bounce the request somewhere unvalidated afterward.

The one case this guard has to make room for is an identity provider an operator runs inside their own cluster, which by definition resolves to a private address. TRUEPPM_EGRESS_ALLOWLISTED_HOSTS is the escape hatch — see Single sign-on for how to configure it, and note there that the allow-list applies to every outbound integration sharing the guard, not just SSO, so it should name only a host at least as trusted as the identity provider itself.

The dividing line is the same one used everywhere else in TruePPM: log in via your own identity provider → open-source core; provision, deprovision, and govern accounts from a directory → Enterprise. Single sign-on — multiple OIDC providers plus GitHub, configured independently, with auto-created membership at a single fixed default role — shipped in 0.4 as part of the open-source core described here. Group-to-role claim mapping, enforced SSO (disabling local password accounts), SCIM provisioning, and LDAP/AD directory sync are Enterprise; the two extension seams this architecture exposes for that boundary — oidc_role_for(claims, policy) and local_login_allowed(user) — resolve to the open-source default (ignore claims; always allow local login) unless Enterprise registers an override. See SSO is not Enterprise for the full reasoning behind where this line sits.

  • Single sign-on — configuring a provider, the sign-in experience, and operator-facing security notes.
  • RBAC and permission architecture — what happens after a caller is authenticated: the role and object-level checks, and how the WebSocket ticket above hands off into the project-role gate.
  • Architecture Decision Records — ADR-0141 (the WebSocket ticket), ADR-0187 (the original bespoke OIDC relying party, since superseded), and ADR-0517 (adopting allauth as a provider registry) are the primary records behind this page.