mirror of
https://github.com/suitenumerique/messages.git
synced 2026-09-11 04:17:47 +02:00
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.
687 lines
42 KiB
Markdown
687 lines
42 KiB
Markdown
# Mobile apps — technical architecture & onboarding
|
|
|
|
The Messages mobile apps (iOS + Android) are **the existing web frontend wrapped
|
|
in a [Capacitor](https://capacitorjs.com/) native shell**. There is no second
|
|
codebase: the same React/Vite bundle that serves `localhost:8900` runs inside a
|
|
`WKWebView` (iOS) / Android `WebView`, and a thin native layer supplies what a
|
|
browser cannot — a shared-cookie login, a native HTTP stack, file sharing and
|
|
over-the-air bundle updates.
|
|
|
|
This document is the onboarding reference for developers who need to work on the
|
|
apps: the architecture, where the code lives, and the prerequisites to build and
|
|
run on each platform.
|
|
|
|
> This is the production-facing companion to [`mobile-poc.md`](./mobile-poc.md),
|
|
> which records how the architecture was **validated** (smoke tests, cross-app
|
|
> SSO re-testing, negative controls). Read this file first; reach for the POC doc
|
|
> when you need the validation procedures.
|
|
|
|
## Why Capacitor (and not React Native)
|
|
|
|
The whole product value — rendering arbitrary email HTML safely — depends on an
|
|
`iframe` with `srcDoc` + `sandbox` + CSP. A previous React Native attempt broke
|
|
on exactly that. Capacitor keeps a real browser engine in the app, so the web
|
|
frontend renders identically to the desktop, and **one team maintains one UI**.
|
|
The cost is a set of WebView limitations the native layer must paper over
|
|
(session cookies, downloads, deep-link auth) — that layer is the interesting
|
|
part of this codebase and the rest of this doc.
|
|
|
|
## Architecture at a glance
|
|
|
|
```
|
|
┌─────────────────────────────────────────── native shell (iOS / Android) ──┐
|
|
│ │
|
|
│ ┌──────────────────────── WebView ───────────────────────┐ │
|
|
│ │ the web bundle (dist/) — React / TanStack / BlockNote │ │
|
|
│ │ │ │
|
|
│ │ window.fetch ──────────┐ (patched by CapacitorHttp) │ │
|
|
│ └─────────────────────────┼───────────────────────────────┘ │
|
|
│ ▼ │
|
|
│ ┌──────────────── native bridge (Capacitor plugins) ─────────────────┐ │
|
|
│ │ CapacitorHttp → native HTTP stack, native cookie jar │ │
|
|
│ │ WebAuthSession → ASWebAuthenticationSession (iOS, app-local) │ │
|
|
│ │ Browser → Chrome Custom Tabs (Android) │ │
|
|
│ │ Filesystem/Share→ downloads to OS share sheet │ │
|
|
│ │ CapacitorUpdater→ OTA bundle download / swap │ │
|
|
│ └────────────────────────────────────────────────────────────────────┘ │
|
|
└────────────────────────────────────────────────────────────────────────────┘
|
|
│ HTTPS (prod) / cleartext localhost (dev) │ system browser
|
|
▼ ▼
|
|
Django backend (confidential OIDC client) Identity provider
|
|
/api/v1.0/… ProConnect (prod) / Keycloak (dev)
|
|
```
|
|
|
|
Four concerns make the shell more than a browser:
|
|
|
|
1. **Networking & session** — `window.fetch` is routed through the native HTTP
|
|
layer so cookies live in the native jar, not the WebView.
|
|
2. **Authentication** — the OIDC flow runs in the *system browser*, never the
|
|
WebView, which is what enables cross-app SSO across all La Suite apps.
|
|
3. **File I/O** — downloads and shares go through native plugins because an
|
|
`<a download>` escapes the WebView and loses the session.
|
|
4. **OTA updates** — the JS bundle can be replaced without a store release.
|
|
|
|
Each is detailed below.
|
|
|
|
## Authentication architecture
|
|
|
|
This is the load-bearing design decision. **The user who logs in on one La Suite
|
|
app (mail, calendar, …) must not re-enter credentials on the others.**
|
|
|
|
The OIDC flow runs in the **system browser** — `ASWebAuthenticationSession` on
|
|
iOS, Chrome Custom Tabs on Android — following RFC 8252. The system browser
|
|
shares its cookie jar across apps, so the IdP session cookie (ProConnect in
|
|
production, Keycloak in development) provides the cross-app SSO: the second
|
|
app's login completes silently.
|
|
|
|
The backend stays the **confidential OIDC client** (`django-lasuite`). The IdP
|
|
only ever sees the ordinary web flow with the backend's HTTPS callback, so **no
|
|
IdP-side configuration is needed for mobile**. The Django session is then handed
|
|
to the app through a one-time token, bound to the app by PKCE:
|
|
|
|
```
|
|
App System browser Backend IdP
|
|
│ openAuthSession() │ │ │
|
|
│──────────────────────────────▶│ GET /api/v1.0/authenticate/ │ │
|
|
│ (+ mobile_scheme, │────────────────────────────▶│ 302 authorize │
|
|
│ code_challenge=S256) │─────────────────────────────┼───────────────────▶│
|
|
│ │ login form (or silent SSO redirect) │
|
|
│ │ GET /api/v1.0/callback/ │◀───────────────────│
|
|
│ stmessages://auth?token=… │◀────────────────────────────│ session + one-time │
|
|
│◀──────────────────────────────│ │ token (60 s TTL) │
|
|
│ POST /api/v1.0/mobile/auth/exchange/ {token, code_verifier}│ │
|
|
│────────────────────────────────────────────────────────────▶│ │
|
|
│◀── Set-Cookie sessionid + csrftoken, body {csrf_token} ─────│ │
|
|
```
|
|
|
|
Step by step:
|
|
|
|
1. **App starts the flow.** `nativeLogin()` generates a PKCE verifier
|
|
(`generateCodeVerifier`), computes its S256 challenge, and opens
|
|
`/api/v1.0/authenticate/?mobile_scheme=stmessages&code_challenge=…` in the
|
|
system browser.
|
|
2. **Backend flags the session.** `OIDCAuthenticationRequestView` checks the
|
|
scheme against `MOBILE_AUTH_CALLBACK_SCHEMES` (rejects unknown schemes) and
|
|
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 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
|
|
`secrets.compare_digest`, rehydrates the session, and emits the
|
|
`Set-Cookie: sessionid` header plus a `csrf_token` in the body.
|
|
|
|
The token is a bearer secret for ~60 s; **PKCE is what makes a stolen deep link
|
|
useless** (the attacker lacks the verifier), which matters because custom URL
|
|
schemes can be claimed by other apps.
|
|
|
|
The `stmessages` return scheme must be registered natively on **both** platforms
|
|
(source of truth: `AUTH_CALLBACK_SCHEME` in `auth.ts`): an `intent-filter` in
|
|
`AndroidManifest.xml`, and `CFBundleURLTypes` in the iOS `Info.plist` — the
|
|
latter is required because `ASWebAuthenticationSession` runs with
|
|
`prefersEphemeralWebBrowserSession = false` (needed to share the IdP cookie), and
|
|
in that mode iOS only delivers the callback for an app-registered scheme. Both
|
|
are independent of `MOBILE_APP_ID`.
|
|
|
|
### Cross-app SSO conditions (both bit us during the POC)
|
|
|
|
- **Requested ACR must be satisfiable.** The backend sends
|
|
`OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}` (required by
|
|
ProConnect). The IdP only skips the form if its existing session already meets
|
|
that Level of Assurance. The dev Keycloak realm therefore **maps `eidas1`**
|
|
(`acr.loa.map` on the `messages` client in `src/keycloak/realm.json`); with an
|
|
empty map Keycloak forces re-authentication on every flow and silently breaks
|
|
cross-app SSO. Do not remove that mapping.
|
|
- **The IdP session cookie must be persistent on iOS.**
|
|
`ASWebAuthenticationSession` only shares Safari's *persistent* cookies; the
|
|
Keycloak identity cookie is a session cookie unless "Remember me" is ticked.
|
|
|
|
> **False positive to avoid:** visiting `localhost:8900` may "log in silently"
|
|
> 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 ends the session everywhere (Django **and** IdP)
|
|
|
|
`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
|
|
|
|
`CapacitorHttp` (enabled in `capacitor.config.ts`) patches `window.fetch` so
|
|
every API call goes through the **native HTTP stack**, and the session cookies
|
|
live in the **native cookie jar**:
|
|
|
|
- No `SameSite` / ITP restriction, no WebView CORS.
|
|
- The plain-HTTP dev backend works (`server.cleartext` on Android — gated by
|
|
`MOBILE_ALLOW_CLEARTEXT_FOR_DEV`, set in `frontend.defaults` — and
|
|
`NSAllowsLocalNetworking` on iOS; both dev-only).
|
|
|
|
The trade-off is that the WebView can no longer read the `csrftoken` cookie from
|
|
`document.cookie`. So the CSRF token is delivered out-of-band by the session
|
|
exchange and cached in `localStorage`:
|
|
|
|
- `csrf.ts` stores/reads it under `messages_native-csrf-token`.
|
|
- `getCSRFToken()` (`src/features/api/utils.ts`) returns the native token on
|
|
native platforms and the web token otherwise; `getHeaders()` echoes it as
|
|
`X-CSRFToken`. This works with the backend's `CSRF_USE_SESSIONS` (the secret
|
|
lives in the session, replayed by the native cookie jar).
|
|
|
|
**Downloads** can't use `<a download>`: on native it escapes the WebView into the
|
|
system browser, which has no session and gets a 401. `nativeDownloadFile()`
|
|
fetches the bytes through `CapacitorHttp` (carrying the session), writes them to
|
|
`Directory.Cache` and hands them to the OS share sheet. Used by the thread-view
|
|
attachment components.
|
|
|
|
## OTA (over-the-air) updates
|
|
|
|
The JS bundle can be replaced without a store release, driven entirely from JS
|
|
against a **public S3 bucket — no Capgo server** (`autoUpdate: false`; the
|
|
`@capgo/capacitor-updater` plugin is used only for its native download/set/reload
|
|
primitives). Because the bucket is world-readable, every bundle is **encrypted
|
|
and signed** (Capgo v2, RSA+AES) with a per-instance key: the public half is
|
|
baked into the app at `cap sync` time (`capacitor.config.ts`, `publicKey` ←
|
|
`MOBILE_OTA_SIGNING_PUBLIC_KEY_B64`), the private half signs at publish time
|
|
(`MOBILE_OTA_SIGNING_PRIVATE_KEY_B64`, CI-only). A substituted zip therefore fails
|
|
native verification instead of running arbitrary code. The key is mandatory as
|
|
soon as OTA is on: `ota.ts` refuses to apply a manifest when the build embeds no
|
|
`MOBILE_OTA_SIGNING_PUBLIC_KEY_B64` (and `cap sync` still fails when the
|
|
deprecated baked `NEXT_PUBLIC_MOBILE_OTA_MANIFEST_URL` is set without it), so an
|
|
OTA-enabled app can never apply an unverified bundle.
|
|
|
|
- **Publish** (`make ota-publish [VERSION=x] [CHANNEL=x]`, i.e. the frontend
|
|
node script `src/frontend/scripts/publish-ota.mjs` — no Django involved):
|
|
`capgo bundle zip` `dist/` (with `index.html` at the zip root), `capgo bundle
|
|
encrypt` it (→ encrypted zip + encrypted `checksum` + `ivSessionKey`), upload
|
|
the *encrypted* zip to `channels/<channel>/bundles/<version>.zip`, and write
|
|
`channels/<channel>/manifest.json` = `{version, url, checksum, sessionKey}`
|
|
(the last two feed native verification). Publishing also refuses a version
|
|
that does not order above the channel's current manifest (mirror of the
|
|
client-side downgrade guard, see *Bundle versioning*; `--force` overrides).
|
|
- **Consume** (`src/features/native/ota.ts`, called at startup):
|
|
`notifyOtaAppReady()` first (confirms the running bundle booted, so
|
|
a broken update auto-rolls-back on next launch), then
|
|
`checkAndApplyOtaUpdate()` polls the manifest URL served by the backend
|
|
`MOBILE_OTA_MANIFEST_URL` setting (`/config` endpoint, resolved in
|
|
`bootstrap.tsx`) and applies the
|
|
advertised bundle **only if it is genuinely newer** — it must differ from
|
|
`CapacitorUpdater.current()`, carry a *strictly greater* version count (see
|
|
*Bundle versioning*), and not be recorded as a prior failed
|
|
boot (see *Rollback*). It then downloads (passing `checksum` + `sessionKey`,
|
|
verified against the baked-in public key) and `set()`s it, which reloads the
|
|
WebView.
|
|
|
|
OTA replaces the *web* bundle only. Anything native (a new Capacitor plugin, a
|
|
permission, the Swift/Gradle side) still requires a store release.
|
|
|
|
### Release channels
|
|
|
|
The bucket hosts one **self-contained folder per channel** —
|
|
`channels/<channel>/manifest.json` plus its `bundles/` — and each app follows
|
|
exactly one channel: the channel segment lives in the `MOBILE_OTA_MANIFEST_URL`
|
|
served by the backend the app talks to (`/config` endpoint). The deploy
|
|
pipeline publishes to `staging` and `prod`; local development uses `dev`
|
|
(commented default in `deploy/env/backend.defaults`), so experiments
|
|
never look like a release.
|
|
|
|
**A bundle is never copied or promoted across channels.** The `NEXT_PUBLIC_*`
|
|
vars (API origin, …) are inlined into the web bundle at build
|
|
time, so a staging build *is not* a prod build pointed elsewhere — it is a
|
|
different artifact targeting the staging backend, whose `/config` in turn pins
|
|
the staging channel. Releasing to prod means
|
|
rebuilding with the prod env and publishing to the prod channel. Keeping the
|
|
zips under their channel also prevents two channels publishing the same commit
|
|
(same `<count>-<sha>` id) from overwriting each other's bundle.
|
|
|
|
The publish target comes from `MOBILE_OTA_CHANNEL` (or `--channel` /
|
|
`make ota-publish CHANNEL=…`), and must match the channel the apps follow
|
|
through the backend `MOBILE_OTA_MANIFEST_URL`: publishing a bundle built for
|
|
another environment would strand the fleet on that other backend's config.
|
|
|
|
### Generating the signing key pair
|
|
|
|
Each deployment generates its **own** RSA-2048 pair once (the two halves must
|
|
stay a matched set — the app rejects any bundle it can't verify):
|
|
|
|
```bash
|
|
make mobile-ota-keygen
|
|
```
|
|
|
|
It prints the two values ready to paste into an env file / CI secret store
|
|
(the guidance goes to stderr, so stdout stays a clean pair):
|
|
|
|
```
|
|
MOBILE_OTA_SIGNING_PUBLIC_KEY_B64=… # baked into the app build env (capacitor.config.ts)
|
|
MOBILE_OTA_SIGNING_PRIVATE_KEY_B64=… # publish-time secret — CI only, never commit
|
|
```
|
|
|
|
Both are single-line base64 PEMs (PKCS1 — the format `capgo bundle encrypt`
|
|
expects) so they survive Docker `env_file` and CI secret stores. The **public
|
|
half** goes into the app build env; the **private half signs bundles at publish
|
|
time and must stay a CI secret**. Rotating the pair requires shipping a new store
|
|
build (the public key is baked in), so treat it as long-lived.
|
|
|
|
### Bundle versioning
|
|
|
|
The manifest `version` (and the `channels/<channel>/bundles/<version>.zip` key)
|
|
is a **hybrid id**, `<count>-<sha>` — e.g. `1234-a1b2c3d`:
|
|
|
|
- `<count>` = `git rev-list --count HEAD`, a **monotonic** commit count that
|
|
orders releases;
|
|
- `<sha>` = `git rev-parse --short HEAD`, tracing the exact source commit.
|
|
|
|
The Makefile derives it once as `MOBILE_OTA_BUILD_ID`; `make ota-publish` uses it as the
|
|
default `VERSION` (override with `VERSION=…` to pin a release). The `version`
|
|
field is a free-form string — Capgo treats it as a *"version code/name"* and does
|
|
**not** require semver — so a commit-based id is fine. We use `-` (not the semver
|
|
`+` build-metadata separator) to keep the id safe in the bundle URL/S3 key.
|
|
|
|
The count drives **ordering, and the client enforces it**: `checkAndApplyOtaUpdate()`
|
|
applies a manifest only when its count is *strictly greater* than the running
|
|
bundle's, so **republishing an older build cannot downgrade the fleet** (an
|
|
accidental old publish or a replayed old bundle is refused). A bare SHA would
|
|
carry no such order. Ids without the numeric prefix — the literal `"builtin"`, or
|
|
a manually pinned non-hybrid version — can't be ordered and fall back to a plain
|
|
inequality check. Because the count comes from `git rev-list --count HEAD`, it is
|
|
only monotonic **along a single line of history**: always publish OTA from the
|
|
release branch, or two diverging branches can mint colliding counts.
|
|
|
|
**Builtin stamping.** `make mobile-build` passes `MOBILE_OTA_BUILD_ID` to `cap sync`,
|
|
which stamps it as the store build's builtin bundle version
|
|
(`CapacitorUpdater.version` in `capacitor.config.ts`). Without it the builtin
|
|
reports the literal `"builtin"`, so the first launch after a store install always
|
|
re-downloads. With it, a first launch whose manifest points at the **same** commit
|
|
skips the download; a **newer** manifest still updates — the normal case, since
|
|
OTA runs ahead of the store.
|
|
|
|
### Rollback
|
|
|
|
There are two kinds, plus a per-device safety net:
|
|
|
|
- **Automatic (a bundle that fails to boot).** If the new bundle never calls
|
|
`notifyAppReady()` (crash / white screen), the plugin reverts to the last
|
|
good bundle — the builtin if there is none — on the next launch and records
|
|
the version as its *last failed update*. `checkAndApplyOtaUpdate()` mirrors
|
|
that record (which self-clears on read) into WebView storage and refuses to
|
|
re-apply the version, so a broken publish can't trap the app in a
|
|
download → crash → revert → re-download loop. The record is boot-specific:
|
|
a transient download failure does not blacklist the version, it is simply
|
|
retried on the next check.
|
|
- **Deliberate (a bundle that boots but is bad).** You **cannot** point the
|
|
manifest back at the older, lower-count bundle — the downgrade guard refuses
|
|
it. Roll *forward* instead: `git revert` the bad commit(s) and
|
|
`make ota-publish`. The revert has a **higher** count, so it passes the guard
|
|
and the fleet converges onto the (restored) good code with a clean git trail.
|
|
Escape hatch if you can't revert: `make ota-publish VERSION=<count-above-current>-<oldsha>`
|
|
from the old build — but that breaks the count↔commit invariant, so prefer the
|
|
revert.
|
|
- **Per-device safety net.** `CapacitorUpdater.reset()` returns a single device to
|
|
the builtin (store) bundle, which is always bootable. Not fleet-wide; useful to
|
|
wire onto a support/debug action.
|
|
|
|
**Never prune old bundles from the bucket** — the plugin's fallback and any
|
|
revert build may still reference them.
|
|
|
|
## Codebase map
|
|
|
|
| Concern | Location |
|
|
| --- | --- |
|
|
| Capacitor config (appId via `MOBILE_APP_ID`, plugins, HTTP, SystemBars, OTA signing key) | `src/frontend/capacitor.config.ts` |
|
|
| Platform detection | `src/frontend/src/features/native/platform.ts` |
|
|
| PKCE helpers | `src/frontend/src/features/native/pkce.ts` |
|
|
| System-browser session | `src/frontend/src/features/native/auth-session.ts` |
|
|
| Native login / logout | `src/frontend/src/features/native/auth.ts` |
|
|
| Native CSRF token store | `src/frontend/src/features/native/csrf.ts` |
|
|
| Native download → share | `src/frontend/src/features/native/download.ts` |
|
|
| Native push client (enable / on-launch refresh / tap deep-link) | `src/frontend/src/features/native/push.ts` |
|
|
| Push opt-in marker + token-hash contract (shared web/native) | `src/frontend/src/features/push/shared.ts` |
|
|
| OTA client | `src/frontend/src/features/native/ota.ts` |
|
|
| Startup wiring (OTA, `native` html class) | `src/frontend/src/main.tsx` |
|
|
| CSRF / API origin wiring | `src/frontend/src/features/api/utils.ts` |
|
|
| Login/logout routing | `src/frontend/src/features/auth/index.tsx` |
|
|
| iOS ASWebAuthenticationSession plugin | `src/frontend/ios/App/App/WebAuthSessionPlugin.swift` |
|
|
| iOS plugin registration | `src/frontend/ios/App/App/MainViewController.swift` |
|
|
| iOS push entitlement + APNs bridge + banner strings | `src/frontend/ios/App/App/App.entitlements`, `AppDelegate.swift`, `{en,fr}.lproj/Localizable.strings` |
|
|
| Android push banner strings (FCM loc-keys) | `src/frontend/android/app/src/main/res/values{,-fr}/strings.xml` |
|
|
| SSO invariants tripwire (CI guard on the native declarations) | `src/frontend/src/features/native/sso-invariants.test.ts` |
|
|
| Android project | `src/frontend/android/` |
|
|
| Backend mobile-aware OIDC views | `src/backend/core/authentication/views.py` |
|
|
| Backend token → session exchange & mobile logout | `src/backend/core/api/viewsets/mobile_auth.py` |
|
|
| OTA publish scripts | `src/frontend/scripts/publish-ota.mjs`, `create-ota-bucket.mjs`, `ota-lib.mjs` |
|
|
|
|
**Native/web branching contract:** the single source of truth is
|
|
`isNativePlatform()`. `main.tsx` also tags `<html class="native">` so stylesheets
|
|
can opt into mobile-only chrome without every component re-deriving the platform.
|
|
|
|
## Prerequisites
|
|
|
|
### Common (all developers)
|
|
|
|
- The **dev stack** running: `make bootstrap` once, then `make start` (or
|
|
`make start-minimal`). Backend on `:8901`, Keycloak on `:8902`, object storage
|
|
on `:8906`.
|
|
- **No host Node toolchain is required**: the web bundle is built inside the
|
|
`frontend-mobile` container — see *Build & run workflow*. The host only needs
|
|
the native toolchains below. If you do run `npm` on the host anyway, it must
|
|
be **Node 22** (`>=22 <23`): any other version corrupts the lockfile and
|
|
breaks the container build.
|
|
- Backend settings must allowlist the scheme:
|
|
`MOBILE_AUTH_CALLBACK_SCHEMES=["stmessages"]` (empty list = mobile login
|
|
disabled). See [env.md](./env.md).
|
|
|
|
### Android
|
|
|
|
If you have never set up an Android toolchain, follow [Capacitor's environment
|
|
setup guide](https://capacitorjs.com/docs/getting-started/environment-setup#android-requirements)
|
|
end-to-end first; the list below is what this project specifically needs.
|
|
|
|
- **Android Studio** (latest stable) with the Android SDK —
|
|
[install guide](https://developer.android.com/studio/install).
|
|
- **SDK levels**: `compileSdk 36` / `targetSdk 36`, `minSdk 24`. Install SDK 36
|
|
+ build-tools via the [SDK Manager](https://developer.android.com/studio/intro/update#sdk-manager).
|
|
- **JDK 17+** (bundled with recent Android Studio).
|
|
- **[`adb`](https://developer.android.com/tools/adb)** on the `PATH` (host), for
|
|
install + port forwarding. Heads-up: the `adb reverse` tunnel to the dev stack
|
|
is dropped on **every emulator reboot** — rerun `make mobile-android-reverse`
|
|
(details under *Build & run workflow*).
|
|
- An **[emulator image](https://developer.android.com/studio/run/managing-avds)
|
|
with Play services** (Google Play / Google APIs). Chrome
|
|
Custom Tabs needs it; a bare AOSP image falls back to an isolated-cookie
|
|
WebView and **breaks cross-app SSO** (a common false negative). A physical
|
|
device always ships Chrome, so it can't hit this.
|
|
|
|
### iOS
|
|
|
|
If you have never set up an iOS toolchain, follow [Capacitor's environment
|
|
setup guide](https://capacitorjs.com/docs/getting-started/environment-setup#ios-requirements)
|
|
end-to-end first; the list below is what this project specifically needs.
|
|
|
|
- A **Mac** — non-negotiable for iOS builds.
|
|
- **[Xcode](https://developer.apple.com/xcode/) 16+** with the iOS 16+ SDK.
|
|
Deployment target is **iOS 15**.
|
|
- Dependencies are managed by **Swift Package Manager** (pinned in
|
|
`ios/App/CapApp-SPM/Package.swift`) — **no CocoaPods / Podfile**. Xcode
|
|
resolves the packages on first open.
|
|
- For a physical device: an Apple developer account and a signing team
|
|
configured in Xcode — see [running your app on a device](https://developer.apple.com/documentation/xcode/running-your-app-in-simulator-or-on-a-device).
|
|
|
|
## Build & run workflow
|
|
|
|
The web bundle is built **in a container** (`frontend-mobile`) so the
|
|
`NEXT_PUBLIC_*` vars from `deploy/env/frontend.{defaults,local}` are
|
|
inlined at build time (Vite `envPrefix: 'NEXT_PUBLIC_'`). Building on the host
|
|
with a bare `npm run build` would inline none of them. The native compile, IDE,
|
|
`adb` and Xcode steps run on the **host**.
|
|
|
|
> **Always run `make mobile-build` after a fresh checkout.** `cap sync` (not a
|
|
> bare copy) also regenerates the gitignored
|
|
> `capacitor-cordova-android-plugins/` scaffolding that Gradle needs.
|
|
|
|
| Command | What it does |
|
|
| --- | --- |
|
|
| `make mobile-build` | web build (container) + `cap sync` into `ios/` and `android/` |
|
|
| `make mobile-assets` | regenerate native icons & splashscreens from `src/frontend/assets/` |
|
|
| `make mobile-android` | `mobile-build`, then open the Android project in Android Studio (host) |
|
|
| `make mobile-android-run` | `mobile-build` + `gradlew assembleDebug` + `adb install` + `adb reverse` (host) |
|
|
| `make mobile-android-reverse` | (re)apply the `adb reverse` port mapping |
|
|
| `make mobile-ios` | `mobile-build`, then open the Xcode project (host, macOS) |
|
|
| `make mobile-ota-keygen` | generate a per-instance OTA signing key pair (base64 PEMs) |
|
|
| `make mobile-ota-bucket` | create the public `messages-ota` bucket |
|
|
| `make ota-publish [VERSION=x] [CHANNEL=x]` | build + publish a signed OTA bundle and its channel manifest (VERSION defaults to `<count>-<sha>`, CHANNEL to `MOBILE_OTA_CHANNEL`) |
|
|
|
|
**Android port forwarding.** The in-app WebView reaches the dev stack through an
|
|
`adb reverse` tunnel for ports **8900, 8901, 8902, 8906** (frontend, backend,
|
|
Keycloak, object storage). It is dropped on every emulator reboot / adb
|
|
reconnection and is **not** re-applied by Android Studio — rerun
|
|
`make mobile-android-reverse` whenever the app suddenly can't reach the backend.
|
|
The same tunnel works over USB for a physical device (enable Developer options +
|
|
USB debugging first). With several devices attached, pin one with
|
|
`export ANDROID_SERIAL=<serial>` (`adb devices` to list).
|
|
|
|
**iOS** needs no tunnel: the simulator reaches the host's `localhost` directly.
|
|
Run the `App` scheme after `make mobile-ios`.
|
|
|
|
### Hot reload (on by default in dev)
|
|
|
|
`MOBILE_DEV_SERVER_URL` — set to `http://localhost:8900` (the Vite dev server)
|
|
in `deploy/env/frontend.defaults` — is baked by `cap sync` into the app
|
|
as Capacitor's `server.url`: the WebView loads the app straight from Vite
|
|
instead of the embedded `dist/`, so JS/CSS changes apply through HMR without
|
|
rebuilding or reinstalling. Since every `make mobile-*` target runs in a
|
|
container carrying the frontend env files, **any dev build gets hot reload out
|
|
of the box**. Requirements and caveats:
|
|
|
|
- The dev stack must be up (`make run` / the `frontend-dev` service): the app
|
|
is blank otherwise. `localhost:8900` is routed by the same `adb reverse`
|
|
tunnel as the backend on Android, and by the shared loopback on the iOS
|
|
simulator. For a **physical iPhone** (no tunnel), point it at the Mac's LAN
|
|
IP in `frontend.local`: `MOBILE_DEV_SERVER_URL=http://<mac-ip>:8900` (ATS
|
|
exempts raw IP literals, so plain HTTP works).
|
|
- Native changes (plugins, `ios/`, `android/`, `capacitor.config.ts`) still
|
|
need a rebuild — hot reload only covers the web bundle.
|
|
- The startup OTA check is skipped during a hot reload session (`ota.ts` skips
|
|
when `import.meta.env.DEV` **and** `MOBILE_DEV_SERVER_URL` are set): applying
|
|
a downloaded bundle would yank the WebView off the dev server mid-session.
|
|
|
|
**Disabling it** — to test the embedded bundle (what a store build ships), or
|
|
the OTA chain end to end: set the variable **empty** in
|
|
`deploy/env/frontend.local` (gitignored, overrides the defaults):
|
|
|
|
```bash
|
|
# deploy/env/frontend.local
|
|
MOBILE_DEV_SERVER_URL=
|
|
```
|
|
|
|
then rerun `make mobile-build` (or any target that wraps it) and reinstall the
|
|
app. A leftover `server.url` fails Android **release** builds (gradle guard in
|
|
`android/app/build.gradle`); see the release checklist for iOS.
|
|
|
|
## Push notifications in dev (optional)
|
|
|
|
Push is **off by default** (`PUSH_ENABLED=False`): the apps build, run and hide
|
|
the notification settings without any of this. Full architecture:
|
|
[push-notifications.md](./push-notifications.md). What ships in the repo
|
|
(entitlements, loc-key banner strings, permission, conditional google-services
|
|
apply) needs no setup; what follows is the per-developer credential part.
|
|
|
|
**The app self-configures per environment where it can** — the client picks its
|
|
transport at runtime (`apns` on iOS / `fcm` on Android), and a dev-signed iOS
|
|
build automatically registers against Apple's *sandbox* gateway
|
|
(`aps-environment = development` in `App.entitlements`; Xcode's distribution
|
|
export rewrites it to `production`). What it **cannot** infer is the backend
|
|
half: the gateway credentials and the sandbox flag below must match the build
|
|
you install.
|
|
|
|
### Android (FCM)
|
|
|
|
1. Create a (free) dev Firebase project and register an **Android app whose
|
|
package name is exactly the `applicationId`** of your build — the
|
|
`MOBILE_APP_ID` default, `local.suitenumerique.messages`. A
|
|
`google-services.json` for another package fails the Android build at the
|
|
google-services step.
|
|
2. Download `google-services.json` into `src/frontend/android/app/`
|
|
(gitignored, per-instance). Rebuild/reinstall.
|
|
3. In Firebase console → project settings → service accounts, generate a
|
|
service-account key and set in `deploy/env/backend.local`:
|
|
`PUSH_ENABLED=True`, `PUSH_FCM_CREDENTIALS` (the JSON, single line),
|
|
`PUSH_FCM_PROJECT_ID`. Restart the backend + celery worker.
|
|
4. Emulator: use the same **Play-services image** the SSO setup already
|
|
requires (see *Prerequisites*) — FCM registration fails on a bare AOSP
|
|
image (the UI then shows the `registration_failed` message, by design).
|
|
|
|
### iOS (APNs)
|
|
|
|
1. **Physical iPhone required** for the end-to-end path: simulators never get a
|
|
real APNs token, so registration against Apple's gateway can't be exercised
|
|
there (`xcrun simctl push` only injects local payloads).
|
|
2. Apple developer account: enable the **Push Notifications capability on the
|
|
App ID** matching your bundle id, and create an **APNs auth key** (`.p8`).
|
|
3. In `deploy/env/backend.local`: `PUSH_ENABLED=True`,
|
|
`PUSH_APNS_KEY` (the `.p8` PEM), `PUSH_APNS_KEY_ID`, `PUSH_APNS_TEAM_ID`,
|
|
`PUSH_APNS_BUNDLE_ID` (= your `MOBILE_APP_ID`), and
|
|
**`PUSH_APNS_USE_SANDBOX=True`** — dev-signed builds hold sandbox tokens;
|
|
against the production gateway they are rejected as `BadDeviceToken`.
|
|
Restart the backend + celery worker.
|
|
|
|
### Smoke test (both platforms)
|
|
|
|
1. In the app: account menu → Notifications → *Enable notifications on this
|
|
device* → accept the OS prompt. The device must appear in the list.
|
|
2. Kill the app, send the mailbox a message from another account: a
|
|
content-free "New message / Nouveau message" banner must show (rendered by
|
|
the OS from the loc-key strings — a blank banner means those strings are
|
|
missing from the build).
|
|
3. Tap it: the app must open on the thread (deep-link path).
|
|
|
|
## Configuration
|
|
|
|
Mobile-specific environment variables (full reference in [env.md](./env.md)):
|
|
|
|
| Variable | Purpose |
|
|
| --- | --- |
|
|
| `MOBILE_APP_ID` | Store/OS bundle identifier (default `local.suitenumerique.messages`). Read by `cap sync` (container) **and** the native builds (gradle `applicationId`, iOS `PRODUCT_BUNDLE_IDENTIFIER`), so it must be exported in both contexts. Independent of the `stmessages` auth scheme |
|
|
| `MOBILE_AUTH_CALLBACK_SCHEMES` | JSON list of allowlisted deep-link schemes (e.g. `["stmessages"]`); empty disables mobile login |
|
|
| `MOBILE_DEV_SERVER_URL` | Dev only: Vite dev server URL baked as Capacitor `server.url` at `cap sync` (hot reload). Set to `http://localhost:8900` in `frontend.defaults`; disable with an empty value in `frontend.local`; never set for release builds (see *Hot reload*) |
|
|
| `MOBILE_ALLOW_CLEARTEXT_FOR_DEV` | Dev only: baked as Capacitor `server.cleartext` at `cap sync` (`android:usesCleartextTraffic`), allowing plain HTTP to the dev backend / Vite / RustFS. Set to `1` in `frontend.defaults`; never set for release builds — the manifest then stays cleartext-free |
|
|
| `MOBILE_AUTH_TOKEN_TTL` | Lifetime (s) of the one-time exchange token (default 60) |
|
|
| `NEXT_PUBLIC_API_ORIGIN` | API base URL — **must be set explicitly** for mobile builds (no meaningful `window.location.origin` in the WebView) |
|
|
| `MOBILE_OTA_MANIFEST_URL` | Backend setting served through `/config`: OTA channel manifest polled at startup — the followed channel changes without a new native build; unset disables OTA (deprecated build-time fallback: `NEXT_PUBLIC_MOBILE_OTA_MANIFEST_URL`) |
|
|
| `MOBILE_OTA_CHANNEL` | Release channel `ota-publish` targets (`dev` locally, `staging`/`prod` in the pipeline); must match the channel the build follows (see *Release channels*) |
|
|
| `MOBILE_OTA_S3_*`, `MOBILE_OTA_PUBLIC_BASE_URL` | OTA publish: S3 write credentials/endpoint (frontend env, not Django) and the device-reachable public base URL written into the manifest |
|
|
| `MOBILE_OTA_SIGNING_PUBLIC_KEY_B64` | Base64 PEM public key baked into the app (`capacitor.config.ts`, native verification) and inlined by Vite (`ota.ts` refuses a server-provided manifest URL without it); required for any OTA-enabled build |
|
|
| `MOBILE_OTA_SIGNING_PRIVATE_KEY_B64` | Base64 PEM private key that signs bundles at publish time (`publish-ota.mjs`, CI-only) |
|
|
|
|
## Production hardening / known gaps
|
|
|
|
The following are POC-scoped shortcuts that must be resolved before shipping.
|
|
Treat this list as the "definition of ready for production".
|
|
|
|
- **OTA over HTTPS.** Bundle signing/encryption (Capgo v2, RSA+AES) and a
|
|
strictly-increasing version guard are in place (see the OTA section), so a
|
|
substituted or replayed old zip is refused. What remains for production is to
|
|
serve the bucket/CDN over **HTTPS** (dev uses cleartext RustFS). A hard
|
|
minimum-version *floor* baked into the app — rejecting anything below a known
|
|
release regardless of the running bundle — would further harden a device stuck
|
|
on a very old build, but the monotonic guard already covers accidental
|
|
downgrades.
|
|
- **Move off custom URL schemes.** Custom schemes can be claimed by other apps
|
|
(mitigated today by the one-time token + PKCE). Production should move to
|
|
**Universal Links (iOS) / App Links (Android)**.
|
|
- **CSRF `Origin` on HTTPS.** Native requests carry no `Origin`/`Referer`, which
|
|
Django requires on secure requests. The fetch wrapper must inject an `Origin`
|
|
listed in `CSRF_TRUSTED_ORIGINS`.
|
|
- **Cleartext transport is dev-only, build-gated on both platforms.** On
|
|
Android, `preReleaseBuild` fails when the synced `capacitor.config.json`
|
|
carries a dev `server.url` or `server.cleartext` (i.e. when
|
|
`MOBILE_DEV_SERVER_URL` / `MOBILE_ALLOW_CLEARTEXT_FOR_DEV` was in the
|
|
`cap sync` env). On iOS, the "Strip dev ATS exception" build phase deletes
|
|
the `NSAppTransportSecurity` dict (`NSAllowsLocalNetworking`) from the built
|
|
product in every non-Debug configuration, so it never ships in an Archive.
|
|
- **IdP logout & session renewal.** Logout ends the Django session but not
|
|
the IdP one; the 12 h Django session has no refresh-token renewal yet.
|
|
Confirm ProConnect SSO session duration and persistent-cookie behaviour
|
|
(esp. iOS) in production.
|
|
- **Safe-area insets.** Disabling Capacitor's `SystemBars` inset handling (to fix
|
|
the double keyboard inset, Capacitor #8181) means Android no longer receives
|
|
the `--safe-area-inset-*` CSS variables; `MainActivity.java` re-injects them
|
|
from the window insets (system bars + display cutout), without touching the
|
|
keyboard behavior. iOS resolves `env(safe-area-inset-*)` natively. The app
|
|
shell folds the top inset into `--header-height` (`globals.scss`), so
|
|
anything laid out from it clears the status bar / notch automatically.
|
|
- **Iframe subresources.** Inline images proxied through the API use the WebView
|
|
network stack, not the native one, and may not load in dev; the HTML body
|
|
itself renders.
|
|
- **App Store guideline 4.2.** A pure web wrapper needs native-feeling
|
|
differentiators (push notifications, share targets…) to pass review.
|
|
|
|
## Release checklist (manual)
|
|
|
|
Some load-bearing behaviors cannot fail loudly: when they regress, **login
|
|
still works** and only the invisible part disappears, so no error ever
|
|
surfaces in development. Run this checklist before every store release, and
|
|
after any change to the native projects (`ios/`, `android/`), the auth plumbing
|
|
or the Capacitor version.
|
|
|
|
1. **iOS cross-app SSO** — *the* critical, silent one. It rests on
|
|
`WebAuthSessionPlugin.swift` using `ASWebAuthenticationSession` with
|
|
`prefersEphemeralWebBrowserSession = false`, its registration in
|
|
`MainViewController.swift`, and a **persistent** IdP cookie. Regenerating the
|
|
iOS project or "simplifying" back to the default `Browser` plugin
|
|
(SFSafariViewController — cookie store isolated from Safari) silently turns
|
|
the second app's silent login back into a credential prompt.
|
|
Run the [two-app procedure](./mobile-poc.md#re-testing-cross-app-sso-with-a-second-app)
|
|
and its objective proofs (Keycloak events, negative control). The
|
|
`sso-invariants.test.ts` tripwire pins the files (flag, registration,
|
|
schemes) so the most likely mechanical regressions turn CI red, but it
|
|
cannot prove the runtime behavior — this manual test stays mandatory.
|
|
For a **store release, run it against the production IdP**: the IdP-side
|
|
half of the contract (ProConnect silently reusing its session for
|
|
`acr_values=eidas1`, persistent cookie) lives outside this repo and no
|
|
CI or dev-realm check can stand in for it.
|
|
2. **Android cross-app SSO** — same two-app procedure through Chrome Custom
|
|
Tabs. Beware the false negative: an emulator without Play services falls
|
|
back to an isolated-cookie WebView (see *Prerequisites*).
|
|
3. **Thread rendering** — open a thread: the message body iframe
|
|
(`srcDoc` + `sandbox` + CSP) must render. This is what killed the previous
|
|
React Native attempt; a WebView/Capacitor upgrade can regress it.
|
|
4. **OTA chain on the release channel** — publish to the channel the build
|
|
follows, relaunch, verify the new bundle applies; then confirm a lower-count
|
|
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 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
|
|
`make mobile-build`. Android release builds fail on a leftover `server.url`
|
|
(gradle guard in `android/app/build.gradle`); Xcode has no equivalent guard,
|
|
so **check manually for iOS** (no `server.url` in
|
|
`ios/App/App/capacitor.config.json`).
|
|
8. **Push environment pairing** — nothing fails loudly on a mismatch, pushes
|
|
just never arrive (or hit `BadDeviceToken` in the sender logs). For a store
|
|
release: the backend serving those users must run
|
|
`PUSH_APNS_USE_SANDBOX=False` (a distribution-signed build holds
|
|
*production* APNs tokens — Xcode rewrites `aps-environment` at export, no
|
|
manual step); the bundled `google-services.json` must come from the
|
|
**production** Firebase project and contain a client for the release
|
|
`MOBILE_APP_ID`; `PUSH_APNS_BUNDLE_ID` must equal that same id. Then run the
|
|
smoke test of *Push notifications in dev* against the release build.
|
|
|
|
## See also
|
|
|
|
- [`mobile-poc.md`](./mobile-poc.md) — validation procedures: backend-only smoke
|
|
test with curl, running on emulators/devices, re-testing cross-app SSO with a
|
|
throwaway second app, and objective SSO proofs (Keycloak events, negative
|
|
controls).
|
|
- [`env.md`](./env.md) — full environment-variable reference.
|