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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user