feat(android): access-token support — same frozen contract as iOS
Android could not connect at all once the server set WEBTERM_TOKEN. Hand-written Cookie header on every request and on the WS upgrade (no CookieJar, matching the frozen decision), POST /auth pairing probe, Keystore-backed storage, and a 401 upgrade as a terminal state with no reconnect loop.
This commit is contained in:
@@ -62,8 +62,40 @@ Setup: `local.properties` → `sdk.dir=/usr/local/share/android-commandlinetools
|
||||
`:wire-protocol` is the **frozen shared contract** (Android analogue of
|
||||
`src/types.ts` + WireProtocol) — `ClientMessage`/`ServerMessage`, `MessageCodec`,
|
||||
`Validation`, `WireConstants`, `HostEndpoint` (the single Origin/wsURL derivation),
|
||||
and the `TermTransport` / `HttpTransport` / `PingableTermTransport` boundary
|
||||
interfaces. New wire types are added **only** here (a coordination point).
|
||||
`AuthCookie`/`AccessTokenRule`/`AccessTokenSource` (the single access-token-cookie
|
||||
derivation), and the `TermTransport` / `HttpTransport` / `PingableTermTransport`
|
||||
boundary interfaces. New wire types are added **only** here (a coordination point).
|
||||
|
||||
## Access token (`WEBTERM_TOKEN`) — how the Android client authenticates
|
||||
|
||||
A server started with `WEBTERM_TOKEN=<16–512 chars of [A-Za-z0-9._~+/=-]>` gates **every**
|
||||
HTTP route *and* the WebSocket upgrade behind a `webterm_auth` cookie. The Android
|
||||
client therefore:
|
||||
|
||||
- **hand-writes `Cookie: webterm_auth=<t>` itself** — no OkHttp `CookieJar`, no
|
||||
`Set-Cookie` parsing. One derivation point (`AuthCookie`), stamped in exactly three
|
||||
places: `:api-client`'s `ApiRoute.toHttpRequest` (all 12+ routes, RO GETs included),
|
||||
the pairing probe's two hand-built legs, and `:transport-okhttp`'s WS upgrade. The
|
||||
hand-built `GET /projects/diff` fetcher in `:app` stamps it too.
|
||||
- keeps the header **orthogonal to `Origin`**: the token never replaces the CSWSH
|
||||
Origin check (the server tests Origin first, then the cookie), and read-only routes
|
||||
still carry no Origin.
|
||||
- validates a typed token ONCE at pairing time with `POST /auth`
|
||||
(`{"token":"…"}`, `Accept: application/json` — an `Accept` containing `text/html`
|
||||
makes the server answer 302 instead of 204/401). Four outcomes: 204+`Set-Cookie` =
|
||||
correct → store it; 204 **without** `Set-Cookie` = that host has no auth → store
|
||||
nothing; 401 = wrong token; 429 = rate-limited (10/min/IP).
|
||||
- stores it per host (keyed by the canonical origin) in `TinkAccessTokenStore`:
|
||||
Tink AEAD under an **AndroidKeystore-wrapped, non-exportable** master key, in an
|
||||
app-private `SharedPreferences` file, with `android:allowBackup="false"` +
|
||||
`data_extraction_rules.xml` excluding cloud backup and device transfer. The token is
|
||||
**never logged, never in a URL, never in app UI state**.
|
||||
- treats a **401 on the WS upgrade as terminal** (`FailureReason.UNAUTHORIZED`): no
|
||||
reconnect back-off loop, and the banner tells the user to re-enter the token. A 401 on
|
||||
any REST route is the typed `ApiClientError.Unauthorized`.
|
||||
|
||||
A host with no `WEBTERM_TOKEN` is byte-identical to before the feature: no token stored ⇒
|
||||
no `Cookie` header at all (LAN zero-config preserved).
|
||||
|
||||
## Toolchain
|
||||
|
||||
|
||||
Reference in New Issue
Block a user