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

@@ -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._~+/=-]`, 16512 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 }
}
}

View File

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

View File

@@ -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._~+/=-]`, 16512) 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",
)
}
}