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.
The credential: a JWT pair behind an httpOnly cookie
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,Secureby 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.
Which identifier authenticates
Section titled “Which identifier authenticates”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:
- 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.
- Secret-at-rest. Allauth’s
SocialApp.secretfield is plaintext. TruePPM stores the client secret Fernet-encrypted on a side-row (SsoProviderPolicy) instead and leavesSocialApp.secretempty. - Stable, redactable callback path — covered above.
- ID-token validation discipline — an explicit algorithm allow-list
(asymmetric algorithms only;
noneandHS*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. - 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:
- Login start mints a single-use
state, a PKCE verifier/challenge pair (S256), and anonce; stores them server-side keyed bystate(5-minute TTL); and additionally sets a browser-binding cookie carrying the samestatevalue, httpOnly,SameSite=Lax. - Callback compares the
statequery 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(notStrict) is required here because the callback is a top-level navigation arriving from the identity provider’s origin, whichStrictwould otherwise strip. - 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 thenoncebound 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.
The WebSocket ticket
Section titled “The WebSocket ticket”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:
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 — keyedws:ticket:<ticket>→ the caller’s user id, with a 30-second TTL.- The client opens the socket with
?ticket=<id>in place of a token. - 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.
OSS and Enterprise
Section titled “OSS and Enterprise”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.
Where to go next
Section titled “Where to go next”- 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.