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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,19 +169,79 @@ 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(
endpoint = candidate.endpoint,
name = candidate.name,
tier = tier,
error = result.error,
message = PairingCopy.describe(result.error, tier),
)
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 = 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) {
val host = Host.create(id = newId(), name = candidate.name.ifBlank { defaultName(candidate.endpoint) }, baseUrl = candidate.endpoint.baseUrl)
if (host == null) {
@@ -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 / 16512 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 ->
"访问令牌格式不合法:需 16512 个字符,且只能包含 AZ az 09 . _ ~ + / = -。"
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 ->
"连接超时。主机可能不可达,或响应过慢。"
}

View File

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

View File

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

View 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>

View File

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

View File

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

View File

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

View File

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