feat(auth): OAuth2 + PKCE token endpoint for native apps#1409
Conversation
Adds the SimpleJWT package and its required Django apps to INSTALLED_APPS. Needed by the upcoming OAuth2 + PKCE flow for native mobile/desktop clients, which issues short-lived access tokens (10 min) and rotating refresh tokens (7 days) backed by `rest_framework_simplejwt.token_blacklist`.
Updates the query string of a URL while preserving scheme, netloc, path and fragment. Will be used by the upcoming PKCE OIDC callback to build the `MOBILE_DEEP_LINK_SCHEME?code=...&state=...` redirect. Ported from `suitenumerique/dictaphone`'s `core/utils.py` — identical behaviour, no new dependencies.
- PKCEAuthenticationRequestModel validates the `code_challenge`, `code_challenge_method` and `state` query params on `/api/v1.0/authenticate/` when `response_type=code` is set. - PKCETokenExchangeModel validates the `code` + `code_verifier` payload sent to `/api/v1.0/oauth/token/`. Field bounds (43-128 chars, base64url alphabet) follow RFC 7636 §4.1. Ported verbatim from `suitenumerique/dictaphone`.
Implements RFC 8252 (OAuth 2.0 for Native Apps) on top of the existing lasuite/mozilla-django-oidc auth code flow: - PKCEOIDCAuthenticationRequestView intercepts `/api/v1.0/authenticate/` when `response_type=code` is set, validates the PKCE params and stores the challenge in the session for use by the callback. Web flows (no `response_type=code`) are untouched. - OIDCAuthenticationCallbackWithPkceView, on successful SSO login for a PKCE-marked session, generates a 64-byte URL-safe authorization code bound to the request's challenge, stores it in cache (TTL controlled by AUTH_PKCE_CACHE_TTL_SECONDS, default 60s), and redirects to `MOBILE_DEEP_LINK_SCHEME?code=...&state=...`. - MobileFriendlyRedirect whitelists the configured deep-link scheme in HttpResponseRedirect.allowed_schemes so Django doesn't reject it. - PKCEOAuthTokenExchangeView serves POST /api/v1.0/oauth/token/. Recomputes the SHA-256 challenge from the verifier, compares it with the cached one using secrets.compare_digest (constant time), and on success issues an access+refresh JWT pair via SimpleJWT. Security properties: * the authorization code is single-use (deleted from cache before verifier check so a failed exchange can't be retried/oracled), * the code is bound to the original client by code_challenge, * the deep-link scheme is whitelisted via MOBILE_DEEP_LINK_SCHEME, no arbitrary scheme is accepted, * the response path is checked via is_mobile_login_url so the swap only kicks in for the explicit `/mobile-login` returnTo marker. Replaces the session-exchange approach of mmaudet/meet#1170 with a RFC-compliant flow, following @sylvinus' review on suitenumerique#1153 pointing at auth0/curity best practices and the path already shipped in `suitenumerique/dictaphone`.
- Swap OIDC_AUTHENTICATE_CLASS and OIDC_CALLBACK_CLASS to the new PKCE-aware subclasses in core.authentication.views. Web SSO flows fall through unchanged when `response_type=code` is absent. - Add MOBILE_DEEP_LINK_SCHEME (default visio://auth-callback) and AUTH_PKCE_CACHE_TTL_SECONDS (default 60s) — both env-overridable for per-environment branding / tighter TTL on production. - Add SIMPLE_JWT config exposed as a Configuration @Property so the signing key can fall back to SECRET_KEY in dev while still being overridable by a dedicated SIMPLE_JWT_SIGNING_KEY env in prod. ACCESS_TOKEN_LIFETIME defaults to 10 min, REFRESH_TOKEN_LIFETIME to 7 days, with rotate-and-blacklist on refresh. - Register JWTAuthentication first in DRF DEFAULT_AUTHENTICATION_CLASSES so Bearer tokens issued by the new /oauth/token/ endpoint authenticate API calls without interfering with the existing OIDC session-cookie path used by the webapp.
- POST /api/v1.0/oauth/token/ → PKCEOAuthTokenExchangeView - POST /api/v1.0/oauth/token/refresh/ → simplejwt TokenRefreshView - GET /mobile-login → 204 marker page used as the `success_url` target for the native-app PKCE flow before the callback view rewrites the response into a custom-scheme redirect. The /mobile-login page is intentionally empty: the native client never actually loads it, but mozilla-django-oidc requires a concrete URL to validate as `returnTo` before our PKCE callback view swaps the response for `MOBILE_DEEP_LINK_SCHEME?code=...&state=...`.
19 tests covering the OAuth2 + PKCE native-app login flow: PKCEOIDCAuthenticationRequestView: * PKCE data stored in session when response_type=code * parameter validation (length boundaries, regex, empty) * fall-through to existing behaviour when response_type != code OIDCAuthenticationCallbackWithPkceView.login_success: * successful login emits a single-use auth code + state via MOBILE_DEEP_LINK_SCHEME redirect * non-mobile-login URL falls back to login_failure * missing PKCE session data falls back to login_failure PKCEOAuthTokenExchangeView (/oauth/token/): * valid code+verifier yields JWT pair, code consumed on first use * invalid code_verifier consumes the code (anti-oracle), returns 400 * code/verifier length validation (43-128 chars) TokenRefreshView (/oauth/token/refresh/): * valid refresh token issues a new access token Verified end-to-end on a local Meet + Keycloak stack: authenticate → SSO login → callback → deep-link → token exchange → Bearer-authenticated /users/me/ → refresh-rotation, all green. Replay (second exchange of same code) correctly rejected. Ported with minimal changes from `suitenumerique/dictaphone`'s `test_pkce_views.py`. All existing 44 auth-backend tests still pass, no regression on the web SSO flow.
91c2064 to
6c79b90
Compare
Sonar flagged this endpoint (S5122) for not specifying accepted HTTP methods. The view returns a 204 marker page consumed by the OIDC callback; only GET makes sense. Adds @require_GET to enforce it.
6c79b90 to
69be750
Compare
PKCEOIDCAuthenticationRequestView extends mozilla_django_oidc's OIDCAuthenticationRequestView, a plain Django View. Returning a DRF rest_framework.response.Response from the validation-error branch left accepted_renderer unset, so the response pipeline crashed with "AssertionError: .accepted_renderer not set on Response" → 500 in production (silent under RequestFactory unit tests, which read response.data before any rendering). Switch that single return to django.http.JsonResponse. OAuth2TokenView in the same module is a DRF APIView and keeps its Response. Adapt the two impacted tests to parse response.content with json.loads (HttpResponse has no .data) and to compare loc to a list (JSON serializes tuples to arrays). 19/19 PKCE tests stay green.
16303d4 to
bec1cd5
Compare
|
|
Hi, as quick notes :
|
|
Hi @FloChehab , thanks for your review! On logout: could you share what specifically isn't clean on the
Happy to add this in a follow-up PR rather than block this one or On django-lasuite: makes total sense. With Meet becoming the second Context on my side: this PR is currently the blocker for the How can I help to unblock the release? Concretely I can:
What would help you most? |
|
Hello @mmaudet, Sorry I am mostly oow until Wednesday. Thanks for your detailed comment. About the process to move forward, I think @lebaudantoine should own the decision, given the impact on visio.numerique.gouv.fr you expect. My understanding is that shipping this directly into Django la suite might be the easiest path forward. I'll ping Antoine internally to quickly get a strategy about this. |



Summary
Adds an RFC 8252 / RFC 7636 PKCE flow to Meet so native mobile and desktop apps
can authenticate against a Meet instance without sharing a session cookie.
Why: native clients (Android, iOS, Tauri) can't keep a Django session
cookie alive cleanly — and the previous session-exchange path of #1170 isn't
a standard the OAuth ecosystem recognises. PKCE is the recommended flow for
public/native clients (OAuth 2.1 §4.1, IETF BCP 212).
How: the implementation mirrors what
suitenumerique/dictaphonealready ships in production. We port its
core/authenticationmodule almostverbatim: same schemas, same view classes, same JWT settings. Web SSO is
untouched — PKCE only activates when
response_type=codeis on/api/v1.0/authenticate/.What changes
djangorestframework-simplejwt5.5.1 +token_blacklistfor rotating refresh tokens.code_challenge/code_challenge_method/stateper RFC 7636.core/authentication/views.py:MOBILE_DEEP_LINK_SCHEME?code=…&state=…,POST /api/v1.0/oauth/token/,POST /api/v1.0/oauth/token/refresh/,GET /mobile-login.OIDC_AUTHENTICATE_CLASS/OIDC_CALLBACK_CLASS, addMOBILE_DEEP_LINK_SCHEMEandAUTH_PKCE_CACHE_TTL_SECONDS, addSIMPLE_JWT(10 min access, 7 day refresh, rotate + blacklist).Test plan
pytest src/backend/core/tests/authentication/test_pkce_views.pygreen (19 tests)./oauth/token/→ Bearer call →/oauth/token/refresh/.