From 31b51b5f67398b66659f858313830d1360fbe18f Mon Sep 17 00:00:00 2001 From: jbpenrath Date: Tue, 11 Aug 2026 16:48:36 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(mobile)=20custom=20logout=20view=20to?= =?UTF-8?q?=20terminates=20the=20IdP=20session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProConnect ignores prompt=login, so preserving the IdP session locked mobile users into the same identity forever. The logout endpoint now accepts a mobile_scheme and ends the RP-initiated round-trip on a new logout-callback view that deep-links back to the app, so the system browser — which holds both the Django session handed over at login and the IdP SSO cookie — terminates both sessions. Then, Proconnect login page's Content Security Policy blocks the direct redirect: Chrome enforces its form-action on the whole redirect chain of the credential form submission, and "*" only matches network schemes — so our network mobile scheme violates it and the user stays stuck on the identity provider during logout workflow. The callback now serves a page that ends the form chain on a network mobile scheme, then hands off to the app from our own page, outside the IdP policy: automatically via script (iOS interception, unchanged) with a button as the always-working fallback. --- docs/mobile.md | 47 ++-- src/backend/core/api/serializers.py | 2 +- src/backend/core/api/viewsets/mobile_auth.py | 10 +- src/backend/core/authentication/urls.py | 7 +- src/backend/core/authentication/views.py | 198 +++++++++++++---- src/backend/core/mda/utils.py | 7 +- .../core/templates/core/mobile_handoff.html | 48 +++++ .../core/tests/authentication/test_logout.py | 201 ++++++++++++++++++ .../tests/authentication/test_mobile_auth.py | 62 +++++- .../Splash.imageset/splash-2732x2732-1.png | Bin 41273 -> 0 bytes .../Splash.imageset/splash-2732x2732-2.png | Bin 41273 -> 0 bytes .../Splash.imageset/splash-2732x2732.png | Bin 41273 -> 0 bytes src/frontend/src/features/native/auth.ts | 43 +++- .../features/native/sso-invariants.test.ts | 76 ++++++- 14 files changed, 615 insertions(+), 86 deletions(-) create mode 100644 src/backend/core/templates/core/mobile_handoff.html delete mode 100644 src/frontend/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png delete mode 100644 src/frontend/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png delete mode 100644 src/frontend/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png diff --git a/docs/mobile.md b/docs/mobile.md index 75aaef56..c40f6a28 100644 --- a/docs/mobile.md +++ b/docs/mobile.md @@ -102,15 +102,28 @@ Step by step: system browser. 2. **Backend flags the session.** `OIDCAuthenticationRequestView` checks the scheme against `MOBILE_AUTH_CALLBACK_SCHEMES` (rejects unknown schemes) and - stashes `{scheme, code_challenge, created_at}` in the Django session. A flag - older than 10 min is later ignored, so an abandoned mobile attempt can't - hijack a subsequent web login in the same browser. + stashes `{scheme, code_challenge, state, created_at}` in the Django session. + The `state` is the one generated for this OIDC round-trip: the callback only + consumes the flag when its own state matches, and a flag older than 10 min + is ignored, so an abandoned or overlapping mobile attempt can't hijack a web + flow running in the same browser (same binding for the mobile logout). 3. **IdP authenticates** — interactively the first time, silently afterwards (see *Cross-app SSO conditions* below). 4. **Callback mints a one-time token.** `OIDCAuthenticationCallbackView` caches `{session_key, code_challenge}` under `mobile-auth-token:` with a - `MOBILE_AUTH_TOKEN_TTL` (60 s) timeout and deep-links back to - `stmessages://auth?token=…`. + `MOBILE_AUTH_TOKEN_TTL` (60 s) timeout and hands the browser back to + `stmessages://auth?token=…` — through a small **hand-off page** + (auto-redirect + "open the app" button), not a plain 302 to the scheme. + The direct redirect gets blocked by the **CSP of the IdP login page**: + Chrome enforces its `form-action` on the whole redirect chain of the + credential form submission, and `*` only matches network schemes, so the + final custom-scheme hop violates it and the sheet stays stuck on the IdP. + ProConnect sends such a CSP; the dev Keycloak does not, which hides the + bug in dev. Ending the chain on a 200 page satisfies the policy, and the + deep link then leaves from our own page. iOS is indifferent — + `ASWebAuthenticationSession` intercepts the scheme navigation either way — + and the logout keeps its direct scheme redirects: its round-trip involves + no form submission, so no `form-action` ever applies. 5. **App exchanges the token.** `MobileSessionExchangeView` (anonymous, single use) deletes the cache key *before* verifying it (a failed attempt can't be retried), checks `S256(code_verifier) == code_challenge` with @@ -146,13 +159,19 @@ are independent of `MOBILE_APP_ID`. > simply because the Django session cookie is still valid — that never hits > `/authorize` and does **not** prove IdP SSO. Always exercise the mobile flow. -### Logout keeps the IdP session alive +### Logout ends the session everywhere (Django **and** IdP) -`nativeLogout()` does **not** call `/logout/`, which would trigger RP-initiated -IdP logout and tear down the cross-app SSO session. It POSTs to -`/api/v1.0/mobile/auth/logout/` instead, which flushes only the server-side -Django session, then clears the native cookies and the cached CSRF token. -IdP-level logout is a follow-up. +`nativeLogout()` runs the RP-initiated logout (`/api/v1.0/logout/` with +`mobile_scheme`) in the system browser, which holds both the Django session +cookie handed over at login and the IdP SSO cookie: the round-trip terminates +both and ends on a `scheme://logout` deep link that closes the sheet. Keeping +the IdP session alive is not an option — it silently signs the same identity +back in on the next login and ProConnect ignores `prompt=login`, so tearing it +down is the only way to let the user switch accounts. The app then POSTs to +`/api/v1.0/mobile/auth/logout/` as a safety net (the browser round-trip only +ends the app-side session when the browser still holds the same session +cookie), clears the native cookies and the cached CSRF token. By design this +also ends the SSO session shared with other La Suite apps. ## Networking & session @@ -637,8 +656,10 @@ or the Capacitor version. manifest is refused (downgrade guard). 5. **Native file paths** — download/share an attachment and a raw `.eml` (native HTTP session), upload an attachment (CSRF token path). -6. **Logout → re-login** — logout ends the Django session only; the - following login must complete silently (IdP session preserved). +6. **Logout → re-login** — logout ends both the Django session and the IdP + session (RP-initiated logout in the system browser); the following login + must stop on the IdP login form, allowing an account switch. A silent + re-login means the IdP session survived: that is a regression. 7. **No dev server baked in** — in dev, `MOBILE_DEV_SERVER_URL` bakes the Vite dev server URL into `capacitor.config.json` (hot reload, see *Build & run workflow*). Before archiving, set it empty in `frontend.local` and rerun diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 0a8f72a1..979f1a43 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -21,8 +21,8 @@ from core.mda.dispatch_webhooks import ( VALID_FORMATS, ) from core.mda.inline_images import extract_inline_images_html -from core.services.attachments import get_attachment_display_name from core.mda.utils import message_snippet +from core.services.attachments import get_attachment_display_name from core.services.blob_gc import schedule_for_gc from core.services.identity import keycloak as keycloak_service from core.services.importer.channel import merged_state diff --git a/src/backend/core/api/viewsets/mobile_auth.py b/src/backend/core/api/viewsets/mobile_auth.py index f26d44ae..8024d727 100644 --- a/src/backend/core/api/viewsets/mobile_auth.py +++ b/src/backend/core/api/viewsets/mobile_auth.py @@ -114,11 +114,11 @@ class MobileSessionExchangeView(APIView): class MobileLogoutView(APIView): """Invalidate the Django session without the RP-initiated IdP logout. - The web logout (`/logout/`) redirects to the identity provider with an - `id_token_hint`, which terminates the IdP session and therefore cross-app - SSO. Mobile logout must only flush the server-side Django session: the app - clears its local cookie jar, and the IdP session stays alive for the other - apps of the suite. + Fallback for the mobile logout: the nominal path runs the full IdP logout + (`/logout/?mobile_scheme=...`) in the system browser, but when that flow + fails or is cancelled the app still needs to end its own session — this + endpoint only flushes the server-side Django session bound to the request + cookie. Anonymous requests are a no-op (204): the app calls this best-effort, and logging out an already expired session must not fail. diff --git a/src/backend/core/authentication/urls.py b/src/backend/core/authentication/urls.py index 55608de8..035880e6 100644 --- a/src/backend/core/authentication/urls.py +++ b/src/backend/core/authentication/urls.py @@ -2,12 +2,17 @@ from django.urls import include, path -from .views import OIDCLogoutView +from .views import OIDCLogoutCallbackView, OIDCLogoutView urlpatterns = [ # lasuite's logout views are not swappable through settings (unlike the # authenticate/callback views): override their paths by URL ordering, the # same way lasuite overrides mozilla-django-oidc's. path("logout/", OIDCLogoutView.as_view(), name="oidc_logout_custom"), + path( + "logout-callback/", + OIDCLogoutCallbackView.as_view(), + name="oidc_logout_callback", + ), path("", include("lasuite.oidc_login.urls")), ] diff --git a/src/backend/core/authentication/views.py b/src/backend/core/authentication/views.py index bfa0c708..decd29be 100644 --- a/src/backend/core/authentication/views.py +++ b/src/backend/core/authentication/views.py @@ -14,7 +14,7 @@ anonymous — see `OIDCLogoutView`. import logging import secrets import time -from urllib.parse import urlencode +from urllib.parse import parse_qs, urlencode, urlparse from django.conf import settings from django.contrib import auth @@ -22,6 +22,7 @@ from django.core.cache import cache from django.core.exceptions import SuspiciousOperation from django.core.handlers.wsgi import WSGIRequest from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseRedirect +from django.shortcuts import render from lasuite.oidc_login.views import ( OIDCAuthenticationCallbackView as LaSuiteOIDCAuthenticationCallbackView, @@ -29,15 +30,19 @@ from lasuite.oidc_login.views import ( from lasuite.oidc_login.views import ( OIDCAuthenticationRequestView as LaSuiteOIDCAuthenticationRequestView, ) +from lasuite.oidc_login.views import ( + OIDCLogoutCallbackView as LaSuiteOIDCLogoutCallbackView, +) from lasuite.oidc_login.views import OIDCLogoutView as LaSuiteOIDCLogoutView logger = logging.getLogger(__name__) MOBILE_AUTH_SESSION_KEY = "mobile_auth" +MOBILE_LOGOUT_SESSION_KEY = "mobile_logout" MOBILE_AUTH_TOKEN_CACHE_PREFIX = "mobile-auth-token" # noqa: S105 (cache key prefix, not a secret) -# A pending mobile login older than this is ignored by the callback, so an -# abandoned mobile attempt cannot turn a later web login performed in the same -# browser session into a deep-link redirect. +# A pending mobile login/logout older than this is ignored by the callbacks, +# so an abandoned mobile attempt cannot turn a later web flow performed in the +# same browser session into a deep-link redirect. MOBILE_AUTH_FLAG_MAX_AGE = 60 * 10 @@ -50,51 +55,117 @@ class AppSchemeRedirect(HttpResponseRedirect): super().__init__(redirect_to, *args, **kwargs) +def mobile_login_handoff(request: WSGIRequest, app_url: str) -> HttpResponse: + """Serve the page handing a finished mobile login back to the app deep link. + + A plain 302 to the custom scheme gets blocked by the Content Security + Policy of the IdP login page: Chrome enforces its ``form-action`` on the + whole redirect chain of the credential form submission, and ``*`` only + matches network schemes — the final custom-scheme hop violates it and the + sheet stays stuck on the IdP ("Sending form data … violates form-action"). + ProConnect sends such a CSP; the dev Keycloak does not, which hides the + bug in dev, and the logout chain involves no form submission, so its + direct scheme redirects are fine. Serving a 200 page ends the form chain + on a network scheme; the deep link then leaves from *this* page, outside + the IdP policy: automatically via script (iOS ASWebAuthenticationSession + intercepts it; Android may still gate an external-protocol launch on user + activation) with a tap target as the fallback that always works. + """ + response = render(request, "core/mobile_handoff.html", {"app_url": app_url}) + # The page embeds a one-time bearer token: it must never outlive the + # navigation that carried it. + response["Cache-Control"] = "no-store" + return response + + +def get_requested_mobile_scheme(request: WSGIRequest) -> str: + """Return the validated `mobile_scheme` query parameter ("" when absent). + + The scheme is the deep-link destination of the whole flow: only + allowlisted schemes may terminate it. + """ + mobile_scheme = request.GET.get("mobile_scheme", "") + if mobile_scheme and mobile_scheme not in settings.MOBILE_AUTH_CALLBACK_SCHEMES: + raise SuspiciousOperation("Unknown mobile callback scheme.") + return mobile_scheme + + +def pop_mobile_flag(session, key: str, state: str | None) -> dict | None: + """Pop the pending mobile flow flag, if it belongs to `state` and is fresh. + + `state` identifies the OIDC round-trip the callback belongs to. A session + can carry several valid states at once (overlapping or abandoned flows), so + a flag consumed by the wrong callback would send that flow to the app deep + link — a web login or logout landing on a custom scheme — and leave the + mobile one without it. + """ + mobile_flag = session.get(key) + if mobile_flag is None: + return None + if not state or mobile_flag.get("state") != state: + return None + del session[key] + session.save() + if time.time() - mobile_flag["created_at"] > MOBILE_AUTH_FLAG_MAX_AGE: + logger.warning("Ignoring stale %s flag.", key) + return None + return mobile_flag + + class OIDCAuthenticationRequestView(LaSuiteOIDCAuthenticationRequestView): """Authentication request view supporting a mobile session handoff. Mobile apps add `mobile_scheme` (an allowlisted deep-link scheme) and `code_challenge` (PKCE S256) to the authenticate URL. The pair is stored in - the session so the callback view knows where to hand the session over. + the session — bound to the state of this OIDC round-trip — so the callback + view knows where to hand the session over. """ def get(self, request: WSGIRequest) -> HttpResponse: """Flag the session when the login is initiated by a mobile app.""" - mobile_scheme = request.GET.get("mobile_scheme", "") - if mobile_scheme: - if mobile_scheme not in settings.MOBILE_AUTH_CALLBACK_SCHEMES: - raise SuspiciousOperation("Unknown mobile callback scheme.") - code_challenge = request.GET.get("code_challenge", "") - if not code_challenge: - return HttpResponseBadRequest("Missing code_challenge.") - request.session[MOBILE_AUTH_SESSION_KEY] = { - "scheme": mobile_scheme, - "code_challenge": code_challenge, - "created_at": time.time(), - } - # The parent view forces a session save before redirecting to the - # identity provider, so the flag survives the OIDC round-trip. - return super().get(request) + mobile_scheme = get_requested_mobile_scheme(request) + if not mobile_scheme: + return super().get(request) + + code_challenge = request.GET.get("code_challenge", "") + if not code_challenge: + return HttpResponseBadRequest("Missing code_challenge.") + + response = super().get(request) + # Flagged after super(): the state the parent view just generated ties + # the flag to this round-trip only. Its own session save happened + # before, hence the explicit one below. + request.session[MOBILE_AUTH_SESSION_KEY] = { + "scheme": mobile_scheme, + "code_challenge": code_challenge, + "state": parse_qs(urlparse(response["Location"]).query)["state"][0], + "created_at": time.time(), + } + request.session.save() + return response class OIDCAuthenticationCallbackView(LaSuiteOIDCAuthenticationCallbackView): """Callback view handing the session over to the mobile app via deep link.""" - def _pop_mobile_auth(self) -> dict | None: - """Pop and return the pending mobile login data, if any and still fresh.""" - mobile_auth = self.request.session.pop(MOBILE_AUTH_SESSION_KEY, None) - if mobile_auth is None: - return None - self.request.session.save() - if time.time() - mobile_auth["created_at"] > MOBILE_AUTH_FLAG_MAX_AGE: - logger.warning("Ignoring stale mobile login flag.") - return None - return mobile_auth + def pop_mobile_auth(self) -> dict | None: + """Return the mobile flag of the login this callback closes, if any. + + The parent view consumed the state from `oidc_states` before handing + over, but the query parameter still identifies the round-trip: a + callback carrying no state at all (a malformed one, never a real IdP + answer) belongs to no known flow and must leave the flag alone. + """ + return pop_mobile_flag( + self.request.session, + MOBILE_AUTH_SESSION_KEY, + self.request.GET.get("state"), + ) def login_success(self) -> HttpResponse: - """Redirect to the mobile app with a one-time token after a mobile login.""" + """Hand a one-time session token to the mobile app after a mobile login.""" response = super().login_success() - mobile_auth = self._pop_mobile_auth() + mobile_auth = self.pop_mobile_auth() if mobile_auth is None: return response @@ -109,15 +180,19 @@ class OIDCAuthenticationCallbackView(LaSuiteOIDCAuthenticationCallbackView): timeout=settings.MOBILE_AUTH_TOKEN_TTL, ) query = urlencode({"token": token}) - return AppSchemeRedirect(f"{mobile_auth['scheme']}://auth?{query}") + return mobile_login_handoff( + self.request, f"{mobile_auth['scheme']}://auth?{query}" + ) def login_failure(self) -> HttpResponse: - """Redirect to the mobile app with an error after a failed mobile login.""" + """Hand the login error back to the mobile app after a failed mobile login.""" response = super().login_failure() - mobile_auth = self._pop_mobile_auth() + mobile_auth = self.pop_mobile_auth() if mobile_auth is None: return response - return AppSchemeRedirect(f"{mobile_auth['scheme']}://auth?error=login_failed") + return mobile_login_handoff( + self.request, f"{mobile_auth['scheme']}://auth?error=login_failed" + ) class OIDCLogoutView(LaSuiteOIDCLogoutView): @@ -135,10 +210,17 @@ class OIDCLogoutView(LaSuiteOIDCLogoutView): rest of the parent flow is untouched (post_logout_redirect_uri is the /logout-callback/ route — it must be registered at the IdP — and the session is kept alive until the callback validates the returned state). + + Mobile apps run this same flow in the system browser (which holds both the + Django session cookie handed over at login and the IdP SSO cookie) and add + `mobile_scheme` so the round-trip ends on a deep link closing the sheet — + see `OIDCLogoutCallbackView`. """ def post(self, request: WSGIRequest) -> HttpResponse: """Log the user out of the IdP session, then out of Django.""" + mobile_scheme = get_requested_mobile_scheme(request) + logout_url = self.redirect_url if request.user.is_authenticated or request.session.get("oidc_id_token"): @@ -147,10 +229,52 @@ class OIDCLogoutView(LaSuiteOIDCLogoutView): if logout_url == self.redirect_url: # No IdP round-trip possible: end the local session right away. auth.logout(request) + if mobile_scheme: + return AppSchemeRedirect(f"{mobile_scheme}://logout") else: + if mobile_scheme: + request.session[MOBILE_LOGOUT_SESSION_KEY] = { + "scheme": mobile_scheme, + # The state generated by construct_oidc_logout_url() ties + # the flag to this round-trip only. + "state": parse_qs(urlparse(logout_url).query)["state"][0], + "created_at": time.time(), + } # Persist the state generated in construct_oidc_logout_url() - # before the browser leaves for the IdP. + # (and the mobile flag) before the browser leaves for the IdP. request.session.modified = True request.session.save() return HttpResponseRedirect(logout_url) + + +class OIDCLogoutCallbackView(LaSuiteOIDCLogoutCallbackView): + """Logout callback view handing control back to the mobile app. + + A logout initiated by a mobile app must end on a deep link so the + system-browser sheet closes and the app can clear its local state; the + parent view otherwise lands on LOGOUT_REDIRECT_URL, leaving the sheet + open on the web homepage. + """ + + def get(self, request: WSGIRequest) -> HttpResponse: + """Redirect to the app once the parent view has ended the session.""" + # The one-shot flag is only consumed on the final IdP callback of the + # very logout that set it — a state known to this session *and* the one + # stored alongside the flag: the parent redirects without ending the + # session on the state-less "preflight" some providers send before the + # actual callback, and raises on an unknown state, while an overlapping + # web logout carries another valid state. Popping on any of those would + # close the sheet while the browser session is still authenticated, + # then leave the real callback without its flag. + # Popped before super(): auth.logout() flushes the session. + mobile_logout = None + state = request.GET.get("state") + if state and state in request.session.get("oidc_states", {}): + mobile_logout = pop_mobile_flag( + request.session, MOBILE_LOGOUT_SESSION_KEY, state=state + ) + response = super().get(request) + if mobile_logout is None: + return response + return AppSchemeRedirect(f"{mobile_logout['scheme']}://logout") diff --git a/src/backend/core/mda/utils.py b/src/backend/core/mda/utils.py index 3ecaf874..554fa2ba 100644 --- a/src/backend/core/mda/utils.py +++ b/src/backend/core/mda/utils.py @@ -26,7 +26,12 @@ from email.utils import make_msgid from django.utils import timezone -from jmap_email import ComposeOptions, body_part_text, decode_rfc2047_header, preview_text +from jmap_email import ( + ComposeOptions, + body_part_text, + decode_rfc2047_header, + preview_text, +) from jmap_email.types import JmapEmail # Compose policy shared by every path that builds MIME for us. diff --git a/src/backend/core/templates/core/mobile_handoff.html b/src/backend/core/templates/core/mobile_handoff.html new file mode 100644 index 00000000..a77fef51 --- /dev/null +++ b/src/backend/core/templates/core/mobile_handoff.html @@ -0,0 +1,48 @@ + +{% comment %} +Hand-off page ending a mobile login on the app deep link — served instead of +a plain 302 to the custom scheme, which the IdP login page's CSP form-action +blocks at the end of the credential form's redirect chain (see +mobile_login_handoff in core/authentication/views.py for the full rationale). +The script keeps the silent path (iOS interception); the link is the Android +fallback — a user-activated navigation from this page, outside the IdP CSP. +{% endcomment %} + + + + + Messages + + + +

Retour vers l’application…
Returning to the app…

+ Ouvrir l’application / Open the app + + + diff --git a/src/backend/core/tests/authentication/test_logout.py b/src/backend/core/tests/authentication/test_logout.py index 418f58fd..42f6bb29 100644 --- a/src/backend/core/tests/authentication/test_logout.py +++ b/src/backend/core/tests/authentication/test_logout.py @@ -1,5 +1,6 @@ """Tests for the logout flow, IdP-terminating even for anonymous sessions.""" +import time from urllib.parse import parse_qs, urlparse from django.test.utils import override_settings @@ -10,6 +11,7 @@ from rest_framework import status from rest_framework.test import APIClient from core import factories +from core.authentication.views import MOBILE_LOGOUT_SESSION_KEY pytestmark = pytest.mark.django_db @@ -18,6 +20,11 @@ LOGOUT_SETTINGS = { "LOGOUT_REDIRECT_URL": "https://app.test/", } +MOBILE_LOGOUT_SETTINGS = { + **LOGOUT_SETTINGS, + "MOBILE_AUTH_CALLBACK_SCHEMES": ["stmessagesa"], +} + class TestLogoutView: """Tests for the logout view.""" @@ -100,3 +107,197 @@ class TestLogoutCallbackView: assert response.status_code == status.HTTP_302_FOUND assert response["Location"] == "https://app.test/" assert "_auth_user_id" not in client.session + + +class TestMobileLogoutHandoff: + """Tests for the mobile handoff of the IdP-terminating logout. + + A logout initiated by a mobile app runs in the system browser and must end + on a deep link so the sheet closes and the app can clear its local state. + """ + + def _login_with_id_token(self, client): + """Authenticate the client with an id_token stored in the session.""" + client.force_login(factories.UserFactory()) + session = client.session + session["oidc_id_token"] = "fake-id-token" + session.save() + + @override_settings(**MOBILE_LOGOUT_SETTINGS) + def test_mobile_logout_goes_through_idp_and_flags_the_session(self): + """A mobile logout must run the IdP round-trip and flag the session.""" + client = APIClient() + self._login_with_id_token(client) + + response = client.get( + reverse("oidc_logout_custom"), {"mobile_scheme": "stmessagesa"} + ) + + assert response.status_code == status.HTTP_302_FOUND + assert response["Location"].startswith("https://oidc.test/logout?") + query = parse_qs(urlparse(response["Location"]).query) + mobile_logout = client.session[MOBILE_LOGOUT_SESSION_KEY] + assert mobile_logout["scheme"] == "stmessagesa" + assert mobile_logout["state"] == query["state"][0] + + @override_settings(**MOBILE_LOGOUT_SETTINGS) + def test_mobile_logout_unknown_scheme_is_rejected(self): + """A scheme not in the allowlist must be rejected as suspicious.""" + response = APIClient().get( + reverse("oidc_logout_custom"), {"mobile_scheme": "evilapp"} + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @override_settings(**MOBILE_LOGOUT_SETTINGS) + def test_mobile_logout_without_id_token_deep_links_immediately(self): + """With no IdP round-trip possible, the deep link must close the flow.""" + client = APIClient() + client.force_login(factories.UserFactory()) + + response = client.get( + reverse("oidc_logout_custom"), {"mobile_scheme": "stmessagesa"} + ) + + assert response.status_code == status.HTTP_302_FOUND + assert response["Location"] == "stmessagesa://logout" + assert "_auth_user_id" not in client.session + + @override_settings(**MOBILE_LOGOUT_SETTINGS) + def test_mobile_callback_deep_links_to_the_app(self): + """The callback must end the session then hand control to the app.""" + client = APIClient() + client.force_login(factories.UserFactory()) + session = client.session + session["oidc_states"] = {"logout-state": {}} + session[MOBILE_LOGOUT_SESSION_KEY] = { + "scheme": "stmessagesa", + "state": "logout-state", + "created_at": time.time(), + } + session.save() + + response = client.get( + reverse("oidc_logout_callback"), {"state": "logout-state"} + ) + + assert response.status_code == status.HTTP_302_FOUND + assert response["Location"] == "stmessagesa://logout" + assert "_auth_user_id" not in client.session + + @override_settings(**MOBILE_LOGOUT_SETTINGS) + def test_stateless_preflight_does_not_consume_the_mobile_flag(self): + """Some IdPs send a state-less preflight before the actual callback: + it must leave the flag and the session untouched so the real + callback still ends on the deep link.""" + client = APIClient() + client.force_login(factories.UserFactory()) + session = client.session + session["oidc_states"] = {"logout-state": {}} + session[MOBILE_LOGOUT_SESSION_KEY] = { + "scheme": "stmessagesa", + "state": "logout-state", + "created_at": time.time(), + } + session.save() + + response = client.get(reverse("oidc_logout_callback")) + + assert response.status_code == status.HTTP_302_FOUND + assert response["Location"] == "https://app.test/" + assert client.session[MOBILE_LOGOUT_SESSION_KEY]["scheme"] == "stmessagesa" + assert "_auth_user_id" in client.session + + response = client.get( + reverse("oidc_logout_callback"), {"state": "logout-state"} + ) + + assert response["Location"] == "stmessagesa://logout" + assert "_auth_user_id" not in client.session + + @override_settings(**MOBILE_LOGOUT_SETTINGS) + def test_unknown_state_does_not_consume_the_mobile_flag(self): + """A callback with a state unknown to the session is rejected by the + parent view: the flag must survive for the legitimate callback.""" + client = APIClient() + client.force_login(factories.UserFactory()) + session = client.session + session["oidc_states"] = {"logout-state": {}} + session[MOBILE_LOGOUT_SESSION_KEY] = { + "scheme": "stmessagesa", + "state": "logout-state", + "created_at": time.time(), + } + session.save() + + response = client.get( + reverse("oidc_logout_callback"), {"state": "forged-state"} + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert client.session[MOBILE_LOGOUT_SESSION_KEY]["scheme"] == "stmessagesa" + + @override_settings(**MOBILE_LOGOUT_SETTINGS) + def test_another_valid_state_keeps_the_web_redirect(self): + """A second flow started from the same browser session leaves another + valid state behind: a still-fresh mobile flag must not turn that + callback into a deep-link redirect on a web page.""" + client = APIClient() + client.force_login(factories.UserFactory()) + session = client.session + session["oidc_states"] = {"web-state": {}, "mobile-state": {}} + session[MOBILE_LOGOUT_SESSION_KEY] = { + "scheme": "stmessagesa", + "state": "mobile-state", + "created_at": time.time(), + } + session.save() + + response = client.get(reverse("oidc_logout_callback"), {"state": "web-state"}) + + assert response.status_code == status.HTTP_302_FOUND + assert response["Location"] == "https://app.test/" + assert "_auth_user_id" not in client.session + + @override_settings(**MOBILE_LOGOUT_SETTINGS) + def test_anonymous_mobile_callback_still_deep_links(self): + """A failed sign-in leaves the session anonymous while the IdP logout + round-trip runs: the callback must still close the sheet via the + deep link even though the parent view returns early.""" + client = APIClient() + session = client.session + session["oidc_states"] = {"logout-state": {}} + session[MOBILE_LOGOUT_SESSION_KEY] = { + "scheme": "stmessagesa", + "state": "logout-state", + "created_at": time.time(), + } + session.save() + + response = client.get( + reverse("oidc_logout_callback"), {"state": "logout-state"} + ) + + assert response.status_code == status.HTTP_302_FOUND + assert response["Location"] == "stmessagesa://logout" + + @override_settings(**MOBILE_LOGOUT_SETTINGS) + def test_stale_mobile_flag_keeps_the_web_redirect(self): + """An abandoned mobile logout must not hijack a later web logout.""" + client = APIClient() + client.force_login(factories.UserFactory()) + session = client.session + session["oidc_states"] = {"logout-state": {}} + session[MOBILE_LOGOUT_SESSION_KEY] = { + "scheme": "stmessagesa", + "state": "logout-state", + "created_at": time.time() - 3600, + } + session.save() + + response = client.get( + reverse("oidc_logout_callback"), {"state": "logout-state"} + ) + + assert response.status_code == status.HTTP_302_FOUND + assert response["Location"] == "https://app.test/" + assert "_auth_user_id" not in client.session diff --git a/src/backend/core/tests/authentication/test_mobile_auth.py b/src/backend/core/tests/authentication/test_mobile_auth.py index bf2f7ee9..9b1b52e5 100644 --- a/src/backend/core/tests/authentication/test_mobile_auth.py +++ b/src/backend/core/tests/authentication/test_mobile_auth.py @@ -1,5 +1,6 @@ """Tests for the mobile (Capacitor) OIDC session handoff.""" +import re import time from importlib import import_module from unittest.mock import patch @@ -92,6 +93,9 @@ class TestMobileAuthenticationRequest: mobile_auth = client.session[MOBILE_AUTH_SESSION_KEY] assert mobile_auth["scheme"] == "stmessagesa" assert mobile_auth["code_challenge"] == "challenge" + query = parse_qs(urlparse(response["Location"]).query) + assert mobile_auth["state"] == query["state"][0] + assert mobile_auth["state"] in client.session["oidc_states"] @override_settings(**AUTHENTICATE_SETTINGS) def test_web_login_does_not_flag_the_session(self): @@ -114,9 +118,10 @@ CALLBACK_SETTINGS = { class TestMobileAuthenticationCallback: """Tests for the mobile handoff performed by the callback view.""" - def _build_view(self, session_data=None): + def _build_view(self, session_data=None, state="login-state"): """Return a callback view bound to a request with a real session.""" - request = RequestFactory().get("/api/v1.0/callback/") + query = {"state": state} if state else {} + request = RequestFactory().get("/api/v1.0/callback/", query) SessionMiddleware(lambda _request: None).process_request(request) for key, value in (session_data or {}).items(): request.session[key] = value @@ -130,6 +135,21 @@ class TestMobileAuthenticationCallback: view.user = user return view + @staticmethod + def _handoff_url(response): + """Extract the app deep link from the hand-off page of a mobile login. + + The login ends on a page (not a 302 to the scheme, which the IdP login + page's CSP form-action blocks at the end of the credential form's + redirect chain) whose auto redirect and tap fallback both point at the + deep link. + """ + assert response.status_code == status.HTTP_200_OK + assert response["Cache-Control"] == "no-store" + match = re.search(rb'href="([^"]+)"', response.content) + assert match is not None + return match[1].decode() + @override_settings(**CALLBACK_SETTINGS) def test_mobile_login_success_redirects_to_the_app(self): """A mobile login ends with a deep link carrying a one-time token.""" @@ -138,14 +158,14 @@ class TestMobileAuthenticationCallback: MOBILE_AUTH_SESSION_KEY: { "scheme": "stmessagesa", "code_challenge": "challenge", + "state": "login-state", "created_at": time.time(), } } ) response = view.login_success() - assert response.status_code == status.HTTP_302_FOUND - location = urlparse(response["Location"]) + location = urlparse(self._handoff_url(response)) assert location.scheme == "stmessagesa" assert location.netloc == "auth" @@ -173,6 +193,7 @@ class TestMobileAuthenticationCallback: MOBILE_AUTH_SESSION_KEY: { "scheme": "stmessagesa", "code_challenge": "challenge", + "state": "login-state", "created_at": time.time() - 3600, } } @@ -181,6 +202,28 @@ class TestMobileAuthenticationCallback: assert response["Location"] == "/" assert MOBILE_AUTH_SESSION_KEY not in view.request.session + @override_settings(**CALLBACK_SETTINGS) + def test_mobile_flag_of_another_login_is_left_alone(self): + """A browser session can carry a pending mobile login while another + login runs: that callback must keep its web redirect and leave the flag + to the callback it belongs to.""" + view = self._build_view( + { + MOBILE_AUTH_SESSION_KEY: { + "scheme": "stmessagesa", + "code_challenge": "challenge", + "state": "mobile-state", + "created_at": time.time(), + } + }, + state="web-state", + ) + response = view.login_success() + + assert response.status_code == status.HTTP_302_FOUND + assert response["Location"] == "/" + assert MOBILE_AUTH_SESSION_KEY in view.request.session + @override_settings(**CALLBACK_SETTINGS) def test_mobile_login_failure_redirects_to_the_app(self): """A failed mobile login notifies the app through the deep link.""" @@ -189,13 +232,13 @@ class TestMobileAuthenticationCallback: MOBILE_AUTH_SESSION_KEY: { "scheme": "stmessagesa", "code_challenge": "challenge", + "state": "login-state", "created_at": time.time(), } } ) response = view.login_failure() - assert response.status_code == status.HTTP_302_FOUND - assert response["Location"] == "stmessagesa://auth?error=login_failed" + assert self._handoff_url(response) == "stmessagesa://auth?error=login_failed" @override_settings(**CALLBACK_SETTINGS) def test_web_login_failure_is_unchanged(self): @@ -345,10 +388,11 @@ class TestMobileSessionExchange: class TestMobileLogout: - """Tests for the mobile logout endpoint. + """Tests for the mobile logout fallback endpoint. - Unlike `/logout/` it must end the Django session without the RP-initiated - IdP logout, so the cross-app SSO session survives. + The nominal mobile logout runs the IdP round-trip through `/logout/`; + this endpoint only flushes the Django session, for when the system + browser flow fails or is cancelled. """ def _login(self, client, user): diff --git a/src/frontend/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png b/src/frontend/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png deleted file mode 100644 index 33ea6c970f2df1db62a624a55e5bbcc4ee07bbdf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 41273 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 diff --git a/src/frontend/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png b/src/frontend/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png deleted file mode 100644 index 33ea6c970f2df1db62a624a55e5bbcc4ee07bbdf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 41273 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 diff --git a/src/frontend/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png b/src/frontend/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png deleted file mode 100644 index 33ea6c970f2df1db62a624a55e5bbcc4ee07bbdf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 41273 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 diff --git a/src/frontend/src/features/native/auth.ts b/src/frontend/src/features/native/auth.ts index ad8ac3f0..bc484702 100644 --- a/src/frontend/src/features/native/auth.ts +++ b/src/frontend/src/features/native/auth.ts @@ -8,9 +8,18 @@ import { computeCodeChallenge, generateCodeVerifier } from "./pkce"; /** * Deep-link scheme ending the mobile OIDC flow. The backend allowlists it - * through MOBILE_AUTH_CALLBACK_SCHEMES. + * through MOBILE_AUTH_CALLBACK_SCHEMES (a list, so several environments can be + * served at once). + * + * Per-environment (MOBILE_AUTH_SCHEME) so staging and production builds can sit + * side by side on one device: two installed apps claiming the same scheme would + * make Android ask the user which one should receive the login callback, in the + * middle of the auth flow. The same value must reach both native declarations — + * the Android manifest through a gradle manifestPlaceholder, the iOS Info.plist + * through the AUTH_CALLBACK_SCHEME build setting — and the default below must + * stay in sync with theirs; sso-invariants.test.ts pins that wiring. */ -const AUTH_CALLBACK_SCHEME = "stmessages"; +const AUTH_CALLBACK_SCHEME = import.meta.env.MOBILE_AUTH_SCHEME || "stmessages"; type ExchangeResponse = { csrf_token: string; @@ -68,18 +77,34 @@ export const nativeLogin = async (): Promise => { }; /** - * End the app session while preserving cross-app SSO. + * End the session everywhere: Django AND the identity provider. * - * Calling /api/v1.0/logout/ would trigger the RP-initiated IdP logout - * (id_token_hint) and terminate the cross-app SSO session. The dedicated - * mobile endpoint flushes only the server-side Django session; the local - * cookies and CSRF token are then dropped. Never rejects: the reload into + * The RP-initiated logout runs in the system browser, which holds both the + * Django session cookie (the session handed over at login) and the IdP SSO + * cookie: the round-trip terminates both, so the next login always stops on + * the IdP login page and the user can switch accounts — prompt=login alone + * cannot guarantee it, ProConnect ignores it. Never rejects: the reload into * the logged-out state must happen even when a step fails. */ export const nativeLogout = async (): Promise => { + const scheme = AUTH_CALLBACK_SCHEME; try { - // Invalidate the server-side session first: even if clearing the native - // cookie jar fails below, the cookie no longer maps to a live session. + // Ends on a scheme://logout deep link once the IdP round-trip flushed + // the server-side session. + await openAuthSession( + getRequestUrl("/api/v1.0/logout/", { mobile_scheme: scheme }), + scheme, + ); + } catch (error) { + // A cancelled sheet or a failed round-trip must not keep the app + // signed in: the local flush below still ends the app session. + console.warn("IdP logout did not complete:", error); + } + try { + // Always flush the app-side server session too: the browser round-trip + // only ends it when the browser still holds the same session cookie — + // if the browser dropped it, the app session would otherwise survive. + // Anonymous no-op when the round-trip already ended it. const csrfToken = getNativeCsrfToken(); await fetch(getRequestUrl("/api/v1.0/mobile/auth/logout/"), { method: "POST", diff --git a/src/frontend/src/features/native/sso-invariants.test.ts b/src/frontend/src/features/native/sso-invariants.test.ts index f1062419..510f0c28 100644 --- a/src/frontend/src/features/native/sso-invariants.test.ts +++ b/src/frontend/src/features/native/sso-invariants.test.ts @@ -25,14 +25,25 @@ const frontendRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../.." const read = (relativePath: string): string => readFileSync(resolve(frontendRoot, relativePath), "utf8"); -// Source of truth for the deep-link scheme (see auth.ts). Extracted from the -// source so a scheme rename fails here unless every declaration follows. +// Source of truth for the deep-link scheme (see auth.ts). The scheme is +// per-environment (MOBILE_AUTH_SCHEME) so staging and production builds can +// coexist on a device, so what has to hold is no longer one literal shared by +// three files: each side must *substitute* the variable, and their fallbacks +// must agree. Either half failing strands the OIDC callback — silently, since +// the login opens normally and only the return never lands. const authSource = read("src/features/native/auth.ts"); -const scheme = /AUTH_CALLBACK_SCHEME = "([a-z0-9]+)"/.exec(authSource)?.[1]; +const schemeDefault = + /AUTH_CALLBACK_SCHEME =[\s\S]{0,120}?\|\|\s*"([a-z][a-z0-9+.-]*)"/.exec(authSource)?.[1]; describe("cross-app SSO invariants", () => { it("declares the deep-link scheme in auth.ts", () => { - expect(scheme).toBeTruthy(); + expect(schemeDefault).toBeTruthy(); + }); + + it("reads the scheme from the build environment", () => { + // Hardcoding it back would silently pin every environment to one scheme, + // and two installed builds would fight over the callback. + expect(authSource).toContain("import.meta.env.MOBILE_AUTH_SCHEME"); }); describe("iOS", () => { @@ -65,18 +76,63 @@ describe("cross-app SSO invariants", () => { it("registers the callback scheme in Info.plist", () => { // With a non-ephemeral session iOS only delivers the callback for an - // app-registered scheme (CFBundleURLTypes). - expect(read("ios/App/App/Info.plist")).toContain(`${scheme}`); + // app-registered scheme (CFBundleURLTypes). The value is substituted by + // Xcode from the AUTH_CALLBACK_SCHEME build setting, which lives in the + // gitignored generated.xcconfig — an unset setting expands to an empty + // string rather than failing, so the point of use must carry the inline + // default, and it must agree with the auth.ts fallback. + expect(read("ios/App/App/Info.plist")).toContain( + `$(AUTH_CALLBACK_SCHEME:default=${schemeDefault})`, + ); + }); + + it("feeds the generated xcconfig scheme from MOBILE_AUTH_SCHEME", () => { + // generated.xcconfig (written at `make mobile-build`) is how the container + // env reaches Xcode. A wrong fallback there would quietly diverge from + // the scheme auth.ts builds its callback URL with — same invariant as the + // gradle manifestPlaceholder on Android. + const script = read("scripts/generate-ios-xcconfig.mjs"); + const fallback = + /MOBILE_AUTH_SCHEME \|\| "([a-z][a-z0-9+.-]*)"/.exec(script)?.[1]; + + expect(fallback).toBe(schemeDefault); + }); + + it("keeps the bundle identifier driven by MOBILE_APP_ID", () => { + // Regenerating the project would hardcode the id back, silently detaching + // it from the env (and from the synced capacitor.config.json the release + // guard build phase compares against). + const pbxproj = read("ios/App/App.xcodeproj/project.pbxproj"); + const bundleIdConfigs = + pbxproj.match( + /PRODUCT_BUNDLE_IDENTIFIER = "\$\(MOBILE_APP_ID:default=[^)]+\)";/g, + ) ?? []; + + expect(bundleIdConfigs.length).toBeGreaterThan(0); }); }); describe("Android", () => { it("registers the callback scheme in the manifest", () => { // Routes the Custom Tab's deep-link redirect back to the app - // (caught by App.addListener("appUrlOpen")). - expect( - read("android/app/src/main/AndroidManifest.xml"), - ).toContain(`android:scheme="${scheme}"`); + // (caught by App.addListener("appUrlOpen")). Substituted by gradle from + // the manifestPlaceholder below. + expect(read("android/app/src/main/AndroidManifest.xml")).toContain( + 'android:scheme="${authCallbackScheme}"', + ); + }); + + it("feeds the manifest placeholder from MOBILE_AUTH_SCHEME", () => { + // A missing placeholder fails the manifest merge loudly, but a wrong + // fallback does not: it would quietly diverge from the scheme auth.ts + // builds its callback URL with. + const buildGradle = read("android/app/build.gradle"); + const fallback = + /authCallbackScheme: System\.getenv\("MOBILE_AUTH_SCHEME"\) \?: "([a-z][a-z0-9+.-]*)"/.exec( + buildGradle, + )?.[1]; + + expect(fallback).toBe(schemeDefault); }); }); });