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:
Yaojia Wang
2026-07-30 12:45:26 +02:00
parent c4f8b5b47f
commit 850531fd07
33 changed files with 4191 additions and 106 deletions

View File

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

View File

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

View File

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

View File

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