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