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