(mobile) custom logout view to terminates the IdP session

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.
This commit is contained in:
jbpenrath
2026-08-13 12:08:41 +02:00
parent fea61e1be2
commit 31b51b5f67
14 changed files with 615 additions and 86 deletions
+34 -13
View File
@@ -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:<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
+1 -1
View File
@@ -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
+5 -5
View File
@@ -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.
+6 -1
View File
@@ -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")),
]
+161 -37
View File
@@ -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")
+6 -1
View File
@@ -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.
@@ -0,0 +1,48 @@
<!doctype html>
{% 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 %}
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Messages</title>
<style>
body {
font-family: system-ui, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.5rem;
min-height: 100dvh;
margin: 0;
padding: 1rem;
text-align: center;
color: #161616;
background: #fff;
}
a {
display: inline-block;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
background: #000091;
color: #fff;
text-decoration: none;
font-weight: 600;
}
</style>
</head>
<body>
<p>Retour vers l&rsquo;application&hellip;<br /><span lang="en">Returning to the app&hellip;</span></p>
<a id="open" href="{{ app_url }}">Ouvrir l&rsquo;application / <span lang="en">Open the app</span></a>
<script>
location.replace(document.getElementById("open").href);
</script>
</body>
</html>
@@ -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
@@ -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):
Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

+34 -9
View File
@@ -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<void> => {
};
/**
* 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<void> => {
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",
@@ -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(`<string>${scheme}</string>`);
// 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(
`<string>$(AUTH_CALLBACK_SCHEME:default=${schemeDefault})</string>`,
);
});
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);
});
});
});