diff --git a/apps/backend/api/adapters.py b/apps/backend/api/adapters.py new file mode 100644 index 0000000000..aedadb9452 --- /dev/null +++ b/apps/backend/api/adapters.py @@ -0,0 +1,132 @@ +"""django-allauth adapters implementing LibrePhotos' OIDC/SSO login policy. + +All of the security-sensitive decisions for single-sign-on live here rather than +in settings, because the adapter API is stable across allauth versions and can +read runtime state (the OIDC_* Constance flags, whether an email provider is +configured, and whether any superuser exists yet). + +Policy (per the maintainer's steer on issue #401): + +* **Hybrid login.** allauth only ever runs for the OIDC flow; regular + username/password login is untouched (see AUTHENTICATION_BACKENDS). +* **No local allauth signup.** LibrePhotos has its own registration; allauth must + never create an account through its own signup forms. +* **Verified email only.** An SSO login may connect to — or provision — a + LibrePhotos account only when the identity provider asserts a *verified* email. + Linking on an unverified email would let anyone who can register that address at + the IdP take over an existing LibrePhotos account. +* **Admin-provisioned by default.** A first-time SSO login creates a new account + only when the admin has turned on OIDC_ALLOW_SIGNUP *and* configured an email + provider (so the verified-email claim is meaningful). Otherwise SSO only logs in + users that already exist and whose email an admin has set to match the IdP. +* **Never privileged.** An account created via SSO is never a superuser or staff, + even during first-time setup when no superuser exists yet. +""" + +from allauth.account.adapter import DefaultAccountAdapter +from allauth.core.exceptions import ImmediateHttpResponse +from allauth.socialaccount.adapter import DefaultSocialAccountAdapter +from constance import config as site_config +from django.http import HttpResponseRedirect + +from api.mail import email_is_configured +from api.models import User + + +def _login_error_redirect(reason): + """Bounce back to the SPA login screen with a machine-readable reason. + + Relative URL on purpose: the browser resolves it against the origin it is + actually talking to (the proxy), sidestepping the fact that the proxy + rewrites the Host header to ``backend`` for ``/api/`` requests. + """ + return ImmediateHttpResponse(HttpResponseRedirect(f"/login?sso_error={reason}")) + + +def _extract_email(sociallogin): + """Return (email, is_verified) for the incoming social login. + + Prefers allauth's parsed EmailAddress list (which reflects the provider's + ``email_verified`` handling) and falls back to the raw OIDC claims. + """ + email = "" + verified = False + for address in sociallogin.email_addresses: + if address.email: + email = address.email.strip().lower() + verified = bool(address.verified) + break + if not email: + extra = sociallogin.account.extra_data or {} + email = (extra.get("email") or "").strip().lower() + verified = bool(extra.get("email_verified", False)) + return email, verified + + +class NoLocalSignupAccountAdapter(DefaultAccountAdapter): + """Disable allauth's own username/password signup entirely. + + LibrePhotos owns local registration; allauth is present purely to broker + OIDC. This closes the door on allauth ever creating a local account. + """ + + def is_open_for_signup(self, request): + return False + + +class SSOSocialAccountAdapter(DefaultSocialAccountAdapter): + def _provisioning_allowed(self): + """Whether a first-time SSO login may create a new LibrePhotos account.""" + return bool(site_config.OIDC_ALLOW_SIGNUP) and email_is_configured() + + def is_open_for_signup(self, request, sociallogin): + return self._provisioning_allowed() + + def pre_social_login(self, request, sociallogin): + """Enforce the linking/provisioning policy before allauth commits. + + Runs after the IdP round-trip but before a user is logged in or created. + """ + # The social account is already linked to a LibrePhotos user: nothing to + # decide, let the login proceed. + if sociallogin.is_existing: + return + + email, verified = _extract_email(sociallogin) + + # Try to match an existing account by email. Only an unambiguous, + # non-empty match counts — many LibrePhotos users have a blank email, and + # we must never link a social identity to the wrong (or every) account. + existing = None + if email: + matches = list(User.objects.filter(email__iexact=email)) + if len(matches) == 1: + existing = matches[0] + + if existing is not None: + if not verified: + # Account-takeover guard: refuse to attach an unverified IdP + # email to an existing local account. + raise _login_error_redirect("email_not_verified") + sociallogin.connect(request, existing) + return + + # No existing account -> this would be a brand-new signup. + if not self._provisioning_allowed(): + raise _login_error_redirect("signup_disabled") + if not verified: + raise _login_error_redirect("email_not_verified") + # Fall through: allauth proceeds to save_user() below. + + def save_user(self, request, sociallogin, form=None): + """Create the LibrePhotos account, forced non-privileged. + + Even during first-time setup (no superuser yet) an SSO-provisioned user + must never become a superuser or staff member. + """ + user = super().save_user(request, sociallogin, form=form) + if user.is_superuser or user.is_staff: + user.is_superuser = False + user.is_staff = False + user.save(update_fields=["is_superuser", "is_staff"]) + return user diff --git a/apps/backend/api/apps.py b/apps/backend/api/apps.py index c35f941550..733260301a 100644 --- a/apps/backend/api/apps.py +++ b/apps/backend/api/apps.py @@ -34,3 +34,28 @@ def ready(self): # ready() runs in every worker and management command: failing to # adjust a rotation setting is never worth refusing to start over. logger.exception("Unexpected error while configuring logging") + + self._install_oidc_adapter() + + def _install_oidc_adapter(self): + """Point allauth's OIDC provider at our callback-URL-aware adapter. + + ``oauth2_adapter_class`` is the attribute allauth expects a provider to + set in order to supply its own adapter, but the generic OIDC provider is + instantiated per ``SocialApp`` rather than subclassed, so there is no + subclass of our own to declare it on — hence assigning it here. + + This has to happen at the provider level rather than in the view: the + login redirect is built by ``OAuth2Provider.redirect()``, which + constructs a *fresh* adapter from this attribute and ignores whatever + instance a view was handed. Overriding only the view would leave the + login redirect using allauth's callback URL and the token exchange using + ours, and OIDC requires the two to be byte-identical. + """ + from allauth.socialaccount.providers.openid_connect.provider import ( + OpenIDConnectProvider, + ) + + from api.views.sso import LibrePhotosOIDCAdapter + + OpenIDConnectProvider.oauth2_adapter_class = LibrePhotosOIDCAdapter diff --git a/apps/backend/api/public_url.py b/apps/backend/api/public_url.py new file mode 100644 index 0000000000..a96bd49529 --- /dev/null +++ b/apps/backend/api/public_url.py @@ -0,0 +1,53 @@ +"""Working out the public, browser-reachable base URL of this installation. + +The reverse proxy forwards ``/api/`` and ``/media/`` to the backend with the +Host header rewritten to ``backend`` (see deploy/docker/proxy/nginx.conf), which +is deliberate: ``ALLOWED_HOSTS`` only lists ``localhost`` and ``BACKEND_HOST``, so +Django's host validation passes no matter what domain the user actually browses +to. The cost is that ``request.build_absolute_uri()`` on an ``/api/`` request +yields ``http://backend/...`` — a name only resolvable inside the Docker network. + +So anything that hands a URL to the outside world (a link in an email, an OAuth +``redirect_uri`` the browser must follow) has to be told the public origin rather +than inferring it from the request. +""" + +import os + +from django.conf import settings + + +def _internal_hosts(): + """Host names the proxy substitutes for ``/api/`` requests. + + A base URL pointing at one of these is reachable only from inside the Docker + network, so it cannot be handed to a browser. ``localhost`` is deliberately + *not* included: for a single-machine install it is a perfectly good public + origin. + """ + return {"backend", os.environ.get("BACKEND_HOST", "backend")} + + +def public_base_url(request=None): + """Return the public base URL (no trailing slash), or ``""`` if unknown. + + Prefers the explicitly configured ``FRONTEND_BASE_URL``. Falls back to the + origin the request arrived on, which is correct for a direct-to-Django + deployment and wrong behind the standard proxy — hence the caller-visible + empty-string case for "could not determine a public URL". + """ + configured = (settings.FRONTEND_BASE_URL or "").rstrip("/") + if configured: + return configured + if request is None: + return "" + return request.build_absolute_uri("/").rstrip("/") + + +def is_internal_base_url(base_url): + """Whether ``base_url`` points somewhere only the Docker network can reach.""" + if not base_url: + return True + without_scheme = base_url.split("://", 1)[-1] + host = without_scheme.split("/", 1)[0].split(":", 1)[0] + return host in _internal_hosts() diff --git a/apps/backend/api/tests/test_oidc_sso.py b/apps/backend/api/tests/test_oidc_sso.py new file mode 100644 index 0000000000..e57304e885 --- /dev/null +++ b/apps/backend/api/tests/test_oidc_sso.py @@ -0,0 +1,386 @@ +"""Tests for OIDC/SSO login (#401). + +Three things are worth guarding here, in rising order of consequence: + +* the public-URL plumbing, because behind the standard proxy the request cannot + tell us the origin and a wrong ``redirect_uri`` breaks the flow outright; +* the JWT bridge, because it is what turns an external identity into a + LibrePhotos credential; +* the adapter policy, because getting it wrong means account takeover — linking + an unverified provider email onto an existing account, or provisioning a + privileged user. +""" + +from allauth.account.models import EmailAddress +from allauth.core.exceptions import ImmediateHttpResponse +from allauth.socialaccount.models import SocialAccount, SocialApp, SocialLogin +from constance.test import override_config +from django.contrib.auth import get_user_model +from django.contrib.messages.middleware import MessageMiddleware +from django.contrib.sessions.middleware import SessionMiddleware +from django.contrib.sites.models import Site +from django.test import RequestFactory, TestCase, override_settings +from rest_framework.test import APIClient +from rest_framework_simplejwt.tokens import AccessToken + +from api.adapters import SSOSocialAccountAdapter +from api.public_url import is_internal_base_url, public_base_url +from api.views.sso import LibrePhotosOIDCAdapter + +User = get_user_model() + +CONFIG_URL = "/api/auth/sso/config/" +FINISH_URL = "/api/auth/sso/finish/" +CALLBACK_PATH = "/api/accounts/oidc/keycloak/login/callback/" + + +def _request(path=CALLBACK_PATH): + """A request complete enough for allauth to log a user in against. + + ``sociallogin.connect()`` touches the session and the message store, which a + bare RequestFactory request does not have. + """ + request = RequestFactory().get(path) + SessionMiddleware(lambda r: None).process_request(request) + request.session.save() + MessageMiddleware(lambda r: None).process_request(request) + return request + + +def _make_social_app(provider_id="keycloak", name="Keycloak"): + """The admin-configured provider. allauth resolves it whenever it needs to + turn a SocialAccount back into a provider, so linking needs one to exist.""" + app = SocialApp.objects.create( + provider="openid_connect", + provider_id=provider_id, + name=name, + client_id="librephotos", + secret="s3cret", + settings={"server_url": "https://idp.example.com"}, + ) + app.sites.add(Site.objects.get_current()) + return app + + +def _make_social_login(email="sso@example.com", verified=True, uid="idp-sub-1"): + """A social login as it looks after the IdP round-trip but before commit.""" + account = SocialAccount(provider="keycloak", uid=uid) + sociallogin = SocialLogin(user=User(), account=account) + sociallogin.email_addresses = ( + [EmailAddress(email=email, verified=verified, primary=True)] if email else [] + ) + if email: + sociallogin.account.extra_data = {"email": email, "email_verified": verified} + else: + sociallogin.account.extra_data = {} + return sociallogin + + +class PublicBaseUrlTest(TestCase): + """The proxy rewrites Host for /api/, so the origin has to be configured.""" + + def setUp(self): + self.rf = RequestFactory() + + @override_settings(FRONTEND_BASE_URL="https://photos.example.com/") + def test_prefers_configured_url_and_strips_trailing_slash(self): + request = self.rf.get("/api/anything/", HTTP_HOST="backend") + self.assertEqual(public_base_url(request), "https://photos.example.com") + + @override_settings(FRONTEND_BASE_URL="") + def test_falls_back_to_request_origin_when_unconfigured(self): + request = self.rf.get("/api/anything/", HTTP_HOST="localhost") + self.assertEqual(public_base_url(request), "http://localhost") + + @override_settings(FRONTEND_BASE_URL="") + def test_no_request_and_no_config_yields_empty(self): + self.assertEqual(public_base_url(None), "") + + def test_internal_detection(self): + self.assertTrue(is_internal_base_url("")) + self.assertTrue(is_internal_base_url("http://backend")) + self.assertTrue(is_internal_base_url("http://backend:8001/api")) + # localhost is a legitimate public origin for a single-machine install. + self.assertFalse(is_internal_base_url("http://localhost:3000")) + self.assertFalse(is_internal_base_url("https://photos.example.com")) + + +class CallbackUrlTest(TestCase): + """The redirect_uri must be browser-reachable and independent of the Host.""" + + @override_settings(FRONTEND_BASE_URL="https://photos.example.com") + def test_callback_url_uses_configured_origin_not_request_host(self): + request = RequestFactory().get(CALLBACK_PATH, HTTP_HOST="backend") + adapter = LibrePhotosOIDCAdapter(request, "keycloak") + + url = adapter.get_callback_url(request, app=None) + + self.assertEqual(url, f"https://photos.example.com{CALLBACK_PATH}") + self.assertNotIn("backend", url) + + def test_the_provider_actually_uses_our_adapter(self): + """The whole login redirect hangs off this being installed. + + ``OAuth2Provider.redirect()`` builds its own adapter from + ``oauth2_adapter_class`` rather than using the one the view holds, so if + ApiConfig.ready() stopped setting it, the login redirect would silently go + back to allauth's unreachable http://backend/... callback while the token + exchange kept using ours. + """ + from allauth.socialaccount.providers.openid_connect.provider import ( + OpenIDConnectProvider, + ) + + self.assertIs( + OpenIDConnectProvider.oauth2_adapter_class, LibrePhotosOIDCAdapter + ) + + @override_config(OIDC_ENABLED=True) + @override_settings(FRONTEND_BASE_URL="") + def test_login_refuses_when_public_url_is_unknown(self): + """Better a named error than an opaque failure at the provider.""" + # HTTP_HOST=backend reproduces what the proxy actually sends. + resp = self.client.get( + "/api/accounts/oidc/keycloak/login/", HTTP_HOST="backend" + ) + + self.assertEqual(resp.status_code, 302) + self.assertEqual(resp["Location"], "/login?sso_error=public_url_not_configured") + + +class OidcKillSwitchTest(TestCase): + """OIDC_ENABLED has to disable the flow, not just hide the button.""" + + def setUp(self): + _make_social_app() + + @override_config(OIDC_ENABLED=False) + @override_settings(FRONTEND_BASE_URL="https://photos.example.com") + def test_login_endpoint_is_gone_when_disabled(self): + resp = self.client.get("/api/accounts/oidc/keycloak/login/") + self.assertEqual(resp.status_code, 404) + + @override_config(OIDC_ENABLED=False) + def test_callback_endpoint_is_gone_when_disabled(self): + resp = self.client.get(CALLBACK_PATH, {"code": "x", "state": "y"}) + self.assertEqual(resp.status_code, 404) + + @override_config(OIDC_ENABLED=True) + @override_settings(FRONTEND_BASE_URL="https://photos.example.com") + def test_unknown_provider_is_a_404_not_a_crash(self): + resp = self.client.get("/api/accounts/oidc/nosuchprovider/login/") + self.assertEqual(resp.status_code, 404) + + +class SSOConfigViewTest(TestCase): + """What the login screen is told, and that it can ask without a token.""" + + def setUp(self): + self.client = APIClient() + + @override_config(OIDC_ENABLED=False) + def test_disabled_when_flag_off(self): + _make_social_app() + resp = self.client.get(CONFIG_URL) + self.assertEqual(resp.status_code, 200) + self.assertFalse(resp.json()["enabled"]) + + @override_config(OIDC_ENABLED=True) + def test_not_enabled_without_a_configured_provider(self): + resp = self.client.get(CONFIG_URL) + self.assertFalse(resp.json()["enabled"]) + self.assertEqual(resp.json()["providers"], []) + + @override_config(OIDC_ENABLED=True, OIDC_BUTTON_LABEL="Sign in with Keycloak") + def test_lists_provider_when_enabled(self): + _make_social_app() + + body = self.client.get(CONFIG_URL).json() + + self.assertTrue(body["enabled"]) + self.assertEqual(body["label"], "Sign in with Keycloak") + self.assertEqual( + body["providers"], + [ + { + "id": "keycloak", + "name": "Keycloak", + "login_url": "/api/accounts/oidc/keycloak/login/", + } + ], + ) + + @override_config(OIDC_ENABLED=True) + def test_reveals_no_secrets(self): + _make_social_app() + self.assertNotIn("s3cret", self.client.get(CONFIG_URL).content.decode()) + + +class JWTBridgeTest(TestCase): + """sso_finish is what makes an external login a LibrePhotos credential.""" + + def setUp(self): + self.user = User.objects.create(username="ssouser", email="sso@example.com") + + def test_unauthenticated_request_mints_nothing(self): + resp = self.client.get(FINISH_URL) + + self.assertEqual(resp.status_code, 302) + self.assertEqual(resp["Location"], "/login?sso_error=not_authenticated") + self.assertNotIn("access", resp.cookies) + self.assertNotIn("jwt", resp.cookies) + + def test_authenticated_request_sets_usable_tokens(self): + self.client.force_login(self.user) + + resp = self.client.get(FINISH_URL) + + self.assertEqual(resp.status_code, 302) + self.assertEqual(resp["Location"], "/") + for name in ("access", "refresh", "jwt"): + self.assertIn(name, resp.cookies, f"{name} cookie not set") + + access = resp.cookies["access"].value + self.assertEqual(int(AccessToken(access)["user_id"]), self.user.pk) + # The SPA reads `jwt` and `access` interchangeably; keep them in step. + self.assertEqual(resp.cookies["jwt"].value, access) + + def test_the_allauth_session_does_not_outlive_the_handoff(self): + """Otherwise every SSO login leaves a second, session-based auth path.""" + self.client.force_login(self.user) + + resp = self.client.get(FINISH_URL) + + self.assertNotIn("_auth_user_id", self.client.session) + # Django signals the drop by expiring the cookie. + self.assertEqual(resp.cookies["sessionid"].value, "") + + def test_minted_token_carries_no_privilege_it_should_not(self): + self.client.force_login(self.user) + + access = self.client.get(FINISH_URL).cookies["access"].value + + self.assertFalse(AccessToken(access).get("is_admin", False)) + + +class AdapterLinkingPolicyTest(TestCase): + """Who an SSO login is allowed to become.""" + + def setUp(self): + _make_social_app() + self.adapter = SSOSocialAccountAdapter() + self.request = _request() + + def _run(self, sociallogin): + """Return the error reason the adapter bounced with, or None if allowed.""" + try: + self.adapter.pre_social_login(self.request, sociallogin) + except ImmediateHttpResponse as exc: + location = exc.response["Location"] + return location.split("sso_error=")[-1] + return None + + def test_verified_email_links_to_the_matching_account(self): + user = User.objects.create(username="alice", email="sso@example.com") + sociallogin = _make_social_login(email="sso@example.com", verified=True) + + self.assertIsNone(self._run(sociallogin)) + self.assertEqual(sociallogin.user.pk, user.pk) + + def test_unverified_email_cannot_claim_an_existing_account(self): + """The account-takeover guard: anyone can register an address at some IdP.""" + User.objects.create(username="alice", email="sso@example.com") + sociallogin = _make_social_login(email="sso@example.com", verified=False) + + self.assertEqual(self._run(sociallogin), "email_not_verified") + + def test_email_match_is_case_insensitive(self): + user = User.objects.create(username="alice", email="Alice@Example.com") + sociallogin = _make_social_login(email="alice@example.com", verified=True) + + self.assertIsNone(self._run(sociallogin)) + self.assertEqual(sociallogin.user.pk, user.pk) + + @override_config(OIDC_ALLOW_SIGNUP=False) + def test_unknown_email_is_refused_when_signup_is_off(self): + sociallogin = _make_social_login(email="nobody@example.com", verified=True) + + self.assertEqual(self._run(sociallogin), "signup_disabled") + + def test_ambiguous_email_never_links(self): + """Two accounts share the address: linking either one is a guess.""" + User.objects.create(username="alice", email="shared@example.com") + User.objects.create(username="bob", email="shared@example.com") + sociallogin = _make_social_login(email="shared@example.com", verified=True) + + self.assertEqual(self._run(sociallogin), "signup_disabled") + + def test_blank_idp_email_never_links(self): + """Many LibrePhotos accounts have no email; none of them is a match.""" + User.objects.create(username="alice", email="") + sociallogin = _make_social_login(email="", verified=True) + + self.assertEqual(self._run(sociallogin), "signup_disabled") + + def test_already_linked_account_skips_the_policy(self): + user = User.objects.create(username="alice", email="sso@example.com") + SocialAccount.objects.create(user=user, provider="keycloak", uid="idp-sub-1") + sociallogin = _make_social_login(email="other@example.com", verified=False) + sociallogin.user = user + sociallogin.account = SocialAccount.objects.get(uid="idp-sub-1") + + self.assertIsNone(self._run(sociallogin)) + + +class AdapterProvisioningTest(TestCase): + """Provisioning needs both the admin's say-so and a meaningful email claim.""" + + def setUp(self): + self.adapter = SSOSocialAccountAdapter() + self.request = _request() + + @override_config(OIDC_ALLOW_SIGNUP=True) + def test_signup_stays_closed_while_email_is_unconfigured(self): + """Without email configured a "verified" claim cannot be trusted.""" + self.assertFalse(self.adapter.is_open_for_signup(self.request, None)) + + @override_config(OIDC_ALLOW_SIGNUP=False) + def test_signup_stays_closed_when_the_admin_has_not_opted_in(self): + with self.settings(EMAIL_HOST="smtp.example.com"): + self.assertFalse(self.adapter.is_open_for_signup(self.request, None)) + + def test_provisioned_user_is_never_privileged(self): + """Notably also during first-time setup, when no superuser exists yet.""" + self.assertFalse(User.objects.filter(is_superuser=True).exists()) + sociallogin = _make_social_login(email="new@example.com", verified=True) + sociallogin.user = User(username="newcomer", email="new@example.com") + sociallogin.user.is_superuser = True + sociallogin.user.is_staff = True + + user = self.adapter.save_user(self.request, sociallogin) + + self.assertFalse(user.is_superuser) + self.assertFalse(user.is_staff) + user.refresh_from_db() + self.assertFalse(user.is_superuser) + self.assertFalse(user.is_staff) + + +class AllauthSurfaceTest(TestCase): + """allauth is here to broker OIDC, not to add a second way to log in.""" + + def test_allauth_local_auth_routes_are_not_exposed(self): + for path in ( + "/api/accounts/signup/", + "/api/accounts/password/reset/", + "/api/accounts/password/change/", + "/api/accounts/email/", + ): + with self.subTest(path=path): + self.assertEqual(self.client.get(path).status_code, 404) + + def test_the_remaining_login_view_cannot_authenticate(self): + resp = self.client.post( + "/api/accounts/login/", {"login": "alice", "password": "hunter2"} + ) + self.assertEqual(resp.status_code, 403) diff --git a/apps/backend/api/views/password_reset.py b/apps/backend/api/views/password_reset.py index dc7c4a79cb..831671a6fc 100644 --- a/apps/backend/api/views/password_reset.py +++ b/apps/backend/api/views/password_reset.py @@ -1,6 +1,5 @@ import logging -from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.password_validation import validate_password from django.contrib.auth.tokens import default_token_generator @@ -13,24 +12,13 @@ from rest_framework.throttling import ScopedRateThrottle from rest_framework.views import APIView +from api.public_url import public_base_url + logger = logging.getLogger(__name__) User = get_user_model() -def _frontend_base_url(request): - """Where the SPA lives, for building the reset link. - - Prefers the explicitly configured FRONTEND_BASE_URL and otherwise falls - back to the origin the request itself arrived on, so a default single-host - deployment works without any extra configuration. - """ - configured = (settings.FRONTEND_BASE_URL or "").rstrip("/") - if configured: - return configured - return request.build_absolute_uri("/").rstrip("/") - - class PasswordResetView(APIView): """Request a password-reset email. @@ -59,7 +47,7 @@ def post(self, request, format=None): def _send_reset_email(self, request, user): uid = urlsafe_base64_encode(force_bytes(user.pk)) token = default_token_generator.make_token(user) - base = _frontend_base_url(request) + base = public_base_url(request) link = f"{base}/password-reset/confirm/{uid}/{token}" subject = "Reset your LibrePhotos password" diff --git a/apps/backend/api/views/sso.py b/apps/backend/api/views/sso.py new file mode 100644 index 0000000000..34e3dbc9c9 --- /dev/null +++ b/apps/backend/api/views/sso.py @@ -0,0 +1,171 @@ +"""The OIDC/SSO -> JWT bridge, plus the login-screen discovery endpoint. + +LibrePhotos authenticates with simplejwt, not Django sessions. allauth runs the +OIDC redirect/callback dance and, on success, establishes a short-lived Django +session and redirects to ``LOGIN_REDIRECT_URL`` (this module's ``sso_finish``). +``sso_finish`` mints the *same* access/refresh pair the password login issues, +sets the cookies the SPA reads on boot, and redirects into the app — so an SSO +login is indistinguishable from a password login everywhere downstream. +""" + +from allauth.socialaccount.models import SocialApp +from allauth.socialaccount.providers.oauth2.views import ( + OAuth2CallbackView, + OAuth2LoginView, +) +from allauth.socialaccount.providers.openid_connect.views import ( + OpenIDConnectOAuth2Adapter, +) +from constance import config as site_config +from django.contrib.auth import logout as django_logout +from django.contrib.auth.decorators import login_not_required +from django.http import Http404, HttpResponseRedirect +from django.urls import reverse +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView + +from api.public_url import is_internal_base_url, public_base_url + + +class LibrePhotosOIDCAdapter(OpenIDConnectOAuth2Adapter): + """Builds the OAuth ``redirect_uri`` from the configured public URL. + + allauth derives the callback from ``request.build_absolute_uri()``, which + behind the standard proxy resolves to ``http://backend/...`` — the browser + cannot follow that, and the IdP would reject it as an unregistered redirect + URI. The public origin has to come from configuration instead; see + api/public_url.py for why the request cannot tell us. + + The value must also be byte-identical between the authorize request and the + later token exchange, which a configured constant guarantees and a + request-derived one does not (a user reaching the same install by LAN IP and + by domain would otherwise produce a mismatch). + """ + + def get_callback_url(self, request, app): + base = public_base_url(request) + path = reverse( + "openid_connect_callback", kwargs={"provider_id": self.provider_id} + ) + return f"{base}{path}" + + +def _oidc_view(view_class, request, provider_id): + """Run one of allauth's OAuth2 views against our adapter subclass. + + allauth's own ``oidc//login/`` and ``.../callback/`` views hardcode the + stock adapter, so we serve those two paths ourselves to substitute ours. + Everything else about the flow is unchanged allauth. + + Both paths are gated on OIDC_ENABLED so that the flag is an actual off switch + and not merely a hidden button: an admin turning SSO off (say, because the + IdP was compromised) must stop these endpoints working, not just stop + advertising them. 404 rather than an error page — when SSO is off, this + genuinely is not a thing the server does. + """ + if not site_config.OIDC_ENABLED: + raise Http404 + try: + view = view_class.adapter_view(LibrePhotosOIDCAdapter(request, provider_id)) + return view(request) + except SocialApp.DoesNotExist: + # Raised when the URL names a provider_id no SocialApp defines — and note + # it surfaces from the view's dispatch, not from building the adapter, so + # the call has to be inside the try (allauth's own view wraps only the + # construction and lets this escape as a 500). + raise Http404 + + +@login_not_required +def oidc_login(request, provider_id): + """Start the OIDC redirect, refusing early if we have no public URL to give. + + Without this guard allauth would happily send the browser to the IdP with + ``redirect_uri=http://backend/...``, and the user would land on an opaque + provider-side error instead of something that names the actual problem. + """ + if site_config.OIDC_ENABLED and is_internal_base_url(public_base_url(request)): + return HttpResponseRedirect("/login?sso_error=public_url_not_configured") + return _oidc_view(OAuth2LoginView, request, provider_id) + + +@login_not_required +def oidc_callback(request, provider_id): + return _oidc_view(OAuth2CallbackView, request, provider_id) + + +def _mint_jwt_for(user): + """Issue a LibrePhotos access/refresh pair with the standard custom claims. + + Imported lazily from the root URLconf to reuse the exact claim logic the + password login uses without creating an import cycle at settings-load time. + """ + from librephotos.urls import CustomTokenObtainPairSerializer + + refresh = CustomTokenObtainPairSerializer.get_token(user) + return str(refresh.access_token), str(refresh) + + +def _set_auth_cookies(response, access, refresh): + # Mirror CustomTokenObtainPairView: the SPA reads these cookies (via + # react-cookie) to boot already authenticated. Not HttpOnly, matching the + # existing password-login behaviour so the client can read them. + response.set_cookie("access", access) + response.set_cookie("refresh", refresh) + response.set_cookie("jwt", access) + response["Access-Control-Allow-Credentials"] = "true" + return response + + +def sso_finish(request): + """allauth's post-login landing: mint the JWT and hand off to the SPA.""" + if not request.user.is_authenticated: + return HttpResponseRedirect("/login?sso_error=not_authenticated") + + access, refresh = _mint_jwt_for(request.user) + + # The Django session was only a vehicle for the allauth handshake; the app + # runs on JWT, so drop it to avoid leaving a second, session-based auth path. + django_logout(request) + + response = HttpResponseRedirect("/") + _set_auth_cookies(response, access, refresh) + return response + + +class SSOConfigView(APIView): + """Public: tells the login screen whether to show the SSO button and where + it should point. Returns nothing sensitive — only enabled providers' display + names and their allauth login URLs.""" + + permission_classes = (AllowAny,) + authentication_classes = () + + def get(self, request): + from allauth.socialaccount.models import SocialApp + + enabled = bool(site_config.OIDC_ENABLED) + providers = [] + if enabled: + for app in SocialApp.objects.filter(provider="openid_connect"): + provider_id = app.provider_id or app.client_id + try: + login_url = reverse( + "openid_connect_login", + kwargs={"provider_id": provider_id}, + ) + except Exception: + # A misconfigured SocialApp shouldn't break the login screen. + continue + providers.append( + {"id": provider_id, "name": app.name, "login_url": login_url} + ) + + return Response( + { + "enabled": enabled and len(providers) > 0, + "label": site_config.OIDC_BUTTON_LABEL, + "providers": providers, + } + ) diff --git a/apps/backend/librephotos/settings/production.py b/apps/backend/librephotos/settings/production.py index 1a22a32098..a454f71083 100644 --- a/apps/backend/librephotos/settings/production.py +++ b/apps/backend/librephotos/settings/production.py @@ -109,6 +109,8 @@ def _env_flag(name, default=True): "django.contrib.messages", "django.contrib.staticfiles", "django.contrib.postgres", + # django.contrib.sites is required by allauth (SocialApp is tied to a Site). + "django.contrib.sites", "api", "nextcloud", "rest_framework", @@ -119,8 +121,19 @@ def _env_flag(name, default=True): "constance", "constance.backends.database", "django_q", + # OIDC / SSO via django-allauth. The generic openid_connect provider handles + # any standards-compliant IdP (Keycloak, Authentik, Authelia, Zitadel, Google, + # Azure, ...). Provider credentials live in the DB (admin-editable SocialApp), + # not the environment. See api/adapters.py for the login/linking policy and + # api/views/sso.py for the JWT bridge. + "allauth", + "allauth.account", + "allauth.socialaccount", + "allauth.socialaccount.providers.openid_connect", ] +SITE_ID = 1 + Q_CLUSTER = { "name": "DjangORM", "queue_limit": 50, @@ -287,6 +300,27 @@ def _env_flag(name, default=True): "Number of rotated log files to keep (default 10)", int, ), + "OIDC_ENABLED": ( + False, + "Show a single-sign-on (SSO) button on the login screen. Requires a " + "configured OpenID Connect provider (Admin → Social Applications) and, " + "for provisioning new users, a configured email provider (Site Settings " + "→ Email) so the identity provider's email can be trusted.", + bool, + ), + "OIDC_BUTTON_LABEL": ( + "Sign in with SSO", + "Text shown on the single-sign-on button on the login screen", + str, + ), + "OIDC_ALLOW_SIGNUP": ( + False, + "Allow a first-time SSO login to create a new LibrePhotos account. When " + "off, SSO only logs in users that already exist (admin-provisioned). " + "Auto-creation additionally requires a configured email provider so the " + "identity provider's verified-email claim can be trusted.", + bool, + ), } INTERNAL_IPS = ("127.0.0.1", "localhost") @@ -335,6 +369,17 @@ def _env_flag(name, default=True): "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "api.middleware.FingerPrintMiddleware", + # Required by allauth (>=0.56): rebuilds request state for the account flow. + "allauth.account.middleware.AccountMiddleware", +] + +# allauth adds its authentication backend alongside the default ModelBackend so +# that regular username/password login keeps working (hybrid login — the +# maintainer's call on #401). ModelBackend stays first so password auth is +# unaffected. +AUTHENTICATION_BACKENDS = [ + "django.contrib.auth.backends.ModelBackend", + "allauth.account.auth_backends.AuthenticationBackend", ] TEMPLATES = [ @@ -440,3 +485,48 @@ def _env_flag(name, default=True): # Base URL of the frontend, used to build the link in the password-reset email. # Falls back to the request's own origin when not set (see PasswordResetView). FRONTEND_BASE_URL = os.environ.get("FRONTEND_BASE_URL", "") + +# --------------------------------------------------------------------------- +# django-allauth (OIDC / SSO) — see api/adapters.py and api/views/sso.py +# --------------------------------------------------------------------------- +# LibrePhotos authenticates with JWT, not sessions. allauth runs the OIDC +# redirect/callback dance server-side (establishing a short-lived Django +# session); LOGIN_REDIRECT_URL then hands off to the JWT bridge, which mints the +# same simplejwt access/refresh the password login issues, sets the cookies +# identically, and redirects into the SPA — so nothing downstream can tell an +# SSO login from a password login. +LOGIN_REDIRECT_URL = "/api/auth/sso/finish/" + +# All login/linking/provisioning policy lives in the adapter (stable API across +# allauth versions, and able to read runtime state such as EmailConfig and the +# OIDC_* Constance flags). Keep the declarative settings minimal. +SOCIALACCOUNT_ADAPTER = "api.adapters.SSOSocialAccountAdapter" +ACCOUNT_ADAPTER = "api.adapters.NoLocalSignupAccountAdapter" + +# We never drive password signup/login or verification emails through allauth — +# LibrePhotos keeps its own login and (separately) its own password reset. Trust +# the IdP's verified-email claim; the adapter enforces it explicitly. +ACCOUNT_EMAIL_VERIFICATION = "none" +SOCIALACCOUNT_EMAIL_VERIFICATION = "none" + +# The SPA button click is the deliberate user action, so redirect straight to the +# IdP on GET rather than rendering allauth's HTML interstitial (we ship no +# allauth templates). +SOCIALACCOUNT_LOGIN_ON_GET = True + +# We only need the identity to mint our own JWT; don't persist the IdP's tokens. +SOCIALACCOUNT_STORE_TOKENS = False + +# Keep allauth to brokering OIDC and nothing else. Without this, including +# allauth.urls also mounts allauth's own session-based account views: a second +# login form, a signup form, and — worst — a second password-reset flow that +# would bypass the throttling and address-enumeration protections on +# LibrePhotos' own reset endpoint. This drops those routes (signup, password +# reset/change/set, email management) while leaving account_login/logout +# resolvable, so allauth's internal reverse() calls still work, and it refuses +# non-GET requests to the login view it does keep. +# +# Note this disables *allauth's* local authentication, not LibrePhotos': the +# username/password login is our own simplejwt endpoint and is untouched, so +# hybrid login still works as the maintainer asked. +SOCIALACCOUNT_ONLY = True diff --git a/apps/backend/librephotos/urls.py b/apps/backend/librephotos/urls.py index c7bb21e297..5f7fd55bc3 100644 --- a/apps/backend/librephotos/urls.py +++ b/apps/backend/librephotos/urls.py @@ -52,6 +52,7 @@ search, services, sharing, + sso, stacks, tags, timezone, @@ -362,6 +363,24 @@ def get(self, request, *args, **kwargs): re_path(r"^api/auth/token/obtain/$", CustomTokenObtainPairView.as_view()), re_path(r"^api/auth/token/refresh/$", CustomTokenRefreshView.as_view()), re_path(r"^api/auth/token/blacklist/", TokenBlacklistView.as_view()), + # OIDC / SSO. allauth drives the redirect/callback under /api/accounts/...; + # sso_finish is the JWT bridge it lands on; sso/config feeds the login screen. + re_path(r"^api/auth/sso/finish/?$", sso.sso_finish), + re_path(r"^api/auth/sso/config/?$", sso.SSOConfigView.as_view()), + # These two shadow allauth's own oidc login/callback views (same paths) so the + # flow runs against LibrePhotosOIDCAdapter, which knows the public callback + # URL. Deliberately listed before the allauth include: URL resolution takes + # the first pattern that matches the path, while reverse() still finds + # allauth's identically-shaped named patterns below, so both agree. + re_path( + r"^api/accounts/oidc/(?P[^/]+)/login/$", + sso.oidc_login, + ), + re_path( + r"^api/accounts/oidc/(?P[^/]+)/login/callback/$", + sso.oidc_callback, + ), + re_path(r"^api/accounts/", include("allauth.urls")), re_path( r"^api/auth/password/reset/$", password_reset.PasswordResetView.as_view(), diff --git a/apps/backend/requirements.txt b/apps/backend/requirements.txt index 4f9d8a4829..56b61dbe4f 100644 --- a/apps/backend/requirements.txt +++ b/apps/backend/requirements.txt @@ -1,4 +1,5 @@ Django==5.2.16 +django-allauth==65.18.0 django-constance==4.3.5 django-cors-headers==4.9.0 django-cryptography-5==2.0.3 diff --git a/apps/docs/docs/installation/environment-variables.md b/apps/docs/docs/installation/environment-variables.md index ee753475d8..7133a5fdff 100644 --- a/apps/docs/docs/installation/environment-variables.md +++ b/apps/docs/docs/installation/environment-variables.md @@ -200,6 +200,17 @@ services: `secret.key` lives in `BASE_LOGS`, next to the log files. Zipping that whole folder for a bug report hands out your Django secret key, which is what every session and token on your instance is signed with. Attach `ownphotos.log` by itself instead. Deleting `secret.key` is not a fix either - it logs every user out and the passwords have to be reset. ::: +### Telling LibrePhotos its own public address + +`FRONTEND_BASE_URL` is the URL your users actually browse to, for example `https://photos.example.com` (no trailing slash). Leave it unset and LibrePhotos falls back to the origin each request appears to have arrived on. + +That fallback is wrong behind the bundled proxy, which forwards `/api/` to the backend with the `Host` header rewritten to `backend` — a name that only resolves inside the Docker network. The rewrite is deliberate (it means Django's host validation passes whatever domain you use), but it does mean the backend cannot work out its own public address on its own. + +So set this whenever LibrePhotos has to hand a URL to something outside the container: + +- **Password-reset emails** — the link in the email. Without it the fallback can produce a link pointing at `http://backend/...`, which no mail recipient can open. +- **Single sign-on** — the OAuth `redirect_uri` sent to your identity provider, which the browser has to follow and the provider has to recognise. SSO refuses to start rather than send a broken one, so this is effectively required for [OIDC](../user-guide/settings/single-sign-on.md). + ### Hosting under a sub-path (subdirectory) By default LibrePhotos is served from the root of a domain (e.g. `https://photos.example.com/`). If you need to host it under a sub-path instead (e.g. `https://example.com/photos/`), the frontend has to know that base path. diff --git a/apps/docs/docs/user-guide/settings/single-sign-on.md b/apps/docs/docs/user-guide/settings/single-sign-on.md new file mode 100644 index 0000000000..2779e706d4 --- /dev/null +++ b/apps/docs/docs/user-guide/settings/single-sign-on.md @@ -0,0 +1,96 @@ +--- +title: "🔑 Single sign-on (OIDC)" +description: "Letting people log in with Keycloak, Authentik, Authelia, Zitadel, Google or any other OpenID Connect provider" +sidebar_position: 2 +--- + +LibrePhotos can hand login over to an external identity provider that speaks +**OpenID Connect** — Keycloak, Authentik, Authelia, Zitadel, Okta, Google, Entra +ID and so on. After the provider confirms who someone is, LibrePhotos issues its +own normal session, so everything downstream behaves exactly as it does for a +password login. + +Username-and-password login keeps working alongside it. Turning SSO on does not +take anyone's existing way of logging in away. + +## What you need before starting + +- **Your server's public URL**, set as the `FRONTEND_BASE_URL` environment + variable (for example `https://photos.example.com`). This one is not optional. + The reverse proxy in front of the backend rewrites the `Host` header, so + LibrePhotos genuinely cannot work out its own public address from an incoming + request — it has to be told. Without it the login button reports that SSO is + not fully configured. +- **An email provider configured** in Site Settings, *if* you want LibrePhotos to + create accounts on first login. See the email settings page. Without email + configured, LibrePhotos will not create accounts over SSO, because it has no + way to act on the provider's claim that an address is verified. + +## Registering LibrePhotos with your identity provider + +Create a **confidential client** (one with a client secret) at your provider and +give it this redirect URI: + +``` +https://your-librephotos-url/api/accounts/oidc//login/callback/ +``` + +`` is a short name you choose in the next step — `keycloak`, +`authentik`, `google`, whatever you like. It must match exactly. + +Note down the client ID, the client secret, and the provider's **issuer URL** (the +one whose `/.well-known/openid-configuration` document describes it). + +## Telling LibrePhotos about the provider + +In the Django admin (`/api/django-admin/`), under **Social applications**, add one: + +| Field | Value | +| --- | --- | +| Provider | `OpenID Connect` | +| Provider ID | the `` you used in the redirect URI | +| Name | what the login button should say, if you add more than one provider | +| Client id | from your provider | +| Secret key | from your provider | +| Settings | `{"server_url": "https://your-idp/realms/example"}` — the issuer URL | +| Sites | pick your site | + +Then, in Site Settings: + +- **`OIDC_ENABLED`** — the on/off switch. While it is off, the login button is + hidden *and* the SSO endpoints are inactive, so switching it off is a real way + to stop SSO logins. +- **`OIDC_BUTTON_LABEL`** — the text on the login button, e.g. "Sign in with + Keycloak". +- **`OIDC_ALLOW_SIGNUP`** — whether a first-time SSO login may create a new + LibrePhotos account. Off by default; also requires email to be configured. + +## How accounts are matched up + +On a first SSO login, LibrePhotos looks for an existing account whose email +matches the one the provider reports: + +- **A single matching account, and the provider says the address is verified** → + the two are linked, and from then on the provider's stable user id identifies + that person. +- **The provider has not verified the address** → refused. Otherwise anyone able + to register your email address at the provider could claim your account. +- **Two accounts share the address, or the provider sends no address** → refused, + because there is no single right answer. +- **No matching account** → a new one is created only if `OIDC_ALLOW_SIGNUP` is on + *and* email is configured. Otherwise create the account first (or set its email + to match) and let the user sign in again. + +Accounts created through SSO are never administrators. Grant that in the admin +afterwards if you want it. + +## When something goes wrong + +A failed attempt returns to the login page with an explanation. The usual causes: + +| Message | What to change | +| --- | --- | +| Account not known to LibrePhotos | Create the account, or turn on `OIDC_ALLOW_SIGNUP` | +| Email address not verified | Verify the address at your identity provider | +| Public URL not configured | Set `FRONTEND_BASE_URL` | +| Provider-side "invalid redirect URI" | The redirect URI at your provider must match the callback URL above exactly, including the trailing slash | diff --git a/apps/frontend/src/api_client/auth/hooks/index.ts b/apps/frontend/src/api_client/auth/hooks/index.ts index 82d5a867fe..f38a656cb1 100644 --- a/apps/frontend/src/api_client/auth/hooks/index.ts +++ b/apps/frontend/src/api_client/auth/hooks/index.ts @@ -5,4 +5,5 @@ export * from "./useConfirmPasswordResetMutation"; export * from "./useLogoutMutation"; export * from "./useIsFirstTimeSetupQuery"; export * from "./useIsAuthenticatedQuery"; +export * from "./useSsoConfigQuery"; export * from "./useAccessToken"; diff --git a/apps/frontend/src/api_client/auth/hooks/useSsoConfigQuery.ts b/apps/frontend/src/api_client/auth/hooks/useSsoConfigQuery.ts new file mode 100644 index 0000000000..03175cd499 --- /dev/null +++ b/apps/frontend/src/api_client/auth/hooks/useSsoConfigQuery.ts @@ -0,0 +1,20 @@ +import { useQuery } from "@tanstack/react-query"; +import { fetchClient } from "../../api"; +import type { SsoConfig } from "../types"; + +export const SsoConfigQueryKeys = ["ssoConfig"] as const; + +/** + * What the login screen needs to know about single sign-on: whether to offer it, + * what to call the button, and where it points. Unauthenticated on purpose — the + * login screen has to be able to ask before anyone is logged in. + */ +export const useSsoConfigQuery = () => + useQuery({ + queryKey: [SsoConfigQueryKeys], + queryFn: () => fetchClient.get("/auth/sso/config/"), + // Providers change only when an admin reconfigures them, and a failure here + // must never keep the password form from rendering. + staleTime: 5 * 60 * 1000, + retry: false, + }); diff --git a/apps/frontend/src/api_client/auth/types.ts b/apps/frontend/src/api_client/auth/types.ts index eaa8e10842..320ee85257 100644 --- a/apps/frontend/src/api_client/auth/types.ts +++ b/apps/frontend/src/api_client/auth/types.ts @@ -28,6 +28,20 @@ export const Token = z.object({ nextcloud_username: z.string().nullable().optional(), }); +export const SsoProvider = z.object({ + id: z.string(), + name: z.string(), + login_url: z.string(), +}); + +export const SsoConfigSchema = z.object({ + enabled: z.boolean(), + label: z.string(), + providers: SsoProvider.array(), +}); + +export type SsoConfig = z.infer; + export const AuthState = z.object({ access: Token.nullable(), refresh: Token.nullable(), diff --git a/apps/frontend/src/locales/en/translation.json b/apps/frontend/src/locales/en/translation.json index e8ceea3b4a..a4776cef10 100644 --- a/apps/frontend/src/locales/en/translation.json +++ b/apps/frontend/src/locales/en/translation.json @@ -667,7 +667,16 @@ "erroremail": "Enter a valid email address.", "tagline": "A comfy place for your photos.", "firsttimesetup": "First time setup", - "setupdatadirectory": "Choose your scan directory" + "setupdatadirectory": "Choose your scan directory", + "sso": { + "divider": "or", + "button": "Sign in with SSO", + "errorsignupdisabled": "That account is not known to LibrePhotos. Ask an administrator to create it, or to allow sign-ups over SSO.", + "erroremailnotverified": "Your identity provider has not verified that email address, so it cannot be used to sign in.", + "errornotauthenticated": "The sign-in did not complete. Please try again.", + "errorpublicurlnotconfigured": "Single sign-on is not fully configured: the administrator needs to set this server's public URL (FRONTEND_BASE_URL).", + "errorunknown": "Single sign-on failed. Please try again or use your password." + } }, "passwordreset": { "forgotpassword": "Forgot your password?", diff --git a/apps/frontend/src/routes/login.tsx b/apps/frontend/src/routes/login.tsx index ad101101fb..27be7bd567 100644 --- a/apps/frontend/src/routes/login.tsx +++ b/apps/frontend/src/routes/login.tsx @@ -1,4 +1,5 @@ import { + Alert, Anchor, Button, Card, @@ -29,6 +30,7 @@ import { useIsFirstTimeSetupQuery, useLoginMutation, useSignUpMutation, + useSsoConfigQuery, } from "../api_client/auth"; import { useScanPhotosMutation } from "../api_client/jobs"; import { useGetSettingsQuery } from "../api_client/settings"; @@ -39,6 +41,7 @@ import { useUpdateUserScanDirectoryMutation, } from "../api_client/user/hooks"; import { DirectoryPicker } from "../components/setup/DirectoryPicker"; +import { ssoErrorMessageKey } from "../util/ssoErrors"; import { isStringEmpty } from "../util/stringUtils"; import { EMAIL_REGEX } from "../util/util"; @@ -55,7 +58,14 @@ export function LoginPage(): JSX.Element { const { t } = useTranslation(); const { data: isAuthenticated } = useIsAuthenticatedQuery(); const { data: siteSettings } = useGetSettingsQuery(); + const { data: ssoConfig } = useSsoConfigQuery(); const { mutate: login, isPending: isLoading } = useLoginMutation(); + // The backend redirects here with a full page load, so the query string is the + // source of truth; there is no router state to carry the reason. + const ssoErrorKey = useMemo( + () => ssoErrorMessageKey(new URLSearchParams(window.location.search).get("sso_error")), + [] + ); const form = useForm({ initialValues: { username: "", @@ -85,6 +95,12 @@ export function LoginPage(): JSX.Element { {t("login.login")} + {ssoErrorKey && ( + + {t(ssoErrorKey)} + + )} +
+ + {ssoConfig?.enabled && ( + <> + + + {ssoConfig.providers.map(provider => ( + // A plain anchor, not a router link: the browser has to leave + // the SPA and hit the backend to start the redirect to the IdP. + + ))} + + + )}
diff --git a/apps/frontend/src/util/ssoErrors.test.ts b/apps/frontend/src/util/ssoErrors.test.ts new file mode 100644 index 0000000000..4ba526aeff --- /dev/null +++ b/apps/frontend/src/util/ssoErrors.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { ssoErrorMessageKey } from "./ssoErrors"; + +describe("ssoErrorMessageKey", () => { + it("returns null when there is no error", () => { + expect(ssoErrorMessageKey(null)).toBeNull(); + expect(ssoErrorMessageKey(undefined)).toBeNull(); + expect(ssoErrorMessageKey("")).toBeNull(); + }); + + it("maps each reason the backend can send", () => { + expect(ssoErrorMessageKey("signup_disabled")).toBe("login.sso.errorsignupdisabled"); + expect(ssoErrorMessageKey("email_not_verified")).toBe("login.sso.erroremailnotverified"); + expect(ssoErrorMessageKey("not_authenticated")).toBe("login.sso.errornotauthenticated"); + expect(ssoErrorMessageKey("public_url_not_configured")).toBe("login.sso.errorpublicurlnotconfigured"); + }); + + it("still says something for a reason it does not know", () => { + expect(ssoErrorMessageKey("something_new_from_a_newer_backend")).toBe("login.sso.errorunknown"); + }); +}); diff --git a/apps/frontend/src/util/ssoErrors.ts b/apps/frontend/src/util/ssoErrors.ts new file mode 100644 index 0000000000..88a29ce822 --- /dev/null +++ b/apps/frontend/src/util/ssoErrors.ts @@ -0,0 +1,23 @@ +/** + * Reasons the backend bounces a failed SSO attempt back to the login screen with + * `?sso_error=`. + */ +const SSO_ERROR_MESSAGE_KEYS: Record = { + signup_disabled: "login.sso.errorsignupdisabled", + email_not_verified: "login.sso.erroremailnotverified", + not_authenticated: "login.sso.errornotauthenticated", + public_url_not_configured: "login.sso.errorpublicurlnotconfigured", +}; + +/** + * Translation key for an `sso_error` reason, or null when there is no error. + * + * An unrecognised reason still gets a message: a backend newer than the frontend + * must not be able to leave the user looking at a silently failed login. + */ +export function ssoErrorMessageKey(reason: string | null | undefined): string | null { + if (!reason) { + return null; + } + return SSO_ERROR_MESSAGE_KEYS[reason] ?? "login.sso.errorunknown"; +}