feat: OpenID Connect single sign-on (Closes #401) - #1960
Open
dotanm wants to merge 1 commit into
Open
Conversation
Lets people log in with an external OIDC provider — Keycloak, Authentik, Authelia, Zitadel, Google, Entra ID — using django-allauth for the redirect/callback dance and then minting the same simplejwt access/refresh pair the password login issues, as you asked for on the issue. Nothing downstream can tell an SSO login from a password one, and username/password login is untouched, so this takes nobody's existing way in away. Provider credentials live in an admin-editable SocialApp rather than the environment, with three Constance flags alongside: OIDC_ENABLED (which disables the endpoints, not just the button), OIDC_BUTTON_LABEL, and OIDC_ALLOW_SIGNUP (off by default). The login screen asks the unauthenticated /api/auth/sso/config whether to offer a button, and failed attempts come back to /login?sso_error=<reason> with a message that says what to fix. All login and account-linking policy sits in api/adapters.py, per your steer about email being the crux: linking to an existing account requires the provider to assert a *verified* email, and refuses an ambiguous match (two accounts sharing an address) or a blank one (many LibrePhotos accounts have no email, and none of them is a match). Creating an account on first login needs OIDC_ALLOW_SIGNUP *and* a configured email provider, since without email a "verified" claim is not something the install can act on. SSO-provisioned accounts are never superuser or staff, including during first-time setup when no superuser exists yet. Two things worth flagging in review: The OAuth redirect_uri cannot be derived from the request. The proxy forwards /api/ with Host rewritten to `backend`, so allauth computed http://backend/api/accounts/... — unreachable by the browser and rejected by the IdP. The rewrite is deliberate (ALLOWED_HOSTS lists only localhost and BACKEND_HOST, so validation passes whatever domain is in use), which rules out recovering the origin from X-Forwarded-Host without loosening ALLOWED_HOSTS. So it comes from FRONTEND_BASE_URL — the setting the password-reset link already established for this exact problem, now centralised in api/public_url.py and used by both. A configured value is also what keeps the redirect_uri byte-identical between the authorize request and the token exchange. The override has to be installed on the provider's oauth2_adapter_class, because OAuth2Provider.redirect() builds its own adapter from that attribute and ignores the one a view holds; overriding only the view would leave login and token exchange disagreeing. There is a test asserting it stays installed. Mounting allauth.urls exposes more than the OIDC routes. It also mounted allauth's own session-based views — a second login form, a signup form, and a second password-reset flow that would have bypassed the throttling and enumeration protections on our own reset endpoint. SOCIALACCOUNT_ONLY = True drops them; despite the name it only disables *allauth's* local auth, not ours. Tested end-to-end through the proxy against two real IdPs (Keycloak and Dex): verified-email linking mints a working JWT for the right non-privileged user and drops the allauth session, unverified email is refused, provisioning is refused while disabled, and both endpoints 404 with OIDC_ENABLED off. 30 backend tests and 3 frontend tests; the security-critical guards were mutation-tested. Full backend suite 1913 tests with the same failures as dev, frontend 104 passing, eslint clean, tsc at baseline, ruff clean. Docs cover the setup and the callback URL to register. PKCE is left configurable per provider rather than forced on, and the auth cookies stay JS-readable exactly as the password login already sets them — changing that is an app-wide decision that shouldn't hide inside an SSO PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds OpenID Connect single sign-on, so people can log in with Keycloak, Authentik, Authelia, Zitadel, Google, Entra ID or anything else that speaks OIDC. It uses django-allauth for the redirect/callback dance and then mints the same simplejwt access/refresh pair the password login issues, which is the shape you asked for on #401 — nothing downstream can tell an SSO login from a password login. Username/password login is untouched, so this takes nobody's existing way in away.
Provider credentials live in an admin-editable
SocialApprather than the environment, with three Constance flags alongside:OIDC_ENABLED,OIDC_BUTTON_LABEL, andOIDC_ALLOW_SIGNUP(off by default). The login screen asks the unauthenticated/api/auth/sso/configwhether to show a button, and a failed attempt returns to/login?sso_error=<reason>with a message naming what to fix rather than a dead end.All the login and account-linking policy sits in
api/adapters.py, following your steer that email is the crux here. Linking a provider identity to an existing account requires the provider to assert a verified email, because otherwise anyone able to register that address at the IdP could claim the account. It refuses an ambiguous match (two accounts sharing an address — linking either would be a guess) and a blank one (plenty of LibrePhotos accounts have no email set, and none of them is a match for""). Creating an account on first login requiresOIDC_ALLOW_SIGNUPand a configured email provider, since without email a "verified" claim isn't something the install can meaningfully act on. Accounts created this way are never superuser or staff — including during first-time setup, when no superuser exists yet.Two things I'd particularly like you to look at.
The OAuth
redirect_urican't be derived from the request. The proxy forwards/api/to the backend withHostrewritten tobackend, so allauth computedhttp://backend/api/accounts/...— a name only resolvable inside the Docker network, unreachable by the browser and rejected by the IdP as an unregistered redirect URI. That rewrite is deliberate (ALLOWED_HOSTSlists onlylocalhostandBACKEND_HOST, so host validation passes whatever domain a user browses to), which rules out recovering the origin fromX-Forwarded-Hostunless we also loosenALLOWED_HOSTS— well beyond this PR. So the public origin comes fromFRONTEND_BASE_URL, the setting the password-reset link already established for exactly this problem; I centralised that policy inapi/public_url.pyand pointed both at it. A configured constant is also what keeps theredirect_uribyte-identical between the authorize request and the token exchange, which a request-derived value can't guarantee (the same install reached by LAN IP and by domain would produce a mismatch).The override has to be installed on the provider's
oauth2_adapter_classrather than on the view, which is the one bit of the diff that looks odd out of context:OAuth2Provider.redirect()constructs its own adapter from that attribute and ignores whatever instance a view was handed, so overriding only the view leaves the login redirect using allauth's callback URL while the token exchange uses ours. There's a test asserting it stays installed, since an allauth upgrade could otherwise break it silently.Mounting
allauth.urlsexposes more than the OIDC routes. It also mounted allauth's own session-based account views, all live and returning 200:/api/accounts/login/(a second, session-based login form),/api/accounts/signup/, and/api/accounts/password/reset/— a second reset flow that would have bypassed the rate limiting and address-enumeration protections on the reset endpoint added in #1907 — plus password change/set and email management.SOCIALACCOUNT_ONLY = Truedrops them while leaving allauth's internalreverse()targets resolvable. Despite the name it doesn't affect hybrid login: it disables allauth's local authentication, and LibrePhotos' password login is a separate simplejwt endpoint allauth never sees. Related:OIDC_ENABLEDoriginally only decided whether the button was advertised, leaving the endpoints working — so turning SSO off didn't actually turn it off. Both now 404 when it's unset.Tested end-to-end through the proxy against two real IdPs, Keycloak and Dex: verified-email linking mints a working JWT for the right non-privileged user and drops the allauth session behind it, unverified email is refused, provisioning is refused while disabled, and both endpoints 404 with
OIDC_ENABLEDoff. 30 backend tests and 3 frontend ones; the security-critical guards are mutation-tested rather than assumed (removing the unverified-email check fails its test,SOCIALACCOUNT_ONLY = Falsefails five). Full backend suite is 1913 tests with exactly the same six failures present on unmodifieddev(OCR/exif/import issues and a timing-sensitive benchmark, all environmental to my dev image); frontend 104 passing, eslint clean,tscat baseline, ruff clean.Docs are included, since this needs a client registered at the IdP, a
SocialApp, three flags and an environment variable — there's a settings page with the exact callback URL to register and what each refusal message means, andFRONTEND_BASE_URLis now documented (it already mattered for password-reset links).Two deliberate omissions: PKCE is left configurable per provider rather than forced on, since the flow is server-side with a confidential client and forcing it would break IdPs that don't support it; and the auth cookies stay JS-readable exactly as the password login already sets them, because changing that is an app-wide decision that shouldn't hide inside an SSO PR. Happy to do either if you'd rather.
Local Keycloak and Dex cover the standard OIDC path but not every provider's quirks (Azure and Google differ in places), which is the honest blind spot here.