mirror of
https://github.com/suitenumerique/messages.git
synced 2026-08-17 21:25:41 +02:00
✨(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:
+34
-13
@@ -102,15 +102,28 @@ Step by step:
|
|||||||
system browser.
|
system browser.
|
||||||
2. **Backend flags the session.** `OIDCAuthenticationRequestView` checks the
|
2. **Backend flags the session.** `OIDCAuthenticationRequestView` checks the
|
||||||
scheme against `MOBILE_AUTH_CALLBACK_SCHEMES` (rejects unknown schemes) and
|
scheme against `MOBILE_AUTH_CALLBACK_SCHEMES` (rejects unknown schemes) and
|
||||||
stashes `{scheme, code_challenge, created_at}` in the Django session. A flag
|
stashes `{scheme, code_challenge, state, created_at}` in the Django session.
|
||||||
older than 10 min is later ignored, so an abandoned mobile attempt can't
|
The `state` is the one generated for this OIDC round-trip: the callback only
|
||||||
hijack a subsequent web login in the same browser.
|
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
|
3. **IdP authenticates** — interactively the first time, silently afterwards
|
||||||
(see *Cross-app SSO conditions* below).
|
(see *Cross-app SSO conditions* below).
|
||||||
4. **Callback mints a one-time token.** `OIDCAuthenticationCallbackView` caches
|
4. **Callback mints a one-time token.** `OIDCAuthenticationCallbackView` caches
|
||||||
`{session_key, code_challenge}` under `mobile-auth-token:<token>` with a
|
`{session_key, code_challenge}` under `mobile-auth-token:<token>` with a
|
||||||
`MOBILE_AUTH_TOKEN_TTL` (60 s) timeout and deep-links back to
|
`MOBILE_AUTH_TOKEN_TTL` (60 s) timeout and hands the browser back to
|
||||||
`stmessages://auth?token=…`.
|
`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
|
5. **App exchanges the token.** `MobileSessionExchangeView` (anonymous, single
|
||||||
use) deletes the cache key *before* verifying it (a failed attempt can't be
|
use) deletes the cache key *before* verifying it (a failed attempt can't be
|
||||||
retried), checks `S256(code_verifier) == code_challenge` with
|
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
|
> simply because the Django session cookie is still valid — that never hits
|
||||||
> `/authorize` and does **not** prove IdP SSO. Always exercise the mobile flow.
|
> `/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
|
`nativeLogout()` runs the RP-initiated logout (`/api/v1.0/logout/` with
|
||||||
IdP logout and tear down the cross-app SSO session. It POSTs to
|
`mobile_scheme`) in the system browser, which holds both the Django session
|
||||||
`/api/v1.0/mobile/auth/logout/` instead, which flushes only the server-side
|
cookie handed over at login and the IdP SSO cookie: the round-trip terminates
|
||||||
Django session, then clears the native cookies and the cached CSRF token.
|
both and ends on a `scheme://logout` deep link that closes the sheet. Keeping
|
||||||
IdP-level logout is a follow-up.
|
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
|
## Networking & session
|
||||||
|
|
||||||
@@ -637,8 +656,10 @@ or the Capacitor version.
|
|||||||
manifest is refused (downgrade guard).
|
manifest is refused (downgrade guard).
|
||||||
5. **Native file paths** — download/share an attachment and a raw `.eml`
|
5. **Native file paths** — download/share an attachment and a raw `.eml`
|
||||||
(native HTTP session), upload an attachment (CSRF token path).
|
(native HTTP session), upload an attachment (CSRF token path).
|
||||||
6. **Logout → re-login** — logout ends the Django session only; the
|
6. **Logout → re-login** — logout ends both the Django session and the IdP
|
||||||
following login must complete silently (IdP session preserved).
|
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
|
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
|
dev server URL into `capacitor.config.json` (hot reload, see *Build & run
|
||||||
workflow*). Before archiving, set it empty in `frontend.local` and rerun
|
workflow*). Before archiving, set it empty in `frontend.local` and rerun
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ from core.mda.dispatch_webhooks import (
|
|||||||
VALID_FORMATS,
|
VALID_FORMATS,
|
||||||
)
|
)
|
||||||
from core.mda.inline_images import extract_inline_images_html
|
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.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.blob_gc import schedule_for_gc
|
||||||
from core.services.identity import keycloak as keycloak_service
|
from core.services.identity import keycloak as keycloak_service
|
||||||
from core.services.importer.channel import merged_state
|
from core.services.importer.channel import merged_state
|
||||||
|
|||||||
@@ -114,11 +114,11 @@ class MobileSessionExchangeView(APIView):
|
|||||||
class MobileLogoutView(APIView):
|
class MobileLogoutView(APIView):
|
||||||
"""Invalidate the Django session without the RP-initiated IdP logout.
|
"""Invalidate the Django session without the RP-initiated IdP logout.
|
||||||
|
|
||||||
The web logout (`/logout/`) redirects to the identity provider with an
|
Fallback for the mobile logout: the nominal path runs the full IdP logout
|
||||||
`id_token_hint`, which terminates the IdP session and therefore cross-app
|
(`/logout/?mobile_scheme=...`) in the system browser, but when that flow
|
||||||
SSO. Mobile logout must only flush the server-side Django session: the app
|
fails or is cancelled the app still needs to end its own session — this
|
||||||
clears its local cookie jar, and the IdP session stays alive for the other
|
endpoint only flushes the server-side Django session bound to the request
|
||||||
apps of the suite.
|
cookie.
|
||||||
|
|
||||||
Anonymous requests are a no-op (204): the app calls this best-effort, and
|
Anonymous requests are a no-op (204): the app calls this best-effort, and
|
||||||
logging out an already expired session must not fail.
|
logging out an already expired session must not fail.
|
||||||
|
|||||||
@@ -2,12 +2,17 @@
|
|||||||
|
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
|
||||||
from .views import OIDCLogoutView
|
from .views import OIDCLogoutCallbackView, OIDCLogoutView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# lasuite's logout views are not swappable through settings (unlike the
|
# lasuite's logout views are not swappable through settings (unlike the
|
||||||
# authenticate/callback views): override their paths by URL ordering, the
|
# authenticate/callback views): override their paths by URL ordering, the
|
||||||
# same way lasuite overrides mozilla-django-oidc's.
|
# same way lasuite overrides mozilla-django-oidc's.
|
||||||
path("logout/", OIDCLogoutView.as_view(), name="oidc_logout_custom"),
|
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")),
|
path("", include("lasuite.oidc_login.urls")),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ anonymous — see `OIDCLogoutView`.
|
|||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
import time
|
import time
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import parse_qs, urlencode, urlparse
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import auth
|
from django.contrib import auth
|
||||||
@@ -22,6 +22,7 @@ from django.core.cache import cache
|
|||||||
from django.core.exceptions import SuspiciousOperation
|
from django.core.exceptions import SuspiciousOperation
|
||||||
from django.core.handlers.wsgi import WSGIRequest
|
from django.core.handlers.wsgi import WSGIRequest
|
||||||
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseRedirect
|
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseRedirect
|
||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
from lasuite.oidc_login.views import (
|
from lasuite.oidc_login.views import (
|
||||||
OIDCAuthenticationCallbackView as LaSuiteOIDCAuthenticationCallbackView,
|
OIDCAuthenticationCallbackView as LaSuiteOIDCAuthenticationCallbackView,
|
||||||
@@ -29,15 +30,19 @@ from lasuite.oidc_login.views import (
|
|||||||
from lasuite.oidc_login.views import (
|
from lasuite.oidc_login.views import (
|
||||||
OIDCAuthenticationRequestView as LaSuiteOIDCAuthenticationRequestView,
|
OIDCAuthenticationRequestView as LaSuiteOIDCAuthenticationRequestView,
|
||||||
)
|
)
|
||||||
|
from lasuite.oidc_login.views import (
|
||||||
|
OIDCLogoutCallbackView as LaSuiteOIDCLogoutCallbackView,
|
||||||
|
)
|
||||||
from lasuite.oidc_login.views import OIDCLogoutView as LaSuiteOIDCLogoutView
|
from lasuite.oidc_login.views import OIDCLogoutView as LaSuiteOIDCLogoutView
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
MOBILE_AUTH_SESSION_KEY = "mobile_auth"
|
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)
|
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
|
# A pending mobile login/logout older than this is ignored by the callbacks,
|
||||||
# abandoned mobile attempt cannot turn a later web login performed in the same
|
# so an abandoned mobile attempt cannot turn a later web flow performed in the
|
||||||
# browser session into a deep-link redirect.
|
# same browser session into a deep-link redirect.
|
||||||
MOBILE_AUTH_FLAG_MAX_AGE = 60 * 10
|
MOBILE_AUTH_FLAG_MAX_AGE = 60 * 10
|
||||||
|
|
||||||
|
|
||||||
@@ -50,51 +55,117 @@ class AppSchemeRedirect(HttpResponseRedirect):
|
|||||||
super().__init__(redirect_to, *args, **kwargs)
|
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):
|
class OIDCAuthenticationRequestView(LaSuiteOIDCAuthenticationRequestView):
|
||||||
"""Authentication request view supporting a mobile session handoff.
|
"""Authentication request view supporting a mobile session handoff.
|
||||||
|
|
||||||
Mobile apps add `mobile_scheme` (an allowlisted deep-link scheme) and
|
Mobile apps add `mobile_scheme` (an allowlisted deep-link scheme) and
|
||||||
`code_challenge` (PKCE S256) to the authenticate URL. The pair is stored in
|
`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:
|
def get(self, request: WSGIRequest) -> HttpResponse:
|
||||||
"""Flag the session when the login is initiated by a mobile app."""
|
"""Flag the session when the login is initiated by a mobile app."""
|
||||||
mobile_scheme = request.GET.get("mobile_scheme", "")
|
mobile_scheme = get_requested_mobile_scheme(request)
|
||||||
if mobile_scheme:
|
if not mobile_scheme:
|
||||||
if mobile_scheme not in settings.MOBILE_AUTH_CALLBACK_SCHEMES:
|
return super().get(request)
|
||||||
raise SuspiciousOperation("Unknown mobile callback scheme.")
|
|
||||||
code_challenge = request.GET.get("code_challenge", "")
|
code_challenge = request.GET.get("code_challenge", "")
|
||||||
if not code_challenge:
|
if not code_challenge:
|
||||||
return HttpResponseBadRequest("Missing 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] = {
|
request.session[MOBILE_AUTH_SESSION_KEY] = {
|
||||||
"scheme": mobile_scheme,
|
"scheme": mobile_scheme,
|
||||||
"code_challenge": code_challenge,
|
"code_challenge": code_challenge,
|
||||||
|
"state": parse_qs(urlparse(response["Location"]).query)["state"][0],
|
||||||
"created_at": time.time(),
|
"created_at": time.time(),
|
||||||
}
|
}
|
||||||
# The parent view forces a session save before redirecting to the
|
request.session.save()
|
||||||
# identity provider, so the flag survives the OIDC round-trip.
|
return response
|
||||||
return super().get(request)
|
|
||||||
|
|
||||||
|
|
||||||
class OIDCAuthenticationCallbackView(LaSuiteOIDCAuthenticationCallbackView):
|
class OIDCAuthenticationCallbackView(LaSuiteOIDCAuthenticationCallbackView):
|
||||||
"""Callback view handing the session over to the mobile app via deep link."""
|
"""Callback view handing the session over to the mobile app via deep link."""
|
||||||
|
|
||||||
def _pop_mobile_auth(self) -> dict | None:
|
def pop_mobile_auth(self) -> dict | None:
|
||||||
"""Pop and return the pending mobile login data, if any and still fresh."""
|
"""Return the mobile flag of the login this callback closes, if any.
|
||||||
mobile_auth = self.request.session.pop(MOBILE_AUTH_SESSION_KEY, None)
|
|
||||||
if mobile_auth is None:
|
The parent view consumed the state from `oidc_states` before handing
|
||||||
return None
|
over, but the query parameter still identifies the round-trip: a
|
||||||
self.request.session.save()
|
callback carrying no state at all (a malformed one, never a real IdP
|
||||||
if time.time() - mobile_auth["created_at"] > MOBILE_AUTH_FLAG_MAX_AGE:
|
answer) belongs to no known flow and must leave the flag alone.
|
||||||
logger.warning("Ignoring stale mobile login flag.")
|
"""
|
||||||
return None
|
return pop_mobile_flag(
|
||||||
return mobile_auth
|
self.request.session,
|
||||||
|
MOBILE_AUTH_SESSION_KEY,
|
||||||
|
self.request.GET.get("state"),
|
||||||
|
)
|
||||||
|
|
||||||
def login_success(self) -> HttpResponse:
|
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()
|
response = super().login_success()
|
||||||
mobile_auth = self._pop_mobile_auth()
|
mobile_auth = self.pop_mobile_auth()
|
||||||
if mobile_auth is None:
|
if mobile_auth is None:
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -109,15 +180,19 @@ class OIDCAuthenticationCallbackView(LaSuiteOIDCAuthenticationCallbackView):
|
|||||||
timeout=settings.MOBILE_AUTH_TOKEN_TTL,
|
timeout=settings.MOBILE_AUTH_TOKEN_TTL,
|
||||||
)
|
)
|
||||||
query = urlencode({"token": token})
|
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:
|
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()
|
response = super().login_failure()
|
||||||
mobile_auth = self._pop_mobile_auth()
|
mobile_auth = self.pop_mobile_auth()
|
||||||
if mobile_auth is None:
|
if mobile_auth is None:
|
||||||
return response
|
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):
|
class OIDCLogoutView(LaSuiteOIDCLogoutView):
|
||||||
@@ -135,10 +210,17 @@ class OIDCLogoutView(LaSuiteOIDCLogoutView):
|
|||||||
rest of the parent flow is untouched (post_logout_redirect_uri is the
|
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
|
/logout-callback/ route — it must be registered at the IdP — and the
|
||||||
session is kept alive until the callback validates the returned state).
|
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:
|
def post(self, request: WSGIRequest) -> HttpResponse:
|
||||||
"""Log the user out of the IdP session, then out of Django."""
|
"""Log the user out of the IdP session, then out of Django."""
|
||||||
|
mobile_scheme = get_requested_mobile_scheme(request)
|
||||||
|
|
||||||
logout_url = self.redirect_url
|
logout_url = self.redirect_url
|
||||||
|
|
||||||
if request.user.is_authenticated or request.session.get("oidc_id_token"):
|
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:
|
if logout_url == self.redirect_url:
|
||||||
# No IdP round-trip possible: end the local session right away.
|
# No IdP round-trip possible: end the local session right away.
|
||||||
auth.logout(request)
|
auth.logout(request)
|
||||||
|
if mobile_scheme:
|
||||||
|
return AppSchemeRedirect(f"{mobile_scheme}://logout")
|
||||||
else:
|
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()
|
# 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.modified = True
|
||||||
request.session.save()
|
request.session.save()
|
||||||
|
|
||||||
return HttpResponseRedirect(logout_url)
|
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")
|
||||||
|
|||||||
@@ -26,7 +26,12 @@ from email.utils import make_msgid
|
|||||||
|
|
||||||
from django.utils import timezone
|
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
|
from jmap_email.types import JmapEmail
|
||||||
|
|
||||||
# Compose policy shared by every path that builds MIME for us.
|
# 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’application…<br /><span lang="en">Returning to the app…</span></p>
|
||||||
|
<a id="open" href="{{ app_url }}">Ouvrir l’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."""
|
"""Tests for the logout flow, IdP-terminating even for anonymous sessions."""
|
||||||
|
|
||||||
|
import time
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
from django.test.utils import override_settings
|
from django.test.utils import override_settings
|
||||||
@@ -10,6 +11,7 @@ from rest_framework import status
|
|||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from core import factories
|
from core import factories
|
||||||
|
from core.authentication.views import MOBILE_LOGOUT_SESSION_KEY
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -18,6 +20,11 @@ LOGOUT_SETTINGS = {
|
|||||||
"LOGOUT_REDIRECT_URL": "https://app.test/",
|
"LOGOUT_REDIRECT_URL": "https://app.test/",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MOBILE_LOGOUT_SETTINGS = {
|
||||||
|
**LOGOUT_SETTINGS,
|
||||||
|
"MOBILE_AUTH_CALLBACK_SCHEMES": ["stmessagesa"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class TestLogoutView:
|
class TestLogoutView:
|
||||||
"""Tests for the logout view."""
|
"""Tests for the logout view."""
|
||||||
@@ -100,3 +107,197 @@ class TestLogoutCallbackView:
|
|||||||
assert response.status_code == status.HTTP_302_FOUND
|
assert response.status_code == status.HTTP_302_FOUND
|
||||||
assert response["Location"] == "https://app.test/"
|
assert response["Location"] == "https://app.test/"
|
||||||
assert "_auth_user_id" not in client.session
|
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."""
|
"""Tests for the mobile (Capacitor) OIDC session handoff."""
|
||||||
|
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
@@ -92,6 +93,9 @@ class TestMobileAuthenticationRequest:
|
|||||||
mobile_auth = client.session[MOBILE_AUTH_SESSION_KEY]
|
mobile_auth = client.session[MOBILE_AUTH_SESSION_KEY]
|
||||||
assert mobile_auth["scheme"] == "stmessagesa"
|
assert mobile_auth["scheme"] == "stmessagesa"
|
||||||
assert mobile_auth["code_challenge"] == "challenge"
|
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)
|
@override_settings(**AUTHENTICATE_SETTINGS)
|
||||||
def test_web_login_does_not_flag_the_session(self):
|
def test_web_login_does_not_flag_the_session(self):
|
||||||
@@ -114,9 +118,10 @@ CALLBACK_SETTINGS = {
|
|||||||
class TestMobileAuthenticationCallback:
|
class TestMobileAuthenticationCallback:
|
||||||
"""Tests for the mobile handoff performed by the callback view."""
|
"""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."""
|
"""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)
|
SessionMiddleware(lambda _request: None).process_request(request)
|
||||||
for key, value in (session_data or {}).items():
|
for key, value in (session_data or {}).items():
|
||||||
request.session[key] = value
|
request.session[key] = value
|
||||||
@@ -130,6 +135,21 @@ class TestMobileAuthenticationCallback:
|
|||||||
view.user = user
|
view.user = user
|
||||||
return view
|
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)
|
@override_settings(**CALLBACK_SETTINGS)
|
||||||
def test_mobile_login_success_redirects_to_the_app(self):
|
def test_mobile_login_success_redirects_to_the_app(self):
|
||||||
"""A mobile login ends with a deep link carrying a one-time token."""
|
"""A mobile login ends with a deep link carrying a one-time token."""
|
||||||
@@ -138,14 +158,14 @@ class TestMobileAuthenticationCallback:
|
|||||||
MOBILE_AUTH_SESSION_KEY: {
|
MOBILE_AUTH_SESSION_KEY: {
|
||||||
"scheme": "stmessagesa",
|
"scheme": "stmessagesa",
|
||||||
"code_challenge": "challenge",
|
"code_challenge": "challenge",
|
||||||
|
"state": "login-state",
|
||||||
"created_at": time.time(),
|
"created_at": time.time(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
response = view.login_success()
|
response = view.login_success()
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_302_FOUND
|
location = urlparse(self._handoff_url(response))
|
||||||
location = urlparse(response["Location"])
|
|
||||||
assert location.scheme == "stmessagesa"
|
assert location.scheme == "stmessagesa"
|
||||||
assert location.netloc == "auth"
|
assert location.netloc == "auth"
|
||||||
|
|
||||||
@@ -173,6 +193,7 @@ class TestMobileAuthenticationCallback:
|
|||||||
MOBILE_AUTH_SESSION_KEY: {
|
MOBILE_AUTH_SESSION_KEY: {
|
||||||
"scheme": "stmessagesa",
|
"scheme": "stmessagesa",
|
||||||
"code_challenge": "challenge",
|
"code_challenge": "challenge",
|
||||||
|
"state": "login-state",
|
||||||
"created_at": time.time() - 3600,
|
"created_at": time.time() - 3600,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,6 +202,28 @@ class TestMobileAuthenticationCallback:
|
|||||||
assert response["Location"] == "/"
|
assert response["Location"] == "/"
|
||||||
assert MOBILE_AUTH_SESSION_KEY not in view.request.session
|
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)
|
@override_settings(**CALLBACK_SETTINGS)
|
||||||
def test_mobile_login_failure_redirects_to_the_app(self):
|
def test_mobile_login_failure_redirects_to_the_app(self):
|
||||||
"""A failed mobile login notifies the app through the deep link."""
|
"""A failed mobile login notifies the app through the deep link."""
|
||||||
@@ -189,13 +232,13 @@ class TestMobileAuthenticationCallback:
|
|||||||
MOBILE_AUTH_SESSION_KEY: {
|
MOBILE_AUTH_SESSION_KEY: {
|
||||||
"scheme": "stmessagesa",
|
"scheme": "stmessagesa",
|
||||||
"code_challenge": "challenge",
|
"code_challenge": "challenge",
|
||||||
|
"state": "login-state",
|
||||||
"created_at": time.time(),
|
"created_at": time.time(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
response = view.login_failure()
|
response = view.login_failure()
|
||||||
assert response.status_code == status.HTTP_302_FOUND
|
assert self._handoff_url(response) == "stmessagesa://auth?error=login_failed"
|
||||||
assert response["Location"] == "stmessagesa://auth?error=login_failed"
|
|
||||||
|
|
||||||
@override_settings(**CALLBACK_SETTINGS)
|
@override_settings(**CALLBACK_SETTINGS)
|
||||||
def test_web_login_failure_is_unchanged(self):
|
def test_web_login_failure_is_unchanged(self):
|
||||||
@@ -345,10 +388,11 @@ class TestMobileSessionExchange:
|
|||||||
|
|
||||||
|
|
||||||
class TestMobileLogout:
|
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
|
The nominal mobile logout runs the IdP round-trip through `/logout/`;
|
||||||
IdP logout, so the cross-app SSO session survives.
|
this endpoint only flushes the Django session, for when the system
|
||||||
|
browser flow fails or is cancelled.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _login(self, client, user):
|
def _login(self, client, user):
|
||||||
|
|||||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 40 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 40 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 40 KiB |
@@ -8,9 +8,18 @@ import { computeCodeChallenge, generateCodeVerifier } from "./pkce";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Deep-link scheme ending the mobile OIDC flow. The backend allowlists it
|
* 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 = {
|
type ExchangeResponse = {
|
||||||
csrf_token: string;
|
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
|
* The RP-initiated logout runs in the system browser, which holds both the
|
||||||
* (id_token_hint) and terminate the cross-app SSO session. The dedicated
|
* Django session cookie (the session handed over at login) and the IdP SSO
|
||||||
* mobile endpoint flushes only the server-side Django session; the local
|
* cookie: the round-trip terminates both, so the next login always stops on
|
||||||
* cookies and CSRF token are then dropped. Never rejects: the reload into
|
* 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.
|
* the logged-out state must happen even when a step fails.
|
||||||
*/
|
*/
|
||||||
export const nativeLogout = async (): Promise<void> => {
|
export const nativeLogout = async (): Promise<void> => {
|
||||||
|
const scheme = AUTH_CALLBACK_SCHEME;
|
||||||
try {
|
try {
|
||||||
// Invalidate the server-side session first: even if clearing the native
|
// Ends on a scheme://logout deep link once the IdP round-trip flushed
|
||||||
// cookie jar fails below, the cookie no longer maps to a live session.
|
// 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();
|
const csrfToken = getNativeCsrfToken();
|
||||||
await fetch(getRequestUrl("/api/v1.0/mobile/auth/logout/"), {
|
await fetch(getRequestUrl("/api/v1.0/mobile/auth/logout/"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -25,14 +25,25 @@ const frontendRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."
|
|||||||
const read = (relativePath: string): string =>
|
const read = (relativePath: string): string =>
|
||||||
readFileSync(resolve(frontendRoot, relativePath), "utf8");
|
readFileSync(resolve(frontendRoot, relativePath), "utf8");
|
||||||
|
|
||||||
// Source of truth for the deep-link scheme (see auth.ts). Extracted from the
|
// Source of truth for the deep-link scheme (see auth.ts). The scheme is
|
||||||
// source so a scheme rename fails here unless every declaration follows.
|
// 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 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", () => {
|
describe("cross-app SSO invariants", () => {
|
||||||
it("declares the deep-link scheme in auth.ts", () => {
|
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", () => {
|
describe("iOS", () => {
|
||||||
@@ -65,18 +76,63 @@ describe("cross-app SSO invariants", () => {
|
|||||||
|
|
||||||
it("registers the callback scheme in Info.plist", () => {
|
it("registers the callback scheme in Info.plist", () => {
|
||||||
// With a non-ephemeral session iOS only delivers the callback for an
|
// With a non-ephemeral session iOS only delivers the callback for an
|
||||||
// app-registered scheme (CFBundleURLTypes).
|
// app-registered scheme (CFBundleURLTypes). The value is substituted by
|
||||||
expect(read("ios/App/App/Info.plist")).toContain(`<string>${scheme}</string>`);
|
// 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", () => {
|
describe("Android", () => {
|
||||||
it("registers the callback scheme in the manifest", () => {
|
it("registers the callback scheme in the manifest", () => {
|
||||||
// Routes the Custom Tab's deep-link redirect back to the app
|
// Routes the Custom Tab's deep-link redirect back to the app
|
||||||
// (caught by App.addListener("appUrlOpen")).
|
// (caught by App.addListener("appUrlOpen")). Substituted by gradle from
|
||||||
expect(
|
// the manifestPlaceholder below.
|
||||||
read("android/app/src/main/AndroidManifest.xml"),
|
expect(read("android/app/src/main/AndroidManifest.xml")).toContain(
|
||||||
).toContain(`android:scheme="${scheme}"`);
|
'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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user