Skip to content

feat: OpenID Connect single sign-on (Closes #401) - #1960

Open
dotanm wants to merge 1 commit into
LibrePhotos:devfrom
dotanm:backend/oidc-sso
Open

feat: OpenID Connect single sign-on (Closes #401)#1960
dotanm wants to merge 1 commit into
LibrePhotos:devfrom
dotanm:backend/oidc-sso

Conversation

@dotanm

@dotanm dotanm commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

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 SocialApp rather than the environment, with three Constance flags alongside: OIDC_ENABLED, OIDC_BUTTON_LABEL, and OIDC_ALLOW_SIGNUP (off by default). The login screen asks the unauthenticated /api/auth/sso/config whether 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 requires OIDC_ALLOW_SIGNUP and 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_uri can't be derived from the request. The proxy forwards /api/ to the backend with Host rewritten to backend, so allauth computed http://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_HOSTS lists only localhost and BACKEND_HOST, so host validation passes whatever domain a user browses to), which rules out recovering the origin from X-Forwarded-Host unless we also loosen ALLOWED_HOSTS — well beyond this PR. So the public origin comes from FRONTEND_BASE_URL, the setting the password-reset link already established for exactly this problem; I centralised that policy in api/public_url.py and pointed both at it. A configured constant is also what keeps the redirect_uri byte-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_class rather 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.urls exposes 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 = True drops them while leaving allauth's internal reverse() 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_ENABLED originally 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_ENABLED off. 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 = False fails five). Full backend suite is 1913 tests with exactly the same six failures present on unmodified dev (OCR/exif/import issues and a timing-sensitive benchmark, all environmental to my dev image); frontend 104 passing, eslint clean, tsc at 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, and FRONTEND_BASE_URL is 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.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant