feat(android): access-token support — same frozen contract as iOS

Android could not connect at all once the server set WEBTERM_TOKEN. Hand-written
Cookie header on every request and on the WS upgrade (no CookieJar, matching the
frozen decision), POST /auth pairing probe, Keystore-backed storage, and a 401
upgrade as a terminal state with no reconnect loop.
This commit is contained in:
Yaojia Wang
2026-07-30 12:45:27 +02:00
parent a5fa843f00
commit 9114630c3a
42 changed files with 2419 additions and 51 deletions

View File

@@ -1,6 +1,7 @@
package wang.yaojia.webterm.transport
import okhttp3.OkHttpClient
import wang.yaojia.webterm.wire.AccessTokenSource
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.X509TrustManager
@@ -84,12 +85,19 @@ public class OkHttpTransports private constructor(
public val client: OkHttpClient,
) {
public companion object {
/**
* @param identityProvider optional mTLS material (see [OkHttpClientFactory.create]).
* @param tokens the access-token seam for the WS upgrade's `Cookie` header (ios-completion §1.1).
* REST requests get their cookie from `:api-client`'s single stamping point instead, so this
* only wires the WS half.
*/
public fun create(
identityProvider: ClientIdentityProvider = ClientIdentityProvider.NONE,
tokens: AccessTokenSource = AccessTokenSource.NONE,
): OkHttpTransports {
val client = OkHttpClientFactory.create(identityProvider)
return OkHttpTransports(
term = OkHttpTermTransport(client),
term = OkHttpTermTransport(client, tokens),
http = OkHttpHttpTransport(client),
client = client,
)

View File

@@ -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()

View File

@@ -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)
}
}
}

View File

@@ -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
}