Merge ios-completion: device builds unblocked, access token on both clients, P2 wave
Closes the six remediation items from the 2026-07-29 iOS completion audit plus the whole P2 wave and Android access-token parity. The audit's headline was that the client was code-complete but stuck at the device door: no DEVELOPMENT_TEAM, no entitlements, so it had never run on real hardware once, and it had fallen two months behind the server (Android had the git panel, iOS had none) while neither native client could connect at all once WEBTERM_TOKEN was set. Package tests 310 -> 452, app bundle 296 -> 550 (iPhone and iPad, zero known issues), integration 10 -> 32, Android 687 -> 691. ClientTLS went 55.76% -> 89.49% and is now actually in the coverage gate, which it never was. Device build now succeeds on the free personal team. src/ and public/ are untouched — the git-panel endpoints already existed server-side; iOS simply never consumed them. # Conflicts: # android/.gitignore # android/README.md # android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt # android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt # android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt # android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt # android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt # docs/PROGRESS_LOG.md
This commit is contained in:
@@ -181,6 +181,30 @@ The `:macrobenchmark` module exists and assembles (`com.android.test`, instrumen
|
||||
expiry summary is expected to throw `NoClassDefFoundError` on-device TODAY.
|
||||
- [ ] 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. (The App Link half needs the release-signed APK and
|
||||
|
||||
@@ -46,8 +46,16 @@ Setup: `local.properties` → `sdk.dir=/usr/local/share/android-commandlinetools
|
||||
| HostRegistry | `:host-registry` | Android (DataStore) | ✅ built |
|
||||
| SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ✅ built |
|
||||
| App/WebTerm | `:app` | Android app (Compose/Hilt/FCM)| ✅ built |
|
||||
| `URLSession*Transport` | `:transport-okhttp` | pure Kotlin/JVM (OkHttp) | ✅ built |
|
||||
| — (no iOS counterpart) | `:macrobenchmark` | `com.android.test` harness | ⬜ scaffolded, no sources |
|
||||
|
||||
> `:transport-okhttp` (A7) holds the OkHttp `TermTransport`/`HttpTransport`
|
||||
> implementations that the two iOS `URLSession*Transport`s consolidate into
|
||||
> (plan §3 framing note). OkHttp is a plain JVM library, so the module builds and
|
||||
> unit-tests (MockWebServer) with **no** Android SDK — including the
|
||||
> access-token cookie on the WS upgrade (`OkHttpAccessTokenTest`) and the
|
||||
> public-host cleartext refusal (`CleartextGuardTest`).
|
||||
|
||||
`:macrobenchmark` (A35) is the one module with no iOS counterpart. It is a
|
||||
`com.android.test` module — a **separate APK** that drives `:app` out of process via
|
||||
UiAutomator, which is the only way startup/frame timings are real. It therefore cannot
|
||||
@@ -76,8 +84,45 @@ written yet.
|
||||
`:wire-protocol` is the **frozen shared contract** (Android analogue of
|
||||
`src/types.ts` + WireProtocol) — `ClientMessage`/`ServerMessage`, `MessageCodec`,
|
||||
`Validation`, `WireConstants`, `HostEndpoint` (the single Origin/wsURL derivation),
|
||||
and the `TermTransport` / `HttpTransport` / `PingableTermTransport` boundary
|
||||
interfaces. New wire types are added **only** here (a coordination point).
|
||||
`AuthCookie`/`AccessTokenRule`/`AccessTokenSource` (the single access-token-cookie
|
||||
derivation), and the `TermTransport` / `HttpTransport` / `PingableTermTransport`
|
||||
boundary interfaces. New wire types are added **only** here (a coordination point).
|
||||
|
||||
## Access token (`WEBTERM_TOKEN`) — how the Android client authenticates
|
||||
|
||||
A server started with `WEBTERM_TOKEN=<16–512 chars of [A-Za-z0-9._~+/=-]>` gates **every**
|
||||
HTTP route *and* the WebSocket upgrade behind a `webterm_auth` cookie. The Android
|
||||
client therefore:
|
||||
|
||||
- **hand-writes `Cookie: webterm_auth=<t>` itself** for a token it already holds — 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.
|
||||
- **also installs one `AuthCookieJar`** on the single shared `OkHttpClient`, which captures
|
||||
the `Set-Cookie` a live pairing (`POST /auth`) hands back and replays it on REST *and* on
|
||||
the WS upgrade (OkHttp's `BridgeInterceptor` runs for WebSocket calls). The two are
|
||||
orthogonal: the hand-written header covers a token restored from the Keystore store, the
|
||||
jar covers a cookie the server just issued. A host with neither sends no cookie at all.
|
||||
- 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
|
||||
|
||||
@@ -111,8 +156,9 @@ JUnit Platform (`tasks.test { useJUnitPlatform() }`).
|
||||
```
|
||||
|
||||
> Testing target: **≥80% Kover coverage** on the pure modules (`:wire-protocol`,
|
||||
> `:session-core`, `:api-client`, `:client-tls` pure half). TDD, immutable data,
|
||||
> small focused files — same discipline as the rest of the repo.
|
||||
> `:session-core`, `:api-client`, `:client-tls` pure half — the four modules that
|
||||
> apply the Kover plugin, and therefore the only ones `koverVerify` gates). TDD,
|
||||
> immutable data, small focused files — same discipline as the rest of the repo.
|
||||
>
|
||||
> **Do not use `--rerun-tasks` in a release gate.** It trips an AGP lint/K2 internal bug
|
||||
> (`FirDeclaration was not found for class KtProperty, fir is null`, on
|
||||
@@ -199,8 +245,7 @@ Before adding a rule, read the merged configuration R8 actually used:
|
||||
`hilt-android-testing` with `kspAndroidTest`) and `testInstrumentationRunner` is set to
|
||||
`wang.yaojia.webterm.HiltTestRunner`.
|
||||
|
||||
⚠️ **That runner class does not exist yet** — it is the first file the A34 author must write,
|
||||
into `app/src/androidTest/java/wang/yaojia/webterm/HiltTestRunner.kt`:
|
||||
`app/src/androidTest/java/wang/yaojia/webterm/HiltTestRunner.kt` supplies it:
|
||||
|
||||
```kotlin
|
||||
class HiltTestRunner : AndroidJUnitRunner() {
|
||||
@@ -215,6 +260,24 @@ replace the app-under-test's `Application` with the generated `HiltTestApplicati
|
||||
*test* APK, not into the app under test. `assembleDebugAndroidTest` builds fine without the
|
||||
class (the runner name is just a manifest value); an actual instrumentation *run* needs it.
|
||||
|
||||
The suite has been **run green on real hardware** (67/0 instrumented tests against this
|
||||
repo's own server), so `src/androidTest/**` is verified, not aspirational — but it still
|
||||
needs a connected device/emulator and is therefore **not** part of the `./gradlew test`
|
||||
gate; run it with `connectedAndroidTest`. An emulator additionally needs
|
||||
`ALLOWED_ORIGINS=http://10.0.2.2:<port>` on the server, because the server derives its
|
||||
allowed origins from NIC IPs and `10.0.2.2` (the emulator's alias for the host) is not one.
|
||||
|
||||
### What is still NOT verified here
|
||||
|
||||
- **DEFERRED — end-to-end access token**: the `WEBTERM_TOKEN` path is covered by
|
||||
unit tests (`OkHttpAccessTokenTest`, `AuthProbeTest`, `AccessTokenCookieTest`, the
|
||||
`:api-client` route tests, the `AuthCookie` derivation tests) and by
|
||||
`TinkAccessTokenStoreTest` on device, but **no automated leg on the Android side starts
|
||||
a real server with `WEBTERM_TOKEN` set and completes a cookie-gated WS upgrade** — that
|
||||
end-to-end coverage exists only on the iOS side (`ios/IntegrationTests`).
|
||||
- **DEFERRED — the rest of `DEVICE_QA_CHECKLIST.md`**: FCM push delivery under Doze on two
|
||||
physical handsets (S2) and the lock-screen Allow/Deny walkthrough have no automated cover.
|
||||
|
||||
## Android SDK setup (proven working)
|
||||
|
||||
The pure JVM modules need only a JDK + Gradle. The **Android-framework** modules
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<String, String>,
|
||||
): 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<String, String>,
|
||||
): 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=<t>` 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<String, String> =
|
||||
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
|
||||
|
||||
@@ -24,6 +24,7 @@ import wang.yaojia.webterm.api.models.UiPrefs
|
||||
import wang.yaojia.webterm.api.models.WorktreeState
|
||||
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
|
||||
@@ -41,10 +42,18 @@ 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=<t>` 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** becomes the typed
|
||||
* [ApiClientError.Unauthorized] so the UI can send the user to re-enter the token — except on the
|
||||
* routes that define their own 401 ([UnauthorizedPolicy.ROUTE_DEFINED], the git-write family).
|
||||
*/
|
||||
public class ApiClient(
|
||||
public val endpoint: HostEndpoint,
|
||||
private val http: HttpTransport,
|
||||
private val tokens: AccessTokenSource = AccessTokenSource.NONE,
|
||||
) {
|
||||
// ── RO (read-only — NO Origin header) ──────────────────────────────────────────────────
|
||||
|
||||
@@ -343,9 +352,27 @@ 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 401 on a read route would degrade into a generic status error and
|
||||
* the UI would never learn to ask for the token (ios-completion §1.1).
|
||||
*
|
||||
* The one exception is declared AT the route ([UnauthorizedPolicy.ROUTE_DEFINED], the git-write
|
||||
* family): there a 401 is the server classifying a HOST-side git credential failure
|
||||
* (`src/http/git-ops.ts:108`), so it must flow on to [gitWrite]'s mapping and reach the user as the
|
||||
* server's own message. Swallowing it would tell the user to rotate an access token that is fine.
|
||||
*/
|
||||
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 &&
|
||||
route.unauthorizedPolicy == UnauthorizedPolicy.ACCESS_TOKEN_GATE
|
||||
) {
|
||||
throw ApiClientError.Unauthorized
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
/** 200 → ok; 404 → `SessionNotFound`; anything else → `UnexpectedStatus`. */
|
||||
|
||||
@@ -20,6 +20,15 @@ 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 a route whose 401 can only be the access-token gate (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. The git-write family is excluded (it defines its own 401 — see
|
||||
* [UnauthorizedPolicy]), so this copy can never be shown for a host-side git credential failure.
|
||||
*/
|
||||
public data object Unauthorized : ApiClientError("此主机需要访问令牌,或令牌已失效。请重新输入访问令牌。")
|
||||
|
||||
/** 403 from a G route's Origin guard (CSWSH defence). */
|
||||
public data object Forbidden : ApiClientError("服务器拒绝了此来源(Origin 校验未通过)。")
|
||||
|
||||
|
||||
@@ -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 CONFLICT = 409
|
||||
@@ -27,6 +29,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 {
|
||||
@@ -47,6 +55,23 @@ internal enum class OriginPolicy {
|
||||
GUARDED,
|
||||
}
|
||||
|
||||
/**
|
||||
* How a **401** on this route must be READ (ios-completion §1.1) — declared at the route so the
|
||||
* exception is visible instead of hidden in a call site. The Android analogue of iOS
|
||||
* `UnauthorizedPolicy` (APIClient/Endpoints.swift).
|
||||
*/
|
||||
internal enum class UnauthorizedPolicy {
|
||||
/** Default — on this route a 401 can only be the access-token gate (`src/server.ts:465`). */
|
||||
ACCESS_TOKEN_GATE,
|
||||
|
||||
/**
|
||||
* The route owns its 401 and it must NOT be read as "your access token is wrong": the git-write
|
||||
* family, where the server classifies a HOST-side git credential failure as
|
||||
* `{status:401, error:"Push authentication required on the host."}` (`src/http/git-ops.ts:108`).
|
||||
*/
|
||||
ROUTE_DEFINED,
|
||||
}
|
||||
|
||||
/**
|
||||
* One buildable API route — an immutable snapshot; building never mutates. The Android analogue of
|
||||
* iOS `APIRoute`. [percentEncodedQuery] is pre-encoded ONCE by the route builder (never at call
|
||||
@@ -58,19 +83,37 @@ 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,
|
||||
/** See [UnauthorizedPolicy]. Defaults to the gate reading. */
|
||||
val unauthorizedPolicy: UnauthorizedPolicy = UnauthorizedPolicy.ACCESS_TOKEN_GATE,
|
||||
) {
|
||||
/**
|
||||
* 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=<t>` 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<String, String>()
|
||||
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
|
||||
}
|
||||
|
||||
@@ -134,7 +134,40 @@ 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":"<t>"}`.
|
||||
*
|
||||
* 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) ────────────────────────────────────────
|
||||
//
|
||||
// Guarded writes, but NOT [UnauthorizedPolicy.ROUTE_DEFINED] (F5): these land in
|
||||
// `src/http/worktrees.ts`, which emits no 401 at all (403 kill-switch / 400 / 404 / 500), and
|
||||
// `src/server.ts:1182-1260` adds none. The only 401 they can ever see is the access-token gate, so
|
||||
// they take the DEFAULT gate reading — pinning them route-defined made a gated host's 401 surface
|
||||
// as a worktree failure instead of the token flow.
|
||||
|
||||
/** `POST /projects/worktree` — `{ path, branch[, base] }`. `base` omitted when null. */
|
||||
fun createWorktree(path: String, branch: String, base: String?): ApiRoute =
|
||||
@@ -162,11 +195,11 @@ internal object Endpoints {
|
||||
|
||||
/** `POST /projects/git/stage` — `{ path, files, stage }`. */
|
||||
fun gitStage(path: String, files: List<String>, stage: Boolean): ApiRoute =
|
||||
jsonBodyRoute(HttpMethod.POST, "/projects/git/stage", StageBody.serializer(), StageBody(path, files, stage))
|
||||
gitWriteRoute(HttpMethod.POST, "/projects/git/stage", StageBody.serializer(), StageBody(path, files, stage))
|
||||
|
||||
/** `POST /projects/git/commit` — `{ path, message }`. */
|
||||
fun gitCommit(path: String, message: String): ApiRoute =
|
||||
jsonBodyRoute(HttpMethod.POST, "/projects/git/commit", CommitBody.serializer(), CommitBody(path, message))
|
||||
gitWriteRoute(HttpMethod.POST, "/projects/git/commit", CommitBody.serializer(), CommitBody(path, message))
|
||||
|
||||
/**
|
||||
* `POST /projects/git/fetch` — `{ path }` (w6/G2). GUARDED even though it only updates
|
||||
@@ -176,11 +209,11 @@ internal object Endpoints {
|
||||
* It is NOT a pull: no working tree, no index, no merge.
|
||||
*/
|
||||
fun gitFetch(path: String): ApiRoute =
|
||||
jsonBodyRoute(HttpMethod.POST, "/projects/git/fetch", FetchBody.serializer(), FetchBody(path))
|
||||
gitWriteRoute(HttpMethod.POST, "/projects/git/fetch", FetchBody.serializer(), FetchBody(path))
|
||||
|
||||
/** `POST /projects/git/push` — `{ path }`. */
|
||||
fun gitPush(path: String): ApiRoute =
|
||||
jsonBodyRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path))
|
||||
gitWriteRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path))
|
||||
|
||||
// ── G: W2 prompt queue (enqueue / cancel-all) ──────────────────────────────────────────
|
||||
|
||||
@@ -212,17 +245,48 @@ internal object Endpoints {
|
||||
|
||||
private fun queuePath(id: UUID): String = "/live-sessions/${pathId(id)}/queue"
|
||||
|
||||
/** Build a GUARDED route with a `ModelJson`-encoded JSON body (Origin stamped in [ApiRoute]). */
|
||||
/**
|
||||
* Build a GUARDED route with a `ModelJson`-encoded JSON body (Origin stamped in [ApiRoute]).
|
||||
*
|
||||
* The 401 reading defaults to [UnauthorizedPolicy.ACCESS_TOKEN_GATE] — correct for every route
|
||||
* whose handler never writes a 401 of its own (the W2 queue: 400/404/429/503 only; the worktree
|
||||
* trio: 403/400/404/500 only). Only the git-ops writes opt out, via [gitWriteRoute].
|
||||
*/
|
||||
private fun <T> jsonBodyRoute(
|
||||
method: HttpMethod,
|
||||
path: String,
|
||||
serializer: kotlinx.serialization.KSerializer<T>,
|
||||
value: T,
|
||||
unauthorizedPolicy: UnauthorizedPolicy = UnauthorizedPolicy.ACCESS_TOKEN_GATE,
|
||||
): ApiRoute {
|
||||
val body = ModelJson.encodeToString(serializer, value).encodeToByteArray()
|
||||
return ApiRoute(method, path, OriginPolicy.GUARDED, body = body)
|
||||
return ApiRoute(
|
||||
method,
|
||||
path,
|
||||
OriginPolicy.GUARDED,
|
||||
body = body,
|
||||
unauthorizedPolicy = unauthorizedPolicy,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a GUARDED **git-ops** route with a `ModelJson`-encoded JSON body (Origin stamped in
|
||||
* [ApiRoute]).
|
||||
*
|
||||
* Every route built here is [UnauthorizedPolicy.ROUTE_DEFINED]: the server classifies a HOST-side
|
||||
* git credential failure as **401** with its own message (`src/http/git-ops.ts:108`), which the UI
|
||||
* must display verbatim instead of the access-token copy (mirrors iOS `gitOpsRoute`).
|
||||
*
|
||||
* Scope is exactly the four routes that reach that classifier — `stage`, `commit`, `push`, `fetch`.
|
||||
* The worktree trio must NOT be built here (F5): its handler has no 401 of its own.
|
||||
*/
|
||||
private fun <T> gitWriteRoute(
|
||||
method: HttpMethod,
|
||||
path: String,
|
||||
serializer: kotlinx.serialization.KSerializer<T>,
|
||||
value: T,
|
||||
): ApiRoute = jsonBodyRoute(method, path, serializer, value, UnauthorizedPolicy.ROUTE_DEFINED)
|
||||
|
||||
/** Mirror of `src/http/git-log.ts` `GIT_LOG_MAX` — the server-side `?n=` clamp ceiling. */
|
||||
private const val GIT_LOG_MAX = 50
|
||||
|
||||
@@ -259,6 +323,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)
|
||||
|
||||
|
||||
@@ -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":"<t>"}` 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<String, String> = 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",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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=<t>`;
|
||||
* - 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
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.GitWriteOutcome
|
||||
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=<t>` 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 a route whose 401 can only be the gate is the typed [ApiClientError.Unauthorized],
|
||||
* never a generic status error, so the UI can route the user to re-enter the token — while the
|
||||
* git-write family, which defines its own 401 (`src/http/git-ops.ts:108`), keeps the server's
|
||||
* message (E1 fix; see the rewritten push case below).
|
||||
*/
|
||||
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",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* E1 · REWRITTEN (this case previously asserted the opposite and pinned a real defect).
|
||||
*
|
||||
* `POST /projects/git/push` defines its OWN 401: `src/http/git-ops.ts:108` classifies a HOST-side
|
||||
* git credential failure ("could not read Username", "permission denied (publickey)", …) as
|
||||
* `{status:401, error:"Push authentication required on the host."}`. Swallowing that into
|
||||
* [ApiClientError.Unauthorized] tells the user their *access token* is broken and sends them to
|
||||
* rotate a secret that is fine, while hiding the real cause. iOS gets this right via
|
||||
* `unauthorizedPolicy: .routeDefined` (APIClient/GitWrite.swift), and plan §4 item 49 requires the
|
||||
* distinction — so the route's own message must reach [GitWriteOutcome.Rejected] verbatim.
|
||||
*/
|
||||
@Test
|
||||
fun `a 401 on git push is the host's own git-credential failure, surfaced verbatim`() = runTest {
|
||||
transport.queueSuccess(
|
||||
method = HttpMethod.POST,
|
||||
url = "$BASE/projects/git/push",
|
||||
status = 401,
|
||||
body = """{"ok":false,"error":"Push authentication required on the host."}""".toByteArray(),
|
||||
)
|
||||
|
||||
val outcome = clientWithToken().gitPush("/repo")
|
||||
|
||||
assertEquals(GitWriteOutcome.Rejected(401, "Push authentication required on the host."), outcome)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the route-defined 401 covers the whole git-ops family, not just push`() = runTest {
|
||||
val body = """{"ok":false,"error":"Push authentication required on the host."}""".toByteArray()
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/stage", status = 401, body = body)
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/commit", status = 401, body = body)
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/fetch", status = 401, body = body)
|
||||
val client = clientWithToken()
|
||||
|
||||
assertEquals(401, (client.gitStage("/repo", listOf("a.txt"), true) as GitWriteOutcome.Rejected).status)
|
||||
assertEquals(401, (client.gitCommit("/repo", "msg") as GitWriteOutcome.Rejected).status)
|
||||
assertEquals(401, (client.gitFetch("/repo") as GitWriteOutcome.Rejected).status)
|
||||
}
|
||||
|
||||
/**
|
||||
* F5 · the worktree trio is NOT part of that family. `src/http/worktrees.ts` emits no 401 (403
|
||||
* kill-switch / 400 / 404 / 500 only) and `src/server.ts:1182-1260` adds none, so the ONLY 401 those
|
||||
* routes can ever see is the access-token gate. Reading it as a git rejection stranded the user on a
|
||||
* "worktree failed" message instead of the token flow.
|
||||
*/
|
||||
@Test
|
||||
fun `a 401 on a worktree write is the access-token gate, not a git rejection`() = runTest {
|
||||
val gateBody = """{"error":"authentication required"}""".toByteArray()
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree", status = 401, body = gateBody)
|
||||
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/projects/worktree", status = 401, body = gateBody)
|
||||
transport.queueSuccess(
|
||||
method = HttpMethod.POST,
|
||||
url = "$BASE/projects/worktree/prune",
|
||||
status = 401,
|
||||
body = gateBody,
|
||||
)
|
||||
val client = clientWithToken()
|
||||
|
||||
assertEquals(ApiClientError.Unauthorized, errorOf { client.createWorktree("/repo", "feat/x") })
|
||||
assertEquals(ApiClientError.Unauthorized, errorOf { client.removeWorktree("/repo", "/repo/wt", false) })
|
||||
assertEquals(ApiClientError.Unauthorized, errorOf { client.pruneWorktrees("/repo") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a git write with no server message still reports the status, never the token copy`() = runTest {
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/push", status = 401)
|
||||
|
||||
val outcome = clientWithoutToken().gitPush("/repo")
|
||||
|
||||
assertEquals(GitWriteOutcome.Rejected(401, null), outcome)
|
||||
}
|
||||
}
|
||||
@@ -158,4 +158,50 @@ class GitRouteShapeTest {
|
||||
client.gitPush("/r")
|
||||
assertTrue(transport.recordedRequests.drop(2).all { it.headers.containsKey(HeaderName.ORIGIN) })
|
||||
}
|
||||
|
||||
/**
|
||||
* E1 (narrowed, F5) · The 401 split declared AT the route (mirrors iOS `UnauthorizedPolicy`).
|
||||
*
|
||||
* **Route-defined 401 = exactly the four `/projects/git/…` writes, and only because of the ONE
|
||||
* server line that can produce it:** `src/http/git-ops.ts:108` turns a HOST-side git credential
|
||||
* failure into `{status:401, error:"Push authentication required on the host."}`. That classifier is
|
||||
* reached only from `stageFiles`/`commit`/`push`/`fetch`.
|
||||
*
|
||||
* **The worktree trio is NOT route-defined.** `createWorktree`/`removeWorktree`/`pruneWorktrees`
|
||||
* land in `src/http/worktrees.ts`, which emits no 401 at all (403 kill-switch / 400 / 404 / 500),
|
||||
* and `src/server.ts:1182-1260` adds none. Pinning them ROUTE_DEFINED meant that on a token-gated
|
||||
* host the GATE's 401 surfaced as a git rejection instead of the token flow.
|
||||
*
|
||||
* Every other 401 the server can emit belongs to the gate: `src/server.ts:425` (`/auth` invalid),
|
||||
* `:472` (the gate itself) and `:1453`/`:1463` (the WS upgrade) — none of which is a route here.
|
||||
*/
|
||||
@Test
|
||||
fun `route-defined 401 is exactly the four git-ops writes, everything else is the gate`() {
|
||||
val gitOpsWrites = listOf(
|
||||
Endpoints.gitStage("/r", listOf("f"), true),
|
||||
Endpoints.gitCommit("/r", "m"),
|
||||
Endpoints.gitPush("/r"),
|
||||
Endpoints.gitFetch("/r"),
|
||||
)
|
||||
val gateRoutes = listOf(
|
||||
// The worktree trio: guarded writes, but the server has no 401 of its own for them.
|
||||
Endpoints.createWorktree("/r", "b", null),
|
||||
Endpoints.removeWorktree("/r", "/r/x", false),
|
||||
Endpoints.pruneWorktrees("/r"),
|
||||
Endpoints.liveSessions(),
|
||||
Endpoints.projects(),
|
||||
Endpoints.projectLog("/r", null),
|
||||
Endpoints.killSession(UUID.randomUUID()),
|
||||
Endpoints.putPrefs(wang.yaojia.webterm.api.models.UiPrefs.create()),
|
||||
)
|
||||
|
||||
assertTrue(gitOpsWrites.all { it.unauthorizedPolicy == UnauthorizedPolicy.ROUTE_DEFINED })
|
||||
gateRoutes.forEach {
|
||||
assertEquals(
|
||||
UnauthorizedPolicy.ACCESS_TOKEN_GATE,
|
||||
it.unauthorizedPolicy,
|
||||
"${it.method} ${it.path} has no server-side 401 of its own — a 401 there IS the token gate",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,17 +23,20 @@
|
||||
never in backups/cloud"). Auto Backup defaults to ON and would sweep up EVERY app-private
|
||||
file: the `webterm` Preferences DataStore (which holds the sealed `webterm_auth` cookie — a
|
||||
full-shell credential the server keeps alive for 30 days) and the Tink keyset/ciphertext pref
|
||||
files. `dataExtractionRules`/`fullBackupContent` were rejected because they are allow-by-
|
||||
default: every store added later is backed up until someone remembers to exclude it, and a
|
||||
silent miss here is an off-device credential leak. Nothing in this app has restore value that
|
||||
justifies that risk — re-pairing a host is a QR scan, and the hosts are LAN-local anyway.
|
||||
NOTE: this attribute is Auto Backup (cloud). Android 12+ DEVICE-TO-DEVICE transfer is governed
|
||||
by a `dataExtractionRules` <device-transfer> block, which needs an @xml resource (tracked
|
||||
separately — see the orchestrator note). Do not set this back to true. -->
|
||||
files. Nothing in this app has restore value that justifies that risk — re-pairing a host is
|
||||
a QR scan, and the hosts are LAN-local anyway.
|
||||
`allowBackup` alone only covers Auto Backup (cloud) / adb backup. Android 12+ DEVICE-TO-DEVICE
|
||||
transfer is governed by a `dataExtractionRules` <device-transfer> block, which needs an @xml
|
||||
resource — @xml/data_extraction_rules now supplies it, and it is deny-everything in BOTH
|
||||
sections rather than an exclusion list, so a store added later is excluded by default instead
|
||||
of leaking until someone remembers it. `fullBackupContent="false"` states the same for the
|
||||
pre-31 path. Do not set any of these back to true. -->
|
||||
<application
|
||||
android:name=".WebTermApp"
|
||||
android:allowBackup="false"
|
||||
android:label="@string/app_name"
|
||||
android:fullBackupContent="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.WebTerm"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
|
||||
@@ -79,11 +79,16 @@ public sealed interface BannerModel {
|
||||
|
||||
/**
|
||||
* A NON-retryable terminal failure (e.g. [FailureReason.REPLAY_TOO_LARGE]). Actionable copy, **no
|
||||
* spinner** (reconnecting would hit the same wall forever), and a "新会话" affordance.
|
||||
* spinner** (reconnecting would hit the same wall forever), and — where a fresh session would
|
||||
* actually help — a "新会话" affordance.
|
||||
*
|
||||
* [FailureReason.UNAUTHORIZED] deliberately offers NO new-session action (B5): the server 401'd the
|
||||
* upgrade, so a new session would 401 too; the fix is to re-enter the host's access token (or fix its
|
||||
* `ALLOWED_ORIGINS`), which the copy says.
|
||||
*/
|
||||
public data class Failed(val reason: FailureReason) : BannerModel {
|
||||
override val showsSpinner: Boolean get() = false
|
||||
override val offersNewSession: Boolean get() = true
|
||||
override val offersNewSession: Boolean get() = reason != FailureReason.UNAUTHORIZED
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,6 +189,8 @@ private fun bannerCopy(model: BannerModel): String = when (model) {
|
||||
is BannerModel.Failed -> when (model.reason) {
|
||||
FailureReason.REPLAY_TOO_LARGE ->
|
||||
"回放数据过大,无法自动重连。请降低服务器 SCROLLBACK_BYTES 或提高客户端上限后开新会话。"
|
||||
FailureReason.UNAUTHORIZED ->
|
||||
"服务器拒绝了连接(401)。请在“配对主机”里重新填写访问令牌;若已填写,请检查主机的 ALLOWED_ORIGINS。"
|
||||
}
|
||||
is BannerModel.Exited ->
|
||||
if (model.isSpawnFailure) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -13,6 +13,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
|
||||
@@ -81,10 +82,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=<t>` — 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
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -41,6 +41,7 @@ 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
|
||||
@@ -70,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).
|
||||
@@ -113,7 +120,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)
|
||||
@@ -247,10 +254,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).
|
||||
@@ -268,6 +281,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 })
|
||||
@@ -278,13 +299,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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
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
|
||||
import wang.yaojia.webterm.wire.HttpTransport
|
||||
import wang.yaojia.webterm.wire.TermTransport
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
@@ -40,24 +50,56 @@ import java.util.UUID
|
||||
*
|
||||
* On probe success the validated host is persisted via [HostStore.upsert].
|
||||
*
|
||||
* ### The `WEBTERM_TOKEN` gate (server `src/http/auth.ts`)
|
||||
* A token-gated host answers the probe's unauthenticated `GET /live-sessions` with **401**, which the
|
||||
* frozen probe taxonomy can only report as [PairingError.HttpOkButNotWebTerminal] ("端口对吗?") — so
|
||||
* without this flow such a host is impossible to pair AND the copy is misleading. On that ambiguous
|
||||
* failure the ViewModel asks [PairingProber.requiresAccessToken]; a gated host routes to
|
||||
* [PairingUiState.TokenRequired], and [submitAccessToken] posts the token (`POST /auth`) so the shared
|
||||
* cookie jar picks up the `webterm_auth` cookie, then resumes pairing through the same choke point.
|
||||
* The token is a SHELL credential: it is a parameter, never a field and never part of any UI state.
|
||||
* ### The `WEBTERM_TOKEN` gate has TWO ways in, and both end in the same place
|
||||
* A token-gated host can be met either way round, so both are supported:
|
||||
* - **Typed up front.** The confirm step has an optional token field; [confirm]/[retry] carry it into
|
||||
* RULE 5 below (validate with `POST /auth`, store, then probe).
|
||||
* - **Discovered by the probe.** A host the user did not know was gated answers the probe's
|
||||
* unauthenticated `GET /live-sessions` with **401**. When the probe classifies that as
|
||||
* [PairingError.AccessTokenRequired] the user goes back to the confirm step with
|
||||
* [TokenError.REQUIRED] under the field. An older/ambiguous host instead collapses the 401 into
|
||||
* [PairingError.HttpOkButNotWebTerminal] ("端口对吗?"), which is actively misleading — so that ONE
|
||||
* case is re-checked against [PairingProber.requiresAccessToken] and, if the host really is gated,
|
||||
* routed to the dedicated [PairingUiState.TokenRequired] prompt. [submitAccessToken] posts the token
|
||||
* (`POST /auth`), which lands the `webterm_auth` cookie in the shared cookie jar, keeps the token in
|
||||
* [tokenStore] so the WS upgrade's own `Cookie` header can carry it too, then resumes pairing through
|
||||
* the same choke point.
|
||||
* Either way the token is a SHELL credential: it is a parameter, never a field and never part of any UI
|
||||
* state.
|
||||
*
|
||||
* ### 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.
|
||||
*
|
||||
* ### RULE 6 — a pairing that does not end in [PairingUiState.Paired] strands no secret (F2)
|
||||
* Storing before probing means the store can be left holding a token for a host that never paired (wrong
|
||||
* port, unreachable, user backs out). So every non-paired exit — probe failure, a throw, and cancellation
|
||||
* by [reset] or by a superseding attempt — rolls the store back to exactly what it held before this
|
||||
* attempt ([TokenAttempt]): the previous token for a re-pair, nothing at all for a first pairing. The
|
||||
* rollback runs under [NonCancellable] so an abandoned attempt still cleans up, and the next attempt
|
||||
* `join`s the one it supersedes so the two never race on the same key.
|
||||
*
|
||||
* 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>(PairingUiState.Entry())
|
||||
@@ -118,30 +160,34 @@ 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the host's shared access token (`WEBTERM_TOKEN`). Only meaningful from
|
||||
* [PairingUiState.TokenRequired] — anywhere else it is a no-op, so a stale UI event can never post a
|
||||
* credential to a host the user has moved on from.
|
||||
* Submit the host's shared access token (`WEBTERM_TOKEN`) from the token prompt. Only meaningful
|
||||
* from [PairingUiState.TokenRequired] — anywhere else it is a no-op, so a stale UI event can never
|
||||
* post a credential to a host the user has moved on from.
|
||||
*
|
||||
* [token] is passed straight through to the prober and is NEVER stored: no field, no UI state, no
|
||||
* log. On acceptance the shared cookie jar holds `webterm_auth` and pairing resumes through the same
|
||||
* choke point as [confirm]/[retry] (so RULE 4's cert-gate still applies); every other outcome
|
||||
* re-arms the prompt with a distinguishable reason and NEVER auto-retries (429 included).
|
||||
* This is the DISCOVERED half of the gate; the typed-up-front half is [confirm]'s `accessToken`
|
||||
* (RULE 5). [token] goes straight to the prober's `POST /auth` and is never held in a field or in
|
||||
* UI state. On acceptance the shared cookie jar holds `webterm_auth` AND the token is kept in
|
||||
* [tokenStore] — so the WS upgrade's own `Cookie` header authenticates exactly as it would for a
|
||||
* typed token — and pairing resumes through the same [performProbe] choke point as [confirm]/[retry]
|
||||
* (so RULE 4's cert-gate still applies, under RULE 6's rollback). Every other outcome re-arms the
|
||||
* prompt with a distinguishable reason and NEVER auto-retries (429 included).
|
||||
*/
|
||||
public fun submitAccessToken(token: String) {
|
||||
val state = _uiState.value
|
||||
@@ -149,14 +195,22 @@ public class PairingViewModel(
|
||||
if (token.isBlank()) return
|
||||
val candidate = Candidate(state.endpoint, state.name, state.tier)
|
||||
val scope = scope ?: return
|
||||
probeJob?.cancel()
|
||||
val superseded = probeJob
|
||||
superseded?.cancel()
|
||||
probeJob = scope.launch {
|
||||
superseded?.join() // same reason as runProbe: never race a superseded attempt's rollback.
|
||||
_uiState.value = PairingUiState.TokenSubmitting(candidate.endpoint, candidate.name, candidate.tier)
|
||||
when (prober.submitAccessToken(candidate.endpoint, token)) {
|
||||
// Straight into the probe body: RULE 3 is already satisfied by construction (reaching the
|
||||
// prompt required a probe, which for a public host required the explicit ack — and an ack
|
||||
// cannot be un-given), while RULE 4's cert-gate lives INSIDE performProbe and re-applies.
|
||||
TokenSubmission.Accepted -> performProbe(candidate)
|
||||
// This submit WAS the `POST /auth` validation, so RULE 5 only has to KEEP the token.
|
||||
TokenSubmission.Accepted -> performProbe(candidate) {
|
||||
writeToken(candidate, token) { error -> promptForToken(candidate, error); null }
|
||||
}
|
||||
// 204 with no Set-Cookie: the host has no auth at all (FROZEN §1.1). Store NOTHING and
|
||||
// never read it as "authenticated" — same rule as `establishToken`'s AuthDisabled.
|
||||
TokenSubmission.AuthDisabled -> performProbe(candidate) { TokenAttempt.NONE }
|
||||
TokenSubmission.Rejected -> promptForToken(candidate, TokenError.INVALID)
|
||||
TokenSubmission.RateLimited -> promptForToken(candidate, TokenError.RATE_LIMITED)
|
||||
is TokenSubmission.Unreachable -> promptForToken(candidate, TokenError.UNREACHABLE)
|
||||
@@ -165,7 +219,7 @@ public class PairingViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun runProbe(candidate: Candidate, acknowledgedPublicRisk: Boolean) {
|
||||
private fun runProbe(candidate: Candidate, acknowledgedPublicRisk: Boolean, accessToken: String) {
|
||||
// RULE 3 — public host: no probe until the risk is explicitly acknowledged. This is the
|
||||
// pre-network UI gate, so it lives here (the entry point the user drives), not in performProbe.
|
||||
if (candidate.tier.needsExplicitAck && !acknowledgedPublicRisk) {
|
||||
@@ -178,17 +232,31 @@ public class PairingViewModel(
|
||||
return
|
||||
}
|
||||
val scope = scope ?: return
|
||||
probeJob?.cancel()
|
||||
probeJob = scope.launch { performProbe(candidate) }
|
||||
val superseded = probeJob
|
||||
superseded?.cancel()
|
||||
probeJob = scope.launch {
|
||||
// Wait out the attempt this one replaces: its RULE 6 rollback must finish before we read or
|
||||
// write the same store key, or the two attempts race and the loser's undo wins.
|
||||
superseded?.join()
|
||||
// RULE 5 — validate + store the token BEFORE probing (the probe authenticates from the store).
|
||||
performProbe(candidate) {
|
||||
if (accessToken.isBlank()) TokenAttempt.NONE else establishToken(candidate, accessToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The ONE probe body — reached from [confirm]/[retry] via [runProbe] and from an accepted access
|
||||
* token. Holds RULE 4 (the cert-gate) so BOTH entry points hit it; RULE 3 (the public-risk ack) is a
|
||||
* token ([submitAccessToken]). Holds RULE 4 (the cert-gate) so BOTH entry points hit it BEFORE any
|
||||
* network I/O, then RULE 5's [tokenStep] and RULE 6's rollback; RULE 3 (the public-risk ack) is a
|
||||
* pre-network UI gate and stays in [runProbe]. Runs inside the caller's job (never re-assigns
|
||||
* [probeJob], which would cancel the coroutine it is called from).
|
||||
*
|
||||
* @param tokenStep the RULE 5 step for this entry point — validate+store a typed token, or merely
|
||||
* store one the prompt already validated. Returns the [TokenAttempt] to undo, or **null** after
|
||||
* publishing why pairing stops (meaning "do not probe").
|
||||
*/
|
||||
private suspend fun performProbe(candidate: Candidate) {
|
||||
private suspend fun performProbe(candidate: Candidate, tokenStep: suspend () -> TokenAttempt?) {
|
||||
val tier = candidate.tier
|
||||
// RULE 4 — tunnel cert-gate: refuse BEFORE any network I/O; retry and the post-token resume both
|
||||
// re-hit this same guard.
|
||||
@@ -197,18 +265,122 @@ public class PairingViewModel(
|
||||
return
|
||||
}
|
||||
_uiState.value = PairingUiState.Probing(candidate.endpoint, candidate.name, tier)
|
||||
when (val result = prober.probe(candidate.endpoint)) {
|
||||
is PairingProbeResult.Success -> onProbeSuccess(candidate)
|
||||
is PairingProbeResult.Failure -> onProbeFailure(candidate, result.error)
|
||||
val attempt = tokenStep() ?: return
|
||||
// RULE 6 — anything short of Paired puts the store back the way this attempt found it.
|
||||
val paired = try {
|
||||
when (val result = prober.probe(candidate.endpoint)) {
|
||||
is PairingProbeResult.Success -> onProbeSuccess(candidate)
|
||||
is PairingProbeResult.Failure -> {
|
||||
onProbeFailure(candidate, result.error)
|
||||
false
|
||||
}
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
// Cancelled (reset / a newer attempt) or a transport throw — the rule is the same, and
|
||||
// NonCancellable is what lets an already-cancelled attempt still clean up after itself.
|
||||
withContext(NonCancellable) { rollBackToken(candidate, attempt) }
|
||||
throw error
|
||||
}
|
||||
if (!paired) rollBackToken(candidate, attempt)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate [token] with `POST /auth` and, if the server accepted it, persist it for this host.
|
||||
* Returns the [TokenAttempt] to undo should pairing not complete (RULE 6) — [TokenAttempt.NONE] when
|
||||
* the host turned out to have no auth at all — or **null** after publishing the matching
|
||||
* [TokenError] / failure state, meaning "stop, do not probe".
|
||||
*/
|
||||
private suspend fun establishToken(candidate: Candidate, token: String): TokenAttempt? {
|
||||
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 null
|
||||
}
|
||||
return when (outcome) {
|
||||
AuthProbeResult.Accepted -> writeToken(candidate, token) { failToken(candidate, it) }
|
||||
// 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 -> TokenAttempt.NONE
|
||||
AuthProbeResult.InvalidToken -> failToken(candidate, TokenError.INVALID)
|
||||
AuthProbeResult.RateLimited -> failToken(candidate, TokenError.RATE_LIMITED)
|
||||
AuthProbeResult.Malformed -> failToken(candidate, TokenError.MALFORMED)
|
||||
is AuthProbeResult.Unexpected -> failToken(candidate, TokenError.UNVERIFIABLE)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A failed probe. [PairingError.HttpOkButNotWebTerminal] is ambiguous — it is ALSO what a
|
||||
* `WEBTERM_TOKEN`-gated host's 401 collapses into — so that one case is re-checked against the token
|
||||
* gate before the misleading "wrong port?" copy is shown. Every other error is reported verbatim.
|
||||
* Persist [token], remembering what the store held before so RULE 6 can put it back. [onFailure]
|
||||
* publishes the reason in whichever step the caller is showing (the confirm field, or the token
|
||||
* prompt) and returns null, so a token we could not keep never reaches the probe.
|
||||
*/
|
||||
private suspend fun writeToken(
|
||||
candidate: Candidate,
|
||||
token: String,
|
||||
onFailure: (TokenError) -> TokenAttempt?,
|
||||
): TokenAttempt? {
|
||||
return try {
|
||||
val previous = tokenStore.tokenFor(candidate.endpoint)
|
||||
// put() == false ⇒ the token failed the client-side rule (unreachable after a server ACCEPT).
|
||||
if (!tokenStore.put(candidate.endpoint, token)) onFailure(TokenError.MALFORMED)
|
||||
else TokenAttempt(stored = true, previous = previous)
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (_: Throwable) {
|
||||
// A throw ⇒ encrypted storage failed; never continue with a token we could not keep.
|
||||
onFailure(TokenError.STORE_FAILED)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo [attempt]'s write: restore the previous token (a failed re-pair must not cost the user the
|
||||
* one that was working) or drop the key entirely when this host had none. Never publishes state —
|
||||
* the caller has already published the reason pairing stopped.
|
||||
*/
|
||||
private suspend fun rollBackToken(candidate: Candidate, attempt: TokenAttempt) {
|
||||
if (!attempt.stored) return
|
||||
try {
|
||||
val previous = attempt.previous
|
||||
if (previous == null) tokenStore.remove(candidate.endpoint)
|
||||
else tokenStore.put(candidate.endpoint, previous)
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (_: Throwable) {
|
||||
// Best effort: encrypted storage just failed on the undo. There is no recovery beyond this,
|
||||
// and surfacing it would replace the pairing error the user actually needs to read.
|
||||
}
|
||||
}
|
||||
|
||||
/** Publish a token-specific error, keeping the user ON the confirm step (the field is there). */
|
||||
private fun failToken(candidate: Candidate, error: TokenError): TokenAttempt? {
|
||||
_uiState.value = PairingUiState.Confirming(
|
||||
endpoint = candidate.endpoint,
|
||||
name = candidate.name,
|
||||
tier = candidate.tier,
|
||||
tokenError = error,
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* A failed probe, with TWO token-gate readings:
|
||||
* - [PairingError.AccessTokenRequired] — the probe saw the gate's own 401, so the user goes back to
|
||||
* the confirm step with the token field and [TokenError.REQUIRED];
|
||||
* - [PairingError.HttpOkButNotWebTerminal] is ambiguous — it is ALSO what a `WEBTERM_TOKEN`-gated
|
||||
* host's 401 collapses into on a host the taxonomy cannot classify — so that one case is
|
||||
* re-checked against [PairingProber.requiresAccessToken] and, when the host really is gated,
|
||||
* routed to the dedicated prompt instead of the misleading "wrong port?" copy.
|
||||
* Every other error is reported verbatim.
|
||||
*/
|
||||
private suspend 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
|
||||
}
|
||||
if (error == PairingError.HttpOkButNotWebTerminal && prober.requiresAccessToken(candidate.endpoint)) {
|
||||
promptForToken(candidate, error = null)
|
||||
return
|
||||
@@ -222,6 +394,7 @@ public class PairingViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
/** Publish the dedicated token prompt (the discovered-gate step), optionally with a reason. */
|
||||
private fun promptForToken(candidate: Candidate, error: TokenError?) {
|
||||
_uiState.value = PairingUiState.TokenRequired(
|
||||
endpoint = candidate.endpoint,
|
||||
@@ -231,7 +404,8 @@ public class PairingViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun onProbeSuccess(candidate: Candidate) {
|
||||
/** Persist the paired host. Returns false when it could not be paired (so RULE 6 undoes the token). */
|
||||
private suspend fun onProbeSuccess(candidate: Candidate): Boolean {
|
||||
val host = Host.create(id = newId(), name = candidate.name.ifBlank { defaultName(candidate.endpoint) }, baseUrl = candidate.endpoint.baseUrl)
|
||||
if (host == null) {
|
||||
// Unreachable in practice (the endpoint already validated), but never persist a null host.
|
||||
@@ -242,10 +416,11 @@ public class PairingViewModel(
|
||||
error = PairingError.HttpOkButNotWebTerminal,
|
||||
message = PairingCopy.describe(PairingError.HttpOkButNotWebTerminal, candidate.tier),
|
||||
)
|
||||
return
|
||||
return false
|
||||
}
|
||||
hostStore.upsert(host)
|
||||
_uiState.value = PairingUiState.Paired(host)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun defaultName(endpoint: HostEndpoint): String =
|
||||
@@ -258,11 +433,13 @@ public class PairingViewModel(
|
||||
* `AppEnvironment.buildPairingProber()` over the ONE shared `OkHttpClient` (so the token cookie the
|
||||
* submit obtains is the same jar every later request and the WS upgrade read from).
|
||||
*
|
||||
* The two token operations are DEFAULTED so a probe-only double still satisfies the interface. Their
|
||||
* defaults are deliberately the honest "this seam cannot reach the token gate" answers — `false` and
|
||||
* [TokenSubmission.Unavailable] — never a silent success.
|
||||
* The two token operations are DEFAULTED so a probe-only double still satisfies the interface — which
|
||||
* also leaves exactly one abstract member, so this stays a `fun interface` and a test/production
|
||||
* binding can be written as `PairingProber { endpoint -> … }`. Their defaults are deliberately the
|
||||
* honest "this seam cannot reach the token gate" answers — `false` and [TokenSubmission.Unavailable] —
|
||||
* never a silent success.
|
||||
*/
|
||||
public interface PairingProber {
|
||||
public fun interface PairingProber {
|
||||
public suspend fun probe(endpoint: HostEndpoint): PairingProbeResult
|
||||
|
||||
/**
|
||||
@@ -295,6 +472,15 @@ public sealed interface TokenSubmission {
|
||||
/** 2xx (the server answers `204` to a non-HTML request) — `Set-Cookie: webterm_auth=…` was returned. */
|
||||
public data object Accepted : TokenSubmission
|
||||
|
||||
/**
|
||||
* **2xx with NO `Set-Cookie: webterm_auth=…`** — this host has `WEBTERM_TOKEN` unset, so there is
|
||||
* nothing to authenticate (FROZEN §1.1; the same discriminator as [AuthProbeResult.AuthDisabled] on
|
||||
* the typed-token path). The submitted token MUST NOT be persisted and this MUST NOT be read as
|
||||
* "authenticated": the host is simply open, and pairing continues exactly as for a zero-config LAN
|
||||
* deploy.
|
||||
*/
|
||||
public data object AuthDisabled : TokenSubmission
|
||||
|
||||
/** `401` — the token does not match the host's `WEBTERM_TOKEN`. */
|
||||
public data object Rejected : TokenSubmission
|
||||
|
||||
@@ -312,21 +498,35 @@ public sealed interface TokenSubmission {
|
||||
public data object Unavailable : TokenSubmission
|
||||
}
|
||||
|
||||
/** Why the access-token prompt is showing an error. Maps to inert, app-authored copy in [PairingCopy]. */
|
||||
public enum class TokenError {
|
||||
/** The submitted token was rejected by the host (`401`). */
|
||||
INVALID,
|
||||
|
||||
/** The host is rate-limiting auth attempts (`429`) — wait, do not hammer it. */
|
||||
RATE_LIMITED,
|
||||
|
||||
/** The host did not answer the submit (or answered unintelligibly). */
|
||||
UNREACHABLE,
|
||||
|
||||
/** The app cannot submit a token at all on this seam. */
|
||||
UNAVAILABLE,
|
||||
/**
|
||||
* 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
|
||||
* layer resolves both transports off the DI graph and hands the resulting prober to the ViewModel —
|
||||
* `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,
|
||||
tokens: AccessTokenSource = AccessTokenSource.NONE,
|
||||
): PairingProber =
|
||||
PairingProber { endpoint -> runPairingProbe(endpoint, http, ws, tokens) }
|
||||
|
||||
// ── Warning tiers (plan §5.4) ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -396,6 +596,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]. */
|
||||
@@ -447,9 +652,57 @@ public enum class EntryError {
|
||||
INVALID_URL,
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the access-token step failed. Rendered next to the token field on the confirm step (the typed-token
|
||||
* path) or under the dedicated [PairingUiState.TokenRequired] prompt (the discovered-gate path), 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/submitted token is wrong. */
|
||||
INVALID,
|
||||
|
||||
/**
|
||||
* `POST /auth` → **429**: the server's 10/min/IP limiter tripped. ONE case for both entry points —
|
||||
* the typed-token path and the prompt's submit path hit the same limiter on the same route, so two
|
||||
* enum constants with two different strings were the merge keeping both branches' names for one
|
||||
* server answer. Surfaced as-is; the app NEVER auto-retries.
|
||||
*/
|
||||
RATE_LIMITED,
|
||||
|
||||
/** 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 host did not answer the submit (or answered unintelligibly) — distinct from a wrong token. */
|
||||
UNREACHABLE,
|
||||
|
||||
/** The app cannot submit a token at all on this seam (a probe-only prober). */
|
||||
UNAVAILABLE,
|
||||
|
||||
/** 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)
|
||||
|
||||
/**
|
||||
* What one pairing attempt wrote to the access-token store, and therefore what has to be put back if the
|
||||
* attempt does not end in [PairingUiState.Paired] (RULE 6). [previous] is what the store held for this
|
||||
* host beforehand — non-null only for a re-pair, whose working token a failed attempt must not cost.
|
||||
*/
|
||||
private data class TokenAttempt(val stored: Boolean, val previous: String?) {
|
||||
companion object {
|
||||
/** No token was written (none typed, or the host has no auth) — nothing to undo. */
|
||||
val NONE: TokenAttempt = TokenAttempt(stored = false, previous = null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun PairingUiState.candidate(): Candidate? = when (this) {
|
||||
is PairingUiState.Confirming -> Candidate(endpoint, name, tier)
|
||||
is PairingUiState.Probing -> Candidate(endpoint, name, tier)
|
||||
@@ -462,6 +715,32 @@ 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.
|
||||
*
|
||||
* A wrong token, an unreachable host and a rate-limit read differently on purpose — collapsing them
|
||||
* would tell the user to re-type a token that was fine — and the rate-limit line tells them to WAIT,
|
||||
* because the app never retries by itself.
|
||||
*/
|
||||
fun describeToken(error: TokenError): String = when (error) {
|
||||
TokenError.REQUIRED ->
|
||||
"该主机启用了访问令牌(WEBTERM_TOKEN)。请填写访问令牌后再连接。"
|
||||
TokenError.INVALID ->
|
||||
"访问令牌不正确。请核对主机上的 WEBTERM_TOKEN 后重试(区分大小写)。"
|
||||
TokenError.RATE_LIMITED ->
|
||||
"尝试次数过多,主机已暂时限流(每分钟最多 10 次)。请等待约一分钟再试——App 不会自动重试。"
|
||||
TokenError.MALFORMED ->
|
||||
"访问令牌格式不合法:需 16–512 个字符,且只能包含 A–Z a–z 0–9 . _ ~ + / = -。"
|
||||
TokenError.UNVERIFIABLE ->
|
||||
"无法验证访问令牌:服务器返回了意外响应。请确认地址和端口指向 web-terminal。"
|
||||
TokenError.UNREACHABLE ->
|
||||
"提交访问令牌时主机没有正常响应。请检查网络与地址后重试。"
|
||||
TokenError.UNAVAILABLE ->
|
||||
"当前无法提交访问令牌(应用未连上该主机的网络通道)。请返回重试配对。"
|
||||
TokenError.STORE_FAILED ->
|
||||
"无法在本机安全保存访问令牌(加密存储写入失败)。请重试。"
|
||||
}
|
||||
|
||||
fun describe(error: PairingError, tier: WarningTier): String = when (error) {
|
||||
is PairingError.HostUnreachable ->
|
||||
"无法连接到主机。请检查地址、端口,以及设备是否在同一局域网 / Tailscale 网络内。"
|
||||
@@ -489,22 +768,8 @@ 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 ->
|
||||
"连接超时。主机可能不可达,或响应过慢。"
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy for the access-token prompt. A wrong token and an unreachable host read differently on
|
||||
* purpose, and the rate-limit line tells the user to WAIT — the app never retries by itself.
|
||||
*/
|
||||
fun describeToken(error: TokenError): String = when (error) {
|
||||
TokenError.INVALID ->
|
||||
"访问令牌不正确。请核对主机上 WEBTERM_TOKEN 的值(区分大小写)后重试。"
|
||||
TokenError.RATE_LIMITED ->
|
||||
"尝试次数过多,主机已暂时限流(每分钟最多 10 次)。请等待约一分钟再试——App 不会自动重试。"
|
||||
TokenError.UNREACHABLE ->
|
||||
"提交访问令牌时主机没有正常响应。请检查网络与地址后重试。"
|
||||
TokenError.UNAVAILABLE ->
|
||||
"当前无法提交访问令牌(应用未连上该主机的网络通道)。请返回重试配对。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<HttpTransport>,
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@ import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import wang.yaojia.webterm.api.models.UiConfig
|
||||
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.AuthCookieRecord
|
||||
import wang.yaojia.webterm.hostregistry.AuthCookieStore
|
||||
import wang.yaojia.webterm.hostregistry.Host
|
||||
@@ -25,6 +27,7 @@ import wang.yaojia.webterm.tlsandroid.IdentityRepository
|
||||
import wang.yaojia.webterm.transport.AuthCookieJar
|
||||
import wang.yaojia.webterm.transport.AuthCookieSnapshot
|
||||
import wang.yaojia.webterm.transport.authCookieHostKey
|
||||
import wang.yaojia.webterm.viewmodels.AccessTokenProber
|
||||
import wang.yaojia.webterm.viewmodels.ApiClientQueueGateway
|
||||
import wang.yaojia.webterm.viewmodels.HostRemovalGateway
|
||||
import wang.yaojia.webterm.viewmodels.PairingProber
|
||||
@@ -32,9 +35,12 @@ import wang.yaojia.webterm.viewmodels.PersistedUnreadWatermarkStore
|
||||
import wang.yaojia.webterm.viewmodels.QueueGateway
|
||||
import wang.yaojia.webterm.viewmodels.TokenSubmission
|
||||
import wang.yaojia.webterm.viewmodels.UnreadWatermarkStore
|
||||
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
|
||||
import wang.yaojia.webterm.wire.HttpResponse
|
||||
import wang.yaojia.webterm.wire.HttpTransport
|
||||
import wang.yaojia.webterm.wire.TermTransport
|
||||
import java.net.URI
|
||||
@@ -81,6 +87,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,
|
||||
@@ -147,12 +158,16 @@ public class AppEnvironment @Inject constructor(
|
||||
* The submit rides the SAME client as everything else, which is the whole point: the `webterm_auth`
|
||||
* cookie it earns lands in the shared [AuthCookieJar] and is therefore replayed on every later REST
|
||||
* call AND on the WS upgrade.
|
||||
*
|
||||
* The probe also authenticates from [accessTokenStore] (B5 / §1.1) — its two HTTP legs AND its WS
|
||||
* upgrade read the token the pairing flow may have just stored, so both halves of the gate work:
|
||||
* a token typed up-front (stored, then probed) and one submitted at the prompt (cookie jar).
|
||||
*/
|
||||
public fun buildPairingProber(): PairingProber = object : PairingProber {
|
||||
private val gateway = AccessTokenGateway { httpTransportLazy.get() }
|
||||
|
||||
override suspend fun probe(endpoint: HostEndpoint): PairingProbeResult =
|
||||
runPairingProbe(endpoint, httpTransportLazy.get(), termTransportLazy.get())
|
||||
runPairingProbe(endpoint, httpTransportLazy.get(), termTransportLazy.get(), accessTokenStore)
|
||||
|
||||
override suspend fun requiresAccessToken(endpoint: HostEndpoint): Boolean =
|
||||
gateway.requiresAccessToken(endpoint)
|
||||
@@ -161,6 +176,14 @@ public class AppEnvironment @Inject constructor(
|
||||
gateway.submit(endpoint, token)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 session-list / manage-page thumbnail pipeline for one host (§6.7). Per-host because its
|
||||
* preview fetch is bound to that host's [ApiClient][wang.yaojia.webterm.api.routes.ApiClient]
|
||||
@@ -225,6 +248,7 @@ public class AppEnvironment @Inject constructor(
|
||||
hostStore = hostStore,
|
||||
lastSessionStore = lastSessionStore,
|
||||
authCookieStore = authCookieStoreLazy.get(),
|
||||
accessTokenStore = accessTokenStore,
|
||||
watermarkStore = sessionWatermarkStore,
|
||||
pushUnregister = pushUnregister,
|
||||
currentPushToken = { pushTokenSource.currentToken() },
|
||||
@@ -390,9 +414,11 @@ public fun interface PushTokenSource {
|
||||
* pushing notifications to the device forever. It is **best-effort**: a host that is switched off
|
||||
* must still be removable, and the local state is what the user asked us to forget.
|
||||
* 2. **Then the local state**, in the order that keeps a failure retryable: the session watermarks, the
|
||||
* last-session pointer, the persisted + live `webterm_auth` cookie, and the paired record LAST. Any
|
||||
* throw propagates with the host still paired, so the user can simply try again — the UI treats that
|
||||
* as "nothing was removed" ([HostRemovalGateway]).
|
||||
* last-session pointer, the persisted + live `webterm_auth` cookie, **the Keystore-held access
|
||||
* token** ([AccessTokenStore] — the same credential's second durable home; the cookie's value IS
|
||||
* the token, `src/http/auth.ts`), and the paired record LAST. Any throw propagates with the host
|
||||
* still paired, so the user can simply try again — the UI treats that as "nothing was removed"
|
||||
* ([HostRemovalGateway]).
|
||||
*
|
||||
* ### What is deliberately NOT removed
|
||||
* - **The device certificate / AndroidKeyStore identity** — it is ONE app-wide mTLS identity shared by
|
||||
@@ -409,6 +435,14 @@ public class HostRemover(
|
||||
private val hostStore: HostStore,
|
||||
private val lastSessionStore: LastSessionStore,
|
||||
private val authCookieStore: AuthCookieStore,
|
||||
/**
|
||||
* The OTHER durable home of the same shell credential (B5). `authCookieStore` alone is not
|
||||
* "every trace": the token is what the cookie's value IS (`src/http/auth.ts` builds
|
||||
* `webterm_auth=<token>`), and [AccessTokenStore] is keyed by
|
||||
* [wang.yaojia.webterm.wire.HostEndpoint.originHeader] — NOT by the paired-host record — so a
|
||||
* retained token silently re-authenticates the moment the same URL is added again.
|
||||
*/
|
||||
private val accessTokenStore: AccessTokenStore,
|
||||
private val watermarkStore: SessionWatermarkStore,
|
||||
private val pushUnregister: HostPushUnregister,
|
||||
private val currentPushToken: suspend () -> String?,
|
||||
@@ -433,6 +467,9 @@ public class HostRemover(
|
||||
authCookieStore.removeHost(hostKey)
|
||||
clearLiveCookies(hostKey) // the in-memory jar too, or the credential rides the next dial
|
||||
}
|
||||
// The SAME credential's other durable home (B5). Keyed by origin, not by host id, so it
|
||||
// survives the record and silently re-authenticates a re-added URL unless dropped here.
|
||||
accessTokenStore.remove(host.endpoint)
|
||||
hostStore.remove(hostId)
|
||||
}
|
||||
|
||||
@@ -504,6 +541,11 @@ public class AccessTokenGateway(private val http: () -> HttpTransport) {
|
||||
/**
|
||||
* `POST /auth` with [token] in an urlencoded form body.
|
||||
*
|
||||
* A 2xx is NOT on its own an acceptance: the presence of `Set-Cookie: webterm_auth=…` is the frozen
|
||||
* §1.1 discriminator between "the token is correct" ([TokenSubmission.Accepted]) and "this host has
|
||||
* no auth configured" ([TokenSubmission.AuthDisabled]) — and the caller persists a secret on the
|
||||
* first but must not on the second.
|
||||
*
|
||||
* [token] appears in the BODY only — never the URL, never a header, never the returned outcome, never
|
||||
* a log line — and nothing here retains it.
|
||||
*/
|
||||
@@ -526,18 +568,34 @@ public class AccessTokenGateway(private val http: () -> HttpTransport) {
|
||||
return TokenSubmission.Unreachable(reason = error::class.simpleName ?: UNKNOWN_ERROR)
|
||||
}
|
||||
return when {
|
||||
response.status in HTTP_OK until HTTP_MULTIPLE_CHOICES -> TokenSubmission.Accepted
|
||||
// 2xx splits in TWO (FROZEN §1.1): only a `Set-Cookie: webterm_auth=…` means the token was
|
||||
// ACCEPTED. A bare 204 means the host has `WEBTERM_TOKEN` unset — nothing to authenticate, so
|
||||
// the caller must persist nothing. Collapsing them puts a shell credential in the Keystore
|
||||
// for an open host and makes the client believe it is authenticated.
|
||||
response.status in HTTP_OK until HTTP_MULTIPLE_CHOICES ->
|
||||
if (response.carriesAuthCookie()) TokenSubmission.Accepted else TokenSubmission.AuthDisabled
|
||||
response.status == HTTP_UNAUTHORIZED -> TokenSubmission.Rejected
|
||||
response.status == HTTP_TOO_MANY_REQUESTS -> TokenSubmission.RateLimited
|
||||
else -> TokenSubmission.Unreachable(reason = "HTTP ${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] — a proxy's own `Set-Cookie` proves nothing about the gate. Same
|
||||
* rule as `:api-client`'s `AuthProbe.carriesAuthCookie`; never logs the value.
|
||||
*/
|
||||
private fun HttpResponse.carriesAuthCookie(): Boolean =
|
||||
headers.entries.any { (name, value) ->
|
||||
name.equals(SET_COOKIE_HEADER, ignoreCase = true) && value.startsWith("${AuthCookie.NAME}=")
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val LIVE_SESSIONS_PATH = "/live-sessions"
|
||||
const val AUTH_PATH = "/auth"
|
||||
const val TOKEN_FIELD = "token"
|
||||
const val CONTENT_TYPE_HEADER = "Content-Type"
|
||||
const val SET_COOKIE_HEADER = "Set-Cookie"
|
||||
const val FORM_CONTENT_TYPE = "application/x-www-form-urlencoded"
|
||||
const val HTTP_OK = 200
|
||||
const val HTTP_MULTIPLE_CHOICES = 300
|
||||
|
||||
29
android/app/src/main/res/xml/data_extraction_rules.xml
Normal file
29
android/app/src/main/res/xml/data_extraction_rules.xml
Normal file
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
B5 / ios-completion §1.1 — the app holds SECRET material on device: the per-host access token
|
||||
(`WEBTERM_TOKEN`, Tink-encrypted in webterm_access_token_prefs) and the mTLS device identity
|
||||
(AndroidKeyStore key + Tink-encrypted cert blob). None of it may leave the device.
|
||||
|
||||
`android:allowBackup="false"` already opts out of cloud backup / adb backup on every API level; this
|
||||
file additionally covers API 31+ cloud-backup and device-to-device TRANSFER, where a plain
|
||||
allowBackup=false does NOT by itself block transfer. Both sections exclude everything.
|
||||
|
||||
(The AndroidKeyStore private key is non-exportable regardless, so a transferred blob would be
|
||||
undecryptable anyway — this is defence in depth, and it keeps the intent explicit.)
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="root" />
|
||||
<exclude domain="file" />
|
||||
<exclude domain="database" />
|
||||
<exclude domain="sharedpref" />
|
||||
<exclude domain="external" />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="root" />
|
||||
<exclude domain="file" />
|
||||
<exclude domain="database" />
|
||||
<exclude domain="sharedpref" />
|
||||
<exclude domain="external" />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -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))))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
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]);
|
||||
* - **no stranded secret** (F2): the flip side of storing before probing is that a pairing which does
|
||||
* not reach [PairingUiState.Paired] — failed probe, or abandoned mid-probe — must roll the store back
|
||||
* to exactly what it held before, restoring a re-paired host's previous token rather than clobbering it;
|
||||
* - 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"
|
||||
|
||||
/** The token an already-paired host holds, so a failed re-pair can be shown not to clobber it. */
|
||||
const val PREVIOUS_TOKEN = "0123456789abcdefOLD1"
|
||||
|
||||
/** A cert-gated native-tunnel host (`WarningTier.TUNNEL`). */
|
||||
const val TUNNEL = "https://star.terminal.yaojia.wang"
|
||||
}
|
||||
|
||||
/** Records every `/auth` validation so ordering vs the pairing probe is directly assertable. */
|
||||
private class FakeAuthProber(var result: (String) -> AuthProbeResult) : AccessTokenProber {
|
||||
val calls = mutableListOf<String>()
|
||||
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<HostEndpoint>()
|
||||
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<String>()
|
||||
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 RATE_LIMITED`() = 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)
|
||||
// ONE constant for the server's ONE 429 — both entry points hit the same /auth limiter.
|
||||
assertEquals(TokenError.RATE_LIMITED, 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()))
|
||||
}
|
||||
|
||||
// ── a pairing that does not succeed must not strand the secret (F2) ─────────────────────────
|
||||
//
|
||||
// The token is stored BEFORE the probe on purpose (the probe's own HTTP+WS legs read it back out
|
||||
// of the store to authenticate). The flip side is that every path which does NOT end in `Paired`
|
||||
// has to put the store back the way it found it — otherwise a mistyped port leaves a live token
|
||||
// on disk for a host that was never paired, and nothing ever removes it.
|
||||
|
||||
@Test
|
||||
fun `a probe failure after the token was accepted leaves no stranded token`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val tokens = InMemoryAccessTokenStore()
|
||||
var tokenVisibleToProbe: String? = null
|
||||
val prober = PairingProber { endpoint ->
|
||||
tokenVisibleToProbe = tokens.tokenFor(endpoint)
|
||||
PairingProbeResult.Failure(PairingError.HostUnreachable("connect refused"))
|
||||
}
|
||||
val vm = newVm(prober, FakeAuthProber { AuthProbeResult.Accepted }, tokens)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm(accessToken = TOKEN)
|
||||
|
||||
assertEquals(TOKEN, tokenVisibleToProbe, "the probe's legs must still authenticate from the store")
|
||||
assertInstanceOf(PairingUiState.Failed::class.java, vm.uiState.value)
|
||||
assertNull(tokens.tokenFor(endpoint()), "a host that never paired must not keep a stored token")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed re-pair restores the token the host was already paired with`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val tokens = InMemoryAccessTokenStore()
|
||||
tokens.put(endpoint(), PREVIOUS_TOKEN)
|
||||
val prober = FakeProber { PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal) }
|
||||
val vm = newVm(prober, FakeAuthProber { AuthProbeResult.Accepted }, tokens)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm(accessToken = TOKEN)
|
||||
|
||||
assertEquals(
|
||||
PREVIOUS_TOKEN,
|
||||
tokens.tokenFor(endpoint()),
|
||||
"a failed re-pair rolls back to the token that was already working, it does not clobber it",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The one test that needs a REAL suspension point mid-probe, so it runs on the default
|
||||
* [kotlinx.coroutines.test.StandardTestDispatcher] rather than the unconfined dispatcher the rest of
|
||||
* the file uses. Note `runCurrent()`, not `advanceUntilIdle()`: the probe runs in `backgroundScope`,
|
||||
* and `advanceUntilIdle` stops as soon as no FOREGROUND work is left — it would never run the probe
|
||||
* at all (verified: the coroutine did not start).
|
||||
*/
|
||||
@Test
|
||||
fun `abandoning an in-flight probe rolls the freshly stored token back`() = runTest {
|
||||
val probeReached = CompletableDeferred<Unit>()
|
||||
val neverAnswers = CompletableDeferred<PairingProbeResult>()
|
||||
val tokens = InMemoryAccessTokenStore()
|
||||
val vm = newVm(
|
||||
prober = { probeReached.complete(Unit); neverAnswers.await() },
|
||||
authProber = FakeAuthProber { AuthProbeResult.Accepted },
|
||||
tokenStore = tokens,
|
||||
)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm(accessToken = TOKEN)
|
||||
runCurrent()
|
||||
|
||||
assertTrue(probeReached.isCompleted, "the probe must be in flight for this to test anything")
|
||||
assertEquals(TOKEN, tokens.tokenFor(endpoint()), "the in-flight probe reads the candidate token")
|
||||
|
||||
vm.reset() // the user backs out while the probe hangs
|
||||
runCurrent()
|
||||
|
||||
assertNull(tokens.tokenFor(endpoint()), "an abandoned pairing must not leave the secret behind")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cert-gated tunnel host is refused before the token is validated or stored`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val tokens = InMemoryAccessTokenStore()
|
||||
val authProber = FakeAuthProber { AuthProbeResult.Accepted }
|
||||
val prober = FakeProber { PairingProbeResult.Success(it) }
|
||||
val vm = newVm(prober, authProber, tokens) // hasDeviceCert = false
|
||||
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(TUNNEL)
|
||||
vm.confirm(accessToken = TOKEN)
|
||||
|
||||
assertInstanceOf(PairingUiState.CertRequired::class.java, vm.uiState.value)
|
||||
assertTrue(authProber.calls.isEmpty(), "the cert-gate refuses BEFORE any network I/O")
|
||||
assertNull(tokens.tokenFor(endpoint(TUNNEL)), "nothing is stored for a host that was never probed")
|
||||
}
|
||||
|
||||
// ── 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" },
|
||||
)
|
||||
|
||||
|
||||
@@ -55,6 +55,15 @@ class AccessTokenGatewayTest {
|
||||
private fun status(code: Int): (HttpRequest) -> HttpResponse =
|
||||
{ HttpResponse(status = code, body = ByteArray(0)) }
|
||||
|
||||
/** 204 **with** the gate's `Set-Cookie: webterm_auth=…` — the "token accepted" answer. */
|
||||
private fun acceptedWithCookie(): (HttpRequest) -> HttpResponse = {
|
||||
HttpResponse(
|
||||
status = 204,
|
||||
body = ByteArray(0),
|
||||
headers = mapOf("Set-Cookie" to "webterm_auth=$TOKEN; Path=/; HttpOnly; SameSite=Strict"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun gateway(transport: HttpTransport) = AccessTokenGateway { transport }
|
||||
|
||||
// ── Gate detection: is this host token-gated, or is it just not a web-terminal? ──────────────────
|
||||
@@ -95,8 +104,37 @@ class AccessTokenGatewayTest {
|
||||
// ── POST /auth status mapping ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `204 accepts the token`() = runTest {
|
||||
assertEquals(TokenSubmission.Accepted, gateway(RecordingTransport(status(204))).submit(endpoint(), TOKEN))
|
||||
fun `204 with the auth cookie accepts the token`() = runTest {
|
||||
assertEquals(
|
||||
TokenSubmission.Accepted,
|
||||
gateway(RecordingTransport(acceptedWithCookie())).submit(endpoint(), TOKEN),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* F4 · the FROZEN §1.1 discriminator, on the submit path too: **204 with no `Set-Cookie` means the
|
||||
* host has `WEBTERM_TOKEN` unset**, so there is nothing to authenticate. Before the merge this only
|
||||
* mattered to the jar; now `PairingViewModel.submitAccessToken` writes durable Keystore storage on
|
||||
* `Accepted`, so collapsing the two readings would persist a shell credential for a host that has no
|
||||
* auth at all — and let the client believe it is authenticated. Mirrors `AuthProbeResult.AuthDisabled`
|
||||
* on the proactive path (`AuthProbe.kt:19-24`).
|
||||
*/
|
||||
@Test
|
||||
fun `204 without Set-Cookie means the host has auth disabled, not an accepted token`() = runTest {
|
||||
assertEquals(
|
||||
TokenSubmission.AuthDisabled,
|
||||
gateway(RecordingTransport(status(204))).submit(endpoint(), TOKEN),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only OUR cookie counts as an acceptance`() = runTest {
|
||||
// A proxy's own Set-Cookie proves nothing about the WEBTERM_TOKEN gate.
|
||||
val foreign: (HttpRequest) -> HttpResponse = {
|
||||
HttpResponse(204, ByteArray(0), mapOf("set-cookie" to "proxy_session=abc123; Path=/"))
|
||||
}
|
||||
|
||||
assertEquals(TokenSubmission.AuthDisabled, gateway(RecordingTransport(foreign)).submit(endpoint(), TOKEN))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -10,9 +10,11 @@ import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.hostregistry.AuthCookieRecord
|
||||
import wang.yaojia.webterm.hostregistry.Host
|
||||
import wang.yaojia.webterm.hostregistry.HostStore
|
||||
import wang.yaojia.webterm.hostregistry.InMemoryAccessTokenStore
|
||||
import wang.yaojia.webterm.hostregistry.InMemoryAuthCookieStore
|
||||
import wang.yaojia.webterm.hostregistry.InMemoryLastSessionStore
|
||||
import wang.yaojia.webterm.hostregistry.InMemorySessionWatermarkStore
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
@@ -32,10 +34,15 @@ class HostRemoverTest {
|
||||
|
||||
private companion object {
|
||||
const val BASE = "http://laptop.local:3000"
|
||||
|
||||
/** A real shell credential shape (`AccessTokenRule`: 16–512 of `[A-Za-z0-9._~+/=-]`). */
|
||||
const val ACCESS_TOKEN = "0123456789abcdefTOKEN"
|
||||
val SESSION_1: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
|
||||
val SESSION_2: UUID = UUID.fromString("22222222-3333-4444-8555-666666666666")
|
||||
|
||||
fun host(id: String = "h1"): Host = Host.create(id = id, name = "laptop", baseUrl = BASE)!!
|
||||
|
||||
fun endpoint(baseUrl: String = BASE): HostEndpoint = HostEndpoint.fromBaseUrl(baseUrl)!!
|
||||
}
|
||||
|
||||
private class RecordingHostStore(initial: List<Host>) : HostStore {
|
||||
@@ -71,7 +78,9 @@ class HostRemoverTest {
|
||||
): Triple<HostRemover, RecordingPush, Quad> {
|
||||
val lastSession = InMemoryLastSessionStore()
|
||||
val cookies = InMemoryAuthCookieStore()
|
||||
val tokens = InMemoryAccessTokenStore()
|
||||
val watermarks = InMemorySessionWatermarkStore()
|
||||
tokens.put(endpoint(), ACCESS_TOKEN)
|
||||
lastSession.setLastSessionId(SESSION_1.toString(), "h1")
|
||||
cookies.upsert(
|
||||
AuthCookieRecord(
|
||||
@@ -91,19 +100,21 @@ class HostRemoverTest {
|
||||
hostStore = hosts,
|
||||
lastSessionStore = lastSession,
|
||||
authCookieStore = cookies,
|
||||
accessTokenStore = tokens,
|
||||
watermarkStore = watermarks,
|
||||
pushUnregister = push,
|
||||
currentPushToken = { token },
|
||||
clearLiveCookies = { key -> cookieJarClears += key },
|
||||
log = { }, // android.util.Log is not mocked under JVM unit tests
|
||||
)
|
||||
return Triple(remover, push, Quad(hosts, lastSession, cookies, watermarks))
|
||||
return Triple(remover, push, Quad(hosts, lastSession, cookies, tokens, watermarks))
|
||||
}
|
||||
|
||||
private data class Quad(
|
||||
val hosts: RecordingHostStore,
|
||||
val lastSession: InMemoryLastSessionStore,
|
||||
val cookies: InMemoryAuthCookieStore,
|
||||
val tokens: InMemoryAccessTokenStore,
|
||||
val watermarks: InMemorySessionWatermarkStore,
|
||||
)
|
||||
|
||||
@@ -122,6 +133,41 @@ class HostRemoverTest {
|
||||
assertTrue(stores.watermarks.loadAll().isEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* F1 · the merge composed a SECOND durable home for the same shell credential
|
||||
* ([wang.yaojia.webterm.hostregistry.AccessTokenStore], Tink/AndroidKeystore) and left the un-pair
|
||||
* path pointed at only the first one. The token IS the cookie's value (`src/http/auth.ts` builds
|
||||
* `webterm_auth=<token>`), so keeping it is keeping a full-shell credential for a host the user
|
||||
* asked us to forget — and it is LIVE, because `AccessTokenSource.tokenFor` keys off the endpoint's
|
||||
* origin, not the paired-host record: re-adding the same URL with the token field left blank would
|
||||
* silently re-authenticate. Mirrors iOS `HostRemovalTests.removalLeavesNoTokenBehind`.
|
||||
*/
|
||||
@Test
|
||||
fun `un-pairing leaves no access token behind`() = runTest {
|
||||
val (remover, _, stores) = fixture()
|
||||
assertEquals(ACCESS_TOKEN, stores.tokens.tokenFor(endpoint())) // precondition: it really is there
|
||||
|
||||
remover.removeHost("h1", listOf(SESSION_1.toString()))
|
||||
|
||||
assertNull(
|
||||
stores.tokens.tokenFor(endpoint()),
|
||||
"the access token is the cookie's own value — un-pairing must not keep a shell credential",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `un-pairing one host leaves another host's token alone`() = runTest {
|
||||
val other = Host.create(id = "h2", name = "other", baseUrl = "http://desk.local:3000")!!
|
||||
val hosts = RecordingHostStore(listOf(host(), other))
|
||||
val (remover, _, stores) = fixture(hosts = hosts)
|
||||
stores.tokens.put(other.endpoint, ACCESS_TOKEN)
|
||||
|
||||
remover.removeHost("h1", listOf(SESSION_1.toString()))
|
||||
|
||||
assertNull(stores.tokens.tokenFor(endpoint()))
|
||||
assertEquals(ACCESS_TOKEN, stores.tokens.tokenFor(other.endpoint), "token removal is host-scoped")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the remote DELETE runs while the host is still paired`() = runTest {
|
||||
val hosts = RecordingHostStore(listOf(host()))
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.junit.jupiter.api.DisplayName
|
||||
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.viewmodels.PairingProber
|
||||
import wang.yaojia.webterm.viewmodels.PairingUiState
|
||||
@@ -78,10 +79,16 @@ class PairingTokenFlowTest {
|
||||
prober: PairingProber,
|
||||
hasCert: () -> Boolean = { false },
|
||||
store: InMemoryHostStore = InMemoryHostStore(),
|
||||
tokenStore: InMemoryAccessTokenStore = InMemoryAccessTokenStore(),
|
||||
) = PairingViewModel(
|
||||
hostStore = store,
|
||||
prober = prober,
|
||||
hasDeviceCert = { hasCert() },
|
||||
// This file drives the DISCOVERED half of the gate (prompt → submit), so the token never comes
|
||||
// in through `confirm(accessToken = …)` and `POST /auth` is the prober's `submitAccessToken`,
|
||||
// never this seam. An accepted submit is still KEPT in the store, hence a real one here.
|
||||
tokenStore = tokenStore,
|
||||
authProber = { _, _ -> error("the typed-token POST /auth path is PairingTokenViewModelTest's") },
|
||||
newId = { "fixed-id" },
|
||||
)
|
||||
|
||||
@@ -158,6 +165,52 @@ class PairingTokenFlowTest {
|
||||
assertEquals(LAN, store.loadAll().single().endpoint.baseUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* F4 · the frozen §1.1 rule, on the reactive (prompt) path. A **204 without `Set-Cookie`** means the
|
||||
* host has no auth configured at all: the submitted token must NOT reach durable storage and the
|
||||
* client must NOT consider itself authenticated. Pairing still continues — an open host pairs exactly
|
||||
* as it did before the feature existed (same as `AuthProbeResult.AuthDisabled` on the typed path).
|
||||
*/
|
||||
@Test
|
||||
fun `a host with auth disabled pairs without ever persisting the submitted token`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val prober = FakeProber(gated = true)
|
||||
prober.probeResult = gatedThenOpen(prober)
|
||||
prober.submission = { prober.gated = false; TokenSubmission.AuthDisabled }
|
||||
val tokenStore = InMemoryAccessTokenStore()
|
||||
val vm = newVm(prober, tokenStore = tokenStore)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm()
|
||||
vm.submitAccessToken(TOKEN)
|
||||
|
||||
assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value)
|
||||
assertEquals(
|
||||
null,
|
||||
tokenStore.tokenFor(endpoint(LAN)),
|
||||
"204 without Set-Cookie = auth disabled — a secret must never be persisted for it",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an accepted token IS persisted, so the WS upgrade can authenticate`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val prober = FakeProber(gated = true)
|
||||
prober.probeResult = gatedThenOpen(prober)
|
||||
prober.submission = { prober.gated = false; TokenSubmission.Accepted }
|
||||
val tokenStore = InMemoryAccessTokenStore()
|
||||
val vm = newVm(prober, tokenStore = tokenStore)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm()
|
||||
vm.submitAccessToken(TOKEN)
|
||||
|
||||
assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value)
|
||||
assertEquals(TOKEN, tokenStore.tokenFor(endpoint(LAN)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a wrong token re-arms the prompt with an invalid-token error and does not re-probe`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/")))
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> = emptyMap(),
|
||||
) : AccessTokenStore {
|
||||
private val mutex = Mutex()
|
||||
|
||||
// Defensive copy so a mutable map handed to the ctor cannot alias our state.
|
||||
private var tokensByOrigin: Map<String, String> = 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 }
|
||||
}
|
||||
}
|
||||
@@ -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<String, String>? = 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<String, String> =
|
||||
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<String, String> =
|
||||
cache ?: withContext(io) { decodeBlob(prefs().getString(BLOB_KEY, null)) }
|
||||
|
||||
private suspend fun persist(tokens: Map<String, String>) {
|
||||
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<String, String> {
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -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")))
|
||||
}
|
||||
}
|
||||
@@ -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<SessionEvent>(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 {
|
||||
@@ -412,5 +453,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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<SessionEvent> {
|
||||
val out = mutableListOf<SessionEvent>()
|
||||
backgroundScope.launch { engine.events.collect { out += it } }
|
||||
return out
|
||||
}
|
||||
|
||||
private fun List<SessionEvent>.connectionStates(): List<ConnectionState> =
|
||||
filterIsInstance<Connection>().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<ConnectionState.Failed>().single().reason,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package wang.yaojia.webterm.transport
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.HttpUrl
|
||||
import wang.yaojia.webterm.wire.AuthCookie
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
@@ -15,11 +16,29 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
* `BridgeInterceptor` runs for WebSocket calls too.
|
||||
*
|
||||
* ── Isolation (security-load-bearing) ────────────────────────────────────────────────────────────
|
||||
* The cookie is a **shell credential**. Two independent layers keep it on its own host:
|
||||
* The cookie is a **shell credential**. Three independent layers keep it on its own host, and keep
|
||||
* everything else out:
|
||||
* 1. storage is partitioned by [authCookieHostKey] (`scheme://host:port`), so another host's key
|
||||
* simply has no entry to read;
|
||||
* 2. what survives that lookup is still filtered through OkHttp's own `Cookie.matches(url)`, which
|
||||
* applies the RFC domain/path rules and refuses to put a `Secure` cookie on cleartext.
|
||||
* applies the RFC domain/path rules and refuses to put a `Secure` cookie on cleartext;
|
||||
* 3. **only [AuthCookie.NAME] is ever kept or returned** — see below.
|
||||
*
|
||||
* ── Why the name filter is load-bearing, not tidiness (F2) ───────────────────────────────────────
|
||||
* The app carries TWO mechanisms for the same cookie: this jar, and the hand-written
|
||||
* `Cookie: webterm_auth=<t>` that `:api-client` stamps on every REST route and [OkHttpTermTransport]
|
||||
* stamps on the WS upgrade (the FROZEN §1.1 contract). OkHttp's `BridgeInterceptor` (4.12.0) reconciles
|
||||
* them with:
|
||||
* ```
|
||||
* val cookies = cookieJar.loadForRequest(userRequest.url)
|
||||
* if (cookies.isNotEmpty()) requestBuilder.header("Cookie", cookieHeader(cookies))
|
||||
* ```
|
||||
* — the guard is "the jar returned ANYTHING", not "the jar has a `webterm_auth` for this host", and
|
||||
* `header(...)` REPLACES the whole header. So storing a foreign cookie meant any TLS-terminating
|
||||
* intermediary that sets one (Cloudflare Access, a captive portal, a future auth edge) silently
|
||||
* stripped the token off every REST call **and off the WS upgrade**, where a 401 is terminal. Keeping
|
||||
* the jar to exactly one name makes that impossible instead of merely unlikely, and stops a chatty
|
||||
* host from evicting the credential through [MAX_COOKIES_PER_HOST].
|
||||
*
|
||||
* Expiry is enforced on BOTH sides of the boundary: expired entries are pruned when read (and the
|
||||
* pruning is pushed to storage) and dropped when restored, so a stale credential is never
|
||||
@@ -48,28 +67,32 @@ public class AuthCookieJar(
|
||||
val hostKey = authCookieHostKey(url)
|
||||
val stored = byHostKey[hostKey] ?: return emptyList()
|
||||
val live = pruneExpired(hostKey, stored)
|
||||
// Second layer: RFC domain/path match + the Secure-over-cleartext refusal.
|
||||
return live.filter { it.matches(url) }
|
||||
// Second layer: RFC domain/path match + the Secure-over-cleartext refusal. Third layer: our
|
||||
// name only — a non-empty return here REPLACES the hand-written `Cookie` header (see the
|
||||
// BridgeInterceptor note above), so nothing foreign may ever be what it replaces it with.
|
||||
return live.filter { it.isOurs() && it.matches(url) }
|
||||
}
|
||||
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||
if (cookies.isEmpty()) return
|
||||
val ours = cookies.filter { it.isOurs() }
|
||||
if (ours.isEmpty()) return
|
||||
val hostKey = authCookieHostKey(url)
|
||||
val now = clock()
|
||||
synchronized(writeLock) {
|
||||
store(hostKey, byHostKey[hostKey].orEmpty().merged(cookies, now))
|
||||
store(hostKey, byHostKey[hostKey].orEmpty().merged(ours, now))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rehydrate one host's cookies at cold start (from `:host-registry`). Already-expired snapshots
|
||||
* and structurally invalid ones are dropped. This is a hydration path, not a change, so the
|
||||
* Rehydrate one host's cookies at cold start (from `:host-registry`). Already-expired snapshots,
|
||||
* structurally invalid ones and anything not named [AuthCookie.NAME] are dropped — records at rest
|
||||
* are untrusted, and may predate the name filter. This is a hydration path, not a change, so the
|
||||
* [AuthCookiePersister] is deliberately NOT notified.
|
||||
*/
|
||||
public fun restore(hostKey: String, cookies: List<AuthCookieSnapshot>) {
|
||||
val now = clock()
|
||||
val live = cookies
|
||||
.filter { it.expiresAtEpochMillis > now }
|
||||
.filter { it.name == AuthCookie.NAME && it.expiresAtEpochMillis > now }
|
||||
.mapNotNull { it.toCookieOrNull() }
|
||||
.capped()
|
||||
synchronized(writeLock) {
|
||||
@@ -138,6 +161,13 @@ private fun List<Cookie>.upserting(cookie: Cookie): List<Cookie> =
|
||||
private fun Cookie.sameIdentity(other: Cookie): Boolean =
|
||||
name == other.name && domain == other.domain && path == other.path
|
||||
|
||||
/**
|
||||
* The name filter (F2). [AuthCookie.NAME] is the single point of derivation in `:wire-protocol` — the
|
||||
* same constant the hand-written header is built from — so the two mechanisms can never drift apart on
|
||||
* what "our cookie" means.
|
||||
*/
|
||||
private fun Cookie.isOurs(): Boolean = name == AuthCookie.NAME
|
||||
|
||||
private fun Cookie.toSnapshot(): AuthCookieSnapshot =
|
||||
AuthCookieSnapshot(
|
||||
name = name,
|
||||
|
||||
@@ -2,18 +2,21 @@ package wang.yaojia.webterm.transport
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import wang.yaojia.webterm.wire.AuthCookie
|
||||
|
||||
/**
|
||||
* The name of the server's shared-access-token cookie (`src/http/auth.ts` `AUTH_COOKIE_NAME`).
|
||||
* Exposed as a constant so nothing has to hard-code the string; the jar itself is name-agnostic
|
||||
* (it stores whatever the paired host sets, scoped to that host).
|
||||
*
|
||||
* An ALIAS of [AuthCookie.NAME], never a second literal: `:wire-protocol` owns the one derivation the
|
||||
* hand-written `Cookie` header is also built from, and [AuthCookieJar] now keeps/returns this name and
|
||||
* nothing else (F2), so a drift between the two spellings would be a security bug.
|
||||
*/
|
||||
public const val AUTH_COOKIE_NAME: String = "webterm_auth"
|
||||
public const val AUTH_COOKIE_NAME: String = AuthCookie.NAME
|
||||
|
||||
/**
|
||||
* Upper bound on how many cookies are retained per host. The paired server sets exactly one
|
||||
* ([AUTH_COOKIE_NAME]); the cap is a boundary guard so a broken or hostile host cannot turn the
|
||||
* client into an unbounded credential sink. Oldest entries are evicted first.
|
||||
* ([AUTH_COOKIE_NAME]) and the jar keeps no other name, so this is a residual boundary guard against a
|
||||
* host that varies domain/path to mint many entries under that one name. Oldest evicted first.
|
||||
*/
|
||||
internal const val MAX_COOKIES_PER_HOST: Int = 16
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package wang.yaojia.webterm.transport
|
||||
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.OkHttpClient
|
||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
@@ -107,13 +108,25 @@ public class OkHttpTransports private constructor(
|
||||
public val client: OkHttpClient,
|
||||
) {
|
||||
public companion object {
|
||||
/**
|
||||
* @param identityProvider optional mTLS material (see [OkHttpClientFactory.create]).
|
||||
* @param cookieJar the shared session-cookie store (see [OkHttpClientFactory.create]). A
|
||||
* token-gated host's `Set-Cookie: webterm_auth=…` lands here and is replayed on REST calls
|
||||
* AND on the WS upgrade, because both transports share this one client.
|
||||
* @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. Orthogonal to [cookieJar]: the hand-written header covers a token
|
||||
* the app already holds (Keystore-restored, never `Set-Cookie`-observed), the jar covers the
|
||||
* cookie a live pairing handed back.
|
||||
*/
|
||||
public fun create(
|
||||
identityProvider: ClientIdentityProvider = ClientIdentityProvider.NONE,
|
||||
cookieJar: CookieJar = CookieJar.NO_COOKIES,
|
||||
tokens: AccessTokenSource = AccessTokenSource.NONE,
|
||||
): OkHttpTransports {
|
||||
val client = OkHttpClientFactory.create(identityProvider, cookieJar)
|
||||
return OkHttpTransports(
|
||||
term = OkHttpTermTransport(client),
|
||||
term = OkHttpTermTransport(client, tokens),
|
||||
http = OkHttpHttpTransport(client),
|
||||
client = client,
|
||||
)
|
||||
|
||||
@@ -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=<t>` — 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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,9 +271,13 @@ class AuthCookieJarTest {
|
||||
|
||||
@Test
|
||||
fun capsTheNumberOfCookiesStoredPerHost() {
|
||||
// Arrange: a hostile/broken server tries to make the client an unbounded cookie sink.
|
||||
// Arrange: a hostile/broken server tries to make the client an unbounded cookie sink. Since the
|
||||
// jar keeps only AUTH_COOKIE_NAME (F2), the sole remaining way to mint many entries is to vary
|
||||
// (domain, path) under that one name — so that is what the cap has to survive.
|
||||
val response = MockResponse().setResponseCode(200).setBody("{}")
|
||||
repeat(MAX_COOKIES_PER_HOST * 2) { i -> response.addHeader("Set-Cookie", "c$i=v$i; Path=/; Max-Age=$MAX_AGE_SEC") }
|
||||
repeat(MAX_COOKIES_PER_HOST * 2) { i ->
|
||||
response.addHeader("Set-Cookie", "$AUTH_COOKIE_NAME=v$i; Path=/p$i; Max-Age=$MAX_AGE_SEC")
|
||||
}
|
||||
server.enqueue(response)
|
||||
|
||||
// Act
|
||||
@@ -283,6 +287,25 @@ class AuthCookieJarTest {
|
||||
assertEquals(MAX_COOKIES_PER_HOST, persister.latestFor(hostKey())?.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun neverStoresACookieThatIsNotOurs() {
|
||||
// Arrange: a TLS-terminating intermediary (Cloudflare Access, a captive portal) sets its own.
|
||||
val response = MockResponse().setResponseCode(200).setBody("{}")
|
||||
repeat(MAX_COOKIES_PER_HOST * 2) { i -> response.addHeader("Set-Cookie", "c$i=v$i; Path=/; Max-Age=$MAX_AGE_SEC") }
|
||||
server.enqueue(response)
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
|
||||
|
||||
// Act
|
||||
server.get("/")
|
||||
server.get("/live-sessions")
|
||||
|
||||
// Assert: nothing was kept, so nothing rides the next request — a non-empty jar would REPLACE
|
||||
// the hand-written `Cookie: webterm_auth=…` header outright (OkHttp BridgeInterceptor).
|
||||
assertNull(persister.latestFor(hostKey()))
|
||||
server.takeRequest()
|
||||
assertNull(server.takeRequest().getHeader("Cookie"))
|
||||
}
|
||||
|
||||
// ── 4. the credential never reaches a log/toString path ──────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
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.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.HttpMethod
|
||||
import wang.yaojia.webterm.wire.HttpRequest
|
||||
|
||||
/**
|
||||
* F2 · the TWO mechanisms that carry `webterm_auth`, wired the way production wires them
|
||||
* (`di/NetworkModule` line 80 installs the jar on the one shared client; line 96 hands the same
|
||||
* client the [AccessTokenSource]) — which is the combination NOTHING covered before: the jar's own
|
||||
* suite builds it with `AccessTokenSource.NONE`, and the token suite builds the client with
|
||||
* `CookieJar.NO_COOKIES`.
|
||||
*
|
||||
* ### Why both live at once cannot be left to luck
|
||||
* OkHttp's `BridgeInterceptor` (4.12.0) does:
|
||||
* ```
|
||||
* val cookies = cookieJar.loadForRequest(userRequest.url)
|
||||
* if (cookies.isNotEmpty()) requestBuilder.header("Cookie", cookieHeader(cookies))
|
||||
* ```
|
||||
* The guard is **"the jar returned anything at all"**, NOT "the jar has a `webterm_auth` for this
|
||||
* host", and `header(...)` REPLACES the whole header rather than merging. So an unrelated cookie from
|
||||
* any TLS-terminating intermediary (Cloudflare Access, a captive portal, a future auth edge) used to
|
||||
* be enough to strip the hand-written token off every REST call **and off the WS upgrade** — where a
|
||||
* 401 is terminal with no reconnect.
|
||||
*
|
||||
* The fix is in [AuthCookieJar]: it now keeps and returns [AuthCookie.NAME] and nothing else, so a
|
||||
* foreign cookie can neither displace the token nor consume the per-host cap.
|
||||
*/
|
||||
class AuthCookieTokenCoexistenceTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
private val persister = RecordingCoexistencePersister()
|
||||
private val jar = AuthCookieJar(persister = persister)
|
||||
private lateinit var client: OkHttpClient
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
server = MockWebServer().apply { start() }
|
||||
// EXACTLY the production graph: one client, the jar installed on it, tokens on the WS transport.
|
||||
client = OkHttpClientFactory.create(cookieJar = jar)
|
||||
}
|
||||
|
||||
@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}"))
|
||||
|
||||
/** The REST half exactly as `:api-client` stamps it (`ApiRoute.toHttpRequest`). */
|
||||
private fun getWithToken(path: String, token: String = TOKEN) = runBlocking {
|
||||
OkHttpHttpTransport(client).send(
|
||||
HttpRequest(
|
||||
method = HttpMethod.GET,
|
||||
url = server.url(path).toString(),
|
||||
headers = mapOf(AuthCookie.HEADER_NAME to AuthCookie.headerValue(token)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Seed the live jar from a real `Set-Cookie`, i.e. the way a proxy would do it. */
|
||||
private fun seedJarWith(vararg setCookie: String) {
|
||||
val response = MockResponse().setResponseCode(200).setBody("{}")
|
||||
setCookie.forEach { response.addHeader("Set-Cookie", it) }
|
||||
server.enqueue(response)
|
||||
runBlocking {
|
||||
OkHttpHttpTransport(client).send(HttpRequest(HttpMethod.GET, server.url("/live-sessions").toString()))
|
||||
}
|
||||
server.takeRequest()
|
||||
}
|
||||
|
||||
// ── 1. an unrelated host cookie must not strip the token ─────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a foreign cookie in the jar never strips the hand-written token from a REST call`() {
|
||||
// Arrange: an intermediary sets its own session cookie on this host.
|
||||
seedJarWith("proxy_session=abc123; Path=/; Max-Age=$MAX_AGE_SEC")
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
|
||||
|
||||
// Act
|
||||
getWithToken("/live-sessions")
|
||||
|
||||
// Assert: the shell credential is still on the wire.
|
||||
val sent = server.takeRequest().getHeader(AuthCookie.HEADER_NAME)
|
||||
assertTrue(
|
||||
sent.orEmpty().contains("${AuthCookie.NAME}=$TOKEN"),
|
||||
"a proxy's cookie must never displace the access token, got: $sent",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a foreign cookie in the jar never strips the token from the WS upgrade`() = runBlocking {
|
||||
// Arrange: the upgrade is the worst place to lose it — a 401 there is terminal, no reconnect.
|
||||
seedJarWith("proxy_session=abc123; Path=/; Max-Age=$MAX_AGE_SEC")
|
||||
server.enqueue(MockResponse().withWebSocketUpgrade(SilentCoexistenceWebSocket()))
|
||||
|
||||
// Act
|
||||
val endpoint = endpoint()
|
||||
val connection = withTimeout(TIMEOUT_MS) {
|
||||
OkHttpTermTransport(client, AccessTokenSource { TOKEN }).connect(endpoint)
|
||||
}
|
||||
|
||||
// Assert: cookie intact AND the CSWSH defence untouched.
|
||||
val upgrade = server.takeRequest()
|
||||
assertTrue(
|
||||
upgrade.getHeader(AuthCookie.HEADER_NAME).orEmpty().contains("${AuthCookie.NAME}=$TOKEN"),
|
||||
"the WS upgrade lost the token to a foreign cookie, got: ${upgrade.getHeader(AuthCookie.HEADER_NAME)}",
|
||||
)
|
||||
assertEquals(endpoint.originHeader, upgrade.getHeader("Origin"), "Origin must be untouched")
|
||||
connection.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a chatty host cannot evict the token by flooding foreign cookies`() {
|
||||
// Arrange: more foreign cookies than the per-host cap (the eviction path).
|
||||
val flood = (0 until MAX_COOKIES_PER_HOST * 2)
|
||||
.map { "junk$it=v$it; Path=/; Max-Age=$MAX_AGE_SEC" }
|
||||
.toTypedArray()
|
||||
seedJarWith(*flood)
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
|
||||
|
||||
// Act
|
||||
getWithToken("/live-sessions")
|
||||
|
||||
// Assert: nothing foreign was ever kept, so nothing could be evicted OR sent.
|
||||
val sent = server.takeRequest().getHeader(AuthCookie.HEADER_NAME)
|
||||
assertEquals("${AuthCookie.NAME}=$TOKEN", sent, "only our own cookie may ever reach the wire")
|
||||
assertTrue(
|
||||
persister.latestFor(hostKey()).orEmpty().none { it.name != AuthCookie.NAME },
|
||||
"the jar must never become a sink for another service's cookies",
|
||||
)
|
||||
}
|
||||
|
||||
// ── 2. the stale-jar-value case ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The jar and the token store hold the SAME cookie name, so when both are populated OkHttp's
|
||||
* bridge decides: `loadForRequest` is consulted last and `header(...)` replaces, therefore **the
|
||||
* jar's live cookie wins over the hand-written one**. Pinned rather than left implicit.
|
||||
*
|
||||
* Against this server the two cannot actually diverge: `POST /auth` is the ONLY writer of either
|
||||
* side, it runs on this same shared client, and its `Set-Cookie` refreshes the jar in the very
|
||||
* exchange whose success gates the token-store write (`PairingViewModel` RULE 5). The value that
|
||||
* arrives is therefore always a single, well-formed `webterm_auth` — never a merge of two.
|
||||
*/
|
||||
@Test
|
||||
fun `with both mechanisms populated exactly one webterm_auth value is sent - the jar's`() {
|
||||
// Arrange: the jar was refreshed by POST /auth; the store still holds an older typed token.
|
||||
seedJarWith("${AuthCookie.NAME}=$JAR_TOKEN; Path=/; Max-Age=$MAX_AGE_SEC")
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
|
||||
|
||||
// Act
|
||||
getWithToken("/live-sessions", token = TOKEN)
|
||||
|
||||
// Assert: ONE value, deterministic, not a concatenation of both.
|
||||
val sent = server.takeRequest().getHeader(AuthCookie.HEADER_NAME)
|
||||
assertEquals("${AuthCookie.NAME}=$JAR_TOKEN", sent)
|
||||
assertFalse(sent.orEmpty().contains(TOKEN), "the two must never be merged into one header")
|
||||
}
|
||||
|
||||
private fun hostKey(): String = requireNotNull(authCookieHostKey(server.url("/").toString()))
|
||||
|
||||
private companion object {
|
||||
const val TOKEN = "0123456789abcdefTOKEN"
|
||||
const val JAR_TOKEN = "0123456789abcdefJARVAL"
|
||||
const val MAX_AGE_SEC = 60L
|
||||
const val TIMEOUT_MS = 5_000L
|
||||
}
|
||||
}
|
||||
|
||||
private class RecordingCoexistencePersister : AuthCookiePersister {
|
||||
private val latest = mutableMapOf<String, List<AuthCookieSnapshot>>()
|
||||
|
||||
override fun persist(hostKey: String, cookies: List<AuthCookieSnapshot>) {
|
||||
latest[hostKey] = cookies
|
||||
}
|
||||
|
||||
fun latestFor(hostKey: String): List<AuthCookieSnapshot>? = latest[hostKey]
|
||||
}
|
||||
|
||||
private class SilentCoexistenceWebSocket : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) = Unit
|
||||
}
|
||||
@@ -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=<t>` 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
|
||||
}
|
||||
@@ -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=<t>` 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=<token>` — 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<Char> =
|
||||
(('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 }
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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=<t>` 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",
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user