Skip to content

feat(auth): OAuth2 + PKCE token endpoint for native apps#1409

Open
mmaudet wants to merge 9 commits into
suitenumerique:mainfrom
mmaudet:feat/oauth-pkce-mobile-flow
Open

feat(auth): OAuth2 + PKCE token endpoint for native apps#1409
mmaudet wants to merge 9 commits into
suitenumerique:mainfrom
mmaudet:feat/oauth-pkce-mobile-flow

Conversation

@mmaudet

@mmaudet mmaudet commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

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/dictaphone
already ships in production. We port its core/authentication module almost
verbatim: same schemas, same view classes, same JWT settings. Web SSO is
untouched — PKCE only activates when response_type=code is on
/api/v1.0/authenticate/.

What changes

  • djangorestframework-simplejwt 5.5.1 + token_blacklist for rotating refresh tokens.
  • Pydantic schemas validating code_challenge / code_challenge_method / state per RFC 7636.
  • Three views in core/authentication/views.py:
    • request: caches the PKCE challenge in the session before SSO,
    • callback: issues a single-use auth code, redirects to MOBILE_DEEP_LINK_SCHEME?code=…&state=…,
    • exchange: verifies the verifier, deletes the code first (anti-oracle), returns a JWT pair.
  • New routes: POST /api/v1.0/oauth/token/, POST /api/v1.0/oauth/token/refresh/, GET /mobile-login.
  • Settings: swap OIDC_AUTHENTICATE_CLASS / OIDC_CALLBACK_CLASS, add MOBILE_DEEP_LINK_SCHEME and AUTH_PKCE_CACHE_TTL_SECONDS, add SIMPLE_JWT (10 min access, 7 day refresh, rotate + blacklist).
  • 19 tests ported from dictaphone covering parameter validation, code single-use, verifier mismatch, deep-link redirect, refresh rotation, and non-PKCE fall-through.

Test plan

  • pytest src/backend/core/tests/authentication/test_pkce_views.py green (19 tests).
  • Full backend suite still green (no regression on web SSO).
  • End-to-end against a local Meet + Keycloak + visio-mobile dev build: SSO login → deep-link → /oauth/token/ → Bearer call → /oauth/token/refresh/.
  • Replay: second exchange of the same code is rejected.

mmaudet added 7 commits June 4, 2026 20:22
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.
@mmaudet mmaudet force-pushed the feat/oauth-pkce-mobile-flow branch 2 times, most recently from 91c2064 to 6c79b90 Compare June 4, 2026 18:27
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.
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.
@mmaudet mmaudet force-pushed the feat/oauth-pkce-mobile-flow branch from 16303d4 to bec1cd5 Compare June 4, 2026 21:50
@sonarqubecloud

sonarqubecloud Bot commented Jun 4, 2026

Copy link
Copy Markdown

@FloChehab

Copy link
Copy Markdown
Collaborator

Hi, as quick notes :

@mmaudet

mmaudet commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator Author

Hi @FloChehab , thanks for your review!

On logout: could you share what specifically isn't clean on the
Assistant Transcripts side? We'd like to avoid reproducing the same
gap on Visio Mobile. Probably :

  • POST /oauth/logout/ that blacklists the refresh token
  • redirect to Keycloak end_session_endpoint
  • clear the Django session created by the SSO callback

Happy to add this in a follow-up PR rather than block this one or
in this PR if you'd prefer.

On django-lasuite: makes total sense. With Meet becoming the second
consumer, the extraction criterion is met. Happy to help on the
move. Let me know how you'd like to coordinate...

Context on my side: this PR is currently the blocker for the
official v1 release of Visio Mobile (Android + iOS + Desktop). The
native apps need a Meet instance that exposes the PKCE endpoints to
authenticate without a Django session cookie without this merged
upstream, I can't ship because users want to test with the official visio.numerique.gouv.fr meet

How can I help to unblock the release? Concretely I can:

  • address any blocker you identify on this PR (logout, tests, code
    organization). Just point me at what needs to change
  • own the django-lasuite extraction PR if that's the path you'd
    prefer before merging into Meet
  • pair on a call to align on scope and timeline

What would help you most?

@FloChehab

Copy link
Copy Markdown
Collaborator

Hello @mmaudet,

Sorry I am mostly oow until Wednesday.

Thanks for your detailed comment.
Regarding the logout, I don't have precise elements in mind but what your are saying seems right to me.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants