feat(ios): access-token support + git-panel endpoints across the package layer
APIClient (77 -> 125 tests, coverage 92.22%): POST /auth probe with the four distinct outcomes from the frozen contract, Cookie/Accept landed at the same single header choke point that already enforces Origin-iff-G, plus the whole project-ops surface the server has had since late July and iOS consumed none of: /projects/log, /projects/pr, /projects/worktree/state, git stage/commit/push/ fetch, worktree create/remove/prune, GET /sessions, follow-up queue. HostRegistry (30 -> 73 tests, 88.12% -> 92.49%): per-host token in the Keychain under the existing SecItemShim conventions (device-only, never synchronizable), charset/length validated at the boundary, old token-less records still decode. SessionCore (93 -> 108 tests, 96.74%): the WS upgrade carries the cookie from the same point that writes Origin, and a 401 handshake is a terminal .unauthorized -- never entering the backoff loop, since retrying one wrong shared token is a brute-force generator against the server's 10/min limiter.
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
/// The ONE place SessionCore turns a configured access token (`WEBTERM_TOKEN`)
|
||||
/// into an HTTP request header (B3 · ios-completion §1.1, FROZEN).
|
||||
///
|
||||
/// Why hand-written and not `HTTPCookieStorage`: the native client already KNOWS
|
||||
/// the token, and `URLSession`'s cookie jar behaves inconsistently on a
|
||||
/// WebSocket upgrade (and is barely testable there). A hand-written header is
|
||||
/// the same pattern as the hand-written `Origin` (plan §5.1) and can be pinned
|
||||
/// by pure unit tests. `Set-Cookie` is never parsed.
|
||||
///
|
||||
/// SECURITY: the token is secret material. It exists here only long enough to be
|
||||
/// handed to `URLRequest.setValue`; it is never logged, never interpolated into a
|
||||
/// URL/query, and never carried by any error this module throws.
|
||||
enum AuthCookie {
|
||||
/// The server's `AUTH_COOKIE_NAME` (src/http/auth.ts:30).
|
||||
static let name = "webterm_auth"
|
||||
|
||||
/// `webterm_auth=<token>` for a well-formed token; `nil` when no token is
|
||||
/// configured OR the configured value is off-policy.
|
||||
///
|
||||
/// Off-policy ⇒ OMIT rather than throw: a host with auth DISABLED ignores
|
||||
/// the cookie entirely, so a junk stored token must not break a working
|
||||
/// connection. When the host DOES enforce auth, the omitted cookie yields
|
||||
/// the server's 401 → `TermTransportError.unauthorized` → the UI asks for a
|
||||
/// correct token. Omitting a secret is never a leak.
|
||||
static func headerValue(token: String?) -> String? {
|
||||
guard let token, isValidToken(token) else { return nil }
|
||||
return "\(name)=\(token)"
|
||||
}
|
||||
|
||||
/// True iff `candidate` satisfies the server's token policy: a byte-identical
|
||||
/// port of `WEBTERM_TOKEN_RE` (src/config.ts:178) — URL/cookie-safe charset,
|
||||
/// 16–512 chars. A token the server would reject at startup can never be a
|
||||
/// valid credential, and enforcing the charset HERE is also what keeps a
|
||||
/// hostile stored value from injecting CRLF into the request headers.
|
||||
///
|
||||
/// The literal is built per call (as in `WireProtocol.Validation`): `Regex`
|
||||
/// is not `Sendable`, so it cannot be hoisted into a `static let`.
|
||||
static func isValidToken(_ candidate: String) -> Bool {
|
||||
let tokenPolicy = /^[A-Za-z0-9._~+\/=-]{16,512}$/
|
||||
return candidate.wholeMatch(of: tokenPolicy) != nil
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,8 @@ import WireProtocol
|
||||
/// frame goes through `MessageCodec.decodeServer`; nil → dropped + counted,
|
||||
/// never a crash.
|
||||
/// - Terminal states never re-enter backoff: `exit` (session over, including
|
||||
/// spawn failure -1, M4) and `.replayTooLarge` (deterministic failure).
|
||||
/// spawn failure -1, M4), `.replayTooLarge` (deterministic failure), and
|
||||
/// `.unauthorized` (401 on the upgrade — B3 · ios-completion §1.1).
|
||||
///
|
||||
/// Sizing (documented decision, plan §7 T-iOS-10): the server spawns every PTY
|
||||
/// at 80×24 (src/server.ts:714-717) and the frozen `open` signature carries no
|
||||
@@ -324,9 +325,9 @@ public actor SessionEngine {
|
||||
eventsContinuation.finish()
|
||||
return false
|
||||
}
|
||||
if let error, isReplayTooLarge(error) {
|
||||
if let error, let reason = Self.terminalFailure(for: error) {
|
||||
hasFailedTerminally = true
|
||||
emit(.connection(.failed(.replayTooLarge))) // NEVER backoff (plan §1)
|
||||
emit(.connection(.failed(reason))) // NEVER backoff (plan §1 / §1.1)
|
||||
eventsContinuation.finish()
|
||||
return false
|
||||
}
|
||||
@@ -470,8 +471,17 @@ public actor SessionEngine {
|
||||
generation == gen
|
||||
}
|
||||
|
||||
private func isReplayTooLarge(_ error: any Error) -> Bool {
|
||||
(error as? TermTransportError) == .replayTooLarge
|
||||
/// The NON-retryable transport classifications, in ONE place. Retrying
|
||||
/// either is pointless: `replayTooLarge` fails deterministically forever
|
||||
/// (plan §1) and `unauthorized` (401 on the upgrade — Origin or access
|
||||
/// token, ios-completion §1.1) does not become correct by waiting, while a
|
||||
/// retry storm on 401 reads as a brute-force attempt. `nil` = retryable.
|
||||
private static func terminalFailure(for error: any Error) -> FailureReason? {
|
||||
switch error as? TermTransportError {
|
||||
case .replayTooLarge: return .replayTooLarge
|
||||
case .unauthorized: return .unauthorized
|
||||
case .sendAfterClose, .none: return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func emit(_ event: SessionEvent) {
|
||||
|
||||
@@ -57,4 +57,12 @@ public enum FailureReason: Sendable, Equatable {
|
||||
/// enters backoff. UI copy: lower the server's SCROLLBACK_BYTES or raise
|
||||
/// the client cap (plan §3.2.1 coupling warning).
|
||||
case replayTooLarge
|
||||
/// The WS upgrade was rejected with **401** (B3 · ios-completion §1.1):
|
||||
/// either the host's Origin whitelist does not contain our dialed origin or
|
||||
/// the access token is missing/wrong (src/server.ts:1367-1379 checks Origin
|
||||
/// first, then the `webterm_auth` cookie — the status is the same, so the UI
|
||||
/// must offer BOTH remedies). Never retried: the credential does not become
|
||||
/// correct by waiting, and a retry storm on 401 is a brute-force signal.
|
||||
/// UI copy: re-enter the access token, or fix `ALLOWED_ORIGINS` on the host.
|
||||
case unauthorized
|
||||
}
|
||||
|
||||
@@ -26,6 +26,15 @@ public enum TermTransportError: Error, Equatable, Sendable {
|
||||
/// backoff loop (plan §1 / §3.2.1 coupling warning), otherwise the retry
|
||||
/// is deterministic and infinite.
|
||||
case replayTooLarge
|
||||
/// The WS upgrade was answered **401** (B3 · ios-completion §1.1). Both
|
||||
/// server-side upgrade gates write that status — the Origin/CSWSH check
|
||||
/// first, then the access-token cookie (src/server.ts:1367-1379) — and
|
||||
/// neither becomes true by waiting. NON-RETRYABLE, exactly like
|
||||
/// `replayTooLarge`: the engine surfaces `connection(.failed(.unauthorized))`
|
||||
/// and must never feed it into the backoff loop, because retrying a wrong
|
||||
/// token forever is useless AND indistinguishable from a brute-force probe.
|
||||
/// Carries NO payload — the token must never ride on an error.
|
||||
case unauthorized
|
||||
/// `send` on a connection that already terminated (client `close()`,
|
||||
/// server close frame, or transport error). Mirrors
|
||||
/// `FakeTransportError.sendAfterClose` (TestSupport).
|
||||
@@ -74,6 +83,8 @@ protocol ConnectionPinger: Sendable {
|
||||
/// snapshot frame → every task gets `Tunables.maxWSMessageBytes` (16 MiB).
|
||||
/// - `receive()` delivers ONE message per call → the loop re-arms forever.
|
||||
/// - No automatic ping → `ConnectionPinger` (driven by `PingScheduler`).
|
||||
/// - Access token: a hand-written `Cookie: webterm_auth=<t>` next to (never
|
||||
/// instead of) `Origin` — B3 · ios-completion §1.1, see `AuthCookie`.
|
||||
public struct URLSessionTermTransport: TermTransport {
|
||||
/// `URLSessionWebSocketTask.maximumMessageSize` applied to every task.
|
||||
private let maxMessageBytes: Int
|
||||
@@ -86,19 +97,32 @@ public struct URLSessionTermTransport: TermTransport {
|
||||
/// cleanly. Local (non-mTLS) hosts never issue the challenge, so a `nil`
|
||||
/// result is inert there.
|
||||
private let identityProvider: @Sendable () -> ClientIdentity?
|
||||
/// B3 · access token, resolved LAZILY once per `connect` for the same
|
||||
/// no-relaunch reason as `identityProvider`: a token entered (or rotated)
|
||||
/// mid-run takes effect on the NEXT connection. `nil` ⇒ no `Cookie` header
|
||||
/// at all, so LAN zero-config hosts are byte-identical to before.
|
||||
/// The value is passed straight to `AuthCookie` — never logged, never stored.
|
||||
private let tokenProvider: @Sendable () -> String?
|
||||
|
||||
/// Fixed-identity convenience (snapshot callers / tests): wraps a constant
|
||||
/// provider, so behaviour is identical to capturing the identity directly.
|
||||
public init(identity: ClientIdentity? = nil) {
|
||||
self.init(identityProvider: { identity })
|
||||
public init(
|
||||
identity: ClientIdentity? = nil,
|
||||
tokenProvider: @escaping @Sendable () -> String? = { nil }
|
||||
) {
|
||||
self.init(identityProvider: { identity }, tokenProvider: tokenProvider)
|
||||
}
|
||||
|
||||
/// C-iOS-2 (MEDIUM no-relaunch fix) · per-connect identity resolution: the
|
||||
/// provider is re-consulted on every `connect`, so a nil→installed
|
||||
/// transition is picked up without relaunch.
|
||||
public init(identityProvider: @escaping @Sendable () -> ClientIdentity?) {
|
||||
public init(
|
||||
identityProvider: @escaping @Sendable () -> ClientIdentity?,
|
||||
tokenProvider: @escaping @Sendable () -> String? = { nil }
|
||||
) {
|
||||
self.init(
|
||||
maxMessageBytes: Tunables.maxWSMessageBytes, identityProvider: identityProvider
|
||||
maxMessageBytes: Tunables.maxWSMessageBytes,
|
||||
identityProvider: identityProvider, tokenProvider: tokenProvider
|
||||
)
|
||||
}
|
||||
|
||||
@@ -107,15 +131,20 @@ public struct URLSessionTermTransport: TermTransport {
|
||||
/// through `init()` / `init(identityProvider:)` and the frozen
|
||||
/// `Tunables.maxWSMessageBytes`.
|
||||
init(maxMessageBytes: Int, identity: ClientIdentity? = nil) {
|
||||
self.init(maxMessageBytes: maxMessageBytes, identityProvider: { identity })
|
||||
self.init(
|
||||
maxMessageBytes: maxMessageBytes, identityProvider: { identity },
|
||||
tokenProvider: { nil }
|
||||
)
|
||||
}
|
||||
|
||||
init(
|
||||
maxMessageBytes: Int,
|
||||
identityProvider: @escaping @Sendable () -> ClientIdentity?
|
||||
identityProvider: @escaping @Sendable () -> ClientIdentity?,
|
||||
tokenProvider: @escaping @Sendable () -> String?
|
||||
) {
|
||||
self.maxMessageBytes = maxMessageBytes
|
||||
self.identityProvider = identityProvider
|
||||
self.tokenProvider = tokenProvider
|
||||
}
|
||||
|
||||
public func connect(to endpoint: HostEndpoint) async throws -> TransportConnection {
|
||||
@@ -123,12 +152,12 @@ public struct URLSessionTermTransport: TermTransport {
|
||||
}
|
||||
|
||||
/// Internal: concrete-typed connect (tests assert task configuration;
|
||||
/// `connectPingable` builds on it). The identity is resolved HERE, per
|
||||
/// connect, from `identityProvider` (no-relaunch pickup).
|
||||
/// `connectPingable` builds on it). Identity AND token are resolved HERE,
|
||||
/// per connect, from their providers (no-relaunch pickup).
|
||||
func openConnection(to endpoint: HostEndpoint) async throws -> WSConnection {
|
||||
try await WSConnection.open(
|
||||
endpoint: endpoint, maxMessageBytes: maxMessageBytes,
|
||||
identity: identityProvider()
|
||||
identity: identityProvider(), token: tokenProvider()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -185,11 +214,13 @@ final class WSConnection: NSObject, @unchecked Sendable {
|
||||
/// handshake (didOpen / didCompleteWithError — no receive-error guessing),
|
||||
/// then start the re-arming receive loop.
|
||||
static func open(
|
||||
endpoint: HostEndpoint, maxMessageBytes: Int, identity: ClientIdentity? = nil
|
||||
endpoint: HostEndpoint, maxMessageBytes: Int, identity: ClientIdentity? = nil,
|
||||
token: String? = nil
|
||||
) async throws -> WSConnection {
|
||||
let connection = WSConnection()
|
||||
connection.configure(
|
||||
endpoint: endpoint, maxMessageBytes: maxMessageBytes, identity: identity
|
||||
endpoint: endpoint, maxMessageBytes: maxMessageBytes, identity: identity,
|
||||
token: token
|
||||
)
|
||||
try await connection.performHandshake()
|
||||
connection.startReceiveLoop()
|
||||
@@ -209,21 +240,52 @@ final class WSConnection: NSObject, @unchecked Sendable {
|
||||
// MARK: Setup
|
||||
|
||||
private func configure(
|
||||
endpoint: HostEndpoint, maxMessageBytes: Int, identity: ClientIdentity?
|
||||
endpoint: HostEndpoint, maxMessageBytes: Int, identity: ClientIdentity?, token: String?
|
||||
) {
|
||||
// Set BEFORE building the task/resume: the mTLS challenge can only fire
|
||||
// after `task.resume()`, so this write happens-before any read of it.
|
||||
self.identity = identity
|
||||
// Origin: SINGLE source of truth = endpoint.originHeader (plan §5.1).
|
||||
var request = URLRequest(url: endpoint.wsURL)
|
||||
request.setValue(endpoint.originHeader, forHTTPHeaderField: "Origin")
|
||||
let session = URLSession(configuration: .ephemeral, delegate: self, delegateQueue: nil)
|
||||
let task = session.webSocketTask(with: request)
|
||||
let session = URLSession(
|
||||
configuration: Self.sessionConfiguration(), delegate: self, delegateQueue: nil
|
||||
)
|
||||
let task = session.webSocketTask(
|
||||
with: Self.upgradeRequest(endpoint: endpoint, token: token)
|
||||
)
|
||||
task.maximumMessageSize = maxMessageBytes
|
||||
self.session = session
|
||||
self.task = task
|
||||
}
|
||||
|
||||
/// THE single choke point for upgrade request headers.
|
||||
///
|
||||
/// - `Origin`: SINGLE source of truth = `endpoint.originHeader` (plan §5.1)
|
||||
/// — always sent.
|
||||
/// - `Cookie`: `webterm_auth=<token>` when a token is configured
|
||||
/// (B3 · ios-completion §1.1). ADDITIVE and ORTHOGONAL: the server checks
|
||||
/// Origin first and the cookie second (src/server.ts:1367-1379), so the
|
||||
/// cookie never replaces Origin. Absent/off-policy token ⇒ header omitted
|
||||
/// (see `AuthCookie.headerValue`).
|
||||
private static func upgradeRequest(endpoint: HostEndpoint, token: String?) -> URLRequest {
|
||||
var request = URLRequest(url: endpoint.wsURL)
|
||||
request.setValue(endpoint.originHeader, forHTTPHeaderField: "Origin")
|
||||
if let cookie = AuthCookie.headerValue(token: token) {
|
||||
request.setValue(cookie, forHTTPHeaderField: "Cookie")
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
/// Ephemeral, with the cookie jar switched OFF: §1.1 freezes the
|
||||
/// hand-written `Cookie` header as the ONLY authority, so no `Set-Cookie`
|
||||
/// from any host can override it, outlive the connection, or park the secret
|
||||
/// in shared storage.
|
||||
private static func sessionConfiguration() -> URLSessionConfiguration {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.httpShouldSetCookies = false
|
||||
configuration.httpCookieAcceptPolicy = .never
|
||||
configuration.httpCookieStorage = nil
|
||||
return configuration
|
||||
}
|
||||
|
||||
private func performHandshake() async throws {
|
||||
try await withCheckedThrowingContinuation {
|
||||
(continuation: CheckedContinuation<Void, any Error>) in
|
||||
@@ -331,6 +393,34 @@ final class WSConnection: NSObject, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP status both server-side upgrade gates answer with: the Origin/CSWSH
|
||||
/// check, then the access-token cookie (src/server.ts:1367-1379).
|
||||
private static let unauthorizedStatusCode = 401
|
||||
|
||||
/// The upgrade never opened — decide what `connect` throws.
|
||||
///
|
||||
/// A **401** is non-retryable by construction (see
|
||||
/// `TermTransportError.unauthorized`), so it becomes the typed error the
|
||||
/// engine treats as terminal. EVERYTHING else is rethrown VERBATIM, because
|
||||
/// `PairingError.classify` (T-iOS-8) keys off the underlying POSIX/NSURLError
|
||||
/// identity to tell localNetworkDenied / atsBlocked / tlsFailure apart —
|
||||
/// wrapping those would break that taxonomy (see `TermTransportError` doc).
|
||||
private static func handshakeFailure(task: URLSessionTask, error: (any Error)?) -> any Error {
|
||||
if httpStatusCode(of: task) == unauthorizedStatusCode {
|
||||
return TermTransportError.unauthorized
|
||||
}
|
||||
return error ?? URLError(.cannotConnectToHost)
|
||||
}
|
||||
|
||||
/// The HTTP status of a rejected upgrade: `URLSessionWebSocketTask` fails the
|
||||
/// task on a non-101 answer and leaves the `HTTPURLResponse` on `task`
|
||||
/// (mutation-verified against a scripted 401 vs 500 in
|
||||
/// `URLSessionTermTransportTests`; without it the 401 arrives as the opaque
|
||||
/// NSURLErrorDomain -1011 that WOULD enter the backoff loop).
|
||||
private static func httpStatusCode(of task: URLSessionTask) -> Int? {
|
||||
(task.response as? HTTPURLResponse)?.statusCode
|
||||
}
|
||||
|
||||
/// T-iOS-2 spike (measured on a real server): exceeding
|
||||
/// `maximumMessageSize` fails `receive()` with NSPOSIXErrorDomain
|
||||
/// EMSGSIZE(40) "Message too long" — not a 1009 close. ENOBUFS(55) is the
|
||||
@@ -399,10 +489,7 @@ extension WSConnection: URLSessionWebSocketDelegate {
|
||||
didCompleteWithError error: (any Error)?
|
||||
) {
|
||||
if let handshake = takeHandshakeContinuation() {
|
||||
// Handshake never opened. Rethrow the UNDERLYING error verbatim —
|
||||
// PairingError.classify (T-iOS-8) depends on its POSIX/NSURLError
|
||||
// identity (see TermTransportError doc).
|
||||
handshake.resume(throwing: error ?? URLError(.cannotConnectToHost))
|
||||
handshake.resume(throwing: Self.handshakeFailure(task: task, error: error))
|
||||
session.finishTasksAndInvalidate()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import Testing
|
||||
@testable import SessionCore
|
||||
|
||||
/// B3 · `AuthCookie` — the ONE place SessionCore turns a configured access
|
||||
/// token into an upgrade request header (`ios-completion` §1.1, FROZEN:
|
||||
/// hand-written `Cookie`, never `Set-Cookie`/`HTTPCookieStorage`).
|
||||
///
|
||||
/// The policy mirrored here is the server's, verbatim:
|
||||
/// - cookie name `webterm_auth` (src/http/auth.ts:30 `AUTH_COOKIE_NAME`)
|
||||
/// - token charset/length `^[A-Za-z0-9._~+/=-]{16,512}$` (src/config.ts:178)
|
||||
@Suite("AuthCookie")
|
||||
struct AuthCookieTests {
|
||||
/// A well-formed token exercising every allowed character class.
|
||||
private static let validToken = "Ab9._~+/=-Ab9._~+/=-"
|
||||
private static let minLengthToken = String(repeating: "a", count: 16)
|
||||
private static let maxLengthToken = String(repeating: "a", count: 512)
|
||||
|
||||
@Test("cookie name matches the server's AUTH_COOKIE_NAME")
|
||||
func cookieNameMatchesServer() {
|
||||
#expect(AuthCookie.name == "webterm_auth")
|
||||
}
|
||||
|
||||
@Test("a valid token becomes exactly `webterm_auth=<token>`")
|
||||
func validTokenBecomesHeaderValue() {
|
||||
// Arrange / Act
|
||||
let header = AuthCookie.headerValue(token: Self.validToken)
|
||||
|
||||
// Assert
|
||||
#expect(header == "webterm_auth=\(Self.validToken)")
|
||||
}
|
||||
|
||||
@Test("no token configured (nil / empty) ⇒ no Cookie header at all")
|
||||
func absentTokenYieldsNoHeader() {
|
||||
#expect(AuthCookie.headerValue(token: nil) == nil)
|
||||
#expect(AuthCookie.headerValue(token: "") == nil)
|
||||
}
|
||||
|
||||
@Test("length policy: 16 and 512 accepted, 15 and 513 rejected (server charset rule)")
|
||||
func lengthPolicyMatchesServer() {
|
||||
#expect(AuthCookie.headerValue(token: Self.minLengthToken) != nil)
|
||||
#expect(AuthCookie.headerValue(token: Self.maxLengthToken) != nil)
|
||||
#expect(AuthCookie.headerValue(token: String(repeating: "a", count: 15)) == nil)
|
||||
#expect(AuthCookie.headerValue(token: String(repeating: "a", count: 513)) == nil)
|
||||
}
|
||||
|
||||
@Test(
|
||||
"off-charset tokens are refused, never smuggled into the header (CRLF injection included)",
|
||||
arguments: [
|
||||
"aaaaaaaaaaaaaaaa\r\nX-Evil: 1", // header injection attempt
|
||||
"aaaaaaaaaaaaaaaa;path=/", // cookie-attribute injection attempt
|
||||
"aaaaaaaaaaaaaaaa aaaa", // space
|
||||
"aaaaaaaaaaaaaaaa\"quoted\"",
|
||||
"aaaaaaaaaaaaaaaa\u{00E9}", // non-ASCII
|
||||
"aaaaaaaaaaaaaaaa\u{0000}",
|
||||
]
|
||||
)
|
||||
func offCharsetTokensRefused(token: String) {
|
||||
#expect(AuthCookie.headerValue(token: token) == nil)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,22 @@ final class ScriptedWSServer: @unchecked Sendable {
|
||||
let headers: [String: String]
|
||||
}
|
||||
|
||||
/// A scripted NON-101 answer to the upgrade, byte-shaped like the real
|
||||
/// server's reject path: it writes a bare status line and destroys the
|
||||
/// socket — no headers, no body (src/server.ts:1367-1379, both the Origin
|
||||
/// gate and the access-token cookie gate).
|
||||
struct UpgradeRejection: Sendable {
|
||||
let statusCode: Int
|
||||
let reasonPhrase: String
|
||||
|
||||
/// What BOTH server-side upgrade gates write (Origin, then cookie).
|
||||
static let unauthorized = UpgradeRejection(statusCode: 401, reasonPhrase: "Unauthorized")
|
||||
/// A non-401 rejection: must stay in the RETRYABLE class.
|
||||
static let serverError = UpgradeRejection(
|
||||
statusCode: 500, reasonPhrase: "Internal Server Error"
|
||||
)
|
||||
}
|
||||
|
||||
/// RFC 6455 wire constants (named — no magic numbers).
|
||||
private enum Wire {
|
||||
static let finBit: UInt8 = 0x80
|
||||
@@ -63,6 +79,7 @@ final class ScriptedWSServer: @unchecked Sendable {
|
||||
private var connection: NWConnection?
|
||||
private var rxBuffer = [UInt8]()
|
||||
private var isHandshakeDone = false
|
||||
private var upgradeRejection: UpgradeRejection?
|
||||
private var startContinuation: CheckedContinuation<UInt16, any Error>?
|
||||
private var stopContinuation: CheckedContinuation<Void, Never>?
|
||||
|
||||
@@ -131,6 +148,13 @@ final class ScriptedWSServer: @unchecked Sendable {
|
||||
lock.withLock { connection }?.forceCancel()
|
||||
}
|
||||
|
||||
/// Answer every subsequent upgrade with `rejection` instead of the 101.
|
||||
/// Call before `connect` (the upgrade request is still recorded, so tests
|
||||
/// can assert the headers a REJECTED handshake carried).
|
||||
func rejectUpgrades(with rejection: UpgradeRejection) {
|
||||
lock.withLock { upgradeRejection = rejection }
|
||||
}
|
||||
|
||||
// MARK: - Connection handling
|
||||
|
||||
private func adopt(connection newConnection: NWConnection) {
|
||||
@@ -184,22 +208,31 @@ final class ScriptedWSServer: @unchecked Sendable {
|
||||
// MARK: - Handshake
|
||||
|
||||
private func tryCompleteHandshake(on connection: NWConnection) {
|
||||
let requestBytes: [UInt8]? = lock.withLock {
|
||||
let pending: (requestBytes: [UInt8], rejection: UpgradeRejection?)? = lock.withLock {
|
||||
guard !isHandshakeDone, let end = Self.headerEndIndex(in: rxBuffer) else {
|
||||
return nil
|
||||
}
|
||||
let request = Array(rxBuffer[..<end])
|
||||
rxBuffer.removeFirst(end + Wire.headerTerminator.count)
|
||||
isHandshakeDone = true
|
||||
return request
|
||||
return (request, upgradeRejection)
|
||||
}
|
||||
guard let requestBytes else { return }
|
||||
let upgrade = Self.parseUpgrade(String(decoding: requestBytes, as: UTF8.self))
|
||||
connection.send(
|
||||
content: Self.handshakeResponse(for: upgrade),
|
||||
completion: .contentProcessed { _ in }
|
||||
)
|
||||
guard let pending else { return }
|
||||
let upgrade = Self.parseUpgrade(String(decoding: pending.requestBytes, as: UTF8.self))
|
||||
// Record BEFORE answering, so a REJECTED upgrade is still observable.
|
||||
upgradesContinuation.yield(upgrade)
|
||||
guard let rejection = pending.rejection else {
|
||||
connection.send(
|
||||
content: Self.handshakeResponse(for: upgrade),
|
||||
completion: .contentProcessed { _ in }
|
||||
)
|
||||
return
|
||||
}
|
||||
// Reject exactly like the server: status line, then destroy the socket.
|
||||
connection.send(
|
||||
content: Self.rejectionResponse(rejection),
|
||||
completion: .contentProcessed { _ in connection.cancel() }
|
||||
)
|
||||
}
|
||||
|
||||
private static func headerEndIndex(in bytes: [UInt8]) -> Int? {
|
||||
@@ -235,6 +268,10 @@ final class ScriptedWSServer: @unchecked Sendable {
|
||||
return Data(response.utf8)
|
||||
}
|
||||
|
||||
private static func rejectionResponse(_ rejection: UpgradeRejection) -> Data {
|
||||
Data("HTTP/1.1 \(rejection.statusCode) \(rejection.reasonPhrase)\r\n\r\n".utf8)
|
||||
}
|
||||
|
||||
// MARK: - Client frame parsing
|
||||
|
||||
private func drainClientFrames(on connection: NWConnection) {
|
||||
|
||||
@@ -476,6 +476,70 @@ struct SessionEngineTests {
|
||||
#expect(await harness.transport.connectAttempts.count == 1)
|
||||
}
|
||||
|
||||
@Test("401 on the upgrade → connection(.failed(.unauthorized)) and NOT ONE reconnect attempt")
|
||||
func unauthorizedIsTerminalAndNeverBacksOff() async throws {
|
||||
// Arrange: the transport rejects the handshake with the typed 401 error
|
||||
// (B3 · ios-completion §1.1 "WS upgrade 收 401 ⇒ 终态,不进退避环").
|
||||
let harness = try Harness()
|
||||
await harness.transport.scriptConnectFailure(TermTransportError.unauthorized)
|
||||
|
||||
// Act
|
||||
await harness.engine.open(sessionId: nil, cwd: nil)
|
||||
|
||||
// Assert: terminal failure instead of a backoff rung — retrying a wrong
|
||||
// token forever is useless AND a brute-force signal.
|
||||
#expect(await harness.next() == .connection(.connecting))
|
||||
#expect(await harness.next() == .connection(.failed(.unauthorized)))
|
||||
#expect(await harness.next() == nil)
|
||||
#expect(harness.clock.pendingSleeperCount == 0)
|
||||
harness.clock.advance(by: .seconds(60))
|
||||
#expect(await harness.transport.connectAttempts.count == 1)
|
||||
|
||||
// Assert: the engine is inert afterwards (same shape as .replayTooLarge).
|
||||
await harness.engine.send(.input(data: "too late"))
|
||||
#expect(await harness.engine.droppedOutboundFrameCount == 1)
|
||||
}
|
||||
|
||||
@Test("ordinary failure still backs off; a 401 on the retry stops the ladder for good")
|
||||
func ordinaryFailureBacksOffThen401EndsIt() async throws {
|
||||
// Arrange: attempt #1 fails the ordinary (retryable) way, attempt #2 401s.
|
||||
let harness = try Harness()
|
||||
await harness.transport.scriptConnectFailure()
|
||||
await harness.transport.scriptConnectFailure(TermTransportError.unauthorized)
|
||||
|
||||
// Act / Assert: rung 1 is unchanged by the new classification.
|
||||
await harness.engine.open(sessionId: nil, cwd: nil)
|
||||
#expect(await harness.next() == .connection(.connecting))
|
||||
#expect(await harness.next() == .connection(.reconnecting(attempt: 1, next: .seconds(1))))
|
||||
await harness.clock.waitForSleepers(count: 1)
|
||||
harness.clock.advance(by: .seconds(1))
|
||||
|
||||
// Assert: the 401 is terminal — no rung 2, no third attempt, ever.
|
||||
#expect(await harness.next() == .connection(.failed(.unauthorized)))
|
||||
#expect(await harness.next() == nil)
|
||||
#expect(harness.clock.pendingSleeperCount == 0)
|
||||
harness.clock.advance(by: .seconds(60))
|
||||
#expect(await harness.transport.connectAttempts.count == 2)
|
||||
}
|
||||
|
||||
@Test("unauthorized surfacing mid-stream is classified terminally too (one classifier)")
|
||||
func unauthorizedOnStreamIsAlsoTerminal() async throws {
|
||||
// Arrange
|
||||
let sessionId = UUID()
|
||||
let harness = try Harness()
|
||||
await harness.openAndAdopt(adopting: sessionId)
|
||||
|
||||
// Act
|
||||
await harness.transport.emitError(TermTransportError.unauthorized)
|
||||
|
||||
// Assert
|
||||
#expect(await harness.next() == .connection(.failed(.unauthorized)))
|
||||
#expect(await harness.next() == nil)
|
||||
#expect(harness.clock.pendingSleeperCount == 0)
|
||||
harness.clock.advance(by: .seconds(60))
|
||||
#expect(await harness.transport.connectAttempts.count == 1)
|
||||
}
|
||||
|
||||
// MARK: - Close
|
||||
|
||||
@Test("close() closes the transport, finishes the events stream, and leaks no tasks")
|
||||
|
||||
@@ -30,6 +30,12 @@ struct URLSessionTermTransportTests {
|
||||
static let normalCloseCode: UInt16 = 1000
|
||||
/// Arbitrary non-text payload for the binary-frame drop test.
|
||||
static let binaryPayload: [UInt8] = [0xDE, 0xAD, 0xBE, 0xEF]
|
||||
/// A well-formed access token (server charset, src/config.ts:178).
|
||||
static let accessToken = "Ab9._~+/=-Ab9._~+/=-"
|
||||
/// A second well-formed token — proves per-connect token resolution.
|
||||
static let rotatedAccessToken = "Zz0-=/+~._Zz0-=/+~._"
|
||||
/// Off-charset token: must NEVER reach the wire (header injection).
|
||||
static let malformedAccessToken = "aaaaaaaaaaaaaaaa\r\nX-Evil: 1"
|
||||
}
|
||||
|
||||
private struct TimedOut: Error {}
|
||||
@@ -74,6 +80,22 @@ struct URLSessionTermTransportTests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The first `count` upgrade requests, drained through ONE iterator
|
||||
/// (`AsyncStream` is single-consumer — buffered yields are never lost).
|
||||
private static func firstUpgrades(
|
||||
count: Int, from server: ScriptedWSServer
|
||||
) async throws -> [ScriptedWSServer.Upgrade] {
|
||||
try await withTimeout {
|
||||
var collected: [ScriptedWSServer.Upgrade] = []
|
||||
var iterator = server.upgrades.makeAsyncIterator()
|
||||
while collected.count < count {
|
||||
guard let upgrade = await iterator.next() else { throw TimedOut() }
|
||||
collected.append(upgrade)
|
||||
}
|
||||
return collected
|
||||
}
|
||||
}
|
||||
|
||||
private static func collect(
|
||||
_ count: Int, from frames: AsyncThrowingStream<String, any Error>
|
||||
) async throws -> [String] {
|
||||
@@ -278,6 +300,135 @@ struct URLSessionTermTransportTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Access token (B3 · ios-completion §1.1)
|
||||
|
||||
@Test("configured token ⇒ upgrade carries Cookie: webterm_auth=<t> AND Origin (additive, §1.1)")
|
||||
func upgradeCarriesAuthCookieAlongsideOrigin() async throws {
|
||||
let (server, endpoint) = try await Self.startServer()
|
||||
defer { server.stop() }
|
||||
let transport = URLSessionTermTransport(tokenProvider: { TestTunables.accessToken })
|
||||
|
||||
let connection = try await transport.connect(to: endpoint)
|
||||
let upgrade = try await Self.firstUpgrade(server)
|
||||
|
||||
// The cookie NEVER replaces Origin — the server checks Origin first,
|
||||
// then the cookie (src/server.ts:1367-1379).
|
||||
#expect(upgrade.headers["origin"] == endpoint.originHeader)
|
||||
#expect(upgrade.headers["cookie"] == "\(AuthCookie.name)=\(TestTunables.accessToken)")
|
||||
await connection.close()
|
||||
}
|
||||
|
||||
@Test("no token configured ⇒ NO Cookie header at all (LAN zero-config unchanged)")
|
||||
func upgradeOmitsCookieWhenNoTokenConfigured() async throws {
|
||||
let (server, endpoint) = try await Self.startServer()
|
||||
defer { server.stop() }
|
||||
|
||||
let connection = try await URLSessionTermTransport().connect(to: endpoint)
|
||||
let upgrade = try await Self.firstUpgrade(server)
|
||||
|
||||
#expect(upgrade.headers["cookie"] == nil)
|
||||
#expect(upgrade.headers["origin"] == endpoint.originHeader)
|
||||
await connection.close()
|
||||
}
|
||||
|
||||
@Test("off-charset token is dropped, never smuggled onto the wire; Origin still sent")
|
||||
func malformedTokenIsNeverSentButOriginIs() async throws {
|
||||
// A malformed stored token must not break a host that has auth DISABLED
|
||||
// (it ignores the cookie), and must not become a header-injection vector.
|
||||
let (server, endpoint) = try await Self.startServer()
|
||||
defer { server.stop() }
|
||||
let transport = URLSessionTermTransport(
|
||||
tokenProvider: { TestTunables.malformedAccessToken }
|
||||
)
|
||||
|
||||
let connection = try await transport.connect(to: endpoint)
|
||||
let upgrade = try await Self.firstUpgrade(server)
|
||||
|
||||
#expect(upgrade.headers["cookie"] == nil)
|
||||
#expect(upgrade.headers["x-evil"] == nil)
|
||||
#expect(upgrade.headers["origin"] == endpoint.originHeader)
|
||||
await connection.close()
|
||||
}
|
||||
|
||||
@Test("token provider is resolved PER CONNECT — a token added/rotated mid-run needs no relaunch")
|
||||
func tokenProviderResolvedPerConnect() async throws {
|
||||
let (server, endpoint) = try await Self.startServer()
|
||||
defer { server.stop() }
|
||||
let tokens = TokenSequence([TestTunables.accessToken, TestTunables.rotatedAccessToken])
|
||||
let transport = URLSessionTermTransport(tokenProvider: { tokens.next() })
|
||||
|
||||
let first = try await transport.connect(to: endpoint)
|
||||
await first.close()
|
||||
let second = try await transport.connect(to: endpoint)
|
||||
await second.close()
|
||||
let upgrades = try await Self.firstUpgrades(count: 2, from: server)
|
||||
|
||||
#expect(upgrades.map { $0.headers["cookie"] } == [
|
||||
"\(AuthCookie.name)=\(TestTunables.accessToken)",
|
||||
"\(AuthCookie.name)=\(TestTunables.rotatedAccessToken)",
|
||||
])
|
||||
}
|
||||
|
||||
@Test("401 on the WS upgrade ⇒ typed TermTransportError.unauthorized (terminal, never retried)")
|
||||
func unauthorizedUpgradeThrowsTypedUnauthorized() async throws {
|
||||
let (server, endpoint) = try await Self.startServer()
|
||||
defer { server.stop() }
|
||||
server.rejectUpgrades(with: .unauthorized)
|
||||
let transport = URLSessionTermTransport(tokenProvider: { TestTunables.accessToken })
|
||||
|
||||
var thrown: (any Error)?
|
||||
do {
|
||||
_ = try await Self.withTimeout { try await transport.connect(to: endpoint) }
|
||||
} catch {
|
||||
thrown = error
|
||||
}
|
||||
|
||||
#expect(
|
||||
(thrown as? TermTransportError) == .unauthorized,
|
||||
"expected .unauthorized, got \(String(describing: thrown))"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("a NON-401 upgrade rejection stays in the retryable class (not .unauthorized)")
|
||||
func nonUnauthorizedUpgradeRejectionStaysRetryable() async throws {
|
||||
let (server, endpoint) = try await Self.startServer()
|
||||
defer { server.stop() }
|
||||
server.rejectUpgrades(with: .serverError)
|
||||
|
||||
var thrown: (any Error)?
|
||||
do {
|
||||
_ = try await Self.withTimeout {
|
||||
try await URLSessionTermTransport().connect(to: endpoint)
|
||||
}
|
||||
} catch {
|
||||
thrown = error
|
||||
}
|
||||
|
||||
let failure = try #require(thrown, "a 500 upgrade must still fail the connect")
|
||||
#expect(!(failure is TimedOut), "connect neither opened nor failed in time")
|
||||
#expect((failure as? TermTransportError) != .unauthorized)
|
||||
}
|
||||
|
||||
@Test("the token never appears in a thrown error (nothing loggable carries the secret)")
|
||||
func thrownErrorNeverCarriesTheToken() async throws {
|
||||
let (server, endpoint) = try await Self.startServer()
|
||||
defer { server.stop() }
|
||||
server.rejectUpgrades(with: .unauthorized)
|
||||
let transport = URLSessionTermTransport(tokenProvider: { TestTunables.accessToken })
|
||||
|
||||
var thrown: (any Error)?
|
||||
do {
|
||||
_ = try await Self.withTimeout { try await transport.connect(to: endpoint) }
|
||||
} catch {
|
||||
thrown = error
|
||||
}
|
||||
|
||||
let failure = try #require(thrown)
|
||||
#expect(!String(describing: failure).contains(TestTunables.accessToken))
|
||||
#expect(!String(reflecting: failure).contains(TestTunables.accessToken))
|
||||
#expect(!failure.localizedDescription.contains(TestTunables.accessToken))
|
||||
}
|
||||
|
||||
/// Thread-safe invocation counter for the `@Sendable` identity provider
|
||||
/// (URLSession may consult it off the test's isolation domain).
|
||||
private final class CallCounter: @unchecked Sendable {
|
||||
@@ -286,5 +437,24 @@ struct URLSessionTermTransportTests {
|
||||
func increment() { lock.withLock { count += 1 } }
|
||||
var value: Int { lock.withLock { count } }
|
||||
}
|
||||
|
||||
/// Thread-safe token script: hands out one token per `connect` (the last
|
||||
/// value repeats), proving the provider is re-consulted every time.
|
||||
private final class TokenSequence: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var remaining: [String]
|
||||
|
||||
init(_ tokens: [String]) {
|
||||
remaining = tokens
|
||||
}
|
||||
|
||||
func next() -> String? {
|
||||
lock.withLock {
|
||||
guard let first = remaining.first else { return nil }
|
||||
if remaining.count > 1 { remaining.removeFirst() }
|
||||
return first
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
Reference in New Issue
Block a user