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:
@@ -59,6 +59,30 @@ Kover ≥80% on the pure modules); this checklist is what a human runs on real h
|
||||
no relaunch** (re-reading `X509KeyManager` + `connectionPool.evictAll()`); remove.
|
||||
- [ ] DataStore host list / `LastSessionStore` set-on-adopted / clear-on-exited.
|
||||
|
||||
## Access token — `WEBTERM_TOKEN` (B5 / ios-completion §1.1) — needs a token-enabled host
|
||||
Run the host as `WEBTERM_TOKEN=<16–512 chars> npm start`. The pure logic is JVM-tested (`AuthProbeTest`,
|
||||
`AccessTokenCookieTest`, `PairingProbeTokenTest`, `OkHttpAccessTokenTest`, `SessionEngineUnauthorizedTest`,
|
||||
`InMemoryAccessTokenStoreTest`); everything below needs real hardware + a real server.
|
||||
- [ ] `TinkAccessTokenStore` instrumented suite on a device:
|
||||
`./gradlew :host-registry:connectedDebugAndroidTest` (real AndroidKeyStore + Tink).
|
||||
- [ ] Pair a token-enabled host with the token typed → paired; the terminal attaches (the **WS upgrade**
|
||||
carries `Cookie: webterm_auth=…`), the session list, projects, diff and thumbnails all load.
|
||||
- [ ] Pair the same host with **no** token typed → the probe 401s → the confirm step comes back asking for
|
||||
the token ("该主机启用了访问令牌"); typing it and retrying pairs.
|
||||
- [ ] Wrong token → "访问令牌不正确" **under the field** (still on the confirm step, nothing stored).
|
||||
- [ ] 11 wrong tries within a minute → "尝试过多" (the server's 10/min/IP `/auth` limiter).
|
||||
- [ ] Pair a host with `WEBTERM_TOKEN` **unset** while typing a token → pairs, and NOTHING is stored
|
||||
(204-without-`Set-Cookie`); the app must not claim to be "authenticated".
|
||||
- [ ] Restart the app → the terminal re-attaches with no re-prompt (the token decrypts from the
|
||||
Keystore-backed store on a cold start).
|
||||
- [ ] Rotate `WEBTERM_TOKEN` on the host and restart it → the WS upgrade 401s → the terminal banner shows
|
||||
the 401 copy, offers **no** "新会话", and there is **no reconnect back-off storm** (verify with
|
||||
`adb logcat` / the server log: exactly one upgrade attempt).
|
||||
- [ ] `adb logcat` during all of the above: the token string appears **nowhere**; no request URL contains
|
||||
it (check the server's access log too).
|
||||
- [ ] `adb backup` / device-to-device transfer excludes the token blob (`allowBackup=false` +
|
||||
`data_extraction_rules.xml`).
|
||||
|
||||
## Nav / deep links / adaptive (A32/A26/A29)
|
||||
- [ ] cold-start AND warm deep link `webterminal://open?host=&join=` + the verified App Link route to the
|
||||
right destination; invalid UUID ignored.
|
||||
|
||||
@@ -62,8 +62,40 @@ Setup: `local.properties` → `sdk.dir=/usr/local/share/android-commandlinetools
|
||||
`:wire-protocol` is the **frozen shared contract** (Android analogue of
|
||||
`src/types.ts` + WireProtocol) — `ClientMessage`/`ServerMessage`, `MessageCodec`,
|
||||
`Validation`, `WireConstants`, `HostEndpoint` (the single Origin/wsURL derivation),
|
||||
and the `TermTransport` / `HttpTransport` / `PingableTermTransport` boundary
|
||||
interfaces. New wire types are added **only** here (a coordination point).
|
||||
`AuthCookie`/`AccessTokenRule`/`AccessTokenSource` (the single access-token-cookie
|
||||
derivation), and the `TermTransport` / `HttpTransport` / `PingableTermTransport`
|
||||
boundary interfaces. New wire types are added **only** here (a coordination point).
|
||||
|
||||
## Access token (`WEBTERM_TOKEN`) — how the Android client authenticates
|
||||
|
||||
A server started with `WEBTERM_TOKEN=<16–512 chars of [A-Za-z0-9._~+/=-]>` gates **every**
|
||||
HTTP route *and* the WebSocket upgrade behind a `webterm_auth` cookie. The Android
|
||||
client therefore:
|
||||
|
||||
- **hand-writes `Cookie: webterm_auth=<t>` itself** — no OkHttp `CookieJar`, no
|
||||
`Set-Cookie` parsing. One derivation point (`AuthCookie`), stamped in exactly three
|
||||
places: `:api-client`'s `ApiRoute.toHttpRequest` (all 12+ routes, RO GETs included),
|
||||
the pairing probe's two hand-built legs, and `:transport-okhttp`'s WS upgrade. The
|
||||
hand-built `GET /projects/diff` fetcher in `:app` stamps it too.
|
||||
- keeps the header **orthogonal to `Origin`**: the token never replaces the CSWSH
|
||||
Origin check (the server tests Origin first, then the cookie), and read-only routes
|
||||
still carry no Origin.
|
||||
- validates a typed token ONCE at pairing time with `POST /auth`
|
||||
(`{"token":"…"}`, `Accept: application/json` — an `Accept` containing `text/html`
|
||||
makes the server answer 302 instead of 204/401). Four outcomes: 204+`Set-Cookie` =
|
||||
correct → store it; 204 **without** `Set-Cookie` = that host has no auth → store
|
||||
nothing; 401 = wrong token; 429 = rate-limited (10/min/IP).
|
||||
- stores it per host (keyed by the canonical origin) in `TinkAccessTokenStore`:
|
||||
Tink AEAD under an **AndroidKeystore-wrapped, non-exportable** master key, in an
|
||||
app-private `SharedPreferences` file, with `android:allowBackup="false"` +
|
||||
`data_extraction_rules.xml` excluding cloud backup and device transfer. The token is
|
||||
**never logged, never in a URL, never in app UI state**.
|
||||
- treats a **401 on the WS upgrade as terminal** (`FailureReason.UNAUTHORIZED`): no
|
||||
reconnect back-off loop, and the banner tells the user to re-enter the token. A 401 on
|
||||
any REST route is the typed `ApiClientError.Unauthorized`.
|
||||
|
||||
A host with no `WEBTERM_TOKEN` is byte-identical to before the feature: no token stored ⇒
|
||||
no `Cookie` header at all (LAN zero-config preserved).
|
||||
|
||||
## Toolchain
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package wang.yaojia.webterm.api.pairing
|
||||
|
||||
import wang.yaojia.webterm.api.routes.Endpoints
|
||||
import wang.yaojia.webterm.wire.AccessTokenRule
|
||||
import wang.yaojia.webterm.wire.AuthCookie
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpResponse
|
||||
import wang.yaojia.webterm.wire.HttpTransport
|
||||
|
||||
/**
|
||||
* The outcome of the pairing-time access-token validation probe (`POST /auth`). The first four cases
|
||||
* are the FROZEN four the server distinguishes (ios-completion §1.1); [Malformed] is a client-side
|
||||
* pre-check and [Unexpected] keeps an unknown status from being mistaken for success.
|
||||
*/
|
||||
public sealed interface AuthProbeResult {
|
||||
/** **204 + `Set-Cookie: webterm_auth=…`** — the token is correct. Persist it for this host. */
|
||||
public data object Accepted : AuthProbeResult
|
||||
|
||||
/**
|
||||
* **204 with NO `Set-Cookie`** — this host has `WEBTERM_TOKEN` unset, so there is nothing to
|
||||
* authenticate. The token MUST NOT be persisted, and this MUST NOT be read as "authenticated"
|
||||
* (frozen §1.1): the host is simply open, exactly as a zero-config LAN deploy.
|
||||
*/
|
||||
public data object AuthDisabled : AuthProbeResult
|
||||
|
||||
/** **401** — wrong token. UI: "访问令牌不正确". */
|
||||
public data object InvalidToken : AuthProbeResult
|
||||
|
||||
/** **429** — the server's `/auth` limiter (10/min/IP) tripped. UI: "尝试过多,稍后再试". */
|
||||
public data object RateLimited : AuthProbeResult
|
||||
|
||||
/** The token cannot be a valid server token ([AccessTokenRule]) — rejected before any network I/O. */
|
||||
public data object Malformed : AuthProbeResult
|
||||
|
||||
/** Any other status (e.g. a captive portal's 302). Never treated as success. */
|
||||
public data class Unexpected(val status: Int) : AuthProbeResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate [token] against [endpoint] via **`POST /auth`** — a one-shot pairing-time probe, NOT a
|
||||
* session-establishing login: the native client keeps stamping its own `Cookie` header afterwards and
|
||||
* ignores the `Set-Cookie` value entirely (frozen §1.1). The response's `Set-Cookie` is used only as a
|
||||
* BOOLEAN signal (was the token accepted, or is auth disabled on this host?).
|
||||
*
|
||||
* Transport-level failures propagate unwrapped so the caller can classify them with
|
||||
* [PairingError.classify] (same convention as [runPairingProbe]).
|
||||
*
|
||||
* Never logs the token; the token appears only in the JSON body, never in the URL.
|
||||
*/
|
||||
public suspend fun probeAccessToken(
|
||||
endpoint: HostEndpoint,
|
||||
http: HttpTransport,
|
||||
token: String,
|
||||
): AuthProbeResult {
|
||||
// Boundary validation first: a token outside the server's charset/length can never be correct, and
|
||||
// there is no point spending a rate-limit slot (or shipping it over the wire) to find that out.
|
||||
val normalized = AccessTokenRule.normalize(token) ?: return AuthProbeResult.Malformed
|
||||
val request = Endpoints.auth(normalized).toHttpRequest(endpoint)
|
||||
?: return AuthProbeResult.Unexpected(UNBUILDABLE_REQUEST)
|
||||
val response = http.send(request)
|
||||
return classifyAuthResponse(response)
|
||||
}
|
||||
|
||||
private fun classifyAuthResponse(response: HttpResponse): AuthProbeResult = when (response.status) {
|
||||
HTTP_NO_CONTENT ->
|
||||
// The ONE discriminator between "token correct" and "this host has no auth at all".
|
||||
if (response.carriesAuthCookie()) AuthProbeResult.Accepted else AuthProbeResult.AuthDisabled
|
||||
HTTP_UNAUTHORIZED -> AuthProbeResult.InvalidToken
|
||||
HTTP_TOO_MANY_REQUESTS -> AuthProbeResult.RateLimited
|
||||
else -> AuthProbeResult.Unexpected(response.status)
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff the response set OUR auth cookie. Header names are case-insensitive on the wire, and the
|
||||
* value must name [AuthCookie.NAME] — some other service's `Set-Cookie` (a proxy's session id) proves
|
||||
* nothing about our token.
|
||||
*/
|
||||
private fun HttpResponse.carriesAuthCookie(): Boolean =
|
||||
headers.entries.any { (name, value) ->
|
||||
name.equals(SET_COOKIE_HEADER, ignoreCase = true) && value.startsWith("${AuthCookie.NAME}=")
|
||||
}
|
||||
|
||||
/**
|
||||
* Sentinel status for "the request could not even be built" — unreachable for a validated
|
||||
* [HostEndpoint], surfaced instead of crashing (never mistaken for a real HTTP status).
|
||||
*/
|
||||
private const val UNBUILDABLE_REQUEST = 0
|
||||
|
||||
private const val SET_COOKIE_HEADER = "Set-Cookie"
|
||||
private const val HTTP_NO_CONTENT = 204
|
||||
private const val HTTP_UNAUTHORIZED = 401
|
||||
private const val HTTP_TOO_MANY_REQUESTS = 429
|
||||
@@ -52,6 +52,17 @@ public sealed interface PairingError {
|
||||
/** TLS negotiation / certificate failure on an https/wss target (`SSLException`/`CertificateException`). */
|
||||
public data object TlsFailure : PairingError
|
||||
|
||||
/**
|
||||
* The host answered **401** to the probe's HTTP legs — it has `WEBTERM_TOKEN` set and we presented
|
||||
* no cookie / a wrong one (ios-completion §1.1). Distinct from [OriginRejected]: this is fixable by
|
||||
* entering the host's access token, not by editing `ALLOWED_ORIGINS`.
|
||||
*
|
||||
* NOTE the probe's ORDERING is what makes the two distinguishable: probe ① authenticates over HTTP
|
||||
* first, so a 401 there is the token; once ① has passed with our cookie, a 401 on the later WS
|
||||
* upgrade can only be the Origin allow-list (the server writes the same bare 401 for both).
|
||||
*/
|
||||
public data object AccessTokenRequired : PairingError
|
||||
|
||||
/** The probe deadline elapsed, or the transport timed out (`SocketTimeoutException`). */
|
||||
public data object Timeout : PairingError
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||
import wang.yaojia.webterm.wire.AuthCookie
|
||||
import wang.yaojia.webterm.wire.ClientMessage
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
@@ -50,8 +52,9 @@ public suspend fun runPairingProbe(
|
||||
endpoint: HostEndpoint,
|
||||
http: HttpTransport,
|
||||
ws: TermTransport,
|
||||
tokens: AccessTokenSource = AccessTokenSource.NONE,
|
||||
): PairingProbeResult =
|
||||
runPairingProbeCore(endpoint, http, ws, timeout = Tunables.PAIRING_PROBE_TIMEOUT)
|
||||
runPairingProbeCore(endpoint, http, ws, timeout = Tunables.PAIRING_PROBE_TIMEOUT, tokens = tokens)
|
||||
|
||||
/**
|
||||
* Deterministic probe core. [timeout] `null` = no app-level deadline (the transport's own timeouts
|
||||
@@ -63,9 +66,10 @@ internal suspend fun runPairingProbeCore(
|
||||
http: HttpTransport,
|
||||
ws: TermTransport,
|
||||
timeout: Duration?,
|
||||
tokens: AccessTokenSource = AccessTokenSource.NONE,
|
||||
): PairingProbeResult {
|
||||
if (timeout == null) return performProbe(endpoint, http, ws)
|
||||
return withTimeoutOrNull(timeout) { performProbe(endpoint, http, ws) }
|
||||
if (timeout == null) return performProbe(endpoint, http, ws, tokens)
|
||||
return withTimeoutOrNull(timeout) { performProbe(endpoint, http, ws, tokens) }
|
||||
?: PairingProbeResult.Failure(PairingError.Timeout)
|
||||
}
|
||||
|
||||
@@ -75,10 +79,16 @@ private suspend fun performProbe(
|
||||
endpoint: HostEndpoint,
|
||||
http: HttpTransport,
|
||||
ws: TermTransport,
|
||||
tokens: AccessTokenSource,
|
||||
): PairingProbeResult {
|
||||
// The access token (if any) rides BOTH HTTP legs as the hand-written auth cookie. The WS leg gets
|
||||
// it from the transport's own AccessTokenSource (the same store), so all three legs authenticate.
|
||||
val cookieHeaders = authHeaders(tokens.tokenFor(endpoint))
|
||||
|
||||
// ① Reachability + shape. Any HTTP-level answer that isn't the /live-sessions array shape
|
||||
// means "some other service" → httpOkButNotWebTerminal ("端口对吗?").
|
||||
probeReachability(endpoint, http)?.let { return it }
|
||||
// means "some other service" → httpOkButNotWebTerminal ("端口对吗?"); a 401 means this host
|
||||
// wants an access token we do not have.
|
||||
probeReachability(endpoint, http, cookieHeaders)?.let { return it }
|
||||
|
||||
// ② WS upgrade — the server's ONLY upgrade-reject path is the Origin 401, so after ① passed an
|
||||
// unrecognizable connect failure is classified as originRejected.
|
||||
@@ -105,7 +115,7 @@ private suspend fun performProbe(
|
||||
return try {
|
||||
when (val adoption = adoptAttachedSession(connection)) {
|
||||
is Adoption.Failure -> PairingProbeResult.Failure(adoption.error)
|
||||
is Adoption.Success -> killProbeSession(adoption.sessionId, endpoint, http)
|
||||
is Adoption.Success -> killProbeSession(adoption.sessionId, endpoint, http, cookieHeaders)
|
||||
}
|
||||
} finally {
|
||||
withContext(NonCancellable) { runCatching { connection.close() } }
|
||||
@@ -120,14 +130,20 @@ private suspend fun performProbe(
|
||||
private suspend fun probeReachability(
|
||||
endpoint: HostEndpoint,
|
||||
http: HttpTransport,
|
||||
authHeaders: Map<String, String>,
|
||||
): PairingProbeResult.Failure? {
|
||||
val response = try {
|
||||
http.send(HttpRequest(method = HttpMethod.GET, url = liveSessionsUrl(endpoint)))
|
||||
http.send(HttpRequest(method = HttpMethod.GET, url = liveSessionsUrl(endpoint), headers = authHeaders))
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (error: Throwable) {
|
||||
return PairingProbeResult.Failure(PairingError.classify(error, endpoint))
|
||||
}
|
||||
// Checked BEFORE the shape check: the 401 body is the server's auth JSON, not a session array, and
|
||||
// reporting "端口对吗?" for a token problem would send the user chasing the wrong thing.
|
||||
if (response.status == HTTP_UNAUTHORIZED) {
|
||||
return PairingProbeResult.Failure(PairingError.AccessTokenRequired)
|
||||
}
|
||||
if (response.status != HTTP_OK || !isJsonArray(response.body)) {
|
||||
return PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal)
|
||||
}
|
||||
@@ -162,13 +178,14 @@ private suspend fun killProbeSession(
|
||||
sessionId: String,
|
||||
endpoint: HostEndpoint,
|
||||
http: HttpTransport,
|
||||
authHeaders: Map<String, String>,
|
||||
): PairingProbeResult {
|
||||
val response = try {
|
||||
http.send(
|
||||
HttpRequest(
|
||||
method = HttpMethod.DELETE,
|
||||
url = killUrl(endpoint, sessionId),
|
||||
headers = mapOf(ORIGIN_HEADER to endpoint.originHeader),
|
||||
headers = authHeaders + (ORIGIN_HEADER to endpoint.originHeader),
|
||||
),
|
||||
)
|
||||
} catch (cancel: CancellationException) {
|
||||
@@ -178,6 +195,7 @@ private suspend fun killProbeSession(
|
||||
}
|
||||
return when (response.status) {
|
||||
HTTP_NO_CONTENT, HTTP_NOT_FOUND -> PairingProbeResult.Success(endpoint)
|
||||
HTTP_UNAUTHORIZED -> PairingProbeResult.Failure(PairingError.AccessTokenRequired)
|
||||
HTTP_FORBIDDEN -> PairingProbeResult.Failure(
|
||||
PairingError.OriginRejected(PairingError.originRejectedHint(endpoint)),
|
||||
)
|
||||
@@ -219,9 +237,17 @@ private fun httpBaseUrl(endpoint: HostEndpoint): String {
|
||||
return "$scheme://$host$portPart"
|
||||
}
|
||||
|
||||
/**
|
||||
* `Cookie: webterm_auth=<t>` for the probe's HTTP legs, or NO header when the host has no token —
|
||||
* derived from the single point ([AuthCookie]), never hand-assembled.
|
||||
*/
|
||||
private fun authHeaders(token: String?): Map<String, String> =
|
||||
if (token == null) emptyMap() else mapOf(AuthCookie.HEADER_NAME to AuthCookie.headerValue(token))
|
||||
|
||||
private const val LIVE_SESSIONS_PATH = "/live-sessions"
|
||||
private const val ORIGIN_HEADER = "Origin"
|
||||
private const val HTTP_OK = 200
|
||||
private const val HTTP_UNAUTHORIZED = 401
|
||||
private const val HTTP_NO_CONTENT = 204
|
||||
private const val HTTP_FORBIDDEN = 403
|
||||
private const val HTTP_NOT_FOUND = 404
|
||||
|
||||
@@ -19,6 +19,7 @@ import wang.yaojia.webterm.api.models.UiConfig
|
||||
import wang.yaojia.webterm.api.models.UiPrefs
|
||||
import wang.yaojia.webterm.api.models.decodeGitError
|
||||
import wang.yaojia.webterm.api.models.decodeGitPayload
|
||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpResponse
|
||||
import wang.yaojia.webterm.wire.HttpTransport
|
||||
@@ -36,10 +37,17 @@ import java.util.UUID
|
||||
*
|
||||
* The server is UNTRUSTED at this boundary: bodies decode tolerantly (malformed entries dropped),
|
||||
* statuses map to explicit [ApiClientError]s, and nothing here crashes on bad input.
|
||||
*
|
||||
* **Access token (ios-completion §1.1):** [tokens] is consulted once per request and its token is
|
||||
* stamped as `Cookie: webterm_auth=<t>` on EVERY route (RO included) inside the same single point that
|
||||
* stamps `Origin` ([ApiRoute.toHttpRequest]). The default [AccessTokenSource.NONE] means "no token" —
|
||||
* an unauthenticated LAN host behaves exactly as before. A **401** from any route becomes the typed
|
||||
* [ApiClientError.Unauthorized] so the UI can send the user to re-enter the token.
|
||||
*/
|
||||
public class ApiClient(
|
||||
public val endpoint: HostEndpoint,
|
||||
private val http: HttpTransport,
|
||||
private val tokens: AccessTokenSource = AccessTokenSource.NONE,
|
||||
) {
|
||||
// ── RO (read-only — NO Origin header) ──────────────────────────────────────────────────
|
||||
|
||||
@@ -249,9 +257,18 @@ public class ApiClient(
|
||||
|
||||
// ── Internals ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The ONE dispatch point: stamp Origin (iff guarded) + the auth cookie (iff a token is stored),
|
||||
* send, and translate a **401** into the typed [ApiClientError.Unauthorized] BEFORE any per-route
|
||||
* status mapping — otherwise a guarded git write would degrade a 401 into a generic `Rejected`
|
||||
* outcome and the UI would never learn to ask for the token (ios-completion §1.1).
|
||||
*/
|
||||
private suspend fun perform(route: ApiRoute): HttpResponse {
|
||||
val request = route.toHttpRequest(endpoint) ?: throw ApiClientError.InvalidRequest
|
||||
return http.send(request)
|
||||
val request = route.toHttpRequest(endpoint, tokens.tokenFor(endpoint))
|
||||
?: throw ApiClientError.InvalidRequest
|
||||
val response = http.send(request)
|
||||
if (response.status == HttpStatus.UNAUTHORIZED) throw ApiClientError.Unauthorized
|
||||
return response
|
||||
}
|
||||
|
||||
/** 200 → ok; 404 → `SessionNotFound`; anything else → `UnexpectedStatus`. */
|
||||
|
||||
@@ -20,6 +20,13 @@ public sealed class ApiClientError(public val userMessage: String) : Exception(u
|
||||
/** 404 on a `/live-sessions/:id/…` sub-route — the session is gone (exited / reaped / killed). */
|
||||
public data object SessionNotFound : ApiClientError("会话已不存在(可能已退出或被清理)。")
|
||||
|
||||
/**
|
||||
* **401** from ANY route (RO or guarded): this host has `WEBTERM_TOKEN` set and our request carried
|
||||
* no cookie / a wrong one (ios-completion §1.1). Distinct from [Forbidden] (that is the Origin
|
||||
* guard) — the UI must send the user to enter or fix the host's access token.
|
||||
*/
|
||||
public data object Unauthorized : ApiClientError("此主机需要访问令牌,或令牌已失效。请重新输入访问令牌。")
|
||||
|
||||
/** 403 from a G route's Origin guard (CSWSH defence). */
|
||||
public data object Forbidden : ApiClientError("服务器拒绝了此来源(Origin 校验未通过)。")
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package wang.yaojia.webterm.api.routes
|
||||
|
||||
import wang.yaojia.webterm.wire.AuthCookie
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
import wang.yaojia.webterm.wire.HttpRequest
|
||||
@@ -10,6 +11,7 @@ internal object HttpStatus {
|
||||
const val OK = 200
|
||||
const val NO_CONTENT = 204
|
||||
const val BAD_REQUEST = 400
|
||||
const val UNAUTHORIZED = 401
|
||||
const val FORBIDDEN = 403
|
||||
const val NOT_FOUND = 404
|
||||
const val TOO_MANY_REQUESTS = 429
|
||||
@@ -24,6 +26,12 @@ internal object HttpStatus {
|
||||
internal object HeaderName {
|
||||
const val ORIGIN = "Origin"
|
||||
const val CONTENT_TYPE = "Content-Type"
|
||||
|
||||
/**
|
||||
* Explicit on the `/auth` probe: the server treats an `Accept` containing `text/html` as a browser
|
||||
* form submit and answers **302** instead of 204/401 (`src/server.ts` `acceptsHtml`).
|
||||
*/
|
||||
const val ACCEPT = "Accept"
|
||||
}
|
||||
|
||||
internal object ContentType {
|
||||
@@ -55,19 +63,35 @@ internal class ApiRoute(
|
||||
val originPolicy: OriginPolicy,
|
||||
val body: ByteArray? = null,
|
||||
val percentEncodedQuery: String? = null,
|
||||
/** Explicit `Accept` when the server's behaviour depends on it (the `/auth` probe). */
|
||||
val accept: String? = null,
|
||||
) {
|
||||
/**
|
||||
* Build the [HttpRequest] against [endpoint]'s scheme/host/port: the path is REPLACED, the
|
||||
* query is REPLACED by [percentEncodedQuery] (dropped when null), fragment/credentials are
|
||||
* dropped — the same derivation philosophy as `HostEndpoint.wsUrl`. Returns `null` if the base
|
||||
* URL cannot be parsed (surfaced by the client as `InvalidRequest`).
|
||||
*
|
||||
* Two headers are stamped HERE and only here, and they are ORTHOGONAL (ios-completion §1.1):
|
||||
* - `Origin` **iff** the route is [OriginPolicy.GUARDED] (the CSWSH 铁律, unchanged);
|
||||
* - `Cookie: webterm_auth=<t>` whenever [accessToken] is non-null — on EVERY route, read-only
|
||||
* ones included, because the server's access-token gate sits in front of the whole app. A null
|
||||
* token adds no header at all, keeping an unauthenticated host byte-identical to before.
|
||||
* The token is secret material: it is only ever placed in this header — never in [path], never in
|
||||
* [percentEncodedQuery], never logged.
|
||||
*/
|
||||
fun toHttpRequest(endpoint: HostEndpoint): HttpRequest? {
|
||||
fun toHttpRequest(endpoint: HostEndpoint, accessToken: String? = null): HttpRequest? {
|
||||
val url = buildUrl(endpoint.baseUrl, path, percentEncodedQuery) ?: return null
|
||||
val headers = LinkedHashMap<String, String>()
|
||||
if (originPolicy == OriginPolicy.GUARDED) {
|
||||
headers[HeaderName.ORIGIN] = endpoint.originHeader
|
||||
}
|
||||
if (accessToken != null) {
|
||||
headers[AuthCookie.HEADER_NAME] = AuthCookie.headerValue(accessToken)
|
||||
}
|
||||
if (accept != null) {
|
||||
headers[HeaderName.ACCEPT] = accept
|
||||
}
|
||||
if (body != null) {
|
||||
headers[HeaderName.CONTENT_TYPE] = ContentType.JSON
|
||||
}
|
||||
|
||||
@@ -114,6 +114,33 @@ internal object Endpoints {
|
||||
|
||||
private const val FCM_TOKEN_PATH = "/push/fcm-token"
|
||||
|
||||
// ── G: access-token validation probe (`POST /auth`, ios-completion §1.1) ───────────────
|
||||
|
||||
/**
|
||||
* `POST /auth` — the pairing-time token-validation probe. Body `{"token":"<t>"}`.
|
||||
*
|
||||
* Two non-obvious requirements, both server-driven (`src/server.ts`):
|
||||
* - **`Accept` must not contain `text/html`.** The route is dual-mode: an HTML-accepting request
|
||||
* is treated as a browser form and answered with a **302** redirect instead of 204/401.
|
||||
* - the route sits BEFORE the auth gate, so it is the one request that legitimately carries no
|
||||
* auth cookie (the token is in the body — this IS the login).
|
||||
*
|
||||
* Guarded (a state-changing POST) so it stamps `Origin` like every other write; the server does not
|
||||
* Origin-check `/auth`, but keeping the Origin-iff-guarded rule uniform avoids a special case.
|
||||
*/
|
||||
fun auth(token: String): ApiRoute {
|
||||
val body = ModelJson.encodeToString(AuthBody.serializer(), AuthBody(token)).encodeToByteArray()
|
||||
return ApiRoute(
|
||||
HttpMethod.POST,
|
||||
AUTH_PATH,
|
||||
OriginPolicy.GUARDED,
|
||||
body = body,
|
||||
accept = ContentType.JSON,
|
||||
)
|
||||
}
|
||||
|
||||
private const val AUTH_PATH = "/auth"
|
||||
|
||||
// ── G: worktree write (create / remove / prune) ────────────────────────────────────────
|
||||
|
||||
/** `POST /projects/worktree` — `{ path, branch[, base] }`. `base` omitted when null. */
|
||||
@@ -199,6 +226,10 @@ internal object Endpoints {
|
||||
@Serializable
|
||||
private data class FcmTokenBody(val token: String)
|
||||
|
||||
/** `POST /auth` body. Holds secret material — never log a request built from it. */
|
||||
@Serializable
|
||||
private data class AuthBody(val token: String)
|
||||
|
||||
@Serializable
|
||||
private data class CreateWorktreeBody(val path: String, val branch: String, val base: String? = null)
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package wang.yaojia.webterm.api.pairing
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
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.Test
|
||||
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||
import wang.yaojia.webterm.wire.AuthCookie
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* B5 · `POST /auth` as the pairing-time validation probe (FROZEN contract, ios-completion §1.1).
|
||||
*
|
||||
* The four distinct server outcomes must stay distinguishable:
|
||||
* | 204 **with** `Set-Cookie` | token correct → persist it |
|
||||
* | 204 **without** `Set-Cookie` | the host has NO auth configured → do NOT persist, and do NOT treat as authenticated |
|
||||
* | 401 | the token is wrong → UI says "令牌不正确" |
|
||||
* | 429 | rate-limited (10/min/IP) → UI says "尝试过多" |
|
||||
*
|
||||
* Plus the request shape the server needs: JSON body `{"token":"<t>"}` and an `Accept` that does NOT
|
||||
* contain `text/html` (an HTML-accepting request is treated as a browser form and answered with a 302
|
||||
* instead of 204/401).
|
||||
*/
|
||||
class AuthProbeTest {
|
||||
private companion object {
|
||||
const val BASE = "http://192.168.1.5:3000"
|
||||
const val TOKEN = "0123456789abcdefTOKEN"
|
||||
}
|
||||
|
||||
private val endpoint: HostEndpoint = requireNotNull(HostEndpoint.fromBaseUrl(BASE))
|
||||
private val transport = FakeHttpTransport()
|
||||
|
||||
private fun queueAuth(status: Int, headers: Map<String, String> = emptyMap()) {
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/auth", status = status, headers = headers)
|
||||
}
|
||||
|
||||
private val setCookieHeader = mapOf(
|
||||
"Set-Cookie" to "${AuthCookie.NAME}=$TOKEN; Path=/; Max-Age=2592000; HttpOnly; SameSite=Strict",
|
||||
)
|
||||
|
||||
// ── the four frozen outcomes ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `204 with Set-Cookie means the token is correct`() = runTest {
|
||||
queueAuth(204, setCookieHeader)
|
||||
|
||||
assertEquals(AuthProbeResult.Accepted, probeAccessToken(endpoint, transport, TOKEN))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `204 without Set-Cookie means the host has no auth configured`() = runTest {
|
||||
queueAuth(204)
|
||||
|
||||
assertEquals(
|
||||
AuthProbeResult.AuthDisabled,
|
||||
probeAccessToken(endpoint, transport, TOKEN),
|
||||
"204-no-cookie must NEVER be read as 'authenticated' (frozen §1.1)",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `401 means the token is wrong`() = runTest {
|
||||
queueAuth(401)
|
||||
|
||||
assertEquals(AuthProbeResult.InvalidToken, probeAccessToken(endpoint, transport, TOKEN))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `429 means rate-limited`() = runTest {
|
||||
queueAuth(429)
|
||||
|
||||
assertEquals(AuthProbeResult.RateLimited, probeAccessToken(endpoint, transport, TOKEN))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `any other status is surfaced as Unexpected with the code`() = runTest {
|
||||
queueAuth(302) // e.g. a proxy/login redirect — never silently treated as success
|
||||
|
||||
assertEquals(AuthProbeResult.Unexpected(302), probeAccessToken(endpoint, transport, TOKEN))
|
||||
}
|
||||
|
||||
// ── request shape ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `the probe posts the token as JSON with an Accept that excludes text-html`() = runTest {
|
||||
queueAuth(204, setCookieHeader)
|
||||
|
||||
probeAccessToken(endpoint, transport, TOKEN)
|
||||
|
||||
val request = transport.recordedRequests.single()
|
||||
assertEquals(HttpMethod.POST, request.method)
|
||||
assertEquals("$BASE/auth", request.url)
|
||||
assertEquals("""{"token":"$TOKEN"}""", request.body?.decodeToString())
|
||||
assertEquals("application/json", request.headers["Content-Type"])
|
||||
val accept = request.headers["Accept"].orEmpty()
|
||||
assertTrue(accept.isNotEmpty(), "Accept must be explicit, not left to the HTTP stack")
|
||||
assertFalse(accept.contains("text/html"), "text/html would make the server answer 302, not 204/401")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the token never appears in the URL`() = runTest {
|
||||
queueAuth(401)
|
||||
|
||||
probeAccessToken(endpoint, transport, TOKEN)
|
||||
|
||||
assertFalse(
|
||||
transport.recordedRequests.single().url.contains(TOKEN),
|
||||
"a secret must never travel in a URL (query strings land in logs/history)",
|
||||
)
|
||||
}
|
||||
|
||||
// ── boundary validation + transport failure ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a malformed token is rejected before any network IO`() = runTest {
|
||||
assertEquals(AuthProbeResult.Malformed, probeAccessToken(endpoint, transport, "short"))
|
||||
assertEquals(AuthProbeResult.Malformed, probeAccessToken(endpoint, transport, "has spaces in it here"))
|
||||
|
||||
assertTrue(transport.recordedRequests.isEmpty(), "a malformed token must not hit the network")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a whitespace-padded token is trimmed once and accepted`() = runTest {
|
||||
queueAuth(204, setCookieHeader)
|
||||
|
||||
assertEquals(AuthProbeResult.Accepted, probeAccessToken(endpoint, transport, " $TOKEN\n"))
|
||||
assertEquals("""{"token":"$TOKEN"}""", transport.recordedRequests.single().body?.decodeToString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a transport failure propagates so the caller can classify it`() = runTest {
|
||||
transport.queueFailure(method = HttpMethod.POST, url = "$BASE/auth", error = IOException("refused"))
|
||||
|
||||
val thrown = runCatching { probeAccessToken(endpoint, transport, TOKEN) }.exceptionOrNull()
|
||||
|
||||
assertTrue(thrown is IOException, "network classification stays with PairingError.classify")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a Set-Cookie header is recognised case-insensitively`() = runTest {
|
||||
queueAuth(204, mapOf("set-cookie" to "${AuthCookie.NAME}=$TOKEN; Path=/"))
|
||||
|
||||
assertEquals(AuthProbeResult.Accepted, probeAccessToken(endpoint, transport, TOKEN))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a Set-Cookie for some other cookie does not count as accepted`() = runTest {
|
||||
queueAuth(204, mapOf("Set-Cookie" to "sessionid=abc; Path=/"))
|
||||
|
||||
assertEquals(
|
||||
AuthProbeResult.AuthDisabled,
|
||||
probeAccessToken(endpoint, transport, TOKEN),
|
||||
"only a webterm_auth cookie proves the token was accepted",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package wang.yaojia.webterm.api.pairing
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||
import wang.yaojia.webterm.testsupport.FakeTermTransport
|
||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||
import wang.yaojia.webterm.wire.AuthCookie
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
|
||||
/**
|
||||
* B5 · the two-step pairing probe under an access-token-protected host:
|
||||
* - both HTTP legs (`GET /live-sessions` and the guarded `DELETE /live-sessions/:id`) carry the
|
||||
* hand-written `Cookie: webterm_auth=<t>`;
|
||||
* - a **401** on the reachability leg is [PairingError.AccessTokenRequired] — NOT
|
||||
* "端口对吗?" ([PairingError.HttpOkButNotWebTerminal]) and NOT an Origin problem, so the UI can ask
|
||||
* for the token instead of sending the user on a wild goose chase;
|
||||
* - with no token configured nothing changes (no `Cookie` header at all).
|
||||
*/
|
||||
class PairingProbeTokenTest {
|
||||
private companion object {
|
||||
const val BASE = "http://192.168.1.5:3000"
|
||||
const val TOKEN = "0123456789abcdefTOKEN"
|
||||
const val SERVER_ID = "11111111-1111-4111-8111-111111111111"
|
||||
}
|
||||
|
||||
private val endpoint: HostEndpoint = requireNotNull(HostEndpoint.fromBaseUrl(BASE))
|
||||
private val http = FakeHttpTransport()
|
||||
private val ws = FakeTermTransport()
|
||||
|
||||
private fun scriptHappyPath() {
|
||||
http.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
|
||||
http.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$SERVER_ID", status = 204)
|
||||
ws.emit("""{"type":"attached","sessionId":"$SERVER_ID"}""")
|
||||
}
|
||||
|
||||
private suspend fun probe(tokens: AccessTokenSource): PairingProbeResult =
|
||||
runPairingProbeCore(endpoint, http, ws, timeout = null, tokens = tokens)
|
||||
|
||||
@Test
|
||||
fun `both HTTP legs of the probe carry the auth cookie`() = runTest {
|
||||
scriptHappyPath()
|
||||
|
||||
val result = probe(AccessTokenSource { TOKEN })
|
||||
|
||||
assertEquals(PairingProbeResult.Success(endpoint), result)
|
||||
assertEquals(2, http.recordedRequests.size, "reachability GET + guarded kill DELETE")
|
||||
http.recordedRequests.forEach { request ->
|
||||
assertEquals(
|
||||
"${AuthCookie.NAME}=$TOKEN",
|
||||
request.headers[AuthCookie.HEADER_NAME],
|
||||
"every probe request must present the token",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `with no token the probe requests are byte-identical to before the feature`() = runTest {
|
||||
scriptHappyPath()
|
||||
|
||||
probe(AccessTokenSource.NONE)
|
||||
|
||||
http.recordedRequests.forEach { request ->
|
||||
assertFalse(request.headers.containsKey(AuthCookie.HEADER_NAME))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 401 on the reachability leg asks for an access token`() = runTest {
|
||||
http.queueSuccess(url = "$BASE/live-sessions", status = 401, body = """{"error":"authentication required"}""".toByteArray())
|
||||
|
||||
val result = probe(AccessTokenSource.NONE)
|
||||
|
||||
assertEquals(PairingProbeResult.Failure(PairingError.AccessTokenRequired), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 401 on the guarded kill leg also asks for an access token`() = runTest {
|
||||
http.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
|
||||
http.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$SERVER_ID", status = 401)
|
||||
ws.emit("""{"type":"attached","sessionId":"$SERVER_ID"}""")
|
||||
|
||||
val result = probe(AccessTokenSource.NONE)
|
||||
|
||||
assertEquals(PairingProbeResult.Failure(PairingError.AccessTokenRequired), result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package wang.yaojia.webterm.api.routes
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
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.Test
|
||||
import wang.yaojia.webterm.api.models.HookDecision
|
||||
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||
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 java.util.UUID
|
||||
|
||||
/**
|
||||
* B5 · the access-token cookie on the REST surface (FROZEN contract, ios-completion §1.1):
|
||||
* - `Cookie: webterm_auth=<t>` is stamped on **EVERY** route — read-only GETs included — because the
|
||||
* server's auth gate runs in front of the whole app, not just the guarded routes;
|
||||
* - it is **orthogonal to `Origin`**: a RO route carries the cookie and still NO Origin (the
|
||||
* Origin-iff-guarded 铁律 is untouched);
|
||||
* - no token configured ⇒ NO `Cookie` header at all (zero-config LAN behaviour is byte-identical);
|
||||
* - a **401** on any RO/G route is the typed [ApiClientError.Unauthorized], never a generic status
|
||||
* error, so the UI can route the user to re-enter the token.
|
||||
*/
|
||||
class AccessTokenCookieTest {
|
||||
private companion object {
|
||||
const val BASE = "http://192.168.1.5:3000"
|
||||
const val TOKEN = "0123456789abcdefTOKEN"
|
||||
val ID: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
|
||||
const val ID_STR = "11111111-2222-4333-8444-555555555555"
|
||||
}
|
||||
|
||||
private val endpoint: HostEndpoint = requireNotNull(HostEndpoint.fromBaseUrl(BASE))
|
||||
private val transport = FakeHttpTransport()
|
||||
|
||||
private fun clientWithToken(): ApiClient =
|
||||
ApiClient(endpoint, transport, AccessTokenSource { TOKEN })
|
||||
|
||||
private fun clientWithoutToken(): ApiClient = ApiClient(endpoint, transport)
|
||||
|
||||
private suspend fun errorOf(block: suspend () -> Unit): Throwable? = runCatching { block() }.exceptionOrNull()
|
||||
|
||||
private fun assertCookie(index: Int) {
|
||||
val headers = transport.recordedRequests[index].headers
|
||||
assertEquals(
|
||||
"${AuthCookie.NAME}=$TOKEN",
|
||||
headers[AuthCookie.HEADER_NAME],
|
||||
"every request must carry the hand-written auth cookie",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a read-only GET carries the cookie and still no Origin`() = runTest {
|
||||
transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
|
||||
|
||||
clientWithToken().liveSessions()
|
||||
|
||||
assertCookie(0)
|
||||
assertFalse(
|
||||
transport.recordedRequests[0].headers.containsKey(HeaderName.ORIGIN),
|
||||
"the cookie is additive — a RO route must still never carry Origin",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a guarded write carries BOTH the byte-equal Origin and the cookie`() = runTest {
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/hook/decision", status = 204)
|
||||
|
||||
clientWithToken().hookDecision(ID, HookDecision.ALLOW, "cap-tok-123")
|
||||
|
||||
assertCookie(0)
|
||||
assertEquals(endpoint.originHeader, transport.recordedRequests[0].headers[HeaderName.ORIGIN])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the cookie rides every route shape - RO query, guarded DELETE and guarded PUT alike`() = runTest {
|
||||
transport.queueSuccess(
|
||||
url = "$BASE/projects/detail?path=%2Frepo",
|
||||
body = """{"name":"repo","path":"/repo","isGit":true}""".toByteArray(),
|
||||
)
|
||||
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$ID_STR", status = 204)
|
||||
transport.queueSuccess(method = HttpMethod.PUT, url = "$BASE/prefs", body = "{}".toByteArray())
|
||||
val client = clientWithToken()
|
||||
|
||||
client.projectDetail("/repo")
|
||||
client.killSession(ID)
|
||||
client.putPrefs(wang.yaojia.webterm.api.models.UiPrefs.create())
|
||||
|
||||
assertEquals(3, transport.recordedRequests.size)
|
||||
transport.recordedRequests.indices.forEach { assertCookie(it) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no configured token means no Cookie header at all`() = runTest {
|
||||
transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
|
||||
|
||||
clientWithoutToken().liveSessions()
|
||||
|
||||
assertFalse(
|
||||
transport.recordedRequests[0].headers.containsKey(AuthCookie.HEADER_NAME),
|
||||
"an unauthenticated host must see a byte-identical request to before the feature",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the token is re-read per request so a token stored later takes effect`() = runTest {
|
||||
transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
|
||||
transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
|
||||
var stored: String? = null
|
||||
val client = ApiClient(endpoint, transport, AccessTokenSource { stored })
|
||||
|
||||
client.liveSessions()
|
||||
stored = TOKEN
|
||||
client.liveSessions()
|
||||
|
||||
assertFalse(transport.recordedRequests[0].headers.containsKey(AuthCookie.HEADER_NAME))
|
||||
assertCookie(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 401 on a read-only route throws the typed Unauthorized error`() = runTest {
|
||||
transport.queueSuccess(url = "$BASE/live-sessions", status = 401, body = """{"error":"authentication required"}""".toByteArray())
|
||||
|
||||
val error = errorOf { clientWithoutToken().liveSessions() }
|
||||
|
||||
assertEquals(ApiClientError.Unauthorized, error)
|
||||
assertTrue(
|
||||
ApiClientError.Unauthorized.userMessage.isNotBlank(),
|
||||
"the UI needs actionable copy for a missing token",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 401 on a guarded git write throws Unauthorized instead of a Rejected outcome`() = runTest {
|
||||
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/push", status = 401)
|
||||
|
||||
assertEquals(ApiClientError.Unauthorized, errorOf { clientWithoutToken().gitPush("/repo") })
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,9 @@
|
||||
<application
|
||||
android:name=".WebTermApp"
|
||||
android:label="@string/app_name"
|
||||
android:allowBackup="false"
|
||||
android:fullBackupContent="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.WebTerm"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
|
||||
@@ -79,11 +79,16 @@ public sealed interface BannerModel {
|
||||
|
||||
/**
|
||||
* A NON-retryable terminal failure (e.g. [FailureReason.REPLAY_TOO_LARGE]). Actionable copy, **no
|
||||
* spinner** (reconnecting would hit the same wall forever), and a "新会话" affordance.
|
||||
* spinner** (reconnecting would hit the same wall forever), and — where a fresh session would
|
||||
* actually help — a "新会话" affordance.
|
||||
*
|
||||
* [FailureReason.UNAUTHORIZED] deliberately offers NO new-session action (B5): the server 401'd the
|
||||
* upgrade, so a new session would 401 too; the fix is to re-enter the host's access token (or fix its
|
||||
* `ALLOWED_ORIGINS`), which the copy says.
|
||||
*/
|
||||
public data class Failed(val reason: FailureReason) : BannerModel {
|
||||
override val showsSpinner: Boolean get() = false
|
||||
override val offersNewSession: Boolean get() = true
|
||||
override val offersNewSession: Boolean get() = reason != FailureReason.UNAUTHORIZED
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,6 +189,8 @@ private fun bannerCopy(model: BannerModel): String = when (model) {
|
||||
is BannerModel.Failed -> when (model.reason) {
|
||||
FailureReason.REPLAY_TOO_LARGE ->
|
||||
"回放数据过大,无法自动重连。请降低服务器 SCROLLBACK_BYTES 或提高客户端上限后开新会话。"
|
||||
FailureReason.UNAUTHORIZED ->
|
||||
"服务器拒绝了连接(401)。请在“配对主机”里重新填写访问令牌;若已填写,请检查主机的 ALLOWED_ORIGINS。"
|
||||
}
|
||||
is BannerModel.Exited ->
|
||||
if (model.isSpawnFailure) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package wang.yaojia.webterm.di
|
||||
|
||||
import android.content.Context
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import wang.yaojia.webterm.hostregistry.AccessTokenStore
|
||||
import wang.yaojia.webterm.hostregistry.TinkAccessTokenStore
|
||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Access-token boundary (B5 / ios-completion §1.1): the ONE per-host access-token store, exposed both as
|
||||
* the mutable [AccessTokenStore] (the pairing screen writes it) and as the read-only
|
||||
* [AccessTokenSource] the transports consume.
|
||||
*
|
||||
* It is its own Hilt module — not part of [StorageModule] — because the token is SECRET material:
|
||||
* [StorageModule]'s DataStore holds only non-secret UI state, while this store is
|
||||
* Tink-AEAD-encrypted under an AndroidKeystore-wrapped master key (the same posture as [TlsModule]'s
|
||||
* cert store, with its own keyset so the two secrets never share a key).
|
||||
*
|
||||
* Both providers resolve to the SAME singleton, so a token stored during pairing is immediately visible
|
||||
* to the WS upgrade and to every REST route — no cache to invalidate. The store is constructor-I/O-free
|
||||
* (Tink/Keystore work is lazy and hops to `Dispatchers.IO` inside), so injecting it on `Main` is cheap.
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
public object AuthModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
public fun provideAccessTokenStore(
|
||||
@ApplicationContext context: Context,
|
||||
): AccessTokenStore = TinkAccessTokenStore(context)
|
||||
|
||||
/** The transports' read seam — the SAME instance the pairing screen wrote to. */
|
||||
@Provides
|
||||
@Singleton
|
||||
public fun provideAccessTokenSource(store: AccessTokenStore): AccessTokenSource = store
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import wang.yaojia.webterm.transport.OkHttpClientFactory
|
||||
import wang.yaojia.webterm.transport.OkHttpHttpTransport
|
||||
import wang.yaojia.webterm.transport.OkHttpTermTransport
|
||||
import wang.yaojia.webterm.tlsandroid.IdentityRepository
|
||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||
import wang.yaojia.webterm.wire.HttpTransport
|
||||
import wang.yaojia.webterm.wire.TermTransport
|
||||
import javax.inject.Singleton
|
||||
@@ -65,10 +66,18 @@ public object NetworkModule {
|
||||
.connectionPool(connectionPool)
|
||||
.build()
|
||||
|
||||
/** WS transport over the shared client (it derives a streaming-tuned variant sharing the pool). */
|
||||
/**
|
||||
* WS transport over the shared client (it derives a streaming-tuned variant sharing the pool).
|
||||
*
|
||||
* [tokens] ([AuthModule]) is what makes the **WS upgrade** carry `Cookie: webterm_auth=<t>` — the
|
||||
* REST half gets its cookie from `:api-client`'s single stamping point instead (B5 / §1.1).
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
public fun provideTermTransport(client: OkHttpClient): TermTransport = OkHttpTermTransport(client)
|
||||
public fun provideTermTransport(
|
||||
client: OkHttpClient,
|
||||
tokens: AccessTokenSource,
|
||||
): TermTransport = OkHttpTermTransport(client, tokens)
|
||||
|
||||
/** REST transport over the SAME shared client (Origin stamped by `:api-client`, never here). */
|
||||
@Provides
|
||||
|
||||
@@ -58,6 +58,10 @@ public fun PairingPane(
|
||||
runCatching { env.identityRepository.hasInstalledIdentity() }.getOrDefault(false)
|
||||
}
|
||||
},
|
||||
// B5 · the access-token half: validated with POST /auth, then kept in the Keystore-backed store
|
||||
// the transports read from.
|
||||
tokenStore = env.accessTokenStore,
|
||||
authProber = env.buildAccessTokenProber(),
|
||||
)
|
||||
}
|
||||
LaunchedEffect(viewModel) { viewModel.bind(this) }
|
||||
@@ -189,7 +193,8 @@ public fun DiffPane(
|
||||
}
|
||||
val viewModel = remember(resolved, path) {
|
||||
DiffViewModel(
|
||||
fetcher = HttpDiffFetcher(resolved.endpoint, env.httpTransport),
|
||||
// The hand-built diff route needs the access-token cookie too (B5 / §1.1).
|
||||
fetcher = HttpDiffFetcher(resolved.endpoint, env.httpTransport, env.accessTokenStore),
|
||||
path = path,
|
||||
// Guarded git-write flows through :api-client's single Origin-stamping point (plan §Security).
|
||||
writer = ApiClientGitWriteGateway(env.apiClientFactory.create(resolved.endpoint)),
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
@@ -38,6 +39,9 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -50,8 +54,10 @@ import com.google.mlkit.vision.common.InputImage
|
||||
import wang.yaojia.webterm.designsystem.Spacing
|
||||
import wang.yaojia.webterm.hostregistry.Host
|
||||
import wang.yaojia.webterm.viewmodels.EntryError
|
||||
import wang.yaojia.webterm.viewmodels.PairingCopy
|
||||
import wang.yaojia.webterm.viewmodels.PairingUiState
|
||||
import wang.yaojia.webterm.viewmodels.PairingViewModel
|
||||
import wang.yaojia.webterm.viewmodels.TokenError
|
||||
import wang.yaojia.webterm.viewmodels.WarningTier
|
||||
import wang.yaojia.webterm.viewmodels.needsExplicitAck
|
||||
|
||||
@@ -65,6 +71,12 @@ import wang.yaojia.webterm.viewmodels.needsExplicitAck
|
||||
* requires an explicit acknowledge, and a tunnel host routes to the device-cert screen when the probe is
|
||||
* cert-gated. Every displayed URL/host is INERT [Text] (no autolink/markdown, plan §8).
|
||||
*
|
||||
* ### Access token (B5 / ios-completion §1.1)
|
||||
* The confirm step carries an OPTIONAL access-token field (masked, with a reveal toggle). The typed token
|
||||
* never enters the ViewModel's UI state — it is passed straight into `confirm()/retry()`, validated by
|
||||
* `POST /auth`, and (only when the server accepts it) stored in the Keystore-backed store. A wrong token /
|
||||
* rate-limit / "this host requires a token" comes back as an inert [TokenError] under the field.
|
||||
*
|
||||
* ### Permissions
|
||||
* - **CAMERA** (QR only) is requested at runtime with a rationale; a denial degrades gracefully to
|
||||
* manual entry (the QR path is never mandatory).
|
||||
@@ -104,7 +116,7 @@ public fun PairingScreen(
|
||||
is PairingUiState.Confirming -> ConfirmStep(
|
||||
state = s,
|
||||
onName = viewModel::setName,
|
||||
onConfirm = viewModel::confirm,
|
||||
onConfirm = { ack, token -> viewModel.confirm(acknowledgedPublicRisk = ack, accessToken = token) },
|
||||
onCancel = viewModel::reset,
|
||||
)
|
||||
is PairingUiState.Probing -> ProbingStep(host = s.name)
|
||||
@@ -228,10 +240,16 @@ private fun CameraPreview(onScanned: (String) -> Unit) {
|
||||
private fun ConfirmStep(
|
||||
state: PairingUiState.Confirming,
|
||||
onName: (String) -> Unit,
|
||||
onConfirm: (Boolean) -> Unit,
|
||||
onConfirm: (Boolean, String) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
var acknowledged by remember(state.endpoint) { mutableStateOf(false) }
|
||||
// The token lives ONLY here (and in the encrypted store once accepted) — never in the ViewModel's
|
||||
// UI state, so it cannot leak through a state snapshot / crash report (§1.1). Keyed on the endpoint
|
||||
// so a token typed for one host is dropped when the candidate changes, but survives a re-render
|
||||
// after a wrong-token error (the field keeps what the user typed).
|
||||
var token by remember(state.endpoint) { mutableStateOf("") }
|
||||
var tokenVisible by remember(state.endpoint) { mutableStateOf(false) }
|
||||
|
||||
TierWarningCard(tier = state.tier, highlight = state.awaitingAck && !acknowledged)
|
||||
// The dialed URL is validated but user/QR-sourced — render INERT (plan §8).
|
||||
@@ -249,6 +267,14 @@ private fun ConfirmStep(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
AccessTokenField(
|
||||
token = token,
|
||||
onToken = { token = it },
|
||||
visible = tokenVisible,
|
||||
onToggleVisible = { tokenVisible = !tokenVisible },
|
||||
error = state.tokenError,
|
||||
)
|
||||
|
||||
if (state.tier.needsExplicitAck) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = acknowledged, onCheckedChange = { acknowledged = it })
|
||||
@@ -259,13 +285,58 @@ private fun ConfirmStep(
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||
OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { Text("取消") }
|
||||
Button(
|
||||
onClick = { onConfirm(acknowledged) },
|
||||
onClick = { onConfirm(acknowledged, token) },
|
||||
enabled = !state.tier.needsExplicitAck || acknowledged,
|
||||
modifier = Modifier.weight(1f),
|
||||
) { Text("连接") }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The optional access-token field (B5 / ios-completion §1.1). Masked by default with an explicit
|
||||
* reveal toggle (a token is often typed from another screen and mistypes are the #1 failure), and
|
||||
* `KeyboardType.Password` + `autoCorrectEnabled = false` so the IME never learns or suggests it.
|
||||
* [error] renders the inert, app-authored copy for the typed [TokenError].
|
||||
*/
|
||||
@Composable
|
||||
private fun AccessTokenField(
|
||||
token: String,
|
||||
onToken: (String) -> Unit,
|
||||
visible: Boolean,
|
||||
onToggleVisible: () -> Unit,
|
||||
error: TokenError?,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = token,
|
||||
onValueChange = onToken,
|
||||
label = { Text("访问令牌(可选,WEBTERM_TOKEN)") },
|
||||
singleLine = true,
|
||||
isError = error != null,
|
||||
visualTransformation = if (visible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
autoCorrectEnabled = false,
|
||||
),
|
||||
trailingIcon = {
|
||||
TextButton(onClick = onToggleVisible) { Text(if (visible) "隐藏" else "显示") }
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (error != null) {
|
||||
Text(
|
||||
text = PairingCopy.describeToken(error),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "主机未设置 WEBTERM_TOKEN 时留空即可。",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TierWarningCard(tier: WarningTier, highlight: Boolean) {
|
||||
val (title, body) = tierCopy(tier)
|
||||
|
||||
@@ -18,6 +18,8 @@ import wang.yaojia.webterm.api.models.GitWriteOutcome
|
||||
import wang.yaojia.webterm.api.models.PushResult
|
||||
import wang.yaojia.webterm.api.models.StageResult
|
||||
import wang.yaojia.webterm.api.routes.ApiClient
|
||||
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
|
||||
@@ -439,14 +441,23 @@ public class DiffUnavailable(public val status: Int) : Exception("diff unavailab
|
||||
/**
|
||||
* Real [DiffFetcher]: builds the RO `GET /projects/diff` against [endpoint] and decodes tolerantly.
|
||||
* Read-only, so it stamps NO `Origin` header (the CSWSH 铁律 — only guarded routes carry it, plan §4.3).
|
||||
*
|
||||
* This route is hand-built (it is not one of `:api-client`'s 12), so the access-token cookie must be
|
||||
* stamped HERE too: the server's auth gate covers EVERY route, and a missing cookie 401s (B5 /
|
||||
* ios-completion §1.1). The value comes from the single derivation point ([AuthCookie]); [tokens] is
|
||||
* the same store the transports read, and a host with no token adds no header at all.
|
||||
*/
|
||||
public class HttpDiffFetcher(
|
||||
private val endpoint: HostEndpoint,
|
||||
private val http: HttpTransport,
|
||||
private val tokens: AccessTokenSource = AccessTokenSource.NONE,
|
||||
) : DiffFetcher {
|
||||
override suspend fun fetch(path: String, staged: Boolean, base: String?): DiffResult {
|
||||
val url = diffUrl(endpoint.baseUrl, path, staged, base) ?: throw DiffUnavailable(HTTP_BAD_REQUEST)
|
||||
val response = http.send(HttpRequest(method = HttpMethod.GET, url = url))
|
||||
val headers = tokens.tokenFor(endpoint)
|
||||
?.let { mapOf(AuthCookie.HEADER_NAME to AuthCookie.headerValue(it)) }
|
||||
?: emptyMap()
|
||||
val response = http.send(HttpRequest(method = HttpMethod.GET, url = url, headers = headers))
|
||||
if (response.status != HTTP_OK) throw DiffUnavailable(response.status)
|
||||
return decodeDiffResult(response.body)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import wang.yaojia.webterm.api.pairing.AuthProbeResult
|
||||
import wang.yaojia.webterm.api.pairing.PairingError
|
||||
import wang.yaojia.webterm.api.pairing.PairingProbeResult
|
||||
import wang.yaojia.webterm.api.pairing.probeAccessToken
|
||||
import wang.yaojia.webterm.api.pairing.runPairingProbe
|
||||
import wang.yaojia.webterm.hostregistry.AccessTokenStore
|
||||
import wang.yaojia.webterm.hostregistry.Host
|
||||
import wang.yaojia.webterm.hostregistry.HostStore
|
||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||
import wang.yaojia.webterm.wire.HostClassifier
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HostNetworkTier
|
||||
@@ -42,15 +47,31 @@ import java.util.UUID
|
||||
*
|
||||
* On probe success the validated host is persisted via [HostStore.upsert].
|
||||
*
|
||||
* ### RULE 5 — the access token is validated and stored BEFORE the probe (B5, ios-completion §1.1)
|
||||
* When the user typed a token, [confirm]/[retry] first validate it with `POST /auth` ([authProber]) and,
|
||||
* only if the server ACCEPTED it (204 **with** `Set-Cookie`), persist it to the Keystore-backed
|
||||
* [tokenStore]. That ordering is load-bearing: the two-step probe's own HTTP legs AND its WS upgrade
|
||||
* read the token back out of that same store, so they can only authenticate if the token is already in
|
||||
* it. A 204 **without** `Set-Cookie` means the host has no auth at all — nothing is stored and pairing
|
||||
* continues (never treated as "authenticated"). A wrong token / rate-limit / malformed token keeps the
|
||||
* user on the confirm step with a typed [TokenError] and NEVER reaches the network probe.
|
||||
*
|
||||
* The token itself never enters [PairingUiState] — it lives in the screen's own field and is passed in
|
||||
* per call, so no state snapshot, log or crash report can carry it.
|
||||
*
|
||||
* @param hostStore where a successfully-paired [Host] is saved.
|
||||
* @param prober the two-step [wang.yaojia.webterm.api.pairing.runPairingProbe] seam; a fake in tests.
|
||||
* @param hasDeviceCert `suspend` cert check (production: `IdentityRepository.hasInstalledIdentity` on IO).
|
||||
* @param tokenStore per-host access-token storage (production: the Tink/AndroidKeystore store).
|
||||
* @param authProber the `POST /auth` validation seam ([accessTokenProber] in production).
|
||||
* @param newId host-id allocator (default random UUID; deterministic in tests).
|
||||
*/
|
||||
public class PairingViewModel(
|
||||
private val hostStore: HostStore,
|
||||
private val prober: PairingProber,
|
||||
private val hasDeviceCert: suspend () -> Boolean,
|
||||
private val tokenStore: AccessTokenStore,
|
||||
private val authProber: AccessTokenProber,
|
||||
private val newId: () -> String = { UUID.randomUUID().toString() },
|
||||
) {
|
||||
private val _uiState = MutableStateFlow<PairingUiState>(PairingUiState.Entry())
|
||||
@@ -111,22 +132,23 @@ public class PairingViewModel(
|
||||
/**
|
||||
* Confirm the on-screen candidate and probe it. For a public host, [acknowledgedPublicRisk] MUST be
|
||||
* true or this re-arms the acknowledge reminder without touching the network (RULE 3).
|
||||
* [accessToken] is the (optional) `WEBTERM_TOKEN` the user typed — blank means "this host has none".
|
||||
*/
|
||||
public fun confirm(acknowledgedPublicRisk: Boolean = false) {
|
||||
public fun confirm(acknowledgedPublicRisk: Boolean = false, accessToken: String = "") {
|
||||
val candidate = _uiState.value.candidate() ?: return
|
||||
runProbe(candidate, acknowledgedPublicRisk)
|
||||
runProbe(candidate, acknowledgedPublicRisk, accessToken)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry after a failure or a cert-gate refusal. Funnels through the SAME [runProbe] choke point as
|
||||
* [confirm] — so the tunnel cert-gate (RULE 4) cannot be bypassed by retrying.
|
||||
*/
|
||||
public fun retry(acknowledgedPublicRisk: Boolean = false) {
|
||||
public fun retry(acknowledgedPublicRisk: Boolean = false, accessToken: String = "") {
|
||||
val candidate = _uiState.value.candidate() ?: return
|
||||
runProbe(candidate, acknowledgedPublicRisk)
|
||||
runProbe(candidate, acknowledgedPublicRisk, accessToken)
|
||||
}
|
||||
|
||||
private fun runProbe(candidate: Candidate, acknowledgedPublicRisk: Boolean) {
|
||||
private fun runProbe(candidate: Candidate, acknowledgedPublicRisk: Boolean, accessToken: String) {
|
||||
val tier = candidate.tier
|
||||
// RULE 3 — public host: no probe until the risk is explicitly acknowledged.
|
||||
if (tier.needsExplicitAck && !acknowledgedPublicRisk) {
|
||||
@@ -147,17 +169,77 @@ public class PairingViewModel(
|
||||
return@launch
|
||||
}
|
||||
_uiState.value = PairingUiState.Probing(candidate.endpoint, candidate.name, tier)
|
||||
// RULE 5 — validate + store the token BEFORE probing (the probe authenticates from the store).
|
||||
if (accessToken.isNotBlank() && !establishToken(candidate, accessToken)) return@launch
|
||||
when (val result = prober.probe(candidate.endpoint)) {
|
||||
is PairingProbeResult.Success -> onProbeSuccess(candidate)
|
||||
is PairingProbeResult.Failure -> _uiState.value = PairingUiState.Failed(
|
||||
is PairingProbeResult.Failure -> onProbeFailure(candidate, result.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate [token] with `POST /auth` and, if the server accepted it, persist it for this host.
|
||||
* Returns true when pairing may continue (accepted-and-stored, OR the host has no auth at all);
|
||||
* false after publishing the matching [TokenError] / failure state.
|
||||
*/
|
||||
private suspend fun establishToken(candidate: Candidate, token: String): Boolean {
|
||||
val outcome = try {
|
||||
authProber.validate(candidate.endpoint, token)
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (error: Throwable) {
|
||||
// A network-level failure is not a token problem — classify it like any other probe error.
|
||||
onProbeFailure(candidate, PairingError.classify(error, candidate.endpoint))
|
||||
return false
|
||||
}
|
||||
return when (outcome) {
|
||||
AuthProbeResult.Accepted -> storeToken(candidate, token)
|
||||
// 204 with no Set-Cookie: the host has no auth. Store NOTHING (never "assume authenticated")
|
||||
// and carry on — an open host pairs exactly as it did before this feature.
|
||||
AuthProbeResult.AuthDisabled -> true
|
||||
AuthProbeResult.InvalidToken -> failToken(candidate, TokenError.INVALID)
|
||||
AuthProbeResult.RateLimited -> failToken(candidate, TokenError.TOO_MANY_ATTEMPTS)
|
||||
AuthProbeResult.Malformed -> failToken(candidate, TokenError.MALFORMED)
|
||||
is AuthProbeResult.Unexpected -> failToken(candidate, TokenError.UNVERIFIABLE)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun storeToken(candidate: Candidate, token: String): Boolean {
|
||||
val stored = runCatching { tokenStore.put(candidate.endpoint, token) }
|
||||
return when {
|
||||
stored.getOrNull() == true -> true
|
||||
// put() == false ⇒ the token failed the client-side rule (unreachable after a server ACCEPT).
|
||||
stored.isSuccess -> failToken(candidate, TokenError.MALFORMED)
|
||||
// A throw ⇒ encrypted storage failed; never continue with a token we could not keep.
|
||||
else -> failToken(candidate, TokenError.STORE_FAILED)
|
||||
}
|
||||
}
|
||||
|
||||
/** Publish a token-specific error, keeping the user ON the confirm step (the field is there). */
|
||||
private fun failToken(candidate: Candidate, error: TokenError): Boolean {
|
||||
_uiState.value = PairingUiState.Confirming(
|
||||
endpoint = candidate.endpoint,
|
||||
name = candidate.name,
|
||||
tier = tier,
|
||||
error = result.error,
|
||||
message = PairingCopy.describe(result.error, tier),
|
||||
tier = candidate.tier,
|
||||
tokenError = error,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
private fun onProbeFailure(candidate: Candidate, error: PairingError) {
|
||||
// The host demands a token we do not have → back to the confirm step to enter one.
|
||||
if (error == PairingError.AccessTokenRequired) {
|
||||
failToken(candidate, TokenError.REQUIRED)
|
||||
return
|
||||
}
|
||||
_uiState.value = PairingUiState.Failed(
|
||||
endpoint = candidate.endpoint,
|
||||
name = candidate.name,
|
||||
tier = candidate.tier,
|
||||
error = error,
|
||||
message = PairingCopy.describe(error, candidate.tier),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun onProbeSuccess(candidate: Candidate) {
|
||||
@@ -186,6 +268,21 @@ public fun interface PairingProber {
|
||||
public suspend fun probe(endpoint: HostEndpoint): PairingProbeResult
|
||||
}
|
||||
|
||||
/**
|
||||
* The `POST /auth` access-token validation seam ([probeAccessToken]); faked in tests. Kept separate from
|
||||
* [PairingProber] so the ORDER (validate+store, then probe) is observable in a unit test.
|
||||
*/
|
||||
public fun interface AccessTokenProber {
|
||||
public suspend fun validate(endpoint: HostEndpoint, token: String): AuthProbeResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Production [AccessTokenProber]: the frozen one-shot `POST /auth` probe over the ONE shared
|
||||
* `OkHttpClient`'s [HttpTransport]. The token travels in the JSON body only — never a URL, never a log.
|
||||
*/
|
||||
public fun accessTokenProber(http: HttpTransport): AccessTokenProber =
|
||||
AccessTokenProber { endpoint, token -> probeAccessToken(endpoint, http, token) }
|
||||
|
||||
/**
|
||||
* Production [PairingProber] binding: the frozen two-step [runPairingProbe] over the ONE shared
|
||||
* `OkHttpClient`'s [HttpTransport] + [TermTransport] (injected in `NetworkModule`). The screen's nav
|
||||
@@ -193,8 +290,12 @@ public fun interface PairingProber {
|
||||
* `warmUp()` must have run first (off-`Main`) so the first handshake here doesn't do AndroidKeyStore/Tink
|
||||
* I/O on a UI thread.
|
||||
*/
|
||||
public fun pairingProber(http: HttpTransport, ws: TermTransport): PairingProber =
|
||||
PairingProber { endpoint -> runPairingProbe(endpoint, http, ws) }
|
||||
public fun pairingProber(
|
||||
http: HttpTransport,
|
||||
ws: TermTransport,
|
||||
tokens: AccessTokenSource = AccessTokenSource.NONE,
|
||||
): PairingProber =
|
||||
PairingProber { endpoint -> runPairingProbe(endpoint, http, ws, tokens) }
|
||||
|
||||
// ── Warning tiers (plan §5.4) ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -265,6 +366,11 @@ public sealed interface PairingUiState {
|
||||
val name: String,
|
||||
val tier: WarningTier,
|
||||
val awaitingAck: Boolean = false,
|
||||
/**
|
||||
* Set when the access-token step failed (or the host demands one). The token VALUE is never
|
||||
* carried here — only why it went wrong (§1.1: a secret never enters app state).
|
||||
*/
|
||||
val tokenError: TokenError? = null,
|
||||
) : PairingUiState
|
||||
|
||||
/** The two-step probe is running against [endpoint]. */
|
||||
@@ -295,6 +401,30 @@ public enum class EntryError {
|
||||
INVALID_URL,
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the access-token step failed. Rendered next to the token field on the confirm step, so the user can
|
||||
* fix it in place (B5 / ios-completion §1.1).
|
||||
*/
|
||||
public enum class TokenError {
|
||||
/** The host answered 401 to the probe: it HAS a token configured and we presented none/a wrong one. */
|
||||
REQUIRED,
|
||||
|
||||
/** `POST /auth` → 401: the typed token is wrong. */
|
||||
INVALID,
|
||||
|
||||
/** `POST /auth` → 429: the server's 10/min/IP limiter tripped. */
|
||||
TOO_MANY_ATTEMPTS,
|
||||
|
||||
/** The typed token cannot be a server token at all (charset / 16–512 length). No request was sent. */
|
||||
MALFORMED,
|
||||
|
||||
/** `POST /auth` answered something unexpected — the token could not be verified either way. */
|
||||
UNVERIFIABLE,
|
||||
|
||||
/** The token was correct but encrypted on-device storage refused to keep it. */
|
||||
STORE_FAILED,
|
||||
}
|
||||
|
||||
/** The endpoint+name+tier a probe runs against, recovered from whatever state currently holds one. */
|
||||
private data class Candidate(val endpoint: HostEndpoint, val name: String, val tier: WarningTier)
|
||||
|
||||
@@ -308,6 +438,22 @@ private fun PairingUiState.candidate(): Candidate? = when (this) {
|
||||
|
||||
/** Maps the [PairingError] taxonomy to inert, app-authored Chinese copy (never a server string, §8). */
|
||||
internal object PairingCopy {
|
||||
/** Inert, app-authored copy for each [TokenError]. Never echoes the token itself. */
|
||||
fun describeToken(error: TokenError): String = when (error) {
|
||||
TokenError.REQUIRED ->
|
||||
"该主机启用了访问令牌(WEBTERM_TOKEN)。请填写访问令牌后再连接。"
|
||||
TokenError.INVALID ->
|
||||
"访问令牌不正确。请核对主机上的 WEBTERM_TOKEN 后重试。"
|
||||
TokenError.TOO_MANY_ATTEMPTS ->
|
||||
"尝试过多,服务器已限流(每分钟 10 次)。请稍后再试。"
|
||||
TokenError.MALFORMED ->
|
||||
"访问令牌格式不合法:需 16–512 个字符,且只能包含 A–Z a–z 0–9 . _ ~ + / = -。"
|
||||
TokenError.UNVERIFIABLE ->
|
||||
"无法验证访问令牌:服务器返回了意外响应。请确认地址和端口指向 web-terminal。"
|
||||
TokenError.STORE_FAILED ->
|
||||
"无法在本机安全保存访问令牌(加密存储写入失败)。请重试。"
|
||||
}
|
||||
|
||||
fun describe(error: PairingError, tier: WarningTier): String = when (error) {
|
||||
is PairingError.HostUnreachable ->
|
||||
"无法连接到主机。请检查地址、端口,以及设备是否在同一局域网 / Tailscale 网络内。"
|
||||
@@ -321,6 +467,7 @@ internal object PairingCopy {
|
||||
// is the problem (plan §5.4: "TLS failure re-mapped to client cert invalid/revoked").
|
||||
if (tier == WarningTier.TUNNEL) "客户端证书无效或已被吊销。请在“设备证书”里重新导入后再试。"
|
||||
else "TLS 握手失败:无法验证服务器证书。"
|
||||
PairingError.AccessTokenRequired -> describeToken(TokenError.REQUIRED)
|
||||
PairingError.Timeout ->
|
||||
"连接超时。主机可能不可达,或响应过慢。"
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package wang.yaojia.webterm.wiring
|
||||
|
||||
import dagger.Lazy
|
||||
import wang.yaojia.webterm.api.routes.ApiClient
|
||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpTransport
|
||||
import javax.inject.Inject
|
||||
@@ -23,6 +24,7 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
public class ApiClientFactory @Inject constructor(
|
||||
private val http: Lazy<HttpTransport>,
|
||||
private val tokens: AccessTokenSource,
|
||||
) {
|
||||
/**
|
||||
* Resolve the shared [HttpTransport] (→ builds the shared `OkHttpClient`) so the slow mTLS I/O runs
|
||||
@@ -32,6 +34,14 @@ public class ApiClientFactory @Inject constructor(
|
||||
http.get()
|
||||
}
|
||||
|
||||
/** Resolves the [Lazy] transport; call [warm] off `Main` first so this is a cheap singleton read. */
|
||||
public fun create(endpoint: HostEndpoint): ApiClient = ApiClient(endpoint = endpoint, http = http.get())
|
||||
/**
|
||||
* Resolves the [Lazy] transport; call [warm] off `Main` first so this is a cheap singleton read.
|
||||
*
|
||||
* Every client is handed the shared [AccessTokenSource], so EVERY REST route — including the
|
||||
* read-only GETs and the push-decision POSTs — presents this host's access token when one is stored
|
||||
* (B5 / ios-completion §1.1). The token is read per request, so pairing a host mid-run takes effect
|
||||
* without rebuilding anything.
|
||||
*/
|
||||
public fun create(endpoint: HostEndpoint): ApiClient =
|
||||
ApiClient(endpoint = endpoint, http = http.get(), tokens = tokens)
|
||||
}
|
||||
|
||||
@@ -4,10 +4,15 @@ import dagger.Lazy
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import wang.yaojia.webterm.api.pairing.runPairingProbe
|
||||
import wang.yaojia.webterm.hostregistry.AccessTokenStore
|
||||
import wang.yaojia.webterm.hostregistry.HostStore
|
||||
import wang.yaojia.webterm.hostregistry.LastSessionStore
|
||||
import wang.yaojia.webterm.tlsandroid.IdentityRepository
|
||||
import wang.yaojia.webterm.viewmodels.AccessTokenProber
|
||||
import wang.yaojia.webterm.viewmodels.PairingProber
|
||||
import wang.yaojia.webterm.viewmodels.accessTokenProber
|
||||
import wang.yaojia.webterm.api.pairing.probeAccessToken
|
||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||
import wang.yaojia.webterm.wire.HttpTransport
|
||||
import wang.yaojia.webterm.wire.TermTransport
|
||||
import javax.inject.Inject
|
||||
@@ -46,6 +51,11 @@ import javax.inject.Singleton
|
||||
public class AppEnvironment @Inject constructor(
|
||||
public val hostStore: HostStore,
|
||||
public val lastSessionStore: LastSessionStore,
|
||||
/**
|
||||
* B5 · per-host access tokens (`WEBTERM_TOKEN`), Tink/AndroidKeystore-encrypted. The pairing screen
|
||||
* WRITES it; the transports read the same instance through [AccessTokenSource] (see `AuthModule`).
|
||||
*/
|
||||
public val accessTokenStore: AccessTokenStore,
|
||||
public val apiClientFactory: ApiClientFactory,
|
||||
public val sessionEngineFactory: SessionEngineFactory,
|
||||
public val coldStartPolicy: ColdStartPolicy,
|
||||
@@ -80,7 +90,19 @@ public class AppEnvironment @Inject constructor(
|
||||
* after [warmUp].
|
||||
*/
|
||||
public fun buildPairingProber(): PairingProber =
|
||||
PairingProber { endpoint -> runPairingProbe(endpoint, httpTransportLazy.get(), termTransportLazy.get()) }
|
||||
PairingProber { endpoint ->
|
||||
// The probe's HTTP legs authenticate from the SAME store the WS transport reads, so a token
|
||||
// stored a moment earlier by the pairing flow applies to all three legs (B5 / §1.1).
|
||||
runPairingProbe(endpoint, httpTransportLazy.get(), termTransportLazy.get(), accessTokenStore)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `POST /auth` access-token validation prober (B5). Like [buildPairingProber], the shared
|
||||
* transport is resolved lazily INSIDE the call so building this on the UI thread does no mTLS/OkHttp
|
||||
* work.
|
||||
*/
|
||||
public fun buildAccessTokenProber(): AccessTokenProber =
|
||||
AccessTokenProber { endpoint, token -> probeAccessToken(endpoint, httpTransportLazy.get(), token) }
|
||||
|
||||
/**
|
||||
* Build the shared `OkHttpClient` and first-touch the device identity OFF `Main` (FIX 3). Resolving
|
||||
|
||||
29
android/app/src/main/res/xml/data_extraction_rules.xml
Normal file
29
android/app/src/main/res/xml/data_extraction_rules.xml
Normal file
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
B5 / ios-completion §1.1 — the app holds SECRET material on device: the per-host access token
|
||||
(`WEBTERM_TOKEN`, Tink-encrypted in webterm_access_token_prefs) and the mTLS device identity
|
||||
(AndroidKeyStore key + Tink-encrypted cert blob). None of it may leave the device.
|
||||
|
||||
`android:allowBackup="false"` already opts out of cloud backup / adb backup on every API level; this
|
||||
file additionally covers API 31+ cloud-backup and device-to-device TRANSFER, where a plain
|
||||
allowBackup=false does NOT by itself block transfer. Both sections exclude everything.
|
||||
|
||||
(The AndroidKeyStore private key is non-exportable regardless, so a transferred blob would be
|
||||
undecryptable anyway — this is defence in depth, and it keeps the intent explicit.)
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="root" />
|
||||
<exclude domain="file" />
|
||||
<exclude domain="database" />
|
||||
<exclude domain="sharedpref" />
|
||||
<exclude domain="external" />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="root" />
|
||||
<exclude domain="file" />
|
||||
<exclude domain="database" />
|
||||
<exclude domain="sharedpref" />
|
||||
<exclude domain="external" />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -109,4 +109,27 @@ class ReconnectBannerTest {
|
||||
val connecting = bannerModel(BannerModel.Hidden, Connection(ConnectionState.Connecting))
|
||||
assertEquals(connecting, bannerModel(connecting, Output("some bytes")))
|
||||
}
|
||||
|
||||
// ── 5. B5 · a 401 upgrade is terminal AND offers no new session ───────────────
|
||||
|
||||
@Test
|
||||
fun `an unauthorized failure is terminal with no spinner and no new-session action`() {
|
||||
val model = bannerModel(BannerModel.Hidden, Connection(ConnectionState.Failed(FailureReason.UNAUTHORIZED)))
|
||||
|
||||
val failed = model as BannerModel.Failed
|
||||
assertEquals(FailureReason.UNAUTHORIZED, failed.reason)
|
||||
assertFalse(failed.showsSpinner, "a 401 is permanent — never show a retry spinner")
|
||||
assertFalse(
|
||||
failed.offersNewSession,
|
||||
"a new session would 401 too; the fix is the access token, not another session",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unauthorized failure outranks later transient events`() {
|
||||
val failed = bannerModel(BannerModel.Hidden, Connection(ConnectionState.Failed(FailureReason.UNAUTHORIZED)))
|
||||
|
||||
assertEquals(failed, bannerModel(failed, Connection(ConnectionState.Connecting)))
|
||||
assertEquals(failed, bannerModel(failed, Connection(ConnectionState.Reconnecting(1, 2.seconds))))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ import wang.yaojia.webterm.api.models.CommitResult
|
||||
import wang.yaojia.webterm.api.models.GitWriteOutcome
|
||||
import wang.yaojia.webterm.api.models.PushResult
|
||||
import wang.yaojia.webterm.api.models.StageResult
|
||||
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
|
||||
/**
|
||||
* A24 DiffViewModel — the JVM-testable read-only diff logic (plan §4.2 / §1): the STRING staged flag
|
||||
@@ -291,4 +294,35 @@ class DiffViewModelTest {
|
||||
assertTrue(DiffUiState(canWrite = true).writeEnabled)
|
||||
assertFalse(DiffUiState(canWrite = true, base = "main").writeEnabled)
|
||||
}
|
||||
|
||||
// ── B5 · the hand-built diff route carries the access-token cookie ───────────────────────────
|
||||
|
||||
@Test
|
||||
fun `the real diff fetcher stamps the auth cookie and never an Origin`() = runTest {
|
||||
val endpoint = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.5:3000"))
|
||||
val http = FakeHttpTransport()
|
||||
val url = "http://10.0.0.5:3000/projects/diff?path=%2Frepo&staged=0"
|
||||
http.queueSuccess(url = url, body = """{"files":[]}""".toByteArray())
|
||||
val fetcher = HttpDiffFetcher(endpoint, http, AccessTokenSource { "0123456789abcdefTOKEN" })
|
||||
|
||||
fetcher.fetch("/repo", staged = false, base = null)
|
||||
|
||||
val request = http.recordedRequests.single()
|
||||
assertEquals("webterm_auth=0123456789abcdefTOKEN", request.headers["Cookie"])
|
||||
assertFalse(request.headers.containsKey("Origin"), "the diff route is read-only")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the real diff fetcher sends no cookie for a host without a token`() = runTest {
|
||||
val endpoint = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.5:3000"))
|
||||
val http = FakeHttpTransport()
|
||||
http.queueSuccess(
|
||||
url = "http://10.0.0.5:3000/projects/diff?path=%2Frepo&staged=0",
|
||||
body = """{"files":[]}""".toByteArray(),
|
||||
)
|
||||
|
||||
HttpDiffFetcher(endpoint, http).fetch("/repo", staged = false, base = null)
|
||||
|
||||
assertFalse(http.recordedRequests.single().headers.containsKey("Cookie"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertInstanceOf
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.api.pairing.AuthProbeResult
|
||||
import wang.yaojia.webterm.api.pairing.PairingError
|
||||
import wang.yaojia.webterm.api.pairing.PairingProbeResult
|
||||
import wang.yaojia.webterm.hostregistry.InMemoryAccessTokenStore
|
||||
import wang.yaojia.webterm.hostregistry.InMemoryHostStore
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* B5 · the access-token half of pairing (FROZEN contract, ios-completion §1.1). The pairing screen is
|
||||
* where a token is entered, validated with `POST /auth`, and stored — so this pins:
|
||||
* - **order**: the token is validated and stored BEFORE the two-step probe runs, because the probe's
|
||||
* own HTTP+WS legs must already be able to authenticate;
|
||||
* - the four `/auth` outcomes → the right UI state (and, for 204-no-Set-Cookie, **no** token stored);
|
||||
* - a wrong token / rate-limit keeps the user ON the confirm step with a typed [TokenError] (the field
|
||||
* is right there) instead of dropping them into a generic failure;
|
||||
* - a host that answers 401 to the probe asks for a token ([TokenError.REQUIRED]);
|
||||
* - the token NEVER appears in the UI state (no accidental leak into a state dump / crash report).
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class PairingTokenViewModelTest {
|
||||
|
||||
private companion object {
|
||||
const val LAN = "http://10.0.0.5:3000"
|
||||
const val TOKEN = "0123456789abcdefTOKEN"
|
||||
}
|
||||
|
||||
/** Records every `/auth` validation so ordering vs the pairing probe is directly assertable. */
|
||||
private class FakeAuthProber(var result: (String) -> AuthProbeResult) : AccessTokenProber {
|
||||
val calls = mutableListOf<String>()
|
||||
override suspend fun validate(endpoint: HostEndpoint, token: String): AuthProbeResult {
|
||||
calls += token
|
||||
return result(token)
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeProber(var result: (HostEndpoint) -> PairingProbeResult) : PairingProber {
|
||||
val calls = mutableListOf<HostEndpoint>()
|
||||
override suspend fun probe(endpoint: HostEndpoint): PairingProbeResult {
|
||||
calls += endpoint
|
||||
return result(endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
private fun endpoint(url: String = LAN): HostEndpoint =
|
||||
requireNotNull(HostEndpoint.fromBaseUrl(url))
|
||||
|
||||
private fun newVm(
|
||||
prober: PairingProber,
|
||||
authProber: AccessTokenProber,
|
||||
tokenStore: InMemoryAccessTokenStore = InMemoryAccessTokenStore(),
|
||||
store: InMemoryHostStore = InMemoryHostStore(),
|
||||
) = PairingViewModel(
|
||||
hostStore = store,
|
||||
prober = prober,
|
||||
hasDeviceCert = { false },
|
||||
tokenStore = tokenStore,
|
||||
authProber = authProber,
|
||||
newId = { "fixed-id" },
|
||||
)
|
||||
|
||||
// ── happy path: validate → store → probe ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a correct token is validated, stored, and only THEN is the host probed`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val order = mutableListOf<String>()
|
||||
val authProber = FakeAuthProber { order += "auth"; AuthProbeResult.Accepted }
|
||||
val prober = FakeProber { order += "probe"; PairingProbeResult.Success(it) }
|
||||
val tokens = InMemoryAccessTokenStore()
|
||||
val hosts = InMemoryHostStore()
|
||||
val vm = newVm(prober, authProber, tokens, hosts)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm(accessToken = TOKEN)
|
||||
|
||||
assertEquals(listOf("auth", "probe"), order, "the probe must be able to authenticate itself")
|
||||
assertEquals(TOKEN, tokens.tokenFor(endpoint()), "an accepted token is persisted per host")
|
||||
assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value)
|
||||
assertEquals(1, hosts.loadAll().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank token skips the auth probe entirely so an open LAN host pairs as before`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val authProber = FakeAuthProber { AuthProbeResult.Accepted }
|
||||
val prober = FakeProber { PairingProbeResult.Success(it) }
|
||||
val tokens = InMemoryAccessTokenStore()
|
||||
val vm = newVm(prober, authProber, tokens)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm()
|
||||
|
||||
assertTrue(authProber.calls.isEmpty(), "no token typed ⇒ never call POST /auth")
|
||||
assertEquals(1, prober.calls.size)
|
||||
assertNull(tokens.tokenFor(endpoint()))
|
||||
assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value)
|
||||
}
|
||||
|
||||
// ── the four frozen /auth outcomes ──────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `204 without Set-Cookie means auth is disabled - nothing is stored but pairing continues`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val authProber = FakeAuthProber { AuthProbeResult.AuthDisabled }
|
||||
val prober = FakeProber { PairingProbeResult.Success(it) }
|
||||
val tokens = InMemoryAccessTokenStore()
|
||||
val vm = newVm(prober, authProber, tokens)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm(accessToken = TOKEN)
|
||||
|
||||
assertNull(tokens.tokenFor(endpoint()), "a host without auth must not get a stored token")
|
||||
assertEquals(1, prober.calls.size, "the host is open — pairing proceeds")
|
||||
assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a wrong token keeps the user on the confirm step with an INVALID token error`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val authProber = FakeAuthProber { AuthProbeResult.InvalidToken }
|
||||
val prober = FakeProber { PairingProbeResult.Success(it) }
|
||||
val tokens = InMemoryAccessTokenStore()
|
||||
val vm = newVm(prober, authProber, tokens)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm(accessToken = TOKEN)
|
||||
|
||||
val state = assertInstanceOf(PairingUiState.Confirming::class.java, vm.uiState.value)
|
||||
assertEquals(TokenError.INVALID, state.tokenError)
|
||||
assertTrue(prober.calls.isEmpty(), "a wrong token must not proceed to the probe")
|
||||
assertNull(tokens.tokenFor(endpoint()), "a rejected token is never stored")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rate-limited auth attempt surfaces TOO_MANY_ATTEMPTS`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val authProber = FakeAuthProber { AuthProbeResult.RateLimited }
|
||||
val vm = newVm(FakeProber { PairingProbeResult.Success(it) }, authProber)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm(accessToken = TOKEN)
|
||||
|
||||
val state = assertInstanceOf(PairingUiState.Confirming::class.java, vm.uiState.value)
|
||||
assertEquals(TokenError.TOO_MANY_ATTEMPTS, state.tokenError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a malformed token is reported without any network call`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val authProber = FakeAuthProber { AuthProbeResult.Malformed }
|
||||
val prober = FakeProber { PairingProbeResult.Success(it) }
|
||||
val vm = newVm(prober, authProber)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm(accessToken = "short")
|
||||
|
||||
val state = assertInstanceOf(PairingUiState.Confirming::class.java, vm.uiState.value)
|
||||
assertEquals(TokenError.MALFORMED, state.tokenError)
|
||||
assertTrue(prober.calls.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unexpected auth status never counts as success`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val authProber = FakeAuthProber { AuthProbeResult.Unexpected(302) }
|
||||
val prober = FakeProber { PairingProbeResult.Success(it) }
|
||||
val vm = newVm(prober, authProber)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm(accessToken = TOKEN)
|
||||
|
||||
val state = assertInstanceOf(PairingUiState.Confirming::class.java, vm.uiState.value)
|
||||
assertEquals(TokenError.UNVERIFIABLE, state.tokenError)
|
||||
assertTrue(prober.calls.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a transport failure during auth validation maps to the normal pairing failure copy`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val authProber = FakeAuthProber { throw IOException("connection refused") }
|
||||
val vm = newVm(FakeProber { PairingProbeResult.Success(it) }, authProber)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm(accessToken = TOKEN)
|
||||
|
||||
val state = assertInstanceOf(PairingUiState.Failed::class.java, vm.uiState.value)
|
||||
assertTrue(state.message.isNotBlank())
|
||||
}
|
||||
|
||||
// ── discovery: the host demands a token we do not have ──────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a probe that 401s asks for an access token`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val prober = FakeProber { PairingProbeResult.Failure(PairingError.AccessTokenRequired) }
|
||||
val vm = newVm(prober, FakeAuthProber { AuthProbeResult.Accepted })
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm()
|
||||
|
||||
val state = assertInstanceOf(PairingUiState.Confirming::class.java, vm.uiState.value)
|
||||
assertEquals(TokenError.REQUIRED, state.tokenError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retrying with the token now typed pairs successfully`() = runTest(UnconfinedTestDispatcher()) {
|
||||
var demandToken = true
|
||||
val prober = FakeProber {
|
||||
if (demandToken) PairingProbeResult.Failure(PairingError.AccessTokenRequired)
|
||||
else PairingProbeResult.Success(it)
|
||||
}
|
||||
val tokens = InMemoryAccessTokenStore()
|
||||
val vm = newVm(prober, FakeAuthProber { AuthProbeResult.Accepted }, tokens)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm()
|
||||
demandToken = false
|
||||
vm.retry(accessToken = TOKEN)
|
||||
|
||||
assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value)
|
||||
assertEquals(TOKEN, tokens.tokenFor(endpoint()))
|
||||
}
|
||||
|
||||
// ── secrecy ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `the token never appears in the UI state`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val vm = newVm(
|
||||
FakeProber { PairingProbeResult.Failure(PairingError.AccessTokenRequired) },
|
||||
FakeAuthProber { AuthProbeResult.Accepted },
|
||||
)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm(accessToken = TOKEN)
|
||||
|
||||
assertTrue(
|
||||
!vm.uiState.value.toString().contains(TOKEN),
|
||||
"a secret must not be reachable through a state snapshot / toString",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every token error has actionable copy`() {
|
||||
TokenError.entries.forEach { error ->
|
||||
assertTrue(PairingCopy.describeToken(error).isNotBlank(), "copy for $error")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.api.pairing.PairingError
|
||||
import wang.yaojia.webterm.api.pairing.PairingProbeResult
|
||||
import wang.yaojia.webterm.hostregistry.InMemoryAccessTokenStore
|
||||
import wang.yaojia.webterm.hostregistry.InMemoryHostStore
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
|
||||
@@ -44,6 +45,11 @@ class PairingViewModelTest {
|
||||
hostStore = store,
|
||||
prober = prober,
|
||||
hasDeviceCert = { hasCert },
|
||||
// B5: these tests cover the NO-token paths — an empty store plus a prober that must never be
|
||||
// called (a blank token skips `POST /auth` entirely). The token paths live in
|
||||
// PairingTokenViewModelTest.
|
||||
tokenStore = InMemoryAccessTokenStore(),
|
||||
authProber = { _, _ -> error("POST /auth must not be called when no token was typed") },
|
||||
newId = { "fixed-id" },
|
||||
)
|
||||
|
||||
|
||||
@@ -45,6 +45,10 @@ dependencies {
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
// Tink AEAD encrypts the per-host access-token blob at rest under an AndroidKeystore-wrapped
|
||||
// master key (B5 / ios-completion §1.1). A SEPARATE keyset from :client-tls-android's cert store —
|
||||
// different secrets, different lifecycles, and no module edge to the TLS half.
|
||||
implementation(libs.tink.android)
|
||||
|
||||
testImplementation(libs.bundles.unit.test)
|
||||
testRuntimeOnly(libs.junit.platform.launcher)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package wang.yaojia.webterm.hostregistry
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Instrumented (device) contract tests for [TinkAccessTokenStore] — Tink's AEAD + the
|
||||
* AndroidKeystore-wrapped master key need a real device Keystore, so these run on hardware (no
|
||||
* emulator in the build env → compiled here, executed in device QA, plan §7 / DEVICE_QA_CHECKLIST).
|
||||
*
|
||||
* They pin the same contract the JVM [InMemoryAccessTokenStoreTest] pins for the double, PLUS the two
|
||||
* things only the real store can prove: the token **survives a restart** (a fresh store instance
|
||||
* decrypts the blob) and the **at-rest bytes are ciphertext**, never the token in the clear.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TinkAccessTokenStoreTest {
|
||||
|
||||
private companion object {
|
||||
const val TOKEN = "0123456789abcdefTOKEN"
|
||||
}
|
||||
|
||||
private val context: Context get() = ApplicationProvider.getApplicationContext()
|
||||
|
||||
/** A store on a per-test keyset/pref-file so tests never share state. */
|
||||
private fun newStore(suffix: String): TinkAccessTokenStore = TinkAccessTokenStore(
|
||||
context = context,
|
||||
keysetName = "test_token_keyset_$suffix",
|
||||
prefFileName = "test_token_prefs_$suffix",
|
||||
masterKeyUri = "android-keystore://test_token_master_$suffix",
|
||||
)
|
||||
|
||||
private fun endpoint(url: String = "http://10.0.0.5:3000"): HostEndpoint =
|
||||
requireNotNull(HostEndpoint.fromBaseUrl(url))
|
||||
|
||||
@Test
|
||||
fun put_then_tokenFor_returns_the_token() = runBlocking {
|
||||
val store = newStore(UUID.randomUUID().toString())
|
||||
|
||||
assertTrue(store.put(endpoint(), TOKEN))
|
||||
|
||||
assertEquals(TOKEN, store.tokenFor(endpoint()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun a_fresh_store_instance_decrypts_the_persisted_token() = runBlocking {
|
||||
val suffix = UUID.randomUUID().toString()
|
||||
newStore(suffix).put(endpoint(), TOKEN)
|
||||
|
||||
// A new instance = a cold app start: no in-memory cache, must decrypt from disk.
|
||||
assertEquals(TOKEN, newStore(suffix).tokenFor(endpoint()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun the_token_is_never_stored_in_the_clear() = runBlocking {
|
||||
val suffix = UUID.randomUUID().toString()
|
||||
newStore(suffix).put(endpoint(), TOKEN)
|
||||
|
||||
val raw = context
|
||||
.getSharedPreferences("test_token_prefs_$suffix", Context.MODE_PRIVATE)
|
||||
.all
|
||||
.values
|
||||
.joinToString(" ") { it.toString() }
|
||||
|
||||
assertFalse("the at-rest blob must be ciphertext", raw.contains(TOKEN))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun remove_drops_the_token_durably() = runBlocking {
|
||||
val suffix = UUID.randomUUID().toString()
|
||||
val store = newStore(suffix)
|
||||
store.put(endpoint(), TOKEN)
|
||||
|
||||
store.remove(endpoint())
|
||||
|
||||
assertNull(store.tokenFor(endpoint()))
|
||||
assertNull("a removed token must not come back on a cold start", newStore(suffix).tokenFor(endpoint()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun a_malformed_token_is_rejected_without_touching_storage() = runBlocking {
|
||||
val suffix = UUID.randomUUID().toString()
|
||||
val store = newStore(suffix)
|
||||
|
||||
assertFalse(store.put(endpoint(), "short"))
|
||||
|
||||
assertNull(store.tokenFor(endpoint()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tokens_are_keyed_per_host() = runBlocking {
|
||||
val store = newStore(UUID.randomUUID().toString())
|
||||
store.put(endpoint("http://10.0.0.5:3000"), TOKEN)
|
||||
|
||||
assertNull(store.tokenFor(endpoint("http://10.0.0.9:3000")))
|
||||
assertEquals(TOKEN, store.tokenFor(endpoint("http://10.0.0.5:3000/")))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package wang.yaojia.webterm.hostregistry
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import wang.yaojia.webterm.wire.AccessTokenRule
|
||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
|
||||
/**
|
||||
* Per-host storage for the optional shared access token (`WEBTERM_TOKEN`, ios-completion §1.1).
|
||||
*
|
||||
* It IS an [AccessTokenSource], so the very same instance that the pairing screen writes to is what
|
||||
* `:api-client` (every REST route) and `:transport-okhttp` (the WS upgrade) read from — one store, no
|
||||
* adapter, no cache to fall out of sync.
|
||||
*
|
||||
* ### Keying: the canonical origin, not the raw base URL
|
||||
* The key is [HostEndpoint.originHeader] — already single-point-derived, lower-cased, default-port
|
||||
* normalized. So `http://box:3000`, `HTTP://box:3000/` and `http://box:3000/some/path` are ONE host
|
||||
* (they are literally the same server, and the token is the server's), while a different port or host
|
||||
* is a different key. Storing under the raw `baseUrl` would silently lose the token when the same
|
||||
* server was re-paired from a URL with a trailing slash.
|
||||
*
|
||||
* ### Security contract (non-negotiable)
|
||||
* Implementations MUST keep the token device-only (Android-Keystore-wrapped, excluded from backup),
|
||||
* MUST validate through [AccessTokenRule] before persisting, and MUST NEVER log it.
|
||||
*/
|
||||
public interface AccessTokenStore : AccessTokenSource {
|
||||
/** The token stored for [endpoint]'s canonical origin, or null. Never logs. */
|
||||
override suspend fun tokenFor(endpoint: HostEndpoint): String?
|
||||
|
||||
/**
|
||||
* Store [token] for [endpoint] (replacing any previous one). Returns **false** and stores nothing
|
||||
* when the token fails [AccessTokenRule] — a value the server could never accept must not be
|
||||
* persisted (nor round-tripped to disk) at all.
|
||||
*/
|
||||
public suspend fun put(endpoint: HostEndpoint, token: String): Boolean
|
||||
|
||||
/** Drop [endpoint]'s token. Idempotent — removing an unknown host is a no-op. */
|
||||
public suspend fun remove(endpoint: HostEndpoint)
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory [AccessTokenStore]. Lives in `main` (not `test`) on purpose — it doubles for the encrypted
|
||||
* store in this module's contract tests AND in the `:app` pairing-ViewModel tests (mirrors
|
||||
* [InMemoryHostStore]).
|
||||
*
|
||||
* A [Mutex] serializes the read-modify-write; the state is an immutable map replaced wholesale on every
|
||||
* change (never mutated in place).
|
||||
*/
|
||||
public class InMemoryAccessTokenStore(
|
||||
initial: Map<String, String> = emptyMap(),
|
||||
) : AccessTokenStore {
|
||||
private val mutex = Mutex()
|
||||
|
||||
// Defensive copy so a mutable map handed to the ctor cannot alias our state.
|
||||
private var tokensByOrigin: Map<String, String> = initial.toMap()
|
||||
|
||||
override suspend fun tokenFor(endpoint: HostEndpoint): String? =
|
||||
mutex.withLock { tokensByOrigin[endpoint.originHeader] }
|
||||
|
||||
override suspend fun put(endpoint: HostEndpoint, token: String): Boolean {
|
||||
val normalized = AccessTokenRule.normalize(token) ?: return false
|
||||
mutex.withLock { tokensByOrigin = tokensByOrigin + (endpoint.originHeader to normalized) }
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun remove(endpoint: HostEndpoint) {
|
||||
mutex.withLock { tokensByOrigin = tokensByOrigin - endpoint.originHeader }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package wang.yaojia.webterm.hostregistry
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Base64
|
||||
import com.google.crypto.tink.Aead
|
||||
import com.google.crypto.tink.KeyTemplates
|
||||
import com.google.crypto.tink.RegistryConfiguration
|
||||
import com.google.crypto.tink.aead.AeadConfig
|
||||
import com.google.crypto.tink.integration.android.AndroidKeysetManager
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.builtins.MapSerializer
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import wang.yaojia.webterm.wire.AccessTokenRule
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* The production [AccessTokenStore]: per-host access tokens AEAD-encrypted with Tink under an
|
||||
* **AndroidKeystore-wrapped master key** (ios-completion §1.1 storage rule — the Android analogue of
|
||||
* iOS Keychain `…AfterFirstUnlockThisDeviceOnly` + no `kSecAttrSynchronizable`).
|
||||
*
|
||||
* What that buys, concretely:
|
||||
* - the master key lives in the Keystore (`android-keystore://…`) and is **non-exportable**, so the
|
||||
* on-disk ciphertext is useless on any other device — device-only, exactly like the cert store;
|
||||
* - the blob sits in an app-private `SharedPreferences` file (uninstall-wiped) and the app manifest
|
||||
* sets `android:allowBackup="false"`, so it is **never** in a cloud/adb backup;
|
||||
* - nothing here logs, and the token never enters a URL (see `AuthCookie`).
|
||||
*
|
||||
* Deliberately a SEPARATE keyset/pref-file/master-key from `:client-tls-android`'s `TinkCertStore`:
|
||||
* different secrets with different lifecycles must not share an AEAD keyset (and this module must not
|
||||
* gain a dependency on the TLS module). The ~20 lines of Tink setup are the price of that isolation.
|
||||
*
|
||||
* All disk + crypto work hops to [io] (never `Main`); the decrypted map is cached in memory after the
|
||||
* first read so the per-request [tokenFor] on the WS-dial / REST path is a cheap map lookup, and a
|
||||
* [Mutex] serializes the read-modify-write of a mutation. A blob that fails to decrypt/decode (tamper,
|
||||
* version skew, a wiped Keystore key after a device restore) degrades to "no tokens" — the user
|
||||
* re-enters the token — rather than throwing on every request.
|
||||
*/
|
||||
public class TinkAccessTokenStore(
|
||||
context: Context,
|
||||
private val io: CoroutineDispatcher = Dispatchers.IO,
|
||||
private val keysetName: String = DEFAULT_KEYSET_NAME,
|
||||
private val prefFileName: String = DEFAULT_PREF_FILE,
|
||||
private val masterKeyUri: String = DEFAULT_MASTER_KEY_URI,
|
||||
) : AccessTokenStore {
|
||||
private val appContext: Context = context.applicationContext
|
||||
private val mutex = Mutex()
|
||||
|
||||
/** Decrypted `origin → token` map; null until the first read. Replaced wholesale, never mutated. */
|
||||
private var cache: Map<String, String>? = null
|
||||
|
||||
// Built on first use (registers AEAD, loads-or-creates the Keystore-wrapped keyset) — slow, so it
|
||||
// only ever happens inside a withContext(io) block.
|
||||
private val aead: Aead by lazy { buildAead() }
|
||||
|
||||
override suspend fun tokenFor(endpoint: HostEndpoint): String? =
|
||||
loadTokens()[endpoint.originHeader]
|
||||
|
||||
/**
|
||||
* Validate → encrypt → durably persist → publish to the cache, in that order: a token the server
|
||||
* could never accept returns false without touching disk, and the in-memory view is only updated
|
||||
* after the write is on disk (so a failed write can never leave a "stored" token that vanishes on
|
||||
* the next launch).
|
||||
*
|
||||
* @throws IOException when the durable write fails (infrastructure failure, distinct from `false`,
|
||||
* which means the token itself was malformed).
|
||||
*/
|
||||
override suspend fun put(endpoint: HostEndpoint, token: String): Boolean {
|
||||
val normalized = AccessTokenRule.normalize(token) ?: return false
|
||||
mutex.withLock {
|
||||
val updated = readThrough() + (endpoint.originHeader to normalized)
|
||||
persist(updated)
|
||||
cache = updated
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun remove(endpoint: HostEndpoint) {
|
||||
mutex.withLock {
|
||||
val current = readThrough()
|
||||
if (!current.containsKey(endpoint.originHeader)) return // idempotent, no needless write
|
||||
val updated = current - endpoint.originHeader
|
||||
persist(updated)
|
||||
cache = updated
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadTokens(): Map<String, String> =
|
||||
cache ?: mutex.withLock {
|
||||
val loaded = readThrough()
|
||||
cache = loaded
|
||||
loaded
|
||||
}
|
||||
|
||||
/** Decrypt the stored blob (or the cache when warm). MUST be called with [mutex] held. */
|
||||
private suspend fun readThrough(): Map<String, String> =
|
||||
cache ?: withContext(io) { decodeBlob(prefs().getString(BLOB_KEY, null)) }
|
||||
|
||||
private suspend fun persist(tokens: Map<String, String>) {
|
||||
withContext(io) {
|
||||
val plaintext = JSON.encodeToString(TOKEN_MAP_SERIALIZER, tokens).encodeToByteArray()
|
||||
val ciphertext = aead.encrypt(plaintext, ASSOCIATED_DATA)
|
||||
val committed = prefs().edit()
|
||||
.putString(BLOB_KEY, Base64.encodeToString(ciphertext, Base64.NO_WRAP))
|
||||
.commit() // synchronous + reports failure (as TinkCertStore); apply() would hide it
|
||||
if (!committed) throw IOException("failed to durably persist the access-token blob")
|
||||
}
|
||||
}
|
||||
|
||||
/** Any decrypt/decode failure ⇒ "no tokens" (never crash a request path on a corrupt blob). */
|
||||
private fun decodeBlob(encoded: String?): Map<String, String> {
|
||||
if (encoded == null) return emptyMap()
|
||||
return runCatching {
|
||||
val ciphertext = Base64.decode(encoded, Base64.NO_WRAP)
|
||||
val plaintext = aead.decrypt(ciphertext, ASSOCIATED_DATA)
|
||||
JSON.decodeFromString(TOKEN_MAP_SERIALIZER, plaintext.decodeToString())
|
||||
}.getOrDefault(emptyMap())
|
||||
}
|
||||
|
||||
private fun buildAead(): Aead {
|
||||
AeadConfig.register()
|
||||
val keysetHandle = AndroidKeysetManager.Builder()
|
||||
.withSharedPref(appContext, keysetName, prefFileName)
|
||||
.withKeyTemplate(KeyTemplates.get(AEAD_KEY_TEMPLATE))
|
||||
.withMasterKeyUri(masterKeyUri)
|
||||
.build()
|
||||
.keysetHandle
|
||||
return keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead::class.java)
|
||||
}
|
||||
|
||||
private fun prefs(): SharedPreferences =
|
||||
appContext.getSharedPreferences(prefFileName, Context.MODE_PRIVATE)
|
||||
|
||||
public companion object {
|
||||
private const val DEFAULT_KEYSET_NAME = "webterm_access_token_keyset"
|
||||
private const val DEFAULT_PREF_FILE = "webterm_access_token_prefs"
|
||||
private const val DEFAULT_MASTER_KEY_URI = "android-keystore://webterm_access_token_master_key"
|
||||
private const val AEAD_KEY_TEMPLATE = "AES256_GCM"
|
||||
private const val BLOB_KEY = "access_tokens_blob"
|
||||
|
||||
/** AEAD associated data — binds the ciphertext to this context so a lifted blob won't decrypt. */
|
||||
private val ASSOCIATED_DATA: ByteArray = "webterm.host-registry.access-tokens".toByteArray()
|
||||
|
||||
private val JSON = Json { ignoreUnknownKeys = true }
|
||||
private val TOKEN_MAP_SERIALIZER = MapSerializer(String.serializer(), String.serializer())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package wang.yaojia.webterm.hostregistry
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
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.Test
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
|
||||
/**
|
||||
* B5 · the per-host access-token store contract (ios-completion §1.1), exercised through the pure
|
||||
* in-memory double (the Tink/AndroidKeyStore-backed implementation is instrumented → device QA):
|
||||
* - a token is keyed by the endpoint's CANONICAL origin, so the same server dialed with a trailing
|
||||
* slash / different case / a path resolves to the SAME token, and a different host never does;
|
||||
* - a malformed token is REJECTED at the boundary and never stored;
|
||||
* - `remove` is idempotent, and the store doubles as the `AccessTokenSource` the transports consume.
|
||||
*/
|
||||
class InMemoryAccessTokenStoreTest {
|
||||
private companion object {
|
||||
const val TOKEN = "0123456789abcdefTOKEN"
|
||||
const val OTHER_TOKEN = "fedcba9876543210OTHER"
|
||||
}
|
||||
|
||||
private fun endpoint(url: String): HostEndpoint =
|
||||
requireNotNull(HostEndpoint.fromBaseUrl(url)) { "fixture URL must validate: $url" }
|
||||
|
||||
@Test
|
||||
fun `a stored token is returned for the same host`() = runTest {
|
||||
val store = InMemoryAccessTokenStore()
|
||||
val host = endpoint("http://10.0.0.5:3000")
|
||||
|
||||
assertTrue(store.put(host, TOKEN))
|
||||
|
||||
assertEquals(TOKEN, store.tokenFor(host))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown host has no token`() = runTest {
|
||||
val store = InMemoryAccessTokenStore()
|
||||
store.put(endpoint("http://10.0.0.5:3000"), TOKEN)
|
||||
|
||||
assertNull(store.tokenFor(endpoint("http://10.0.0.6:3000")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the key is the canonical origin so the same server matches however it was dialed`() = runTest {
|
||||
val store = InMemoryAccessTokenStore()
|
||||
store.put(endpoint("http://10.0.0.5:3000"), TOKEN)
|
||||
|
||||
assertEquals(TOKEN, store.tokenFor(endpoint("http://10.0.0.5:3000/")))
|
||||
assertEquals(TOKEN, store.tokenFor(endpoint("HTTP://10.0.0.5:3000")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a different port is a different host`() = runTest {
|
||||
val store = InMemoryAccessTokenStore()
|
||||
store.put(endpoint("http://10.0.0.5:3000"), TOKEN)
|
||||
|
||||
assertNull(store.tokenFor(endpoint("http://10.0.0.5:3001")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `putting again replaces the token for that host only`() = runTest {
|
||||
val store = InMemoryAccessTokenStore()
|
||||
val a = endpoint("http://10.0.0.5:3000")
|
||||
val b = endpoint("http://10.0.0.6:3000")
|
||||
store.put(a, TOKEN)
|
||||
store.put(b, OTHER_TOKEN)
|
||||
|
||||
store.put(a, OTHER_TOKEN)
|
||||
|
||||
assertEquals(OTHER_TOKEN, store.tokenFor(a))
|
||||
assertEquals(OTHER_TOKEN, store.tokenFor(b))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a malformed token is rejected and never stored`() = runTest {
|
||||
val store = InMemoryAccessTokenStore()
|
||||
val host = endpoint("http://10.0.0.5:3000")
|
||||
|
||||
assertFalse(store.put(host, "short"), "below the server's 16-char minimum")
|
||||
assertFalse(store.put(host, "has spaces in it here"), "outside the server charset")
|
||||
|
||||
assertNull(store.tokenFor(host), "a rejected token must not reach storage")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a whitespace-padded token is trimmed once on the way in`() = runTest {
|
||||
val store = InMemoryAccessTokenStore()
|
||||
val host = endpoint("http://10.0.0.5:3000")
|
||||
|
||||
store.put(host, " $TOKEN\n")
|
||||
|
||||
assertEquals(TOKEN, store.tokenFor(host))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remove drops the token and is idempotent`() = runTest {
|
||||
val store = InMemoryAccessTokenStore()
|
||||
val host = endpoint("http://10.0.0.5:3000")
|
||||
store.put(host, TOKEN)
|
||||
|
||||
store.remove(host)
|
||||
store.remove(host)
|
||||
|
||||
assertNull(store.tokenFor(host))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the store is usable directly as the transports' AccessTokenSource`() = runTest {
|
||||
val store: wang.yaojia.webterm.wire.AccessTokenSource = InMemoryAccessTokenStore(
|
||||
initial = mapOf(endpoint("http://10.0.0.5:3000").originHeader to TOKEN),
|
||||
)
|
||||
|
||||
assertEquals(TOKEN, store.tokenFor(endpoint("http://10.0.0.5:3000")))
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import wang.yaojia.webterm.wire.TermTransport
|
||||
import wang.yaojia.webterm.wire.TimelineEvent
|
||||
import wang.yaojia.webterm.wire.TransportConnection
|
||||
import wang.yaojia.webterm.wire.Tunables
|
||||
import wang.yaojia.webterm.wire.UnauthorizedUpgradeException
|
||||
|
||||
/**
|
||||
* The session lifecycle state machine (A14, plan §5 / §6.6). The Android analogue of the iOS
|
||||
@@ -51,6 +52,9 @@ import wang.yaojia.webterm.wire.Tunables
|
||||
* frame would be dropped rather than emitted onto a newer generation.
|
||||
* - **oversized replay is terminal:** a frame past [maxFrameBytes] is the non-retryable
|
||||
* [FailureReason.REPLAY_TOO_LARGE] — distinct from a retryable disconnect (never fed to backoff).
|
||||
* - **a 401 upgrade is terminal:** an [UnauthorizedUpgradeException] out of the dial (missing/invalid
|
||||
* access token, or a foreign Origin) is the non-retryable [FailureReason.UNAUTHORIZED] — it is
|
||||
* permanent until the user fixes configuration, so it NEVER enters the back-off ladder.
|
||||
*/
|
||||
public class SessionEngine(
|
||||
private val transport: TermTransport,
|
||||
@@ -76,6 +80,19 @@ public class SessionEngine(
|
||||
/** One live connection plus its optional ping capability (null = plain [TermTransport]). */
|
||||
private class OpenConn(val connection: TransportConnection, val pinger: ConnectionPinger?)
|
||||
|
||||
/**
|
||||
* Outcome of one dial. A dial failure is retryable ([Retryable]) EXCEPT an upgrade 401
|
||||
* ([Unauthorized]), which is permanent until the user fixes the access token / Origin allow-list —
|
||||
* so it must never be fed to the back-off ladder (see [FailureReason.UNAUTHORIZED]).
|
||||
*/
|
||||
private sealed interface DialResult {
|
||||
class Open(val conn: OpenConn) : DialResult
|
||||
|
||||
data object Retryable : DialResult
|
||||
|
||||
data object Unauthorized : DialResult
|
||||
}
|
||||
|
||||
private val eventsChannel = Channel<SessionEvent>(Channel.UNLIMITED)
|
||||
|
||||
/** Engine→UI event stream (single consumer; the App's EventBus fans out per R10). */
|
||||
@@ -157,7 +174,14 @@ public class SessionEngine(
|
||||
}
|
||||
|
||||
private suspend fun runOneConnection(gen: Int): EndCause {
|
||||
val open = dialOrNull() ?: return EndCause.Disconnected
|
||||
val open = when (val dial = dial()) {
|
||||
DialResult.Retryable -> return EndCause.Disconnected
|
||||
DialResult.Unauthorized -> {
|
||||
emit(Connection(ConnectionState.Failed(FailureReason.UNAUTHORIZED)))
|
||||
return EndCause.Terminal // no back-off: a 401 is permanent until reconfigured
|
||||
}
|
||||
is DialResult.Open -> dial.conn
|
||||
}
|
||||
if (closed) return abandon(open) // close() landed during dial → detach, never publish
|
||||
if (!attachFirst(open)) return EndCause.Disconnected
|
||||
if (closed) return abandon(open) // close() landed during attach → detach, never publish
|
||||
@@ -184,18 +208,35 @@ public class SessionEngine(
|
||||
return EndCause.Terminal
|
||||
}
|
||||
|
||||
private suspend fun dialOrNull(): OpenConn? =
|
||||
private suspend fun dial(): DialResult =
|
||||
try {
|
||||
if (transport is PingableTermTransport) {
|
||||
val pingable = transport.connectPingable(endpoint)
|
||||
OpenConn(pingable.connection, pingable.pinger)
|
||||
DialResult.Open(OpenConn(pingable.connection, pingable.pinger))
|
||||
} else {
|
||||
OpenConn(transport.connect(endpoint), null)
|
||||
DialResult.Open(OpenConn(transport.connect(endpoint), null))
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Throwable) {
|
||||
null // connect-time failure → retryable
|
||||
} catch (error: Throwable) {
|
||||
// A 401 upgrade is the ONE non-retryable dial failure; everything else backs off.
|
||||
if (isUnauthorized(error)) DialResult.Unauthorized else DialResult.Retryable
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff [error] (or a bounded, cycle-guarded walk of its causes) is the typed 401-upgrade
|
||||
* rejection. The cause walk matters because a transport may wrap it in its own `IOException`.
|
||||
*/
|
||||
private fun isUnauthorized(error: Throwable): Boolean {
|
||||
var current: Throwable? = error
|
||||
var depth = 0
|
||||
while (current != null && depth < MAX_CAUSE_DEPTH) {
|
||||
if (current is UnauthorizedUpgradeException) return true
|
||||
if (current === current.cause) return false // defensive: never loop on a self-cause
|
||||
current = current.cause
|
||||
depth++
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** attach-first (+ replay [lastDims]) BEFORE publishing the connection. */
|
||||
@@ -411,5 +452,8 @@ public class SessionEngine(
|
||||
public companion object {
|
||||
/** Max [AwayDigest.recent] entries carried in the once-per-reconnect digest. */
|
||||
public const val DEFAULT_DIGEST_LIMIT: Int = 20
|
||||
|
||||
/** Bound on the `cause` walk in [isUnauthorized] (never trust an error graph not to cycle). */
|
||||
private const val MAX_CAUSE_DEPTH: Int = 8
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,4 +68,14 @@ public enum class FailureReason {
|
||||
* back-off. UI copy: lower the server's SCROLLBACK_BYTES or raise the client cap.
|
||||
*/
|
||||
REPLAY_TOO_LARGE,
|
||||
|
||||
/**
|
||||
* The server answered the WS upgrade with **HTTP 401**
|
||||
* ([wang.yaojia.webterm.wire.UnauthorizedUpgradeException]) — the host's access token
|
||||
* (`WEBTERM_TOKEN`) is missing/wrong, or our `Origin` is not on its allow-list. Both are
|
||||
* permanent until the user fixes configuration, so — exactly like [REPLAY_TOO_LARGE] — the
|
||||
* engine goes terminal and NEVER enters the back-off ladder (a retry storm against a 401 is
|
||||
* pointless and looks like an attack). UI copy: go fix / re-enter the access token.
|
||||
*/
|
||||
UNAUTHORIZED,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package wang.yaojia.webterm.session
|
||||
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.testsupport.FakeTermTransport
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.UnauthorizedUpgradeException
|
||||
import java.io.IOException
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* B5 · a **401 on the WS upgrade is TERMINAL** (ios-completion §1.1): the engine emits
|
||||
* `Failed(UNAUTHORIZED)` and stops — it must NEVER enter the reconnect back-off ladder, because a
|
||||
* missing/invalid access token (or a foreign Origin) 401s deterministically forever. Same treatment as
|
||||
* `REPLAY_TOO_LARGE`.
|
||||
*
|
||||
* Driven with the frozen [FakeTermTransport] double under `runTest` virtual time (`runCurrent` /
|
||||
* `advanceTimeBy`, matching `SessionEngineTest`) — no OkHttp, no host, no wall-clock waits.
|
||||
*/
|
||||
class SessionEngineUnauthorizedTest {
|
||||
|
||||
private fun endpoint(): HostEndpoint =
|
||||
requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.5:3000"))
|
||||
|
||||
private fun TestScope.newEngine(transport: FakeTermTransport): SessionEngine =
|
||||
SessionEngine(transport = transport, endpoint = endpoint(), scope = backgroundScope)
|
||||
|
||||
/** Live-updating sink of everything the engine emits (single consumer, as in SessionEngineTest). */
|
||||
private fun TestScope.collectEvents(engine: SessionEngine): List<SessionEvent> {
|
||||
val out = mutableListOf<SessionEvent>()
|
||||
backgroundScope.launch { engine.events.collect { out += it } }
|
||||
return out
|
||||
}
|
||||
|
||||
private fun List<SessionEvent>.connectionStates(): List<ConnectionState> =
|
||||
filterIsInstance<Connection>().map { it.state }
|
||||
|
||||
@Test
|
||||
fun `a 401 upgrade ends the engine terminally with UNAUTHORIZED and never dials again`() = runTest {
|
||||
// Arrange: every dial would be rejected with the typed 401 (the server 401s until a token is stored).
|
||||
val transport = FakeTermTransport()
|
||||
repeat(3) { transport.scriptConnectFailure(UnauthorizedUpgradeException()) }
|
||||
val engine = newEngine(transport)
|
||||
val events = collectEvents(engine)
|
||||
|
||||
// Act
|
||||
engine.start()
|
||||
testScheduler.runCurrent()
|
||||
testScheduler.advanceTimeBy(60.seconds) // a back-off ladder, if entered, would have fired by now
|
||||
testScheduler.runCurrent()
|
||||
|
||||
// Assert: exactly ONE dial, a terminal Failed(UNAUTHORIZED), and no Reconnecting event at all.
|
||||
assertEquals(1, transport.connectAttempts.size, "a 401 must not be retried")
|
||||
assertEquals(
|
||||
listOf(ConnectionState.Connecting, ConnectionState.Failed(FailureReason.UNAUTHORIZED)),
|
||||
events.connectionStates(),
|
||||
"a terminal 401 must never enter the back-off ladder",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an ordinary transport failure still reconnects with back-off`() = runTest {
|
||||
// Regression lock: only the typed 401 is terminal — a plain IOException stays retryable.
|
||||
val transport = FakeTermTransport()
|
||||
transport.scriptConnectFailure(IOException("connection refused"))
|
||||
val engine = newEngine(transport)
|
||||
val events = collectEvents(engine)
|
||||
|
||||
engine.start()
|
||||
testScheduler.runCurrent()
|
||||
testScheduler.advanceTimeBy(1.seconds) // first rung of the ladder
|
||||
testScheduler.runCurrent()
|
||||
|
||||
assertEquals(2, transport.connectAttempts.size, "a retryable failure must redial")
|
||||
assertTrue(
|
||||
events.connectionStates().any { it is ConnectionState.Reconnecting },
|
||||
"a retryable failure must announce the back-off",
|
||||
)
|
||||
assertTrue(
|
||||
events.connectionStates().none { it is ConnectionState.Failed },
|
||||
"a retryable failure must not be reported as a terminal failure",
|
||||
)
|
||||
engine.close()
|
||||
testScheduler.runCurrent()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 401 wrapped as a cause of the transport error is still terminal`() = runTest {
|
||||
// OkHttp hands back its own IOException; the transport wraps the 401 so the CAUSE carries it.
|
||||
val transport = FakeTermTransport()
|
||||
transport.scriptConnectFailure(IOException("upgrade failed", UnauthorizedUpgradeException()))
|
||||
val engine = newEngine(transport)
|
||||
val events = collectEvents(engine)
|
||||
|
||||
engine.start()
|
||||
testScheduler.runCurrent()
|
||||
testScheduler.advanceTimeBy(60.seconds)
|
||||
testScheduler.runCurrent()
|
||||
|
||||
assertEquals(1, transport.connectAttempts.size)
|
||||
assertEquals(
|
||||
FailureReason.UNAUTHORIZED,
|
||||
events.connectionStates().filterIsInstance<ConnectionState.Failed>().single().reason,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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