From 9114630c3ab059f2a2948ff695632e395cbfd439 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Thu, 30 Jul 2026 12:45:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(android):=20access-token=20support=20?= =?UTF-8?q?=E2=80=94=20same=20frozen=20contract=20as=20iOS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- android/DEVICE_QA_CHECKLIST.md | 24 ++ android/README.md | 36 ++- .../yaojia/webterm/api/pairing/AuthProbe.kt | 92 ++++++ .../webterm/api/pairing/PairingError.kt | 11 + .../webterm/api/pairing/PairingProbe.kt | 42 ++- .../yaojia/webterm/api/routes/ApiClient.kt | 21 +- .../webterm/api/routes/ApiClientError.kt | 7 + .../yaojia/webterm/api/routes/ApiRoute.kt | 26 +- .../yaojia/webterm/api/routes/Endpoints.kt | 31 ++ .../webterm/api/pairing/AuthProbeTest.kt | 159 +++++++++++ .../api/pairing/PairingProbeTokenTest.kt | 90 ++++++ .../api/routes/AccessTokenCookieTest.kt | 140 +++++++++ android/app/src/main/AndroidManifest.xml | 3 + .../webterm/components/ReconnectBanner.kt | 11 +- .../java/wang/yaojia/webterm/di/AuthModule.kt | 42 +++ .../wang/yaojia/webterm/di/NetworkModule.kt | 13 +- .../java/wang/yaojia/webterm/nav/Panes.kt | 7 +- .../yaojia/webterm/screens/PairingScreen.kt | 77 ++++- .../webterm/viewmodels/DiffViewModel.kt | 13 +- .../webterm/viewmodels/PairingViewModel.kt | 175 +++++++++++- .../yaojia/webterm/wiring/ApiClientFactory.kt | 14 +- .../yaojia/webterm/wiring/AppEnvironment.kt | 24 +- .../main/res/xml/data_extraction_rules.xml | 29 ++ .../webterm/components/ReconnectBannerTest.kt | 23 ++ .../webterm/viewmodels/DiffViewModelTest.kt | 34 +++ .../viewmodels/PairingTokenViewModelTest.kt | 266 ++++++++++++++++++ .../viewmodels/PairingViewModelTest.kt | 6 + android/host-registry/build.gradle.kts | 4 + .../hostregistry/TinkAccessTokenStoreTest.kt | 107 +++++++ .../webterm/hostregistry/AccessTokenStore.kt | 70 +++++ .../hostregistry/TinkAccessTokenStore.kt | 153 ++++++++++ .../InMemoryAccessTokenStoreTest.kt | 118 ++++++++ .../yaojia/webterm/session/SessionEngine.kt | 56 +++- .../yaojia/webterm/session/SessionEvent.kt | 10 + .../session/SessionEngineUnauthorizedTest.kt | 110 ++++++++ .../webterm/transport/OkHttpClientFactory.kt | 10 +- .../webterm/transport/OkHttpTermTransport.kt | 26 +- .../transport/OkHttpWebSocketConnection.kt | 13 +- .../transport/OkHttpAccessTokenTest.kt | 145 ++++++++++ .../wang/yaojia/webterm/wire/AccessToken.kt | 92 ++++++ .../wire/UnauthorizedUpgradeException.kt | 31 ++ .../yaojia/webterm/wire/AccessTokenTest.kt | 109 +++++++ 42 files changed, 2419 insertions(+), 51 deletions(-) create mode 100644 android/api-client/src/main/kotlin/wang/yaojia/webterm/api/pairing/AuthProbe.kt create mode 100644 android/api-client/src/test/kotlin/wang/yaojia/webterm/api/pairing/AuthProbeTest.kt create mode 100644 android/api-client/src/test/kotlin/wang/yaojia/webterm/api/pairing/PairingProbeTokenTest.kt create mode 100644 android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/AccessTokenCookieTest.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/di/AuthModule.kt create mode 100644 android/app/src/main/res/xml/data_extraction_rules.xml create mode 100644 android/app/src/test/java/wang/yaojia/webterm/viewmodels/PairingTokenViewModelTest.kt create mode 100644 android/host-registry/src/androidTest/kotlin/wang/yaojia/webterm/hostregistry/TinkAccessTokenStoreTest.kt create mode 100644 android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/AccessTokenStore.kt create mode 100644 android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/TinkAccessTokenStore.kt create mode 100644 android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/InMemoryAccessTokenStoreTest.kt create mode 100644 android/session-core/src/test/kotlin/wang/yaojia/webterm/session/SessionEngineUnauthorizedTest.kt create mode 100644 android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/OkHttpAccessTokenTest.kt create mode 100644 android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/AccessToken.kt create mode 100644 android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/UnauthorizedUpgradeException.kt create mode 100644 android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/AccessTokenTest.kt diff --git a/android/DEVICE_QA_CHECKLIST.md b/android/DEVICE_QA_CHECKLIST.md index 060b3b6..70f0c49 100644 --- a/android/DEVICE_QA_CHECKLIST.md +++ b/android/DEVICE_QA_CHECKLIST.md @@ -59,6 +59,30 @@ Kover ≥80% on the pure modules); this checklist is what a human runs on real h no relaunch** (re-reading `X509KeyManager` + `connectionPool.evictAll()`); remove. - [ ] DataStore host list / `LastSessionStore` set-on-adopted / clear-on-exited. +## Access token — `WEBTERM_TOKEN` (B5 / ios-completion §1.1) — needs a token-enabled host +Run the host as `WEBTERM_TOKEN=<16–512 chars> npm start`. The pure logic is JVM-tested (`AuthProbeTest`, +`AccessTokenCookieTest`, `PairingProbeTokenTest`, `OkHttpAccessTokenTest`, `SessionEngineUnauthorizedTest`, +`InMemoryAccessTokenStoreTest`); everything below needs real hardware + a real server. +- [ ] `TinkAccessTokenStore` instrumented suite on a device: + `./gradlew :host-registry:connectedDebugAndroidTest` (real AndroidKeyStore + Tink). +- [ ] Pair a token-enabled host with the token typed → paired; the terminal attaches (the **WS upgrade** + carries `Cookie: webterm_auth=…`), the session list, projects, diff and thumbnails all load. +- [ ] Pair the same host with **no** token typed → the probe 401s → the confirm step comes back asking for + the token ("该主机启用了访问令牌"); typing it and retrying pairs. +- [ ] Wrong token → "访问令牌不正确" **under the field** (still on the confirm step, nothing stored). +- [ ] 11 wrong tries within a minute → "尝试过多" (the server's 10/min/IP `/auth` limiter). +- [ ] Pair a host with `WEBTERM_TOKEN` **unset** while typing a token → pairs, and NOTHING is stored + (204-without-`Set-Cookie`); the app must not claim to be "authenticated". +- [ ] Restart the app → the terminal re-attaches with no re-prompt (the token decrypts from the + Keystore-backed store on a cold start). +- [ ] Rotate `WEBTERM_TOKEN` on the host and restart it → the WS upgrade 401s → the terminal banner shows + the 401 copy, offers **no** "新会话", and there is **no reconnect back-off storm** (verify with + `adb logcat` / the server log: exactly one upgrade attempt). +- [ ] `adb logcat` during all of the above: the token string appears **nowhere**; no request URL contains + it (check the server's access log too). +- [ ] `adb backup` / device-to-device transfer excludes the token blob (`allowBackup=false` + + `data_extraction_rules.xml`). + ## Nav / deep links / adaptive (A32/A26/A29) - [ ] cold-start AND warm deep link `webterminal://open?host=&join=` + the verified App Link route to the right destination; invalid UUID ignored. diff --git a/android/README.md b/android/README.md index 0a0a431..5c3be97 100644 --- a/android/README.md +++ b/android/README.md @@ -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=` 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 diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/pairing/AuthProbe.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/pairing/AuthProbe.kt new file mode 100644 index 0000000..3d8e2e6 --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/pairing/AuthProbe.kt @@ -0,0 +1,92 @@ +package wang.yaojia.webterm.api.pairing + +import wang.yaojia.webterm.api.routes.Endpoints +import wang.yaojia.webterm.wire.AccessTokenRule +import wang.yaojia.webterm.wire.AuthCookie +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HttpResponse +import wang.yaojia.webterm.wire.HttpTransport + +/** + * The outcome of the pairing-time access-token validation probe (`POST /auth`). The first four cases + * are the FROZEN four the server distinguishes (ios-completion §1.1); [Malformed] is a client-side + * pre-check and [Unexpected] keeps an unknown status from being mistaken for success. + */ +public sealed interface AuthProbeResult { + /** **204 + `Set-Cookie: webterm_auth=…`** — the token is correct. Persist it for this host. */ + public data object Accepted : AuthProbeResult + + /** + * **204 with NO `Set-Cookie`** — this host has `WEBTERM_TOKEN` unset, so there is nothing to + * authenticate. The token MUST NOT be persisted, and this MUST NOT be read as "authenticated" + * (frozen §1.1): the host is simply open, exactly as a zero-config LAN deploy. + */ + public data object AuthDisabled : AuthProbeResult + + /** **401** — wrong token. UI: "访问令牌不正确". */ + public data object InvalidToken : AuthProbeResult + + /** **429** — the server's `/auth` limiter (10/min/IP) tripped. UI: "尝试过多,稍后再试". */ + public data object RateLimited : AuthProbeResult + + /** The token cannot be a valid server token ([AccessTokenRule]) — rejected before any network I/O. */ + public data object Malformed : AuthProbeResult + + /** Any other status (e.g. a captive portal's 302). Never treated as success. */ + public data class Unexpected(val status: Int) : AuthProbeResult +} + +/** + * Validate [token] against [endpoint] via **`POST /auth`** — a one-shot pairing-time probe, NOT a + * session-establishing login: the native client keeps stamping its own `Cookie` header afterwards and + * ignores the `Set-Cookie` value entirely (frozen §1.1). The response's `Set-Cookie` is used only as a + * BOOLEAN signal (was the token accepted, or is auth disabled on this host?). + * + * Transport-level failures propagate unwrapped so the caller can classify them with + * [PairingError.classify] (same convention as [runPairingProbe]). + * + * Never logs the token; the token appears only in the JSON body, never in the URL. + */ +public suspend fun probeAccessToken( + endpoint: HostEndpoint, + http: HttpTransport, + token: String, +): AuthProbeResult { + // Boundary validation first: a token outside the server's charset/length can never be correct, and + // there is no point spending a rate-limit slot (or shipping it over the wire) to find that out. + val normalized = AccessTokenRule.normalize(token) ?: return AuthProbeResult.Malformed + val request = Endpoints.auth(normalized).toHttpRequest(endpoint) + ?: return AuthProbeResult.Unexpected(UNBUILDABLE_REQUEST) + val response = http.send(request) + return classifyAuthResponse(response) +} + +private fun classifyAuthResponse(response: HttpResponse): AuthProbeResult = when (response.status) { + HTTP_NO_CONTENT -> + // The ONE discriminator between "token correct" and "this host has no auth at all". + if (response.carriesAuthCookie()) AuthProbeResult.Accepted else AuthProbeResult.AuthDisabled + HTTP_UNAUTHORIZED -> AuthProbeResult.InvalidToken + HTTP_TOO_MANY_REQUESTS -> AuthProbeResult.RateLimited + else -> AuthProbeResult.Unexpected(response.status) +} + +/** + * True iff the response set OUR auth cookie. Header names are case-insensitive on the wire, and the + * value must name [AuthCookie.NAME] — some other service's `Set-Cookie` (a proxy's session id) proves + * nothing about our token. + */ +private fun HttpResponse.carriesAuthCookie(): Boolean = + headers.entries.any { (name, value) -> + name.equals(SET_COOKIE_HEADER, ignoreCase = true) && value.startsWith("${AuthCookie.NAME}=") + } + +/** + * Sentinel status for "the request could not even be built" — unreachable for a validated + * [HostEndpoint], surfaced instead of crashing (never mistaken for a real HTTP status). + */ +private const val UNBUILDABLE_REQUEST = 0 + +private const val SET_COOKIE_HEADER = "Set-Cookie" +private const val HTTP_NO_CONTENT = 204 +private const val HTTP_UNAUTHORIZED = 401 +private const val HTTP_TOO_MANY_REQUESTS = 429 diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/pairing/PairingError.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/pairing/PairingError.kt index 5fdb730..8705fc1 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/pairing/PairingError.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/pairing/PairingError.kt @@ -52,6 +52,17 @@ public sealed interface PairingError { /** TLS negotiation / certificate failure on an https/wss target (`SSLException`/`CertificateException`). */ public data object TlsFailure : PairingError + /** + * The host answered **401** to the probe's HTTP legs — it has `WEBTERM_TOKEN` set and we presented + * no cookie / a wrong one (ios-completion §1.1). Distinct from [OriginRejected]: this is fixable by + * entering the host's access token, not by editing `ALLOWED_ORIGINS`. + * + * NOTE the probe's ORDERING is what makes the two distinguishable: probe ① authenticates over HTTP + * first, so a 401 there is the token; once ① has passed with our cookie, a 401 on the later WS + * upgrade can only be the Origin allow-list (the server writes the same bare 401 for both). + */ + public data object AccessTokenRequired : PairingError + /** The probe deadline elapsed, or the transport timed out (`SocketTimeoutException`). */ public data object Timeout : PairingError diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/pairing/PairingProbe.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/pairing/PairingProbe.kt index a04320f..df5eb01 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/pairing/PairingProbe.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/pairing/PairingProbe.kt @@ -8,6 +8,8 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray +import wang.yaojia.webterm.wire.AccessTokenSource +import wang.yaojia.webterm.wire.AuthCookie import wang.yaojia.webterm.wire.ClientMessage import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wire.HttpMethod @@ -50,8 +52,9 @@ public suspend fun runPairingProbe( endpoint: HostEndpoint, http: HttpTransport, ws: TermTransport, + tokens: AccessTokenSource = AccessTokenSource.NONE, ): PairingProbeResult = - runPairingProbeCore(endpoint, http, ws, timeout = Tunables.PAIRING_PROBE_TIMEOUT) + runPairingProbeCore(endpoint, http, ws, timeout = Tunables.PAIRING_PROBE_TIMEOUT, tokens = tokens) /** * Deterministic probe core. [timeout] `null` = no app-level deadline (the transport's own timeouts @@ -63,9 +66,10 @@ internal suspend fun runPairingProbeCore( http: HttpTransport, ws: TermTransport, timeout: Duration?, + tokens: AccessTokenSource = AccessTokenSource.NONE, ): PairingProbeResult { - if (timeout == null) return performProbe(endpoint, http, ws) - return withTimeoutOrNull(timeout) { performProbe(endpoint, http, ws) } + if (timeout == null) return performProbe(endpoint, http, ws, tokens) + return withTimeoutOrNull(timeout) { performProbe(endpoint, http, ws, tokens) } ?: PairingProbeResult.Failure(PairingError.Timeout) } @@ -75,10 +79,16 @@ private suspend fun performProbe( endpoint: HostEndpoint, http: HttpTransport, ws: TermTransport, + tokens: AccessTokenSource, ): PairingProbeResult { + // The access token (if any) rides BOTH HTTP legs as the hand-written auth cookie. The WS leg gets + // it from the transport's own AccessTokenSource (the same store), so all three legs authenticate. + val cookieHeaders = authHeaders(tokens.tokenFor(endpoint)) + // ① Reachability + shape. Any HTTP-level answer that isn't the /live-sessions array shape - // means "some other service" → httpOkButNotWebTerminal ("端口对吗?"). - probeReachability(endpoint, http)?.let { return it } + // means "some other service" → httpOkButNotWebTerminal ("端口对吗?"); a 401 means this host + // wants an access token we do not have. + probeReachability(endpoint, http, cookieHeaders)?.let { return it } // ② WS upgrade — the server's ONLY upgrade-reject path is the Origin 401, so after ① passed an // unrecognizable connect failure is classified as originRejected. @@ -105,7 +115,7 @@ private suspend fun performProbe( return try { when (val adoption = adoptAttachedSession(connection)) { is Adoption.Failure -> PairingProbeResult.Failure(adoption.error) - is Adoption.Success -> killProbeSession(adoption.sessionId, endpoint, http) + is Adoption.Success -> killProbeSession(adoption.sessionId, endpoint, http, cookieHeaders) } } finally { withContext(NonCancellable) { runCatching { connection.close() } } @@ -120,14 +130,20 @@ private suspend fun performProbe( private suspend fun probeReachability( endpoint: HostEndpoint, http: HttpTransport, + authHeaders: Map, ): PairingProbeResult.Failure? { val response = try { - http.send(HttpRequest(method = HttpMethod.GET, url = liveSessionsUrl(endpoint))) + http.send(HttpRequest(method = HttpMethod.GET, url = liveSessionsUrl(endpoint), headers = authHeaders)) } catch (cancel: CancellationException) { throw cancel } catch (error: Throwable) { return PairingProbeResult.Failure(PairingError.classify(error, endpoint)) } + // Checked BEFORE the shape check: the 401 body is the server's auth JSON, not a session array, and + // reporting "端口对吗?" for a token problem would send the user chasing the wrong thing. + if (response.status == HTTP_UNAUTHORIZED) { + return PairingProbeResult.Failure(PairingError.AccessTokenRequired) + } if (response.status != HTTP_OK || !isJsonArray(response.body)) { return PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal) } @@ -162,13 +178,14 @@ private suspend fun killProbeSession( sessionId: String, endpoint: HostEndpoint, http: HttpTransport, + authHeaders: Map, ): PairingProbeResult { val response = try { http.send( HttpRequest( method = HttpMethod.DELETE, url = killUrl(endpoint, sessionId), - headers = mapOf(ORIGIN_HEADER to endpoint.originHeader), + headers = authHeaders + (ORIGIN_HEADER to endpoint.originHeader), ), ) } catch (cancel: CancellationException) { @@ -178,6 +195,7 @@ private suspend fun killProbeSession( } return when (response.status) { HTTP_NO_CONTENT, HTTP_NOT_FOUND -> PairingProbeResult.Success(endpoint) + HTTP_UNAUTHORIZED -> PairingProbeResult.Failure(PairingError.AccessTokenRequired) HTTP_FORBIDDEN -> PairingProbeResult.Failure( PairingError.OriginRejected(PairingError.originRejectedHint(endpoint)), ) @@ -219,9 +237,17 @@ private fun httpBaseUrl(endpoint: HostEndpoint): String { return "$scheme://$host$portPart" } +/** + * `Cookie: webterm_auth=` for the probe's HTTP legs, or NO header when the host has no token — + * derived from the single point ([AuthCookie]), never hand-assembled. + */ +private fun authHeaders(token: String?): Map = + if (token == null) emptyMap() else mapOf(AuthCookie.HEADER_NAME to AuthCookie.headerValue(token)) + private const val LIVE_SESSIONS_PATH = "/live-sessions" private const val ORIGIN_HEADER = "Origin" private const val HTTP_OK = 200 +private const val HTTP_UNAUTHORIZED = 401 private const val HTTP_NO_CONTENT = 204 private const val HTTP_FORBIDDEN = 403 private const val HTTP_NOT_FOUND = 404 diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClient.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClient.kt index 60005c4..f00e676 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClient.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClient.kt @@ -19,6 +19,7 @@ import wang.yaojia.webterm.api.models.UiConfig import wang.yaojia.webterm.api.models.UiPrefs import wang.yaojia.webterm.api.models.decodeGitError import wang.yaojia.webterm.api.models.decodeGitPayload +import wang.yaojia.webterm.wire.AccessTokenSource import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wire.HttpResponse import wang.yaojia.webterm.wire.HttpTransport @@ -36,10 +37,17 @@ import java.util.UUID * * The server is UNTRUSTED at this boundary: bodies decode tolerantly (malformed entries dropped), * statuses map to explicit [ApiClientError]s, and nothing here crashes on bad input. + * + * **Access token (ios-completion §1.1):** [tokens] is consulted once per request and its token is + * stamped as `Cookie: webterm_auth=` on EVERY route (RO included) inside the same single point that + * stamps `Origin` ([ApiRoute.toHttpRequest]). The default [AccessTokenSource.NONE] means "no token" — + * an unauthenticated LAN host behaves exactly as before. A **401** from any route becomes the typed + * [ApiClientError.Unauthorized] so the UI can send the user to re-enter the token. */ public class ApiClient( public val endpoint: HostEndpoint, private val http: HttpTransport, + private val tokens: AccessTokenSource = AccessTokenSource.NONE, ) { // ── RO (read-only — NO Origin header) ────────────────────────────────────────────────── @@ -249,9 +257,18 @@ public class ApiClient( // ── Internals ────────────────────────────────────────────────────────────────────────── + /** + * The ONE dispatch point: stamp Origin (iff guarded) + the auth cookie (iff a token is stored), + * send, and translate a **401** into the typed [ApiClientError.Unauthorized] BEFORE any per-route + * status mapping — otherwise a guarded git write would degrade a 401 into a generic `Rejected` + * outcome and the UI would never learn to ask for the token (ios-completion §1.1). + */ private suspend fun perform(route: ApiRoute): HttpResponse { - val request = route.toHttpRequest(endpoint) ?: throw ApiClientError.InvalidRequest - return http.send(request) + val request = route.toHttpRequest(endpoint, tokens.tokenFor(endpoint)) + ?: throw ApiClientError.InvalidRequest + val response = http.send(request) + if (response.status == HttpStatus.UNAUTHORIZED) throw ApiClientError.Unauthorized + return response } /** 200 → ok; 404 → `SessionNotFound`; anything else → `UnexpectedStatus`. */ diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClientError.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClientError.kt index 3bd6d2f..eaaac93 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClientError.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClientError.kt @@ -20,6 +20,13 @@ public sealed class ApiClientError(public val userMessage: String) : Exception(u /** 404 on a `/live-sessions/:id/…` sub-route — the session is gone (exited / reaped / killed). */ public data object SessionNotFound : ApiClientError("会话已不存在(可能已退出或被清理)。") + /** + * **401** from ANY route (RO or guarded): this host has `WEBTERM_TOKEN` set and our request carried + * no cookie / a wrong one (ios-completion §1.1). Distinct from [Forbidden] (that is the Origin + * guard) — the UI must send the user to enter or fix the host's access token. + */ + public data object Unauthorized : ApiClientError("此主机需要访问令牌,或令牌已失效。请重新输入访问令牌。") + /** 403 from a G route's Origin guard (CSWSH defence). */ public data object Forbidden : ApiClientError("服务器拒绝了此来源(Origin 校验未通过)。") diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiRoute.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiRoute.kt index cbddb4c..54e1684 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiRoute.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiRoute.kt @@ -1,5 +1,6 @@ package wang.yaojia.webterm.api.routes +import wang.yaojia.webterm.wire.AuthCookie import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wire.HttpMethod import wang.yaojia.webterm.wire.HttpRequest @@ -10,6 +11,7 @@ internal object HttpStatus { const val OK = 200 const val NO_CONTENT = 204 const val BAD_REQUEST = 400 + const val UNAUTHORIZED = 401 const val FORBIDDEN = 403 const val NOT_FOUND = 404 const val TOO_MANY_REQUESTS = 429 @@ -24,6 +26,12 @@ internal object HttpStatus { internal object HeaderName { const val ORIGIN = "Origin" const val CONTENT_TYPE = "Content-Type" + + /** + * Explicit on the `/auth` probe: the server treats an `Accept` containing `text/html` as a browser + * form submit and answers **302** instead of 204/401 (`src/server.ts` `acceptsHtml`). + */ + const val ACCEPT = "Accept" } internal object ContentType { @@ -55,19 +63,35 @@ internal class ApiRoute( val originPolicy: OriginPolicy, val body: ByteArray? = null, val percentEncodedQuery: String? = null, + /** Explicit `Accept` when the server's behaviour depends on it (the `/auth` probe). */ + val accept: String? = null, ) { /** * Build the [HttpRequest] against [endpoint]'s scheme/host/port: the path is REPLACED, the * query is REPLACED by [percentEncodedQuery] (dropped when null), fragment/credentials are * dropped — the same derivation philosophy as `HostEndpoint.wsUrl`. Returns `null` if the base * URL cannot be parsed (surfaced by the client as `InvalidRequest`). + * + * Two headers are stamped HERE and only here, and they are ORTHOGONAL (ios-completion §1.1): + * - `Origin` **iff** the route is [OriginPolicy.GUARDED] (the CSWSH 铁律, unchanged); + * - `Cookie: webterm_auth=` whenever [accessToken] is non-null — on EVERY route, read-only + * ones included, because the server's access-token gate sits in front of the whole app. A null + * token adds no header at all, keeping an unauthenticated host byte-identical to before. + * The token is secret material: it is only ever placed in this header — never in [path], never in + * [percentEncodedQuery], never logged. */ - fun toHttpRequest(endpoint: HostEndpoint): HttpRequest? { + fun toHttpRequest(endpoint: HostEndpoint, accessToken: String? = null): HttpRequest? { val url = buildUrl(endpoint.baseUrl, path, percentEncodedQuery) ?: return null val headers = LinkedHashMap() if (originPolicy == OriginPolicy.GUARDED) { headers[HeaderName.ORIGIN] = endpoint.originHeader } + if (accessToken != null) { + headers[AuthCookie.HEADER_NAME] = AuthCookie.headerValue(accessToken) + } + if (accept != null) { + headers[HeaderName.ACCEPT] = accept + } if (body != null) { headers[HeaderName.CONTENT_TYPE] = ContentType.JSON } diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt index 21ac018..3ba1aad 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt @@ -114,6 +114,33 @@ internal object Endpoints { private const val FCM_TOKEN_PATH = "/push/fcm-token" + // ── G: access-token validation probe (`POST /auth`, ios-completion §1.1) ─────────────── + + /** + * `POST /auth` — the pairing-time token-validation probe. Body `{"token":""}`. + * + * Two non-obvious requirements, both server-driven (`src/server.ts`): + * - **`Accept` must not contain `text/html`.** The route is dual-mode: an HTML-accepting request + * is treated as a browser form and answered with a **302** redirect instead of 204/401. + * - the route sits BEFORE the auth gate, so it is the one request that legitimately carries no + * auth cookie (the token is in the body — this IS the login). + * + * Guarded (a state-changing POST) so it stamps `Origin` like every other write; the server does not + * Origin-check `/auth`, but keeping the Origin-iff-guarded rule uniform avoids a special case. + */ + fun auth(token: String): ApiRoute { + val body = ModelJson.encodeToString(AuthBody.serializer(), AuthBody(token)).encodeToByteArray() + return ApiRoute( + HttpMethod.POST, + AUTH_PATH, + OriginPolicy.GUARDED, + body = body, + accept = ContentType.JSON, + ) + } + + private const val AUTH_PATH = "/auth" + // ── G: worktree write (create / remove / prune) ──────────────────────────────────────── /** `POST /projects/worktree` — `{ path, branch[, base] }`. `base` omitted when null. */ @@ -199,6 +226,10 @@ internal object Endpoints { @Serializable private data class FcmTokenBody(val token: String) + /** `POST /auth` body. Holds secret material — never log a request built from it. */ + @Serializable + private data class AuthBody(val token: String) + @Serializable private data class CreateWorktreeBody(val path: String, val branch: String, val base: String? = null) diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/pairing/AuthProbeTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/pairing/AuthProbeTest.kt new file mode 100644 index 0000000..27b4b5a --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/pairing/AuthProbeTest.kt @@ -0,0 +1,159 @@ +package wang.yaojia.webterm.api.pairing + +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.testsupport.FakeHttpTransport +import wang.yaojia.webterm.wire.AuthCookie +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HttpMethod +import java.io.IOException + +/** + * B5 · `POST /auth` as the pairing-time validation probe (FROZEN contract, ios-completion §1.1). + * + * The four distinct server outcomes must stay distinguishable: + * | 204 **with** `Set-Cookie` | token correct → persist it | + * | 204 **without** `Set-Cookie` | the host has NO auth configured → do NOT persist, and do NOT treat as authenticated | + * | 401 | the token is wrong → UI says "令牌不正确" | + * | 429 | rate-limited (10/min/IP) → UI says "尝试过多" | + * + * Plus the request shape the server needs: JSON body `{"token":""}` and an `Accept` that does NOT + * contain `text/html` (an HTML-accepting request is treated as a browser form and answered with a 302 + * instead of 204/401). + */ +class AuthProbeTest { + private companion object { + const val BASE = "http://192.168.1.5:3000" + const val TOKEN = "0123456789abcdefTOKEN" + } + + private val endpoint: HostEndpoint = requireNotNull(HostEndpoint.fromBaseUrl(BASE)) + private val transport = FakeHttpTransport() + + private fun queueAuth(status: Int, headers: Map = emptyMap()) { + transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/auth", status = status, headers = headers) + } + + private val setCookieHeader = mapOf( + "Set-Cookie" to "${AuthCookie.NAME}=$TOKEN; Path=/; Max-Age=2592000; HttpOnly; SameSite=Strict", + ) + + // ── the four frozen outcomes ──────────────────────────────────────────────────────────────── + + @Test + fun `204 with Set-Cookie means the token is correct`() = runTest { + queueAuth(204, setCookieHeader) + + assertEquals(AuthProbeResult.Accepted, probeAccessToken(endpoint, transport, TOKEN)) + } + + @Test + fun `204 without Set-Cookie means the host has no auth configured`() = runTest { + queueAuth(204) + + assertEquals( + AuthProbeResult.AuthDisabled, + probeAccessToken(endpoint, transport, TOKEN), + "204-no-cookie must NEVER be read as 'authenticated' (frozen §1.1)", + ) + } + + @Test + fun `401 means the token is wrong`() = runTest { + queueAuth(401) + + assertEquals(AuthProbeResult.InvalidToken, probeAccessToken(endpoint, transport, TOKEN)) + } + + @Test + fun `429 means rate-limited`() = runTest { + queueAuth(429) + + assertEquals(AuthProbeResult.RateLimited, probeAccessToken(endpoint, transport, TOKEN)) + } + + @Test + fun `any other status is surfaced as Unexpected with the code`() = runTest { + queueAuth(302) // e.g. a proxy/login redirect — never silently treated as success + + assertEquals(AuthProbeResult.Unexpected(302), probeAccessToken(endpoint, transport, TOKEN)) + } + + // ── request shape ─────────────────────────────────────────────────────────────────────────── + + @Test + fun `the probe posts the token as JSON with an Accept that excludes text-html`() = runTest { + queueAuth(204, setCookieHeader) + + probeAccessToken(endpoint, transport, TOKEN) + + val request = transport.recordedRequests.single() + assertEquals(HttpMethod.POST, request.method) + assertEquals("$BASE/auth", request.url) + assertEquals("""{"token":"$TOKEN"}""", request.body?.decodeToString()) + assertEquals("application/json", request.headers["Content-Type"]) + val accept = request.headers["Accept"].orEmpty() + assertTrue(accept.isNotEmpty(), "Accept must be explicit, not left to the HTTP stack") + assertFalse(accept.contains("text/html"), "text/html would make the server answer 302, not 204/401") + } + + @Test + fun `the token never appears in the URL`() = runTest { + queueAuth(401) + + probeAccessToken(endpoint, transport, TOKEN) + + assertFalse( + transport.recordedRequests.single().url.contains(TOKEN), + "a secret must never travel in a URL (query strings land in logs/history)", + ) + } + + // ── boundary validation + transport failure ───────────────────────────────────────────────── + + @Test + fun `a malformed token is rejected before any network IO`() = runTest { + assertEquals(AuthProbeResult.Malformed, probeAccessToken(endpoint, transport, "short")) + assertEquals(AuthProbeResult.Malformed, probeAccessToken(endpoint, transport, "has spaces in it here")) + + assertTrue(transport.recordedRequests.isEmpty(), "a malformed token must not hit the network") + } + + @Test + fun `a whitespace-padded token is trimmed once and accepted`() = runTest { + queueAuth(204, setCookieHeader) + + assertEquals(AuthProbeResult.Accepted, probeAccessToken(endpoint, transport, " $TOKEN\n")) + assertEquals("""{"token":"$TOKEN"}""", transport.recordedRequests.single().body?.decodeToString()) + } + + @Test + fun `a transport failure propagates so the caller can classify it`() = runTest { + transport.queueFailure(method = HttpMethod.POST, url = "$BASE/auth", error = IOException("refused")) + + val thrown = runCatching { probeAccessToken(endpoint, transport, TOKEN) }.exceptionOrNull() + + assertTrue(thrown is IOException, "network classification stays with PairingError.classify") + } + + @Test + fun `a Set-Cookie header is recognised case-insensitively`() = runTest { + queueAuth(204, mapOf("set-cookie" to "${AuthCookie.NAME}=$TOKEN; Path=/")) + + assertEquals(AuthProbeResult.Accepted, probeAccessToken(endpoint, transport, TOKEN)) + } + + @Test + fun `a Set-Cookie for some other cookie does not count as accepted`() = runTest { + queueAuth(204, mapOf("Set-Cookie" to "sessionid=abc; Path=/")) + + assertEquals( + AuthProbeResult.AuthDisabled, + probeAccessToken(endpoint, transport, TOKEN), + "only a webterm_auth cookie proves the token was accepted", + ) + } +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/pairing/PairingProbeTokenTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/pairing/PairingProbeTokenTest.kt new file mode 100644 index 0000000..a8b0df6 --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/pairing/PairingProbeTokenTest.kt @@ -0,0 +1,90 @@ +package wang.yaojia.webterm.api.pairing + +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.testsupport.FakeHttpTransport +import wang.yaojia.webterm.testsupport.FakeTermTransport +import wang.yaojia.webterm.wire.AccessTokenSource +import wang.yaojia.webterm.wire.AuthCookie +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HttpMethod + +/** + * B5 · the two-step pairing probe under an access-token-protected host: + * - both HTTP legs (`GET /live-sessions` and the guarded `DELETE /live-sessions/:id`) carry the + * hand-written `Cookie: webterm_auth=`; + * - a **401** on the reachability leg is [PairingError.AccessTokenRequired] — NOT + * "端口对吗?" ([PairingError.HttpOkButNotWebTerminal]) and NOT an Origin problem, so the UI can ask + * for the token instead of sending the user on a wild goose chase; + * - with no token configured nothing changes (no `Cookie` header at all). + */ +class PairingProbeTokenTest { + private companion object { + const val BASE = "http://192.168.1.5:3000" + const val TOKEN = "0123456789abcdefTOKEN" + const val SERVER_ID = "11111111-1111-4111-8111-111111111111" + } + + private val endpoint: HostEndpoint = requireNotNull(HostEndpoint.fromBaseUrl(BASE)) + private val http = FakeHttpTransport() + private val ws = FakeTermTransport() + + private fun scriptHappyPath() { + http.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray()) + http.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$SERVER_ID", status = 204) + ws.emit("""{"type":"attached","sessionId":"$SERVER_ID"}""") + } + + private suspend fun probe(tokens: AccessTokenSource): PairingProbeResult = + runPairingProbeCore(endpoint, http, ws, timeout = null, tokens = tokens) + + @Test + fun `both HTTP legs of the probe carry the auth cookie`() = runTest { + scriptHappyPath() + + val result = probe(AccessTokenSource { TOKEN }) + + assertEquals(PairingProbeResult.Success(endpoint), result) + assertEquals(2, http.recordedRequests.size, "reachability GET + guarded kill DELETE") + http.recordedRequests.forEach { request -> + assertEquals( + "${AuthCookie.NAME}=$TOKEN", + request.headers[AuthCookie.HEADER_NAME], + "every probe request must present the token", + ) + } + } + + @Test + fun `with no token the probe requests are byte-identical to before the feature`() = runTest { + scriptHappyPath() + + probe(AccessTokenSource.NONE) + + http.recordedRequests.forEach { request -> + assertFalse(request.headers.containsKey(AuthCookie.HEADER_NAME)) + } + } + + @Test + fun `a 401 on the reachability leg asks for an access token`() = runTest { + http.queueSuccess(url = "$BASE/live-sessions", status = 401, body = """{"error":"authentication required"}""".toByteArray()) + + val result = probe(AccessTokenSource.NONE) + + assertEquals(PairingProbeResult.Failure(PairingError.AccessTokenRequired), result) + } + + @Test + fun `a 401 on the guarded kill leg also asks for an access token`() = runTest { + http.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray()) + http.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$SERVER_ID", status = 401) + ws.emit("""{"type":"attached","sessionId":"$SERVER_ID"}""") + + val result = probe(AccessTokenSource.NONE) + + assertEquals(PairingProbeResult.Failure(PairingError.AccessTokenRequired), result) + } +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/AccessTokenCookieTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/AccessTokenCookieTest.kt new file mode 100644 index 0000000..b68b238 --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/AccessTokenCookieTest.kt @@ -0,0 +1,140 @@ +package wang.yaojia.webterm.api.routes + +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.api.models.HookDecision +import wang.yaojia.webterm.testsupport.FakeHttpTransport +import wang.yaojia.webterm.wire.AccessTokenSource +import wang.yaojia.webterm.wire.AuthCookie +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HttpMethod +import java.util.UUID + +/** + * B5 · the access-token cookie on the REST surface (FROZEN contract, ios-completion §1.1): + * - `Cookie: webterm_auth=` is stamped on **EVERY** route — read-only GETs included — because the + * server's auth gate runs in front of the whole app, not just the guarded routes; + * - it is **orthogonal to `Origin`**: a RO route carries the cookie and still NO Origin (the + * Origin-iff-guarded 铁律 is untouched); + * - no token configured ⇒ NO `Cookie` header at all (zero-config LAN behaviour is byte-identical); + * - a **401** on any RO/G route is the typed [ApiClientError.Unauthorized], never a generic status + * error, so the UI can route the user to re-enter the token. + */ +class AccessTokenCookieTest { + private companion object { + const val BASE = "http://192.168.1.5:3000" + const val TOKEN = "0123456789abcdefTOKEN" + val ID: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555") + const val ID_STR = "11111111-2222-4333-8444-555555555555" + } + + private val endpoint: HostEndpoint = requireNotNull(HostEndpoint.fromBaseUrl(BASE)) + private val transport = FakeHttpTransport() + + private fun clientWithToken(): ApiClient = + ApiClient(endpoint, transport, AccessTokenSource { TOKEN }) + + private fun clientWithoutToken(): ApiClient = ApiClient(endpoint, transport) + + private suspend fun errorOf(block: suspend () -> Unit): Throwable? = runCatching { block() }.exceptionOrNull() + + private fun assertCookie(index: Int) { + val headers = transport.recordedRequests[index].headers + assertEquals( + "${AuthCookie.NAME}=$TOKEN", + headers[AuthCookie.HEADER_NAME], + "every request must carry the hand-written auth cookie", + ) + } + + @Test + fun `a read-only GET carries the cookie and still no Origin`() = runTest { + transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray()) + + clientWithToken().liveSessions() + + assertCookie(0) + assertFalse( + transport.recordedRequests[0].headers.containsKey(HeaderName.ORIGIN), + "the cookie is additive — a RO route must still never carry Origin", + ) + } + + @Test + fun `a guarded write carries BOTH the byte-equal Origin and the cookie`() = runTest { + transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/hook/decision", status = 204) + + clientWithToken().hookDecision(ID, HookDecision.ALLOW, "cap-tok-123") + + assertCookie(0) + assertEquals(endpoint.originHeader, transport.recordedRequests[0].headers[HeaderName.ORIGIN]) + } + + @Test + fun `the cookie rides every route shape - RO query, guarded DELETE and guarded PUT alike`() = runTest { + transport.queueSuccess( + url = "$BASE/projects/detail?path=%2Frepo", + body = """{"name":"repo","path":"/repo","isGit":true}""".toByteArray(), + ) + transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$ID_STR", status = 204) + transport.queueSuccess(method = HttpMethod.PUT, url = "$BASE/prefs", body = "{}".toByteArray()) + val client = clientWithToken() + + client.projectDetail("/repo") + client.killSession(ID) + client.putPrefs(wang.yaojia.webterm.api.models.UiPrefs.create()) + + assertEquals(3, transport.recordedRequests.size) + transport.recordedRequests.indices.forEach { assertCookie(it) } + } + + @Test + fun `no configured token means no Cookie header at all`() = runTest { + transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray()) + + clientWithoutToken().liveSessions() + + assertFalse( + transport.recordedRequests[0].headers.containsKey(AuthCookie.HEADER_NAME), + "an unauthenticated host must see a byte-identical request to before the feature", + ) + } + + @Test + fun `the token is re-read per request so a token stored later takes effect`() = runTest { + transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray()) + transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray()) + var stored: String? = null + val client = ApiClient(endpoint, transport, AccessTokenSource { stored }) + + client.liveSessions() + stored = TOKEN + client.liveSessions() + + assertFalse(transport.recordedRequests[0].headers.containsKey(AuthCookie.HEADER_NAME)) + assertCookie(1) + } + + @Test + fun `a 401 on a read-only route throws the typed Unauthorized error`() = runTest { + transport.queueSuccess(url = "$BASE/live-sessions", status = 401, body = """{"error":"authentication required"}""".toByteArray()) + + val error = errorOf { clientWithoutToken().liveSessions() } + + assertEquals(ApiClientError.Unauthorized, error) + assertTrue( + ApiClientError.Unauthorized.userMessage.isNotBlank(), + "the UI needs actionable copy for a missing token", + ) + } + + @Test + fun `a 401 on a guarded git write throws Unauthorized instead of a Rejected outcome`() = runTest { + transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/push", status = 401) + + assertEquals(ApiClientError.Unauthorized, errorOf { clientWithoutToken().gitPush("/repo") }) + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 350ade0..3c7b638 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -15,6 +15,9 @@ when (model.reason) { FailureReason.REPLAY_TOO_LARGE -> "回放数据过大,无法自动重连。请降低服务器 SCROLLBACK_BYTES 或提高客户端上限后开新会话。" + FailureReason.UNAUTHORIZED -> + "服务器拒绝了连接(401)。请在“配对主机”里重新填写访问令牌;若已填写,请检查主机的 ALLOWED_ORIGINS。" } is BannerModel.Exited -> if (model.isSpawnFailure) { diff --git a/android/app/src/main/java/wang/yaojia/webterm/di/AuthModule.kt b/android/app/src/main/java/wang/yaojia/webterm/di/AuthModule.kt new file mode 100644 index 0000000..ccd680a --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/di/AuthModule.kt @@ -0,0 +1,42 @@ +package wang.yaojia.webterm.di + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import wang.yaojia.webterm.hostregistry.AccessTokenStore +import wang.yaojia.webterm.hostregistry.TinkAccessTokenStore +import wang.yaojia.webterm.wire.AccessTokenSource +import javax.inject.Singleton + +/** + * Access-token boundary (B5 / ios-completion §1.1): the ONE per-host access-token store, exposed both as + * the mutable [AccessTokenStore] (the pairing screen writes it) and as the read-only + * [AccessTokenSource] the transports consume. + * + * It is its own Hilt module — not part of [StorageModule] — because the token is SECRET material: + * [StorageModule]'s DataStore holds only non-secret UI state, while this store is + * Tink-AEAD-encrypted under an AndroidKeystore-wrapped master key (the same posture as [TlsModule]'s + * cert store, with its own keyset so the two secrets never share a key). + * + * Both providers resolve to the SAME singleton, so a token stored during pairing is immediately visible + * to the WS upgrade and to every REST route — no cache to invalidate. The store is constructor-I/O-free + * (Tink/Keystore work is lazy and hops to `Dispatchers.IO` inside), so injecting it on `Main` is cheap. + */ +@Module +@InstallIn(SingletonComponent::class) +public object AuthModule { + + @Provides + @Singleton + public fun provideAccessTokenStore( + @ApplicationContext context: Context, + ): AccessTokenStore = TinkAccessTokenStore(context) + + /** The transports' read seam — the SAME instance the pairing screen wrote to. */ + @Provides + @Singleton + public fun provideAccessTokenSource(store: AccessTokenStore): AccessTokenSource = store +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/di/NetworkModule.kt b/android/app/src/main/java/wang/yaojia/webterm/di/NetworkModule.kt index ab0165e..de7770d 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/di/NetworkModule.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/di/NetworkModule.kt @@ -12,6 +12,7 @@ import wang.yaojia.webterm.transport.OkHttpClientFactory import wang.yaojia.webterm.transport.OkHttpHttpTransport import wang.yaojia.webterm.transport.OkHttpTermTransport import wang.yaojia.webterm.tlsandroid.IdentityRepository +import wang.yaojia.webterm.wire.AccessTokenSource import wang.yaojia.webterm.wire.HttpTransport import wang.yaojia.webterm.wire.TermTransport import javax.inject.Singleton @@ -65,10 +66,18 @@ public object NetworkModule { .connectionPool(connectionPool) .build() - /** WS transport over the shared client (it derives a streaming-tuned variant sharing the pool). */ + /** + * WS transport over the shared client (it derives a streaming-tuned variant sharing the pool). + * + * [tokens] ([AuthModule]) is what makes the **WS upgrade** carry `Cookie: webterm_auth=` — the + * REST half gets its cookie from `:api-client`'s single stamping point instead (B5 / §1.1). + */ @Provides @Singleton - public fun provideTermTransport(client: OkHttpClient): TermTransport = OkHttpTermTransport(client) + public fun provideTermTransport( + client: OkHttpClient, + tokens: AccessTokenSource, + ): TermTransport = OkHttpTermTransport(client, tokens) /** REST transport over the SAME shared client (Origin stamped by `:api-client`, never here). */ @Provides diff --git a/android/app/src/main/java/wang/yaojia/webterm/nav/Panes.kt b/android/app/src/main/java/wang/yaojia/webterm/nav/Panes.kt index 6ebbb5d..d6a7fa6 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/nav/Panes.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/nav/Panes.kt @@ -58,6 +58,10 @@ public fun PairingPane( runCatching { env.identityRepository.hasInstalledIdentity() }.getOrDefault(false) } }, + // B5 · the access-token half: validated with POST /auth, then kept in the Keystore-backed store + // the transports read from. + tokenStore = env.accessTokenStore, + authProber = env.buildAccessTokenProber(), ) } LaunchedEffect(viewModel) { viewModel.bind(this) } @@ -189,7 +193,8 @@ public fun DiffPane( } val viewModel = remember(resolved, path) { DiffViewModel( - fetcher = HttpDiffFetcher(resolved.endpoint, env.httpTransport), + // The hand-built diff route needs the access-token cookie too (B5 / §1.1). + fetcher = HttpDiffFetcher(resolved.endpoint, env.httpTransport, env.accessTokenStore), path = path, // Guarded git-write flows through :api-client's single Origin-stamping point (plan §Security). writer = ApiClientGitWriteGateway(env.apiClientFactory.create(resolved.endpoint)), diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt index 7b9addb..e53eb9d 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -38,6 +39,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat @@ -50,8 +54,10 @@ import com.google.mlkit.vision.common.InputImage import wang.yaojia.webterm.designsystem.Spacing import wang.yaojia.webterm.hostregistry.Host import wang.yaojia.webterm.viewmodels.EntryError +import wang.yaojia.webterm.viewmodels.PairingCopy import wang.yaojia.webterm.viewmodels.PairingUiState import wang.yaojia.webterm.viewmodels.PairingViewModel +import wang.yaojia.webterm.viewmodels.TokenError import wang.yaojia.webterm.viewmodels.WarningTier import wang.yaojia.webterm.viewmodels.needsExplicitAck @@ -65,6 +71,12 @@ import wang.yaojia.webterm.viewmodels.needsExplicitAck * requires an explicit acknowledge, and a tunnel host routes to the device-cert screen when the probe is * cert-gated. Every displayed URL/host is INERT [Text] (no autolink/markdown, plan §8). * + * ### Access token (B5 / ios-completion §1.1) + * The confirm step carries an OPTIONAL access-token field (masked, with a reveal toggle). The typed token + * never enters the ViewModel's UI state — it is passed straight into `confirm()/retry()`, validated by + * `POST /auth`, and (only when the server accepts it) stored in the Keystore-backed store. A wrong token / + * rate-limit / "this host requires a token" comes back as an inert [TokenError] under the field. + * * ### Permissions * - **CAMERA** (QR only) is requested at runtime with a rationale; a denial degrades gracefully to * manual entry (the QR path is never mandatory). @@ -104,7 +116,7 @@ public fun PairingScreen( is PairingUiState.Confirming -> ConfirmStep( state = s, onName = viewModel::setName, - onConfirm = viewModel::confirm, + onConfirm = { ack, token -> viewModel.confirm(acknowledgedPublicRisk = ack, accessToken = token) }, onCancel = viewModel::reset, ) is PairingUiState.Probing -> ProbingStep(host = s.name) @@ -228,10 +240,16 @@ private fun CameraPreview(onScanned: (String) -> Unit) { private fun ConfirmStep( state: PairingUiState.Confirming, onName: (String) -> Unit, - onConfirm: (Boolean) -> Unit, + onConfirm: (Boolean, String) -> Unit, onCancel: () -> Unit, ) { var acknowledged by remember(state.endpoint) { mutableStateOf(false) } + // The token lives ONLY here (and in the encrypted store once accepted) — never in the ViewModel's + // UI state, so it cannot leak through a state snapshot / crash report (§1.1). Keyed on the endpoint + // so a token typed for one host is dropped when the candidate changes, but survives a re-render + // after a wrong-token error (the field keeps what the user typed). + var token by remember(state.endpoint) { mutableStateOf("") } + var tokenVisible by remember(state.endpoint) { mutableStateOf(false) } TierWarningCard(tier = state.tier, highlight = state.awaitingAck && !acknowledged) // The dialed URL is validated but user/QR-sourced — render INERT (plan §8). @@ -249,6 +267,14 @@ private fun ConfirmStep( modifier = Modifier.fillMaxWidth(), ) + AccessTokenField( + token = token, + onToken = { token = it }, + visible = tokenVisible, + onToggleVisible = { tokenVisible = !tokenVisible }, + error = state.tokenError, + ) + if (state.tier.needsExplicitAck) { Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(checked = acknowledged, onCheckedChange = { acknowledged = it }) @@ -259,13 +285,58 @@ private fun ConfirmStep( Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) { OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { Text("取消") } Button( - onClick = { onConfirm(acknowledged) }, + onClick = { onConfirm(acknowledged, token) }, enabled = !state.tier.needsExplicitAck || acknowledged, modifier = Modifier.weight(1f), ) { Text("连接") } } } +/** + * The optional access-token field (B5 / ios-completion §1.1). Masked by default with an explicit + * reveal toggle (a token is often typed from another screen and mistypes are the #1 failure), and + * `KeyboardType.Password` + `autoCorrectEnabled = false` so the IME never learns or suggests it. + * [error] renders the inert, app-authored copy for the typed [TokenError]. + */ +@Composable +private fun AccessTokenField( + token: String, + onToken: (String) -> Unit, + visible: Boolean, + onToggleVisible: () -> Unit, + error: TokenError?, +) { + OutlinedTextField( + value = token, + onValueChange = onToken, + label = { Text("访问令牌(可选,WEBTERM_TOKEN)") }, + singleLine = true, + isError = error != null, + visualTransformation = if (visible) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + autoCorrectEnabled = false, + ), + trailingIcon = { + TextButton(onClick = onToggleVisible) { Text(if (visible) "隐藏" else "显示") } + }, + modifier = Modifier.fillMaxWidth(), + ) + if (error != null) { + Text( + text = PairingCopy.describeToken(error), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } else { + Text( + text = "主机未设置 WEBTERM_TOKEN 时留空即可。", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + } +} + @Composable private fun TierWarningCard(tier: WarningTier, highlight: Boolean) { val (title, body) = tierCopy(tier) diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/DiffViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/DiffViewModel.kt index b7b88ed..1d8e4cf 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/DiffViewModel.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/DiffViewModel.kt @@ -18,6 +18,8 @@ import wang.yaojia.webterm.api.models.GitWriteOutcome import wang.yaojia.webterm.api.models.PushResult import wang.yaojia.webterm.api.models.StageResult import wang.yaojia.webterm.api.routes.ApiClient +import wang.yaojia.webterm.wire.AccessTokenSource +import wang.yaojia.webterm.wire.AuthCookie import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wire.HttpMethod import wang.yaojia.webterm.wire.HttpRequest @@ -439,14 +441,23 @@ public class DiffUnavailable(public val status: Int) : Exception("diff unavailab /** * Real [DiffFetcher]: builds the RO `GET /projects/diff` against [endpoint] and decodes tolerantly. * Read-only, so it stamps NO `Origin` header (the CSWSH 铁律 — only guarded routes carry it, plan §4.3). + * + * This route is hand-built (it is not one of `:api-client`'s 12), so the access-token cookie must be + * stamped HERE too: the server's auth gate covers EVERY route, and a missing cookie 401s (B5 / + * ios-completion §1.1). The value comes from the single derivation point ([AuthCookie]); [tokens] is + * the same store the transports read, and a host with no token adds no header at all. */ public class HttpDiffFetcher( private val endpoint: HostEndpoint, private val http: HttpTransport, + private val tokens: AccessTokenSource = AccessTokenSource.NONE, ) : DiffFetcher { override suspend fun fetch(path: String, staged: Boolean, base: String?): DiffResult { val url = diffUrl(endpoint.baseUrl, path, staged, base) ?: throw DiffUnavailable(HTTP_BAD_REQUEST) - val response = http.send(HttpRequest(method = HttpMethod.GET, url = url)) + val headers = tokens.tokenFor(endpoint) + ?.let { mapOf(AuthCookie.HEADER_NAME to AuthCookie.headerValue(it)) } + ?: emptyMap() + val response = http.send(HttpRequest(method = HttpMethod.GET, url = url, headers = headers)) if (response.status != HTTP_OK) throw DiffUnavailable(response.status) return decodeDiffResult(response.body) } diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt index 72871e6..2d889fe 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt @@ -1,16 +1,21 @@ package wang.yaojia.webterm.viewmodels +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import wang.yaojia.webterm.api.pairing.AuthProbeResult import wang.yaojia.webterm.api.pairing.PairingError import wang.yaojia.webterm.api.pairing.PairingProbeResult +import wang.yaojia.webterm.api.pairing.probeAccessToken import wang.yaojia.webterm.api.pairing.runPairingProbe +import wang.yaojia.webterm.hostregistry.AccessTokenStore import wang.yaojia.webterm.hostregistry.Host import wang.yaojia.webterm.hostregistry.HostStore +import wang.yaojia.webterm.wire.AccessTokenSource import wang.yaojia.webterm.wire.HostClassifier import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wire.HostNetworkTier @@ -42,15 +47,31 @@ import java.util.UUID * * On probe success the validated host is persisted via [HostStore.upsert]. * + * ### RULE 5 — the access token is validated and stored BEFORE the probe (B5, ios-completion §1.1) + * When the user typed a token, [confirm]/[retry] first validate it with `POST /auth` ([authProber]) and, + * only if the server ACCEPTED it (204 **with** `Set-Cookie`), persist it to the Keystore-backed + * [tokenStore]. That ordering is load-bearing: the two-step probe's own HTTP legs AND its WS upgrade + * read the token back out of that same store, so they can only authenticate if the token is already in + * it. A 204 **without** `Set-Cookie` means the host has no auth at all — nothing is stored and pairing + * continues (never treated as "authenticated"). A wrong token / rate-limit / malformed token keeps the + * user on the confirm step with a typed [TokenError] and NEVER reaches the network probe. + * + * The token itself never enters [PairingUiState] — it lives in the screen's own field and is passed in + * per call, so no state snapshot, log or crash report can carry it. + * * @param hostStore where a successfully-paired [Host] is saved. * @param prober the two-step [wang.yaojia.webterm.api.pairing.runPairingProbe] seam; a fake in tests. * @param hasDeviceCert `suspend` cert check (production: `IdentityRepository.hasInstalledIdentity` on IO). + * @param tokenStore per-host access-token storage (production: the Tink/AndroidKeystore store). + * @param authProber the `POST /auth` validation seam ([accessTokenProber] in production). * @param newId host-id allocator (default random UUID; deterministic in tests). */ public class PairingViewModel( private val hostStore: HostStore, private val prober: PairingProber, private val hasDeviceCert: suspend () -> Boolean, + private val tokenStore: AccessTokenStore, + private val authProber: AccessTokenProber, private val newId: () -> String = { UUID.randomUUID().toString() }, ) { private val _uiState = MutableStateFlow(PairingUiState.Entry()) @@ -111,22 +132,23 @@ public class PairingViewModel( /** * Confirm the on-screen candidate and probe it. For a public host, [acknowledgedPublicRisk] MUST be * true or this re-arms the acknowledge reminder without touching the network (RULE 3). + * [accessToken] is the (optional) `WEBTERM_TOKEN` the user typed — blank means "this host has none". */ - public fun confirm(acknowledgedPublicRisk: Boolean = false) { + public fun confirm(acknowledgedPublicRisk: Boolean = false, accessToken: String = "") { val candidate = _uiState.value.candidate() ?: return - runProbe(candidate, acknowledgedPublicRisk) + runProbe(candidate, acknowledgedPublicRisk, accessToken) } /** * Retry after a failure or a cert-gate refusal. Funnels through the SAME [runProbe] choke point as * [confirm] — so the tunnel cert-gate (RULE 4) cannot be bypassed by retrying. */ - public fun retry(acknowledgedPublicRisk: Boolean = false) { + public fun retry(acknowledgedPublicRisk: Boolean = false, accessToken: String = "") { val candidate = _uiState.value.candidate() ?: return - runProbe(candidate, acknowledgedPublicRisk) + runProbe(candidate, acknowledgedPublicRisk, accessToken) } - private fun runProbe(candidate: Candidate, acknowledgedPublicRisk: Boolean) { + private fun runProbe(candidate: Candidate, acknowledgedPublicRisk: Boolean, accessToken: String) { val tier = candidate.tier // RULE 3 — public host: no probe until the risk is explicitly acknowledged. if (tier.needsExplicitAck && !acknowledgedPublicRisk) { @@ -147,19 +169,79 @@ public class PairingViewModel( return@launch } _uiState.value = PairingUiState.Probing(candidate.endpoint, candidate.name, tier) + // RULE 5 — validate + store the token BEFORE probing (the probe authenticates from the store). + if (accessToken.isNotBlank() && !establishToken(candidate, accessToken)) return@launch when (val result = prober.probe(candidate.endpoint)) { is PairingProbeResult.Success -> onProbeSuccess(candidate) - is PairingProbeResult.Failure -> _uiState.value = PairingUiState.Failed( - endpoint = candidate.endpoint, - name = candidate.name, - tier = tier, - error = result.error, - message = PairingCopy.describe(result.error, tier), - ) + is PairingProbeResult.Failure -> onProbeFailure(candidate, result.error) } } } + /** + * Validate [token] with `POST /auth` and, if the server accepted it, persist it for this host. + * Returns true when pairing may continue (accepted-and-stored, OR the host has no auth at all); + * false after publishing the matching [TokenError] / failure state. + */ + private suspend fun establishToken(candidate: Candidate, token: String): Boolean { + val outcome = try { + authProber.validate(candidate.endpoint, token) + } catch (cancel: CancellationException) { + throw cancel + } catch (error: Throwable) { + // A network-level failure is not a token problem — classify it like any other probe error. + onProbeFailure(candidate, PairingError.classify(error, candidate.endpoint)) + return false + } + return when (outcome) { + AuthProbeResult.Accepted -> storeToken(candidate, token) + // 204 with no Set-Cookie: the host has no auth. Store NOTHING (never "assume authenticated") + // and carry on — an open host pairs exactly as it did before this feature. + AuthProbeResult.AuthDisabled -> true + AuthProbeResult.InvalidToken -> failToken(candidate, TokenError.INVALID) + AuthProbeResult.RateLimited -> failToken(candidate, TokenError.TOO_MANY_ATTEMPTS) + AuthProbeResult.Malformed -> failToken(candidate, TokenError.MALFORMED) + is AuthProbeResult.Unexpected -> failToken(candidate, TokenError.UNVERIFIABLE) + } + } + + private suspend fun storeToken(candidate: Candidate, token: String): Boolean { + val stored = runCatching { tokenStore.put(candidate.endpoint, token) } + return when { + stored.getOrNull() == true -> true + // put() == false ⇒ the token failed the client-side rule (unreachable after a server ACCEPT). + stored.isSuccess -> failToken(candidate, TokenError.MALFORMED) + // A throw ⇒ encrypted storage failed; never continue with a token we could not keep. + else -> failToken(candidate, TokenError.STORE_FAILED) + } + } + + /** Publish a token-specific error, keeping the user ON the confirm step (the field is there). */ + private fun failToken(candidate: Candidate, error: TokenError): Boolean { + _uiState.value = PairingUiState.Confirming( + endpoint = candidate.endpoint, + name = candidate.name, + tier = candidate.tier, + tokenError = error, + ) + return false + } + + private fun onProbeFailure(candidate: Candidate, error: PairingError) { + // The host demands a token we do not have → back to the confirm step to enter one. + if (error == PairingError.AccessTokenRequired) { + failToken(candidate, TokenError.REQUIRED) + return + } + _uiState.value = PairingUiState.Failed( + endpoint = candidate.endpoint, + name = candidate.name, + tier = candidate.tier, + error = error, + message = PairingCopy.describe(error, candidate.tier), + ) + } + private suspend fun onProbeSuccess(candidate: Candidate) { val host = Host.create(id = newId(), name = candidate.name.ifBlank { defaultName(candidate.endpoint) }, baseUrl = candidate.endpoint.baseUrl) if (host == null) { @@ -186,6 +268,21 @@ public fun interface PairingProber { public suspend fun probe(endpoint: HostEndpoint): PairingProbeResult } +/** + * The `POST /auth` access-token validation seam ([probeAccessToken]); faked in tests. Kept separate from + * [PairingProber] so the ORDER (validate+store, then probe) is observable in a unit test. + */ +public fun interface AccessTokenProber { + public suspend fun validate(endpoint: HostEndpoint, token: String): AuthProbeResult +} + +/** + * Production [AccessTokenProber]: the frozen one-shot `POST /auth` probe over the ONE shared + * `OkHttpClient`'s [HttpTransport]. The token travels in the JSON body only — never a URL, never a log. + */ +public fun accessTokenProber(http: HttpTransport): AccessTokenProber = + AccessTokenProber { endpoint, token -> probeAccessToken(endpoint, http, token) } + /** * Production [PairingProber] binding: the frozen two-step [runPairingProbe] over the ONE shared * `OkHttpClient`'s [HttpTransport] + [TermTransport] (injected in `NetworkModule`). The screen's nav @@ -193,8 +290,12 @@ public fun interface PairingProber { * `warmUp()` must have run first (off-`Main`) so the first handshake here doesn't do AndroidKeyStore/Tink * I/O on a UI thread. */ -public fun pairingProber(http: HttpTransport, ws: TermTransport): PairingProber = - PairingProber { endpoint -> runPairingProbe(endpoint, http, ws) } +public fun pairingProber( + http: HttpTransport, + ws: TermTransport, + tokens: AccessTokenSource = AccessTokenSource.NONE, +): PairingProber = + PairingProber { endpoint -> runPairingProbe(endpoint, http, ws, tokens) } // ── Warning tiers (plan §5.4) ─────────────────────────────────────────────────────────────────────── @@ -265,6 +366,11 @@ public sealed interface PairingUiState { val name: String, val tier: WarningTier, val awaitingAck: Boolean = false, + /** + * Set when the access-token step failed (or the host demands one). The token VALUE is never + * carried here — only why it went wrong (§1.1: a secret never enters app state). + */ + val tokenError: TokenError? = null, ) : PairingUiState /** The two-step probe is running against [endpoint]. */ @@ -295,6 +401,30 @@ public enum class EntryError { INVALID_URL, } +/** + * Why the access-token step failed. Rendered next to the token field on the confirm step, so the user can + * fix it in place (B5 / ios-completion §1.1). + */ +public enum class TokenError { + /** The host answered 401 to the probe: it HAS a token configured and we presented none/a wrong one. */ + REQUIRED, + + /** `POST /auth` → 401: the typed token is wrong. */ + INVALID, + + /** `POST /auth` → 429: the server's 10/min/IP limiter tripped. */ + TOO_MANY_ATTEMPTS, + + /** The typed token cannot be a server token at all (charset / 16–512 length). No request was sent. */ + MALFORMED, + + /** `POST /auth` answered something unexpected — the token could not be verified either way. */ + UNVERIFIABLE, + + /** The token was correct but encrypted on-device storage refused to keep it. */ + STORE_FAILED, +} + /** The endpoint+name+tier a probe runs against, recovered from whatever state currently holds one. */ private data class Candidate(val endpoint: HostEndpoint, val name: String, val tier: WarningTier) @@ -308,6 +438,22 @@ private fun PairingUiState.candidate(): Candidate? = when (this) { /** Maps the [PairingError] taxonomy to inert, app-authored Chinese copy (never a server string, §8). */ internal object PairingCopy { + /** Inert, app-authored copy for each [TokenError]. Never echoes the token itself. */ + fun describeToken(error: TokenError): String = when (error) { + TokenError.REQUIRED -> + "该主机启用了访问令牌(WEBTERM_TOKEN)。请填写访问令牌后再连接。" + TokenError.INVALID -> + "访问令牌不正确。请核对主机上的 WEBTERM_TOKEN 后重试。" + TokenError.TOO_MANY_ATTEMPTS -> + "尝试过多,服务器已限流(每分钟 10 次)。请稍后再试。" + TokenError.MALFORMED -> + "访问令牌格式不合法:需 16–512 个字符,且只能包含 A–Z a–z 0–9 . _ ~ + / = -。" + TokenError.UNVERIFIABLE -> + "无法验证访问令牌:服务器返回了意外响应。请确认地址和端口指向 web-terminal。" + TokenError.STORE_FAILED -> + "无法在本机安全保存访问令牌(加密存储写入失败)。请重试。" + } + fun describe(error: PairingError, tier: WarningTier): String = when (error) { is PairingError.HostUnreachable -> "无法连接到主机。请检查地址、端口,以及设备是否在同一局域网 / Tailscale 网络内。" @@ -321,6 +467,7 @@ internal object PairingCopy { // is the problem (plan §5.4: "TLS failure re-mapped to client cert invalid/revoked"). if (tier == WarningTier.TUNNEL) "客户端证书无效或已被吊销。请在“设备证书”里重新导入后再试。" else "TLS 握手失败:无法验证服务器证书。" + PairingError.AccessTokenRequired -> describeToken(TokenError.REQUIRED) PairingError.Timeout -> "连接超时。主机可能不可达,或响应过慢。" } diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/ApiClientFactory.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/ApiClientFactory.kt index c50c856..8583c38 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/wiring/ApiClientFactory.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/ApiClientFactory.kt @@ -2,6 +2,7 @@ package wang.yaojia.webterm.wiring import dagger.Lazy import wang.yaojia.webterm.api.routes.ApiClient +import wang.yaojia.webterm.wire.AccessTokenSource import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wire.HttpTransport import javax.inject.Inject @@ -23,6 +24,7 @@ import javax.inject.Singleton @Singleton public class ApiClientFactory @Inject constructor( private val http: Lazy, + private val tokens: AccessTokenSource, ) { /** * Resolve the shared [HttpTransport] (→ builds the shared `OkHttpClient`) so the slow mTLS I/O runs @@ -32,6 +34,14 @@ public class ApiClientFactory @Inject constructor( http.get() } - /** Resolves the [Lazy] transport; call [warm] off `Main` first so this is a cheap singleton read. */ - public fun create(endpoint: HostEndpoint): ApiClient = ApiClient(endpoint = endpoint, http = http.get()) + /** + * Resolves the [Lazy] transport; call [warm] off `Main` first so this is a cheap singleton read. + * + * Every client is handed the shared [AccessTokenSource], so EVERY REST route — including the + * read-only GETs and the push-decision POSTs — presents this host's access token when one is stored + * (B5 / ios-completion §1.1). The token is read per request, so pairing a host mid-run takes effect + * without rebuilding anything. + */ + public fun create(endpoint: HostEndpoint): ApiClient = + ApiClient(endpoint = endpoint, http = http.get(), tokens = tokens) } diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt index 7ef05a7..adc91f7 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt @@ -4,10 +4,15 @@ import dagger.Lazy import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import wang.yaojia.webterm.api.pairing.runPairingProbe +import wang.yaojia.webterm.hostregistry.AccessTokenStore import wang.yaojia.webterm.hostregistry.HostStore import wang.yaojia.webterm.hostregistry.LastSessionStore import wang.yaojia.webterm.tlsandroid.IdentityRepository +import wang.yaojia.webterm.viewmodels.AccessTokenProber import wang.yaojia.webterm.viewmodels.PairingProber +import wang.yaojia.webterm.viewmodels.accessTokenProber +import wang.yaojia.webterm.api.pairing.probeAccessToken +import wang.yaojia.webterm.wire.AccessTokenSource import wang.yaojia.webterm.wire.HttpTransport import wang.yaojia.webterm.wire.TermTransport import javax.inject.Inject @@ -46,6 +51,11 @@ import javax.inject.Singleton public class AppEnvironment @Inject constructor( public val hostStore: HostStore, public val lastSessionStore: LastSessionStore, + /** + * B5 · per-host access tokens (`WEBTERM_TOKEN`), Tink/AndroidKeystore-encrypted. The pairing screen + * WRITES it; the transports read the same instance through [AccessTokenSource] (see `AuthModule`). + */ + public val accessTokenStore: AccessTokenStore, public val apiClientFactory: ApiClientFactory, public val sessionEngineFactory: SessionEngineFactory, public val coldStartPolicy: ColdStartPolicy, @@ -80,7 +90,19 @@ public class AppEnvironment @Inject constructor( * after [warmUp]. */ public fun buildPairingProber(): PairingProber = - PairingProber { endpoint -> runPairingProbe(endpoint, httpTransportLazy.get(), termTransportLazy.get()) } + PairingProber { endpoint -> + // The probe's HTTP legs authenticate from the SAME store the WS transport reads, so a token + // stored a moment earlier by the pairing flow applies to all three legs (B5 / §1.1). + runPairingProbe(endpoint, httpTransportLazy.get(), termTransportLazy.get(), accessTokenStore) + } + + /** + * Build the `POST /auth` access-token validation prober (B5). Like [buildPairingProber], the shared + * transport is resolved lazily INSIDE the call so building this on the UI thread does no mTLS/OkHttp + * work. + */ + public fun buildAccessTokenProber(): AccessTokenProber = + AccessTokenProber { endpoint, token -> probeAccessToken(endpoint, httpTransportLazy.get(), token) } /** * Build the shared `OkHttpClient` and first-touch the device identity OFF `Main` (FIX 3). Resolving diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..5dda001 --- /dev/null +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/test/java/wang/yaojia/webterm/components/ReconnectBannerTest.kt b/android/app/src/test/java/wang/yaojia/webterm/components/ReconnectBannerTest.kt index d108436..986f898 100644 --- a/android/app/src/test/java/wang/yaojia/webterm/components/ReconnectBannerTest.kt +++ b/android/app/src/test/java/wang/yaojia/webterm/components/ReconnectBannerTest.kt @@ -109,4 +109,27 @@ class ReconnectBannerTest { val connecting = bannerModel(BannerModel.Hidden, Connection(ConnectionState.Connecting)) assertEquals(connecting, bannerModel(connecting, Output("some bytes"))) } + + // ── 5. B5 · a 401 upgrade is terminal AND offers no new session ─────────────── + + @Test + fun `an unauthorized failure is terminal with no spinner and no new-session action`() { + val model = bannerModel(BannerModel.Hidden, Connection(ConnectionState.Failed(FailureReason.UNAUTHORIZED))) + + val failed = model as BannerModel.Failed + assertEquals(FailureReason.UNAUTHORIZED, failed.reason) + assertFalse(failed.showsSpinner, "a 401 is permanent — never show a retry spinner") + assertFalse( + failed.offersNewSession, + "a new session would 401 too; the fix is the access token, not another session", + ) + } + + @Test + fun `an unauthorized failure outranks later transient events`() { + val failed = bannerModel(BannerModel.Hidden, Connection(ConnectionState.Failed(FailureReason.UNAUTHORIZED))) + + assertEquals(failed, bannerModel(failed, Connection(ConnectionState.Connecting))) + assertEquals(failed, bannerModel(failed, Connection(ConnectionState.Reconnecting(1, 2.seconds)))) + } } diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/DiffViewModelTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/DiffViewModelTest.kt index f800be2..78943c3 100644 --- a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/DiffViewModelTest.kt +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/DiffViewModelTest.kt @@ -14,6 +14,9 @@ import wang.yaojia.webterm.api.models.CommitResult import wang.yaojia.webterm.api.models.GitWriteOutcome import wang.yaojia.webterm.api.models.PushResult import wang.yaojia.webterm.api.models.StageResult +import wang.yaojia.webterm.testsupport.FakeHttpTransport +import wang.yaojia.webterm.wire.AccessTokenSource +import wang.yaojia.webterm.wire.HostEndpoint /** * A24 DiffViewModel — the JVM-testable read-only diff logic (plan §4.2 / §1): the STRING staged flag @@ -291,4 +294,35 @@ class DiffViewModelTest { assertTrue(DiffUiState(canWrite = true).writeEnabled) assertFalse(DiffUiState(canWrite = true, base = "main").writeEnabled) } + + // ── B5 · the hand-built diff route carries the access-token cookie ─────────────────────────── + + @Test + fun `the real diff fetcher stamps the auth cookie and never an Origin`() = runTest { + val endpoint = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.5:3000")) + val http = FakeHttpTransport() + val url = "http://10.0.0.5:3000/projects/diff?path=%2Frepo&staged=0" + http.queueSuccess(url = url, body = """{"files":[]}""".toByteArray()) + val fetcher = HttpDiffFetcher(endpoint, http, AccessTokenSource { "0123456789abcdefTOKEN" }) + + fetcher.fetch("/repo", staged = false, base = null) + + val request = http.recordedRequests.single() + assertEquals("webterm_auth=0123456789abcdefTOKEN", request.headers["Cookie"]) + assertFalse(request.headers.containsKey("Origin"), "the diff route is read-only") + } + + @Test + fun `the real diff fetcher sends no cookie for a host without a token`() = runTest { + val endpoint = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.5:3000")) + val http = FakeHttpTransport() + http.queueSuccess( + url = "http://10.0.0.5:3000/projects/diff?path=%2Frepo&staged=0", + body = """{"files":[]}""".toByteArray(), + ) + + HttpDiffFetcher(endpoint, http).fetch("/repo", staged = false, base = null) + + assertFalse(http.recordedRequests.single().headers.containsKey("Cookie")) + } } diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/PairingTokenViewModelTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/PairingTokenViewModelTest.kt new file mode 100644 index 0000000..732b99c --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/PairingTokenViewModelTest.kt @@ -0,0 +1,266 @@ +package wang.yaojia.webterm.viewmodels + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.api.pairing.AuthProbeResult +import wang.yaojia.webterm.api.pairing.PairingError +import wang.yaojia.webterm.api.pairing.PairingProbeResult +import wang.yaojia.webterm.hostregistry.InMemoryAccessTokenStore +import wang.yaojia.webterm.hostregistry.InMemoryHostStore +import wang.yaojia.webterm.wire.HostEndpoint +import java.io.IOException + +/** + * B5 · the access-token half of pairing (FROZEN contract, ios-completion §1.1). The pairing screen is + * where a token is entered, validated with `POST /auth`, and stored — so this pins: + * - **order**: the token is validated and stored BEFORE the two-step probe runs, because the probe's + * own HTTP+WS legs must already be able to authenticate; + * - the four `/auth` outcomes → the right UI state (and, for 204-no-Set-Cookie, **no** token stored); + * - a wrong token / rate-limit keeps the user ON the confirm step with a typed [TokenError] (the field + * is right there) instead of dropping them into a generic failure; + * - a host that answers 401 to the probe asks for a token ([TokenError.REQUIRED]); + * - the token NEVER appears in the UI state (no accidental leak into a state dump / crash report). + */ +@OptIn(ExperimentalCoroutinesApi::class) +class PairingTokenViewModelTest { + + private companion object { + const val LAN = "http://10.0.0.5:3000" + const val TOKEN = "0123456789abcdefTOKEN" + } + + /** Records every `/auth` validation so ordering vs the pairing probe is directly assertable. */ + private class FakeAuthProber(var result: (String) -> AuthProbeResult) : AccessTokenProber { + val calls = mutableListOf() + override suspend fun validate(endpoint: HostEndpoint, token: String): AuthProbeResult { + calls += token + return result(token) + } + } + + private class FakeProber(var result: (HostEndpoint) -> PairingProbeResult) : PairingProber { + val calls = mutableListOf() + override suspend fun probe(endpoint: HostEndpoint): PairingProbeResult { + calls += endpoint + return result(endpoint) + } + } + + private fun endpoint(url: String = LAN): HostEndpoint = + requireNotNull(HostEndpoint.fromBaseUrl(url)) + + private fun newVm( + prober: PairingProber, + authProber: AccessTokenProber, + tokenStore: InMemoryAccessTokenStore = InMemoryAccessTokenStore(), + store: InMemoryHostStore = InMemoryHostStore(), + ) = PairingViewModel( + hostStore = store, + prober = prober, + hasDeviceCert = { false }, + tokenStore = tokenStore, + authProber = authProber, + newId = { "fixed-id" }, + ) + + // ── happy path: validate → store → probe ──────────────────────────────────────────────────── + + @Test + fun `a correct token is validated, stored, and only THEN is the host probed`() = + runTest(UnconfinedTestDispatcher()) { + val order = mutableListOf() + val authProber = FakeAuthProber { order += "auth"; AuthProbeResult.Accepted } + val prober = FakeProber { order += "probe"; PairingProbeResult.Success(it) } + val tokens = InMemoryAccessTokenStore() + val hosts = InMemoryHostStore() + val vm = newVm(prober, authProber, tokens, hosts) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm(accessToken = TOKEN) + + assertEquals(listOf("auth", "probe"), order, "the probe must be able to authenticate itself") + assertEquals(TOKEN, tokens.tokenFor(endpoint()), "an accepted token is persisted per host") + assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value) + assertEquals(1, hosts.loadAll().size) + } + + @Test + fun `a blank token skips the auth probe entirely so an open LAN host pairs as before`() = + runTest(UnconfinedTestDispatcher()) { + val authProber = FakeAuthProber { AuthProbeResult.Accepted } + val prober = FakeProber { PairingProbeResult.Success(it) } + val tokens = InMemoryAccessTokenStore() + val vm = newVm(prober, authProber, tokens) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + + assertTrue(authProber.calls.isEmpty(), "no token typed ⇒ never call POST /auth") + assertEquals(1, prober.calls.size) + assertNull(tokens.tokenFor(endpoint())) + assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value) + } + + // ── the four frozen /auth outcomes ────────────────────────────────────────────────────────── + + @Test + fun `204 without Set-Cookie means auth is disabled - nothing is stored but pairing continues`() = + runTest(UnconfinedTestDispatcher()) { + val authProber = FakeAuthProber { AuthProbeResult.AuthDisabled } + val prober = FakeProber { PairingProbeResult.Success(it) } + val tokens = InMemoryAccessTokenStore() + val vm = newVm(prober, authProber, tokens) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm(accessToken = TOKEN) + + assertNull(tokens.tokenFor(endpoint()), "a host without auth must not get a stored token") + assertEquals(1, prober.calls.size, "the host is open — pairing proceeds") + assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value) + } + + @Test + fun `a wrong token keeps the user on the confirm step with an INVALID token error`() = + runTest(UnconfinedTestDispatcher()) { + val authProber = FakeAuthProber { AuthProbeResult.InvalidToken } + val prober = FakeProber { PairingProbeResult.Success(it) } + val tokens = InMemoryAccessTokenStore() + val vm = newVm(prober, authProber, tokens) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm(accessToken = TOKEN) + + val state = assertInstanceOf(PairingUiState.Confirming::class.java, vm.uiState.value) + assertEquals(TokenError.INVALID, state.tokenError) + assertTrue(prober.calls.isEmpty(), "a wrong token must not proceed to the probe") + assertNull(tokens.tokenFor(endpoint()), "a rejected token is never stored") + } + + @Test + fun `a rate-limited auth attempt surfaces TOO_MANY_ATTEMPTS`() = runTest(UnconfinedTestDispatcher()) { + val authProber = FakeAuthProber { AuthProbeResult.RateLimited } + val vm = newVm(FakeProber { PairingProbeResult.Success(it) }, authProber) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm(accessToken = TOKEN) + + val state = assertInstanceOf(PairingUiState.Confirming::class.java, vm.uiState.value) + assertEquals(TokenError.TOO_MANY_ATTEMPTS, state.tokenError) + } + + @Test + fun `a malformed token is reported without any network call`() = runTest(UnconfinedTestDispatcher()) { + val authProber = FakeAuthProber { AuthProbeResult.Malformed } + val prober = FakeProber { PairingProbeResult.Success(it) } + val vm = newVm(prober, authProber) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm(accessToken = "short") + + val state = assertInstanceOf(PairingUiState.Confirming::class.java, vm.uiState.value) + assertEquals(TokenError.MALFORMED, state.tokenError) + assertTrue(prober.calls.isEmpty()) + } + + @Test + fun `an unexpected auth status never counts as success`() = runTest(UnconfinedTestDispatcher()) { + val authProber = FakeAuthProber { AuthProbeResult.Unexpected(302) } + val prober = FakeProber { PairingProbeResult.Success(it) } + val vm = newVm(prober, authProber) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm(accessToken = TOKEN) + + val state = assertInstanceOf(PairingUiState.Confirming::class.java, vm.uiState.value) + assertEquals(TokenError.UNVERIFIABLE, state.tokenError) + assertTrue(prober.calls.isEmpty()) + } + + @Test + fun `a transport failure during auth validation maps to the normal pairing failure copy`() = + runTest(UnconfinedTestDispatcher()) { + val authProber = FakeAuthProber { throw IOException("connection refused") } + val vm = newVm(FakeProber { PairingProbeResult.Success(it) }, authProber) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm(accessToken = TOKEN) + + val state = assertInstanceOf(PairingUiState.Failed::class.java, vm.uiState.value) + assertTrue(state.message.isNotBlank()) + } + + // ── discovery: the host demands a token we do not have ────────────────────────────────────── + + @Test + fun `a probe that 401s asks for an access token`() = runTest(UnconfinedTestDispatcher()) { + val prober = FakeProber { PairingProbeResult.Failure(PairingError.AccessTokenRequired) } + val vm = newVm(prober, FakeAuthProber { AuthProbeResult.Accepted }) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + + val state = assertInstanceOf(PairingUiState.Confirming::class.java, vm.uiState.value) + assertEquals(TokenError.REQUIRED, state.tokenError) + } + + @Test + fun `retrying with the token now typed pairs successfully`() = runTest(UnconfinedTestDispatcher()) { + var demandToken = true + val prober = FakeProber { + if (demandToken) PairingProbeResult.Failure(PairingError.AccessTokenRequired) + else PairingProbeResult.Success(it) + } + val tokens = InMemoryAccessTokenStore() + val vm = newVm(prober, FakeAuthProber { AuthProbeResult.Accepted }, tokens) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + demandToken = false + vm.retry(accessToken = TOKEN) + + assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value) + assertEquals(TOKEN, tokens.tokenFor(endpoint())) + } + + // ── secrecy ───────────────────────────────────────────────────────────────────────────────── + + @Test + fun `the token never appears in the UI state`() = runTest(UnconfinedTestDispatcher()) { + val vm = newVm( + FakeProber { PairingProbeResult.Failure(PairingError.AccessTokenRequired) }, + FakeAuthProber { AuthProbeResult.Accepted }, + ) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm(accessToken = TOKEN) + + assertTrue( + !vm.uiState.value.toString().contains(TOKEN), + "a secret must not be reachable through a state snapshot / toString", + ) + } + + @Test + fun `every token error has actionable copy`() { + TokenError.entries.forEach { error -> + assertTrue(PairingCopy.describeToken(error).isNotBlank(), "copy for $error") + } + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/PairingViewModelTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/PairingViewModelTest.kt index cfc4ea1..7870a8c 100644 --- a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/PairingViewModelTest.kt +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/PairingViewModelTest.kt @@ -10,6 +10,7 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import wang.yaojia.webterm.api.pairing.PairingError import wang.yaojia.webterm.api.pairing.PairingProbeResult +import wang.yaojia.webterm.hostregistry.InMemoryAccessTokenStore import wang.yaojia.webterm.hostregistry.InMemoryHostStore import wang.yaojia.webterm.wire.HostEndpoint @@ -44,6 +45,11 @@ class PairingViewModelTest { hostStore = store, prober = prober, hasDeviceCert = { hasCert }, + // B5: these tests cover the NO-token paths — an empty store plus a prober that must never be + // called (a blank token skips `POST /auth` entirely). The token paths live in + // PairingTokenViewModelTest. + tokenStore = InMemoryAccessTokenStore(), + authProber = { _, _ -> error("POST /auth must not be called when no token was typed") }, newId = { "fixed-id" }, ) diff --git a/android/host-registry/build.gradle.kts b/android/host-registry/build.gradle.kts index 2235b72..3ea8140 100644 --- a/android/host-registry/build.gradle.kts +++ b/android/host-registry/build.gradle.kts @@ -45,6 +45,10 @@ dependencies { implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.coroutines.core) implementation(libs.androidx.datastore.preferences) + // Tink AEAD encrypts the per-host access-token blob at rest under an AndroidKeystore-wrapped + // master key (B5 / ios-completion §1.1). A SEPARATE keyset from :client-tls-android's cert store — + // different secrets, different lifecycles, and no module edge to the TLS half. + implementation(libs.tink.android) testImplementation(libs.bundles.unit.test) testRuntimeOnly(libs.junit.platform.launcher) diff --git a/android/host-registry/src/androidTest/kotlin/wang/yaojia/webterm/hostregistry/TinkAccessTokenStoreTest.kt b/android/host-registry/src/androidTest/kotlin/wang/yaojia/webterm/hostregistry/TinkAccessTokenStoreTest.kt new file mode 100644 index 0000000..668065a --- /dev/null +++ b/android/host-registry/src/androidTest/kotlin/wang/yaojia/webterm/hostregistry/TinkAccessTokenStoreTest.kt @@ -0,0 +1,107 @@ +package wang.yaojia.webterm.hostregistry + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import wang.yaojia.webterm.wire.HostEndpoint +import java.util.UUID + +/** + * Instrumented (device) contract tests for [TinkAccessTokenStore] — Tink's AEAD + the + * AndroidKeystore-wrapped master key need a real device Keystore, so these run on hardware (no + * emulator in the build env → compiled here, executed in device QA, plan §7 / DEVICE_QA_CHECKLIST). + * + * They pin the same contract the JVM [InMemoryAccessTokenStoreTest] pins for the double, PLUS the two + * things only the real store can prove: the token **survives a restart** (a fresh store instance + * decrypts the blob) and the **at-rest bytes are ciphertext**, never the token in the clear. + */ +@RunWith(AndroidJUnit4::class) +class TinkAccessTokenStoreTest { + + private companion object { + const val TOKEN = "0123456789abcdefTOKEN" + } + + private val context: Context get() = ApplicationProvider.getApplicationContext() + + /** A store on a per-test keyset/pref-file so tests never share state. */ + private fun newStore(suffix: String): TinkAccessTokenStore = TinkAccessTokenStore( + context = context, + keysetName = "test_token_keyset_$suffix", + prefFileName = "test_token_prefs_$suffix", + masterKeyUri = "android-keystore://test_token_master_$suffix", + ) + + private fun endpoint(url: String = "http://10.0.0.5:3000"): HostEndpoint = + requireNotNull(HostEndpoint.fromBaseUrl(url)) + + @Test + fun put_then_tokenFor_returns_the_token() = runBlocking { + val store = newStore(UUID.randomUUID().toString()) + + assertTrue(store.put(endpoint(), TOKEN)) + + assertEquals(TOKEN, store.tokenFor(endpoint())) + } + + @Test + fun a_fresh_store_instance_decrypts_the_persisted_token() = runBlocking { + val suffix = UUID.randomUUID().toString() + newStore(suffix).put(endpoint(), TOKEN) + + // A new instance = a cold app start: no in-memory cache, must decrypt from disk. + assertEquals(TOKEN, newStore(suffix).tokenFor(endpoint())) + } + + @Test + fun the_token_is_never_stored_in_the_clear() = runBlocking { + val suffix = UUID.randomUUID().toString() + newStore(suffix).put(endpoint(), TOKEN) + + val raw = context + .getSharedPreferences("test_token_prefs_$suffix", Context.MODE_PRIVATE) + .all + .values + .joinToString(" ") { it.toString() } + + assertFalse("the at-rest blob must be ciphertext", raw.contains(TOKEN)) + } + + @Test + fun remove_drops_the_token_durably() = runBlocking { + val suffix = UUID.randomUUID().toString() + val store = newStore(suffix) + store.put(endpoint(), TOKEN) + + store.remove(endpoint()) + + assertNull(store.tokenFor(endpoint())) + assertNull("a removed token must not come back on a cold start", newStore(suffix).tokenFor(endpoint())) + } + + @Test + fun a_malformed_token_is_rejected_without_touching_storage() = runBlocking { + val suffix = UUID.randomUUID().toString() + val store = newStore(suffix) + + assertFalse(store.put(endpoint(), "short")) + + assertNull(store.tokenFor(endpoint())) + } + + @Test + fun tokens_are_keyed_per_host() = runBlocking { + val store = newStore(UUID.randomUUID().toString()) + store.put(endpoint("http://10.0.0.5:3000"), TOKEN) + + assertNull(store.tokenFor(endpoint("http://10.0.0.9:3000"))) + assertEquals(TOKEN, store.tokenFor(endpoint("http://10.0.0.5:3000/"))) + } +} diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/AccessTokenStore.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/AccessTokenStore.kt new file mode 100644 index 0000000..d042aac --- /dev/null +++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/AccessTokenStore.kt @@ -0,0 +1,70 @@ +package wang.yaojia.webterm.hostregistry + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import wang.yaojia.webterm.wire.AccessTokenRule +import wang.yaojia.webterm.wire.AccessTokenSource +import wang.yaojia.webterm.wire.HostEndpoint + +/** + * Per-host storage for the optional shared access token (`WEBTERM_TOKEN`, ios-completion §1.1). + * + * It IS an [AccessTokenSource], so the very same instance that the pairing screen writes to is what + * `:api-client` (every REST route) and `:transport-okhttp` (the WS upgrade) read from — one store, no + * adapter, no cache to fall out of sync. + * + * ### Keying: the canonical origin, not the raw base URL + * The key is [HostEndpoint.originHeader] — already single-point-derived, lower-cased, default-port + * normalized. So `http://box:3000`, `HTTP://box:3000/` and `http://box:3000/some/path` are ONE host + * (they are literally the same server, and the token is the server's), while a different port or host + * is a different key. Storing under the raw `baseUrl` would silently lose the token when the same + * server was re-paired from a URL with a trailing slash. + * + * ### Security contract (non-negotiable) + * Implementations MUST keep the token device-only (Android-Keystore-wrapped, excluded from backup), + * MUST validate through [AccessTokenRule] before persisting, and MUST NEVER log it. + */ +public interface AccessTokenStore : AccessTokenSource { + /** The token stored for [endpoint]'s canonical origin, or null. Never logs. */ + override suspend fun tokenFor(endpoint: HostEndpoint): String? + + /** + * Store [token] for [endpoint] (replacing any previous one). Returns **false** and stores nothing + * when the token fails [AccessTokenRule] — a value the server could never accept must not be + * persisted (nor round-tripped to disk) at all. + */ + public suspend fun put(endpoint: HostEndpoint, token: String): Boolean + + /** Drop [endpoint]'s token. Idempotent — removing an unknown host is a no-op. */ + public suspend fun remove(endpoint: HostEndpoint) +} + +/** + * In-memory [AccessTokenStore]. Lives in `main` (not `test`) on purpose — it doubles for the encrypted + * store in this module's contract tests AND in the `:app` pairing-ViewModel tests (mirrors + * [InMemoryHostStore]). + * + * A [Mutex] serializes the read-modify-write; the state is an immutable map replaced wholesale on every + * change (never mutated in place). + */ +public class InMemoryAccessTokenStore( + initial: Map = emptyMap(), +) : AccessTokenStore { + private val mutex = Mutex() + + // Defensive copy so a mutable map handed to the ctor cannot alias our state. + private var tokensByOrigin: Map = initial.toMap() + + override suspend fun tokenFor(endpoint: HostEndpoint): String? = + mutex.withLock { tokensByOrigin[endpoint.originHeader] } + + override suspend fun put(endpoint: HostEndpoint, token: String): Boolean { + val normalized = AccessTokenRule.normalize(token) ?: return false + mutex.withLock { tokensByOrigin = tokensByOrigin + (endpoint.originHeader to normalized) } + return true + } + + override suspend fun remove(endpoint: HostEndpoint) { + mutex.withLock { tokensByOrigin = tokensByOrigin - endpoint.originHeader } + } +} diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/TinkAccessTokenStore.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/TinkAccessTokenStore.kt new file mode 100644 index 0000000..839df98 --- /dev/null +++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/TinkAccessTokenStore.kt @@ -0,0 +1,153 @@ +package wang.yaojia.webterm.hostregistry + +import android.content.Context +import android.content.SharedPreferences +import android.util.Base64 +import com.google.crypto.tink.Aead +import com.google.crypto.tink.KeyTemplates +import com.google.crypto.tink.RegistryConfiguration +import com.google.crypto.tink.aead.AeadConfig +import com.google.crypto.tink.integration.android.AndroidKeysetManager +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json +import wang.yaojia.webterm.wire.AccessTokenRule +import wang.yaojia.webterm.wire.HostEndpoint +import java.io.IOException + +/** + * The production [AccessTokenStore]: per-host access tokens AEAD-encrypted with Tink under an + * **AndroidKeystore-wrapped master key** (ios-completion §1.1 storage rule — the Android analogue of + * iOS Keychain `…AfterFirstUnlockThisDeviceOnly` + no `kSecAttrSynchronizable`). + * + * What that buys, concretely: + * - the master key lives in the Keystore (`android-keystore://…`) and is **non-exportable**, so the + * on-disk ciphertext is useless on any other device — device-only, exactly like the cert store; + * - the blob sits in an app-private `SharedPreferences` file (uninstall-wiped) and the app manifest + * sets `android:allowBackup="false"`, so it is **never** in a cloud/adb backup; + * - nothing here logs, and the token never enters a URL (see `AuthCookie`). + * + * Deliberately a SEPARATE keyset/pref-file/master-key from `:client-tls-android`'s `TinkCertStore`: + * different secrets with different lifecycles must not share an AEAD keyset (and this module must not + * gain a dependency on the TLS module). The ~20 lines of Tink setup are the price of that isolation. + * + * All disk + crypto work hops to [io] (never `Main`); the decrypted map is cached in memory after the + * first read so the per-request [tokenFor] on the WS-dial / REST path is a cheap map lookup, and a + * [Mutex] serializes the read-modify-write of a mutation. A blob that fails to decrypt/decode (tamper, + * version skew, a wiped Keystore key after a device restore) degrades to "no tokens" — the user + * re-enters the token — rather than throwing on every request. + */ +public class TinkAccessTokenStore( + context: Context, + private val io: CoroutineDispatcher = Dispatchers.IO, + private val keysetName: String = DEFAULT_KEYSET_NAME, + private val prefFileName: String = DEFAULT_PREF_FILE, + private val masterKeyUri: String = DEFAULT_MASTER_KEY_URI, +) : AccessTokenStore { + private val appContext: Context = context.applicationContext + private val mutex = Mutex() + + /** Decrypted `origin → token` map; null until the first read. Replaced wholesale, never mutated. */ + private var cache: Map? = null + + // Built on first use (registers AEAD, loads-or-creates the Keystore-wrapped keyset) — slow, so it + // only ever happens inside a withContext(io) block. + private val aead: Aead by lazy { buildAead() } + + override suspend fun tokenFor(endpoint: HostEndpoint): String? = + loadTokens()[endpoint.originHeader] + + /** + * Validate → encrypt → durably persist → publish to the cache, in that order: a token the server + * could never accept returns false without touching disk, and the in-memory view is only updated + * after the write is on disk (so a failed write can never leave a "stored" token that vanishes on + * the next launch). + * + * @throws IOException when the durable write fails (infrastructure failure, distinct from `false`, + * which means the token itself was malformed). + */ + override suspend fun put(endpoint: HostEndpoint, token: String): Boolean { + val normalized = AccessTokenRule.normalize(token) ?: return false + mutex.withLock { + val updated = readThrough() + (endpoint.originHeader to normalized) + persist(updated) + cache = updated + } + return true + } + + override suspend fun remove(endpoint: HostEndpoint) { + mutex.withLock { + val current = readThrough() + if (!current.containsKey(endpoint.originHeader)) return // idempotent, no needless write + val updated = current - endpoint.originHeader + persist(updated) + cache = updated + } + } + + private suspend fun loadTokens(): Map = + cache ?: mutex.withLock { + val loaded = readThrough() + cache = loaded + loaded + } + + /** Decrypt the stored blob (or the cache when warm). MUST be called with [mutex] held. */ + private suspend fun readThrough(): Map = + cache ?: withContext(io) { decodeBlob(prefs().getString(BLOB_KEY, null)) } + + private suspend fun persist(tokens: Map) { + withContext(io) { + val plaintext = JSON.encodeToString(TOKEN_MAP_SERIALIZER, tokens).encodeToByteArray() + val ciphertext = aead.encrypt(plaintext, ASSOCIATED_DATA) + val committed = prefs().edit() + .putString(BLOB_KEY, Base64.encodeToString(ciphertext, Base64.NO_WRAP)) + .commit() // synchronous + reports failure (as TinkCertStore); apply() would hide it + if (!committed) throw IOException("failed to durably persist the access-token blob") + } + } + + /** Any decrypt/decode failure ⇒ "no tokens" (never crash a request path on a corrupt blob). */ + private fun decodeBlob(encoded: String?): Map { + if (encoded == null) return emptyMap() + return runCatching { + val ciphertext = Base64.decode(encoded, Base64.NO_WRAP) + val plaintext = aead.decrypt(ciphertext, ASSOCIATED_DATA) + JSON.decodeFromString(TOKEN_MAP_SERIALIZER, plaintext.decodeToString()) + }.getOrDefault(emptyMap()) + } + + private fun buildAead(): Aead { + AeadConfig.register() + val keysetHandle = AndroidKeysetManager.Builder() + .withSharedPref(appContext, keysetName, prefFileName) + .withKeyTemplate(KeyTemplates.get(AEAD_KEY_TEMPLATE)) + .withMasterKeyUri(masterKeyUri) + .build() + .keysetHandle + return keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead::class.java) + } + + private fun prefs(): SharedPreferences = + appContext.getSharedPreferences(prefFileName, Context.MODE_PRIVATE) + + public companion object { + private const val DEFAULT_KEYSET_NAME = "webterm_access_token_keyset" + private const val DEFAULT_PREF_FILE = "webterm_access_token_prefs" + private const val DEFAULT_MASTER_KEY_URI = "android-keystore://webterm_access_token_master_key" + private const val AEAD_KEY_TEMPLATE = "AES256_GCM" + private const val BLOB_KEY = "access_tokens_blob" + + /** AEAD associated data — binds the ciphertext to this context so a lifted blob won't decrypt. */ + private val ASSOCIATED_DATA: ByteArray = "webterm.host-registry.access-tokens".toByteArray() + + private val JSON = Json { ignoreUnknownKeys = true } + private val TOKEN_MAP_SERIALIZER = MapSerializer(String.serializer(), String.serializer()) + } +} diff --git a/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/InMemoryAccessTokenStoreTest.kt b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/InMemoryAccessTokenStoreTest.kt new file mode 100644 index 0000000..c5e7b16 --- /dev/null +++ b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/InMemoryAccessTokenStoreTest.kt @@ -0,0 +1,118 @@ +package wang.yaojia.webterm.hostregistry + +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.wire.HostEndpoint + +/** + * B5 · the per-host access-token store contract (ios-completion §1.1), exercised through the pure + * in-memory double (the Tink/AndroidKeyStore-backed implementation is instrumented → device QA): + * - a token is keyed by the endpoint's CANONICAL origin, so the same server dialed with a trailing + * slash / different case / a path resolves to the SAME token, and a different host never does; + * - a malformed token is REJECTED at the boundary and never stored; + * - `remove` is idempotent, and the store doubles as the `AccessTokenSource` the transports consume. + */ +class InMemoryAccessTokenStoreTest { + private companion object { + const val TOKEN = "0123456789abcdefTOKEN" + const val OTHER_TOKEN = "fedcba9876543210OTHER" + } + + private fun endpoint(url: String): HostEndpoint = + requireNotNull(HostEndpoint.fromBaseUrl(url)) { "fixture URL must validate: $url" } + + @Test + fun `a stored token is returned for the same host`() = runTest { + val store = InMemoryAccessTokenStore() + val host = endpoint("http://10.0.0.5:3000") + + assertTrue(store.put(host, TOKEN)) + + assertEquals(TOKEN, store.tokenFor(host)) + } + + @Test + fun `an unknown host has no token`() = runTest { + val store = InMemoryAccessTokenStore() + store.put(endpoint("http://10.0.0.5:3000"), TOKEN) + + assertNull(store.tokenFor(endpoint("http://10.0.0.6:3000"))) + } + + @Test + fun `the key is the canonical origin so the same server matches however it was dialed`() = runTest { + val store = InMemoryAccessTokenStore() + store.put(endpoint("http://10.0.0.5:3000"), TOKEN) + + assertEquals(TOKEN, store.tokenFor(endpoint("http://10.0.0.5:3000/"))) + assertEquals(TOKEN, store.tokenFor(endpoint("HTTP://10.0.0.5:3000"))) + } + + @Test + fun `a different port is a different host`() = runTest { + val store = InMemoryAccessTokenStore() + store.put(endpoint("http://10.0.0.5:3000"), TOKEN) + + assertNull(store.tokenFor(endpoint("http://10.0.0.5:3001"))) + } + + @Test + fun `putting again replaces the token for that host only`() = runTest { + val store = InMemoryAccessTokenStore() + val a = endpoint("http://10.0.0.5:3000") + val b = endpoint("http://10.0.0.6:3000") + store.put(a, TOKEN) + store.put(b, OTHER_TOKEN) + + store.put(a, OTHER_TOKEN) + + assertEquals(OTHER_TOKEN, store.tokenFor(a)) + assertEquals(OTHER_TOKEN, store.tokenFor(b)) + } + + @Test + fun `a malformed token is rejected and never stored`() = runTest { + val store = InMemoryAccessTokenStore() + val host = endpoint("http://10.0.0.5:3000") + + assertFalse(store.put(host, "short"), "below the server's 16-char minimum") + assertFalse(store.put(host, "has spaces in it here"), "outside the server charset") + + assertNull(store.tokenFor(host), "a rejected token must not reach storage") + } + + @Test + fun `a whitespace-padded token is trimmed once on the way in`() = runTest { + val store = InMemoryAccessTokenStore() + val host = endpoint("http://10.0.0.5:3000") + + store.put(host, " $TOKEN\n") + + assertEquals(TOKEN, store.tokenFor(host)) + } + + @Test + fun `remove drops the token and is idempotent`() = runTest { + val store = InMemoryAccessTokenStore() + val host = endpoint("http://10.0.0.5:3000") + store.put(host, TOKEN) + + store.remove(host) + store.remove(host) + + assertNull(store.tokenFor(host)) + } + + @Test + fun `the store is usable directly as the transports' AccessTokenSource`() = runTest { + val store: wang.yaojia.webterm.wire.AccessTokenSource = InMemoryAccessTokenStore( + initial = mapOf(endpoint("http://10.0.0.5:3000").originHeader to TOKEN), + ) + + assertEquals(TOKEN, store.tokenFor(endpoint("http://10.0.0.5:3000"))) + } +} diff --git a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEngine.kt b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEngine.kt index 4857a73..1dd60aa 100644 --- a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEngine.kt +++ b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEngine.kt @@ -20,6 +20,7 @@ import wang.yaojia.webterm.wire.TermTransport import wang.yaojia.webterm.wire.TimelineEvent import wang.yaojia.webterm.wire.TransportConnection import wang.yaojia.webterm.wire.Tunables +import wang.yaojia.webterm.wire.UnauthorizedUpgradeException /** * The session lifecycle state machine (A14, plan §5 / §6.6). The Android analogue of the iOS @@ -51,6 +52,9 @@ import wang.yaojia.webterm.wire.Tunables * frame would be dropped rather than emitted onto a newer generation. * - **oversized replay is terminal:** a frame past [maxFrameBytes] is the non-retryable * [FailureReason.REPLAY_TOO_LARGE] — distinct from a retryable disconnect (never fed to backoff). + * - **a 401 upgrade is terminal:** an [UnauthorizedUpgradeException] out of the dial (missing/invalid + * access token, or a foreign Origin) is the non-retryable [FailureReason.UNAUTHORIZED] — it is + * permanent until the user fixes configuration, so it NEVER enters the back-off ladder. */ public class SessionEngine( private val transport: TermTransport, @@ -76,6 +80,19 @@ public class SessionEngine( /** One live connection plus its optional ping capability (null = plain [TermTransport]). */ private class OpenConn(val connection: TransportConnection, val pinger: ConnectionPinger?) + /** + * Outcome of one dial. A dial failure is retryable ([Retryable]) EXCEPT an upgrade 401 + * ([Unauthorized]), which is permanent until the user fixes the access token / Origin allow-list — + * so it must never be fed to the back-off ladder (see [FailureReason.UNAUTHORIZED]). + */ + private sealed interface DialResult { + class Open(val conn: OpenConn) : DialResult + + data object Retryable : DialResult + + data object Unauthorized : DialResult + } + private val eventsChannel = Channel(Channel.UNLIMITED) /** Engine→UI event stream (single consumer; the App's EventBus fans out per R10). */ @@ -157,7 +174,14 @@ public class SessionEngine( } private suspend fun runOneConnection(gen: Int): EndCause { - val open = dialOrNull() ?: return EndCause.Disconnected + val open = when (val dial = dial()) { + DialResult.Retryable -> return EndCause.Disconnected + DialResult.Unauthorized -> { + emit(Connection(ConnectionState.Failed(FailureReason.UNAUTHORIZED))) + return EndCause.Terminal // no back-off: a 401 is permanent until reconfigured + } + is DialResult.Open -> dial.conn + } if (closed) return abandon(open) // close() landed during dial → detach, never publish if (!attachFirst(open)) return EndCause.Disconnected if (closed) return abandon(open) // close() landed during attach → detach, never publish @@ -184,20 +208,37 @@ public class SessionEngine( return EndCause.Terminal } - private suspend fun dialOrNull(): OpenConn? = + private suspend fun dial(): DialResult = try { if (transport is PingableTermTransport) { val pingable = transport.connectPingable(endpoint) - OpenConn(pingable.connection, pingable.pinger) + DialResult.Open(OpenConn(pingable.connection, pingable.pinger)) } else { - OpenConn(transport.connect(endpoint), null) + DialResult.Open(OpenConn(transport.connect(endpoint), null)) } } catch (e: CancellationException) { throw e - } catch (_: Throwable) { - null // connect-time failure → retryable + } catch (error: Throwable) { + // A 401 upgrade is the ONE non-retryable dial failure; everything else backs off. + if (isUnauthorized(error)) DialResult.Unauthorized else DialResult.Retryable } + /** + * True iff [error] (or a bounded, cycle-guarded walk of its causes) is the typed 401-upgrade + * rejection. The cause walk matters because a transport may wrap it in its own `IOException`. + */ + private fun isUnauthorized(error: Throwable): Boolean { + var current: Throwable? = error + var depth = 0 + while (current != null && depth < MAX_CAUSE_DEPTH) { + if (current is UnauthorizedUpgradeException) return true + if (current === current.cause) return false // defensive: never loop on a self-cause + current = current.cause + depth++ + } + return false + } + /** attach-first (+ replay [lastDims]) BEFORE publishing the connection. */ private suspend fun attachFirst(open: OpenConn): Boolean = try { @@ -411,5 +452,8 @@ public class SessionEngine( public companion object { /** Max [AwayDigest.recent] entries carried in the once-per-reconnect digest. */ public const val DEFAULT_DIGEST_LIMIT: Int = 20 + + /** Bound on the `cause` walk in [isUnauthorized] (never trust an error graph not to cycle). */ + private const val MAX_CAUSE_DEPTH: Int = 8 } } diff --git a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEvent.kt b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEvent.kt index ca01027..dc340e9 100644 --- a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEvent.kt +++ b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEvent.kt @@ -68,4 +68,14 @@ public enum class FailureReason { * back-off. UI copy: lower the server's SCROLLBACK_BYTES or raise the client cap. */ REPLAY_TOO_LARGE, + + /** + * The server answered the WS upgrade with **HTTP 401** + * ([wang.yaojia.webterm.wire.UnauthorizedUpgradeException]) — the host's access token + * (`WEBTERM_TOKEN`) is missing/wrong, or our `Origin` is not on its allow-list. Both are + * permanent until the user fixes configuration, so — exactly like [REPLAY_TOO_LARGE] — the + * engine goes terminal and NEVER enters the back-off ladder (a retry storm against a 401 is + * pointless and looks like an attack). UI copy: go fix / re-enter the access token. + */ + UNAUTHORIZED, } diff --git a/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/SessionEngineUnauthorizedTest.kt b/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/SessionEngineUnauthorizedTest.kt new file mode 100644 index 0000000..8d88e77 --- /dev/null +++ b/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/SessionEngineUnauthorizedTest.kt @@ -0,0 +1,110 @@ +package wang.yaojia.webterm.session + +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.testsupport.FakeTermTransport +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.UnauthorizedUpgradeException +import java.io.IOException +import kotlin.time.Duration.Companion.seconds + +/** + * B5 · a **401 on the WS upgrade is TERMINAL** (ios-completion §1.1): the engine emits + * `Failed(UNAUTHORIZED)` and stops — it must NEVER enter the reconnect back-off ladder, because a + * missing/invalid access token (or a foreign Origin) 401s deterministically forever. Same treatment as + * `REPLAY_TOO_LARGE`. + * + * Driven with the frozen [FakeTermTransport] double under `runTest` virtual time (`runCurrent` / + * `advanceTimeBy`, matching `SessionEngineTest`) — no OkHttp, no host, no wall-clock waits. + */ +class SessionEngineUnauthorizedTest { + + private fun endpoint(): HostEndpoint = + requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.5:3000")) + + private fun TestScope.newEngine(transport: FakeTermTransport): SessionEngine = + SessionEngine(transport = transport, endpoint = endpoint(), scope = backgroundScope) + + /** Live-updating sink of everything the engine emits (single consumer, as in SessionEngineTest). */ + private fun TestScope.collectEvents(engine: SessionEngine): List { + val out = mutableListOf() + backgroundScope.launch { engine.events.collect { out += it } } + return out + } + + private fun List.connectionStates(): List = + filterIsInstance().map { it.state } + + @Test + fun `a 401 upgrade ends the engine terminally with UNAUTHORIZED and never dials again`() = runTest { + // Arrange: every dial would be rejected with the typed 401 (the server 401s until a token is stored). + val transport = FakeTermTransport() + repeat(3) { transport.scriptConnectFailure(UnauthorizedUpgradeException()) } + val engine = newEngine(transport) + val events = collectEvents(engine) + + // Act + engine.start() + testScheduler.runCurrent() + testScheduler.advanceTimeBy(60.seconds) // a back-off ladder, if entered, would have fired by now + testScheduler.runCurrent() + + // Assert: exactly ONE dial, a terminal Failed(UNAUTHORIZED), and no Reconnecting event at all. + assertEquals(1, transport.connectAttempts.size, "a 401 must not be retried") + assertEquals( + listOf(ConnectionState.Connecting, ConnectionState.Failed(FailureReason.UNAUTHORIZED)), + events.connectionStates(), + "a terminal 401 must never enter the back-off ladder", + ) + } + + @Test + fun `an ordinary transport failure still reconnects with back-off`() = runTest { + // Regression lock: only the typed 401 is terminal — a plain IOException stays retryable. + val transport = FakeTermTransport() + transport.scriptConnectFailure(IOException("connection refused")) + val engine = newEngine(transport) + val events = collectEvents(engine) + + engine.start() + testScheduler.runCurrent() + testScheduler.advanceTimeBy(1.seconds) // first rung of the ladder + testScheduler.runCurrent() + + assertEquals(2, transport.connectAttempts.size, "a retryable failure must redial") + assertTrue( + events.connectionStates().any { it is ConnectionState.Reconnecting }, + "a retryable failure must announce the back-off", + ) + assertTrue( + events.connectionStates().none { it is ConnectionState.Failed }, + "a retryable failure must not be reported as a terminal failure", + ) + engine.close() + testScheduler.runCurrent() + } + + @Test + fun `a 401 wrapped as a cause of the transport error is still terminal`() = runTest { + // OkHttp hands back its own IOException; the transport wraps the 401 so the CAUSE carries it. + val transport = FakeTermTransport() + transport.scriptConnectFailure(IOException("upgrade failed", UnauthorizedUpgradeException())) + val engine = newEngine(transport) + val events = collectEvents(engine) + + engine.start() + testScheduler.runCurrent() + testScheduler.advanceTimeBy(60.seconds) + testScheduler.runCurrent() + + assertEquals(1, transport.connectAttempts.size) + assertEquals( + FailureReason.UNAUTHORIZED, + events.connectionStates().filterIsInstance().single().reason, + ) + } +} diff --git a/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt index ca64aae..a275f35 100644 --- a/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt +++ b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt @@ -1,6 +1,7 @@ package wang.yaojia.webterm.transport import okhttp3.OkHttpClient +import wang.yaojia.webterm.wire.AccessTokenSource import javax.net.ssl.SSLSocketFactory import javax.net.ssl.X509TrustManager @@ -84,12 +85,19 @@ public class OkHttpTransports private constructor( public val client: OkHttpClient, ) { public companion object { + /** + * @param identityProvider optional mTLS material (see [OkHttpClientFactory.create]). + * @param tokens the access-token seam for the WS upgrade's `Cookie` header (ios-completion §1.1). + * REST requests get their cookie from `:api-client`'s single stamping point instead, so this + * only wires the WS half. + */ public fun create( identityProvider: ClientIdentityProvider = ClientIdentityProvider.NONE, + tokens: AccessTokenSource = AccessTokenSource.NONE, ): OkHttpTransports { val client = OkHttpClientFactory.create(identityProvider) return OkHttpTransports( - term = OkHttpTermTransport(client), + term = OkHttpTermTransport(client, tokens), http = OkHttpHttpTransport(client), client = client, ) diff --git a/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpTermTransport.kt b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpTermTransport.kt index 2c6a537..4b2650f 100644 --- a/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpTermTransport.kt +++ b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpTermTransport.kt @@ -2,6 +2,8 @@ package wang.yaojia.webterm.transport import okhttp3.OkHttpClient import okhttp3.Request +import wang.yaojia.webterm.wire.AccessTokenSource +import wang.yaojia.webterm.wire.AuthCookie import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wire.PingableConnection import wang.yaojia.webterm.wire.PingableTermTransport @@ -12,6 +14,9 @@ import java.util.concurrent.TimeUnit /** The `Origin` header name — THE CSWSH defence; stamped byte-equal from [HostEndpoint.originHeader]. */ internal const val HEADER_ORIGIN: String = "Origin" +/** HTTP status the server answers an upgrade with when it rejects Origin OR the access-token cookie. */ +internal const val HTTP_UNAUTHORIZED: Int = 401 + /** * The only concrete WS transport (A7): implements [PingableTermTransport] (and thus `TermTransport`) * over OkHttp. `SessionEngine` (A14) cannot tell it apart from the `FakeTermTransport` double. @@ -29,12 +34,25 @@ internal const val HEADER_ORIGIN: String = "Origin" * is cancellation-safe: if the caller's coroutine is cancelled (or the handshake fails) while it is * in flight, the just-created WebSocket is torn down before rethrowing, so no socket is leaked. * + * ### Access token (ios-completion §1.1) + * When [tokens] has a token for the host, the upgrade ALSO carries a hand-written + * `Cookie: webterm_auth=` — no OkHttp `CookieJar`, no `Set-Cookie` parsing (a jar's behaviour on a + * WS upgrade is stack-specific and hard to test; the hand-written header is the same pattern as + * `Origin` and is asserted byte-equal by a MockWebServer test). The two headers are orthogonal: the + * server checks Origin first, then the cookie. No token ⇒ no header at all. + * + * An upgrade answered **401** (rejected Origin OR rejected/missing token — the server writes the same + * bare 401 for both) is surfaced as the typed `UnauthorizedUpgradeException`, which `SessionEngine` + * treats as TERMINAL. The original error is kept as its `cause` so `PairingError.classify`'s cause-walk + * is unaffected. + * * Inbound frames flow up UNMODIFIED — there is deliberately NO transport-level frame-size cap here. * `SessionEngine` (A14) self-measures each inbound frame's UTF-8 size and is the single authoritative * classifier of an oversized ring-buffer replay (the non-retryable `REPLAY_TOO_LARGE` failure). */ public class OkHttpTermTransport( sharedClient: OkHttpClient, + private val tokens: AccessTokenSource = AccessTokenSource.NONE, ) : PingableTermTransport { private val wsClient: OkHttpClient = sharedClient.newBuilder() @@ -51,10 +69,14 @@ public class OkHttpTermTransport( } private suspend fun openConnection(endpoint: HostEndpoint): OkHttpWebSocketConnection { - val request = Request.Builder() + val builder = Request.Builder() .url(endpoint.wsUrl) // OkHttp maps ws(s):// → http(s):// internally .header(HEADER_ORIGIN, endpoint.originHeader) - .build() + // Additive and orthogonal to Origin; absent entirely when the host has no token. + tokens.tokenFor(endpoint)?.let { token -> + builder.header(AuthCookie.HEADER_NAME, AuthCookie.headerValue(token)) + } + val request = builder.build() val connection = OkHttpWebSocketConnection(wsClient, request) try { connection.awaitOpen() diff --git a/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpWebSocketConnection.kt b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpWebSocketConnection.kt index fcb0a5f..cf356f1 100644 --- a/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpWebSocketConnection.kt +++ b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpWebSocketConnection.kt @@ -15,6 +15,7 @@ import okhttp3.WebSocketListener import okio.ByteString import wang.yaojia.webterm.wire.ConnectionPinger import wang.yaojia.webterm.wire.TransportConnection +import wang.yaojia.webterm.wire.UnauthorizedUpgradeException import java.io.IOException /** RFC 6455 normal-closure status code, used for a client-initiated detach. */ @@ -145,9 +146,15 @@ internal class OkHttpWebSocketConnection( } override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { - finish(t) - // If we never opened, unblock connect() with the verbatim cause; no-op once opened. - opened.completeExceptionally(t) + // A 401 handshake answer is the server's ONE upgrade rejection (foreign Origin OR a + // missing/invalid access-token cookie) and is permanent until reconfigured, so it is TYPED + // for SessionEngine to end terminally on. `t` is kept as the cause, so the verbatim + // transport error is still available to PairingError.classify's cause-walk. Every other + // failure propagates untouched (frozen "rethrow verbatim" contract). + val error = if (response?.code == HTTP_UNAUTHORIZED) UnauthorizedUpgradeException(t) else t + finish(error) + // If we never opened, unblock connect() with the cause; no-op once opened. + opened.completeExceptionally(error) } } } diff --git a/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/OkHttpAccessTokenTest.kt b/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/OkHttpAccessTokenTest.kt new file mode 100644 index 0000000..a670fc4 --- /dev/null +++ b/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/OkHttpAccessTokenTest.kt @@ -0,0 +1,145 @@ +package wang.yaojia.webterm.transport + +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import okhttp3.OkHttpClient +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.wire.AccessTokenSource +import wang.yaojia.webterm.wire.AuthCookie +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.UnauthorizedUpgradeException +import java.io.IOException + +/** + * B5 · the access token on the **WS upgrade** (FROZEN contract, ios-completion §1.1), over + * MockWebServer: + * - the upgrade request carries the hand-written `Cookie: webterm_auth=` ALONGSIDE `Origin` + * (the two are orthogonal — the server checks Origin first, then the cookie); + * - no token ⇒ no `Cookie` header at all (an unauthenticated LAN host is unaffected); + * - an upgrade answered with **401** throws the typed [UnauthorizedUpgradeException] so + * `SessionEngine` can go terminal instead of back-off-looping against a permanent rejection; + * - any OTHER upgrade failure still surfaces verbatim (untyped) — only 401 is special. + */ +class OkHttpAccessTokenTest { + private companion object { + const val TIMEOUT_MS = 5_000L + const val TOKEN = "0123456789abcdefTOKEN" + } + + private lateinit var server: MockWebServer + private val client: OkHttpClient = OkHttpClientFactory.create() + + @BeforeEach + fun setUp() { + server = MockWebServer() + server.start() + } + + @AfterEach + fun tearDown() { + client.dispatcher.cancelAll() + client.connectionPool.evictAll() + runCatching { server.shutdown() } + client.dispatcher.executorService.shutdown() + } + + private fun endpoint(): HostEndpoint = + requireNotNull(HostEndpoint.fromBaseUrl("http://${server.hostName}:${server.port}")) + + private fun transport(tokens: AccessTokenSource): OkHttpTermTransport = + OkHttpTermTransport(client, tokens) + + @Test + fun stampsTheAuthCookieAlongsideOriginOnTheWsUpgrade() = runBlocking { + // Arrange + server.enqueue(MockResponse().withWebSocketUpgrade(SilentServerWebSocket())) + val endpoint = endpoint() + + // Act + val connection = withTimeout(TIMEOUT_MS) { transport(AccessTokenSource { TOKEN }).connect(endpoint) } + + // Assert + val upgrade = server.takeRequest() + assertEquals("${AuthCookie.NAME}=$TOKEN", upgrade.getHeader(AuthCookie.HEADER_NAME)) + assertEquals(endpoint.originHeader, upgrade.getHeader("Origin"), "Origin must be untouched") + connection.close() + } + + @Test + fun sendsNoCookieHeaderWhenTheHostHasNoToken() = runBlocking { + server.enqueue(MockResponse().withWebSocketUpgrade(SilentServerWebSocket())) + + val connection = withTimeout(TIMEOUT_MS) { transport(AccessTokenSource.NONE).connect(endpoint()) } + + assertNull(server.takeRequest().getHeader(AuthCookie.HEADER_NAME)) + connection.close() + } + + @Test + fun theTokenNeverAppearsInTheUpgradeUrl() = runBlocking { + server.enqueue(MockResponse().withWebSocketUpgrade(SilentServerWebSocket())) + + val connection = withTimeout(TIMEOUT_MS) { transport(AccessTokenSource { TOKEN }).connect(endpoint()) } + + assertFalse(server.takeRequest().path.orEmpty().contains(TOKEN), "a secret must never be in a URL") + connection.close() + } + + @Test + fun aPingableDialAlsoCarriesTheAuthCookie() = runBlocking { + server.enqueue(MockResponse().withWebSocketUpgrade(SilentServerWebSocket())) + + val pingable = withTimeout(TIMEOUT_MS) { + transport(AccessTokenSource { TOKEN }).connectPingable(endpoint()) + } + + assertEquals("${AuthCookie.NAME}=$TOKEN", server.takeRequest().getHeader(AuthCookie.HEADER_NAME)) + pingable.connection.close() + } + + @Test + fun a401UpgradeThrowsTheTypedUnauthorizedError() = runBlocking { + // Arrange: the server's bare `HTTP/1.1 401 Unauthorized` upgrade rejection. + server.enqueue(MockResponse().setResponseCode(401)) + + // Act + val thrown = runCatching { + withTimeout(TIMEOUT_MS) { transport(AccessTokenSource.NONE).connect(endpoint()) } + }.exceptionOrNull() + + // Assert: typed, and the verbatim transport cause is retained for PairingError.classify. + assertTrue( + thrown is UnauthorizedUpgradeException, + "a 401 upgrade must be typed so the engine can end terminally, got $thrown", + ) + assertTrue((thrown as UnauthorizedUpgradeException).cause is Throwable) + } + + @Test + fun aNon401UpgradeFailureStillSurfacesVerbatim() = runBlocking { + server.enqueue(MockResponse().setResponseCode(500)) + + val thrown = runCatching { + withTimeout(TIMEOUT_MS) { transport(AccessTokenSource.NONE).connect(endpoint()) } + }.exceptionOrNull() + + assertFalse(thrown is UnauthorizedUpgradeException, "only 401 is the auth rejection") + assertTrue(thrown is IOException || thrown is IllegalStateException, "verbatim transport error, got $thrown") + } +} + +/** Server side that accepts the upgrade and says nothing. */ +private class SilentServerWebSocket : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) = Unit +} diff --git a/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/AccessToken.kt b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/AccessToken.kt new file mode 100644 index 0000000..5f0db31 --- /dev/null +++ b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/AccessToken.kt @@ -0,0 +1,92 @@ +package wang.yaojia.webterm.wire + +/** + * The optional shared access token (`WEBTERM_TOKEN`) contract — the Android half of the FROZEN + * cross-client contract (ios-completion §1.1; server: `src/http/auth.ts`, `src/server.ts`). + * + * ### Why a hand-written header (and NOT an OkHttp `CookieJar`) + * A native client already KNOWS its token, so it stamps `Cookie: webterm_auth=` itself and never + * parses `Set-Cookie` / relies on a cookie store. Reason (frozen): a cookie jar's behaviour on a **WS + * upgrade** differs between HTTP stacks and is hard to test, whereas a hand-written header is the same + * pattern as the existing `Origin` header ([HostEndpoint.originHeader]) and can be pinned by pure unit + * tests. [AuthCookie] is therefore the SINGLE point of derivation — assembling the header string + * anywhere else is a review CRITICAL, exactly like hand-assembling an Origin. + * + * ### Orthogonal to Origin + * The token does NOT replace the Origin/CSWSH check: the server runs Origin first, then the cookie + * (`src/server.ts` upgrade handler), so a request carries BOTH (Origin only on guarded routes, per the + * Origin-iff-guarded rule; the cookie on EVERY request plus the WS upgrade). + * + * ### Secret handling (non-negotiable, §1.1) + * The token is secret material: it lives only in Android-Keystore-backed storage, is NEVER logged, + * NEVER placed in a URL query, and NEVER put into a crash report. Nothing in this file logs. + */ +public object AuthCookie { + /** The server's auth cookie name (`src/http/auth.ts` `AUTH_COOKIE_NAME`). */ + public const val NAME: String = "webterm_auth" + + /** The request header the value is stamped on. */ + public const val HEADER_NAME: String = "Cookie" + + /** + * `webterm_auth=` — the exact header value the server's `parseCookieHeader` expects. No + * attributes, no URL-encoding: the token charset ([AccessTokenRule]) is already cookie-safe, and + * the server reads the value verbatim. + */ + public fun headerValue(token: String): String = "$NAME=$token" +} + +/** + * Client-side access-token validator (validate at the boundary). Mirrors the server's config check: + * `[A-Za-z0-9._~+/=-]`, 16–512 chars — a server that fails this refuses to start, so a token outside + * it can never be correct and must be rejected before any network I/O (and before it reaches storage). + * + * The charset also guarantees the token cannot forge cookie syntax (no `;`, `,`, `=`-prefix, space or + * quote injection into the `Cookie` header). + * + * [normalize] trims ONCE here (a pasted/QR-sourced token often carries stray whitespace) and returns + * the trimmed token, or `null` when it is not a possible server token. + */ +public object AccessTokenRule { + /** Server-side minimum (CLAUDE.md / config validation). */ + public const val MIN_LENGTH: Int = 16 + + /** Server-side maximum. */ + public const val MAX_LENGTH: Int = 512 + + private val ALLOWED: Set = + (('A'..'Z') + ('a'..'z') + ('0'..'9') + listOf('.', '_', '~', '+', '/', '=', '-')).toSet() + + /** The trimmed token when it could be a valid server token, else `null`. Never logs its input. */ + public fun normalize(raw: String): String? { + val trimmed = raw.trim() + if (trimmed.length < MIN_LENGTH || trimmed.length > MAX_LENGTH) return null + if (!trimmed.all { it in ALLOWED }) return null + return trimmed + } +} + +/** + * The access-token injection seam: "what token, if any, should be presented to THIS host?". + * + * Defined here (top of the dependency graph) so all three consumers share ONE seam without new module + * edges: `:api-client` (every REST route + the pairing probe), `:transport-okhttp` (the WS upgrade), + * and `:app` (the Keystore-backed store that implements it). It is the token analogue of + * `:transport-okhttp`'s `ClientIdentityProvider` mTLS seam. + * + * `suspend` because the production implementation reads encrypted at-rest storage (Tink + + * AndroidKeyStore) and must be able to hop off the caller's thread; it is consulted per request so a + * token added, changed or removed later takes effect with no client rebuild. + * + * Returning `null` means "this host has no token" — the request then carries no `Cookie` header at + * all, which is exactly the zero-config LAN behaviour of a server with `WEBTERM_TOKEN` unset. + */ +public fun interface AccessTokenSource { + /** The access token for [endpoint], or `null` when none is stored. MUST NOT log the token. */ + public suspend fun tokenFor(endpoint: HostEndpoint): String? + + public companion object { + /** No token for any host — the default everywhere, so an unauthenticated host is unaffected. */ + public val NONE: AccessTokenSource = AccessTokenSource { null } + } +} diff --git a/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/UnauthorizedUpgradeException.kt b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/UnauthorizedUpgradeException.kt new file mode 100644 index 0000000..adb1ae8 --- /dev/null +++ b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/UnauthorizedUpgradeException.kt @@ -0,0 +1,31 @@ +package wang.yaojia.webterm.wire + +import java.io.IOException + +/** + * The server answered the WebSocket upgrade with **HTTP 401** instead of 101 — a NON-retryable + * rejection (ios-completion §1.1). + * + * Declared in `:wire-protocol` so the three modules that must agree on it have no new dependency + * edges: `:transport-okhttp` throws it out of `TermTransport.connect`, `:session-core`'s + * `SessionEngine` maps it to a TERMINAL failure (never into the reconnect back-off ladder — retrying + * would 401 forever), and `:api-client`'s pairing probe can classify it. + * + * The server writes a bare `HTTP/1.1 401 Unauthorized` for BOTH of its two upgrade rejections — a + * foreign `Origin` and a missing/invalid access-token cookie (`src/server.ts` upgrade handler) — so + * this type deliberately does NOT claim which one it was. Both are permanent-until-reconfigured, so + * the engine's treatment (terminal, no back-off) is correct either way; the pairing probe disambiguates + * by ordering (it authenticates over HTTP first, so a 401 there is the token and a 401 on the later + * upgrade is the Origin). + * + * [cause] carries the transport's verbatim error so existing cause-chain classification + * (`PairingError.classify`) still sees the original type. + */ +public class UnauthorizedUpgradeException( + cause: Throwable? = null, +) : IOException(MESSAGE, cause) { + private companion object { + /** Fixed copy — never interpolate the token or any header value into an exception message. */ + const val MESSAGE = "the server rejected the WebSocket upgrade with HTTP 401" + } +} diff --git a/android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/AccessTokenTest.kt b/android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/AccessTokenTest.kt new file mode 100644 index 0000000..23749a6 --- /dev/null +++ b/android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/AccessTokenTest.kt @@ -0,0 +1,109 @@ +package wang.yaojia.webterm.wire + +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +/** + * B5 · the FROZEN access-token contract (ios-completion §1.1) as pure functions: + * - [AuthCookie] is the SINGLE point of `Cookie: webterm_auth=` derivation (the token analogue of + * [HostEndpoint.originHeader] — never hand-assembled at a call site); + * - [AccessTokenRule] mirrors the server's config validator (`[A-Za-z0-9._~+/=-]`, 16–512) so a + * malformed token is rejected BEFORE any network I/O; + * - [AccessTokenSource.NONE] is the "no token configured" default (LAN zero-config unchanged). + */ +class AccessTokenTest { + + // ── AuthCookie: the one derivation point ──────────────────────────────────────────────────── + + @Test + fun `cookie name and header name match the server contract`() { + assertEquals("webterm_auth", AuthCookie.NAME) + assertEquals("Cookie", AuthCookie.HEADER_NAME) + } + + @Test + fun `header value is name equals token with no extra whitespace or attributes`() { + assertEquals("webterm_auth=abcdefghijklmnop", AuthCookie.headerValue("abcdefghijklmnop")) + } + + // ── AccessTokenRule: charset + length, trimmed once ───────────────────────────────────────── + + @Test + fun `a valid token in the server charset normalizes to itself`() { + val token = "Aa0._~+/=-Aa0._~+/=-" + assertEquals(token, AccessTokenRule.normalize(token)) + } + + @Test + fun `surrounding whitespace is trimmed once at the boundary`() { + assertEquals("0123456789abcdef", AccessTokenRule.normalize(" 0123456789abcdef\n")) + } + + @Test + fun `a token shorter than the minimum or longer than the maximum is rejected`() { + assertEquals(16, AccessTokenRule.MIN_LENGTH) + assertEquals(512, AccessTokenRule.MAX_LENGTH) + assertNull(AccessTokenRule.normalize("a".repeat(AccessTokenRule.MIN_LENGTH - 1))) + assertNull(AccessTokenRule.normalize("a".repeat(AccessTokenRule.MAX_LENGTH + 1))) + assertNotNull(AccessTokenRule.normalize("a".repeat(AccessTokenRule.MIN_LENGTH))) + assertNotNull(AccessTokenRule.normalize("a".repeat(AccessTokenRule.MAX_LENGTH))) + } + + @Test + fun `an empty or blank token is rejected`() { + assertNull(AccessTokenRule.normalize("")) + assertNull(AccessTokenRule.normalize(" ")) + } + + @Test + fun `characters outside the server charset are rejected`() { + // A `;` would forge a second cookie attribute; a space/quote/CJK char is simply not in the set. + listOf( + "abcdefghijklmno;x", + "abcdefghijklmno x", + "abcdefghijklmno\"x", + "abcdefghijklmno\nx", + "令牌令牌令牌令牌令牌令牌令牌令牌", + ).forEach { raw -> + assertNull(AccessTokenRule.normalize(raw), "must reject: $raw") + } + } + + // ── AccessTokenSource ─────────────────────────────────────────────────────────────────────── + + @Test + fun `NONE yields no token so an unauthenticated LAN host keeps working`() = runTest { + val endpoint = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.5:3000")) + assertNull(AccessTokenSource.NONE.tokenFor(endpoint)) + } + + @Test + fun `a source can be a lambda keyed by the endpoint`() = runTest { + val lan = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.5:3000")) + val other = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.6:3000")) + val source = AccessTokenSource { endpoint -> + if (endpoint == lan) "0123456789abcdef" else null + } + + assertEquals("0123456789abcdef", source.tokenFor(lan)) + assertNull(source.tokenFor(other)) + } + + // ── UnauthorizedUpgradeException ──────────────────────────────────────────────────────────── + + @Test + fun `the 401 upgrade error is an IOException keeping the verbatim cause`() { + val cause = IllegalStateException("expected 101, got 401") + val error: java.io.IOException = UnauthorizedUpgradeException(cause) + + assertEquals(cause, error.cause, "the verbatim transport cause must survive for classify()") + assertEquals( + "the server rejected the WebSocket upgrade with HTTP 401", + error.message, + "fixed copy — no header/token value may ever be interpolated into the message", + ) + } +}