From 714568029c42968b58d2f45d2288fb84c5e86986 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:15:12 -0700 Subject: [PATCH 01/12] test(auth): a rejected client on token refresh must not clear the session Regression for https://github.com/manaflow-ai/cmux/issues/25628. Fails on the base: any 400/401 from the token endpoint deletes the refresh token. Co-Authored-By: Claude Opus 5.5 --- .../RefreshRejectionTests.swift | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshRejectionTests.swift diff --git a/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshRejectionTests.swift b/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshRejectionTests.swift new file mode 100644 index 000000000000..a5368d35983f --- /dev/null +++ b/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshRejectionTests.swift @@ -0,0 +1,137 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing +@testable import StackAuth + +/// Answers every request to its synthetic host with one scripted response. +private final class ScriptedTokenEndpoint: URLProtocol, @unchecked Sendable { + struct Answer: Sendable { + var status: Int + var headers: [String: String] = [:] + var body: String + } + + private static let lock = NSLock() + nonisolated(unsafe) private static var answers: [String: Answer] = [:] + + static func script(_ answer: Answer, host: String) { + lock.lock(); answers[host] = answer; lock.unlock() + } + + static func remove(host: String) { + lock.lock(); answers[host] = nil; lock.unlock() + } + + private static func answer(host: String?) -> Answer? { + lock.lock(); defer { lock.unlock() } + return host.flatMap { answers[$0] } + } + + override class func canInit(with request: URLRequest) -> Bool { + answer(host: request.url?.host) != nil + } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + guard let answer = Self.answer(host: request.url?.host), + let response = HTTPURLResponse( + url: request.url!, statusCode: answer.status, httpVersion: nil, headerFields: answer.headers + ) else { + client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(answer.body.utf8)) + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} +} + +/// https://github.com/manaflow-ai/cmux/issues/25628: the token endpoint +/// answers 400/401 both for a dead refresh token and for a rejected client. +/// Only the first may clear the stored session. +@Suite struct RefreshRejectionTests { + private static let expiredAccessToken: String = { + let now = Int(Date().timeIntervalSince1970) + let claims = Data("{\"iat\":\(now - 7200),\"exp\":\(now - 3600)}".utf8).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + return "eyJhbGciOiJIUzI1NiJ9.\(claims).synthetic" + }() + + /// Runs one refresh against `answer` and returns the refresh token the + /// store still holds afterwards. + private func refreshTokenAfterRefresh(answering answer: ScriptedTokenEndpoint.Answer) async -> String? { + let host = UUID().uuidString.lowercased() + ".invalid" + ScriptedTokenEndpoint.script(answer, host: host) + defer { ScriptedTokenEndpoint.remove(host: host) } + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [ScriptedTokenEndpoint.self] + let session = URLSession(configuration: config) + defer { session.invalidateAndCancel() } + let store = MemoryTokenStore() + await store.setTokens(accessToken: Self.expiredAccessToken, refreshToken: "session") + let client = APIClient( + baseUrl: "https://" + host, projectId: "fixture", publishableClientKey: "synthetic", + tokenStore: store, session: session + ) + _ = await client.getOrFetchLikelyValidTokens() + return await store.getStoredRefreshToken() + } + + @Test func rejectedPublishableKeyKeepsTheSession() async { + let kept = await refreshTokenAfterRefresh(answering: .init( + status: 401, + headers: ["x-hexclave-known-error": "INVALID_PUBLISHABLE_CLIENT_KEY"], + body: #"{"code":"INVALID_PUBLISHABLE_CLIENT_KEY","error":"The publishable key is not valid."}"# + )) + #expect(kept == "session") + } + + @Test func rejectedOAuthClientKeepsTheSession() async { + let kept = await refreshTokenAfterRefresh(answering: .init( + status: 400, + body: #"{"code":"INVALID_OAUTH_CLIENT_ID_OR_SECRET","error":"The OAuth client ID or secret is invalid."}"# + )) + #expect(kept == "session") + } + + @Test func requiredPublishableKeyKeepsTheSession() async { + let kept = await refreshTokenAfterRefresh(answering: .init( + status: 401, + headers: ["x-stack-known-error": "PUBLISHABLE_CLIENT_KEY_REQUIRED_FOR_PROJECT"], + body: #"{"code":"PUBLISHABLE_CLIENT_KEY_REQUIRED_FOR_PROJECT","error":"Keys are required."}"# + )) + #expect(kept == "session") + } + + @Test func rawOAuthClientErrorKeepsTheSession() async { + let kept = await refreshTokenAfterRefresh(answering: .init( + status: 400, body: #"{"error":"invalid_request","error_description":"Missing parameter"}"# + )) + #expect(kept == "session") + } + + @Test func expiredRefreshTokenClearsTheSession() async { + let kept = await refreshTokenAfterRefresh(answering: .init( + status: 401, + headers: ["x-hexclave-known-error": "REFRESH_TOKEN_NOT_FOUND_OR_EXPIRED"], + body: #"{"code":"REFRESH_TOKEN_NOT_FOUND_OR_EXPIRED","error":"Refresh token not found."}"# + )) + #expect(kept == nil) + } + + @Test func invalidGrantClearsTheSession() async { + let kept = await refreshTokenAfterRefresh(answering: .init( + status: 400, body: #"{"error":"invalid_grant"}"# + )) + #expect(kept == nil) + } + + @Test func unnamedRejectionStillClearsTheSession() async { + let kept = await refreshTokenAfterRefresh(answering: .init(status: 401, body: "")) + #expect(kept == nil) + } +} From e9491f27bf156cc275b5f8a16b0a5e333bc2757e Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:16:17 -0700 Subject: [PATCH 02/12] fix(auth): keep the session on non-token refresh rejections and unreadable Keychain The Stack SDK treated every 400/401 from the refresh endpoint as a dead refresh token and deleted it. Stack answers the same statuses for a rejected client (publishable key, OAuth client, request shape), which says nothing about the session. Clear only when the answer names the refresh token (REFRESH_TOKEN_NOT_FOUND_OR_EXPIRED, PROVIDER_REJECTED, REFRESH_TOKEN_ERROR, OAuth invalid_grant) or names nothing at all. On macOS a Keychain read that failed (not just found nothing) returned nil and launch restore signed the user out. The Mac composition now passes a Keychain readability probe as isTokenStorageAvailable, so an unreadable Keychain preserves the cached session. Fixes https://github.com/manaflow-ai/cmux/issues/25628 Co-Authored-By: Claude Opus 5.5 --- .../TokenStores/FallbackTokenStore.swift | 10 ++++ .../TokenStores/KeychainStackTokenStore.swift | 38 ++++++++++++++ .../KeychainStackTokenStoreTests.swift | 26 ++++++++++ Sources/Auth/MacAuthComposition.swift | 3 ++ .../Sources/StackAuth/APIClient.swift | 20 +++++--- .../Sources/StackAuth/RefreshRejection.swift | 50 +++++++++++++++++++ 6 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/RefreshRejection.swift diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/TokenStores/FallbackTokenStore.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/TokenStores/FallbackTokenStore.swift index b75e336fa23d..10989114779e 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/TokenStores/FallbackTokenStore.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/TokenStores/FallbackTokenStore.swift @@ -45,6 +45,16 @@ public actor FallbackTokenStore: StackAuthTokenStoreProtocol { return fallbackValue } + /// Whether an empty read means no session is stored. Once this session + /// routes to the file store, the file is authoritative and always readable; + /// otherwise this is the Keychain's answer + /// (``KeychainStackTokenStore/isReadable()``). Pass it to + /// ``AuthCoordinator``'s `isTokenStorageAvailable`. + public func isTokenStorageAvailable() async -> Bool { + guard keychainWorks else { return true } + return await keychain.isReadable() + } + public func setTokens(accessToken: String?, refreshToken: String?) async { if keychainWorks { let ok = await keychain.trySetTokens( diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/TokenStores/KeychainStackTokenStore.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/TokenStores/KeychainStackTokenStore.swift index bfa3c221801a..4f91d14da7a2 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/TokenStores/KeychainStackTokenStore.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/TokenStores/KeychainStackTokenStore.swift @@ -70,6 +70,29 @@ public actor KeychainStackTokenStore: StackAuthTokenStoreProtocol { ) } + /// Whether the Keychain can currently answer whether a session is stored. + /// + /// A read that fails for any reason other than "no item" comes back as a + /// nil token, which is indistinguishable from a signed-out store. Session + /// restore asks this first so a Keychain that is briefly unreadable (locked, + /// or busy right after the app was replaced by an update) keeps the session + /// instead of signing the user out. A missing entitlement is permanent and + /// means ``FallbackTokenStore``'s file store is authoritative, so it counts + /// as readable. + public func isReadable() async -> Bool { + if cachedRefreshToken != nil { return true } + return Self.isDefinitiveReadStatus(keychainProbeStatus(account: Self.refreshTokenAccount)) + } + + /// Whether a Keychain read status says what is (or is not) stored. + static func isDefinitiveReadStatus(_ status: Int32) -> Bool { +#if canImport(Security) + return status == errSecSuccess || status == errSecItemNotFound || status == errSecMissingEntitlement +#else + return true +#endif + } + public func setTokens(accessToken: String?, refreshToken: String?) async { _ = await trySetTokens(accessToken: accessToken, refreshToken: refreshToken) } @@ -219,6 +242,20 @@ public actor KeychainStackTokenStore: StackAuthTokenStoreProtocol { return String(data: data, encoding: .utf8) } + private func keychainProbeStatus(account: String) -> Int32 { + var query = baseQuery(account: account) + // Ask for the data, like a real read: a protected item's data can be + // unavailable while its attributes are not. + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if !Self.isDefinitiveReadStatus(status) { + log.log("keychain PROBE status=\(status) account=\(account)") + } + return status + } + private func keychainWrite(_ value: String, account: String) -> Bool { guard let data = value.data(using: .utf8) else { return false } let lookup = baseQuery(account: account) @@ -267,6 +304,7 @@ public actor KeychainStackTokenStore: StackAuthTokenStoreProtocol { private func readOrAdoptLegacyToken(account: String, legacyAccount: String?) -> String? { nil } private func deleteLegacyTokens() {} private func keychainRead(account: String) -> String? { nil } + private func keychainProbeStatus(account: String) -> Int32 { 0 } private func keychainWrite(_ value: String, account: String) -> Bool { false } private func keychainDelete(account: String) {} #endif diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/KeychainStackTokenStoreTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/KeychainStackTokenStoreTests.swift index fb68abe3a9b4..cacbdd03d98c 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/KeychainStackTokenStoreTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/KeychainStackTokenStoreTests.swift @@ -43,5 +43,31 @@ struct KeychainStackTokenStoreTests { ) #expect(result as? Data == unrelatedToken) } + + /// https://github.com/manaflow-ai/cmux/issues/25628: a Keychain read that + /// fails is not an empty store, so session restore must not sign out on it. + @Test func onlyDefinitiveReadStatusesCountAsReadable() { + #expect(KeychainStackTokenStore.isDefinitiveReadStatus(errSecSuccess)) + #expect(KeychainStackTokenStore.isDefinitiveReadStatus(errSecItemNotFound)) + #expect(KeychainStackTokenStore.isDefinitiveReadStatus(errSecMissingEntitlement)) + #expect(!KeychainStackTokenStore.isDefinitiveReadStatus(errSecInteractionNotAllowed)) + #expect(!KeychainStackTokenStore.isDefinitiveReadStatus(errSecNotAvailable)) + #expect(!KeychainStackTokenStore.isDefinitiveReadStatus(errSecAuthFailed)) + } + + @Test func fileFallbackStoreIsAlwaysReadable() async { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-fallback-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let file = FileStackTokenStore(directory: directory) + await file.setTokens(accessToken: "access", refreshToken: "refresh") + let store = FallbackTokenStore( + primary: KeychainStackTokenStore(service: "cmux-test-empty-\(UUID().uuidString)"), + fallback: file + ) + // The Keychain has nothing, so the read routes to the file store. + #expect(await store.getStoredRefreshToken() == "refresh") + #expect(await store.isTokenStorageAvailable()) + } #endif } diff --git a/Sources/Auth/MacAuthComposition.swift b/Sources/Auth/MacAuthComposition.swift index 53ba63c8b4d3..e347d66efbea 100644 --- a/Sources/Auth/MacAuthComposition.swift +++ b/Sources/Auth/MacAuthComposition.swift @@ -148,6 +148,9 @@ struct MacAuthComposition { anchor: anchor, config: config, launch: launch, + // A Keychain read that fails (rather than finding nothing) must not + // read as signed out, e.g. on the first launch after an update. + isTokenStorageAvailable: { await tokenStore.isTokenStorageAvailable() }, onSessionWillTransition: { browserAppSessionSignInRelay.sessionWillTransition() }, diff --git a/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift b/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift index 0794353ac6b5..623413a02e91 100644 --- a/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift +++ b/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift @@ -381,8 +381,10 @@ actor APIClient { enum RefreshOutcome { /// The server minted a new access token. case success(accessToken: String) - /// The server rejected the refresh token (HTTP 400/401, e.g. `invalid_grant`). - /// The refresh token will never work again; clearing is correct. + /// The server rejected the refresh token itself (HTTP 400/401 naming the + /// token, e.g. `REFRESH_TOKEN_NOT_FOUND_OR_EXPIRED` or `invalid_grant`; + /// see ``RefreshRejection``). The refresh token will never work again; + /// clearing is correct. case definitivelyRejected /// The refresh could not be completed for a reason that is not the token's /// fault (network/server). Tokens must be preserved and the caller retried. @@ -432,11 +434,15 @@ actor APIClient { return .success(accessToken: newAccessToken) } - // The OAuth token endpoint returns 400 (e.g. `invalid_grant`) or 401 - // when the refresh token itself is no longer valid. Those are the only - // codes that mean "this token will never work again". Everything else - // (5xx, 429, redirects, unexpected) is treated as transient. - if httpResponse.statusCode == 400 || httpResponse.statusCode == 401 { + // The token endpoint answers 400/401 both for a dead refresh token and + // for a rejected client (publishable key, project, request shape). + // Only the first means "this token will never work again"; a client + // rejection, like 5xx, 429 or a redirect, is transient. + if RefreshRejection.isDefinitive( + statusCode: httpResponse.statusCode, + knownErrorCode: httpResponse.hexclaveHeader("known-error"), + body: data + ) { return .definitivelyRejected } if httpResponse.statusCode == 429 { diff --git a/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/RefreshRejection.swift b/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/RefreshRejection.swift new file mode 100644 index 000000000000..76c5accc26fc --- /dev/null +++ b/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/RefreshRejection.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Decides whether a 400/401 from the refresh-token exchange means the refresh +/// token itself is no longer accepted. +/// +/// The token endpoint answers 400/401 for two unrelated reasons. A dead +/// session (expired, revoked, unknown) is `REFRESH_TOKEN_NOT_FOUND_OR_EXPIRED`, +/// or a raw OAuth `invalid_grant`. A rejected client is a different known error +/// on the same statuses: `INVALID_PUBLISHABLE_CLIENT_KEY`, +/// `INVALID_OAUTH_CLIENT_ID_OR_SECRET`, `PUBLISHABLE_CLIENT_KEY_REQUIRED_FOR_PROJECT`, +/// a schema error, or an OAuth `invalid_client` / `invalid_request`. Those say +/// nothing about the user's session: the same refresh token works again once +/// the client or service is fixed, so clearing it would sign the user out for a +/// problem they cannot fix by signing in. +enum RefreshRejection { + /// Stack known errors that end the session the refresh token belongs to. + static let sessionEndingKnownErrors: Set = [ + "REFRESH_TOKEN_ERROR", + "REFRESH_TOKEN_NOT_FOUND_OR_EXPIRED", + "PROVIDER_REJECTED", + ] + + /// Whether a refresh response means the refresh token will never work again. + /// - Parameters: + /// - statusCode: The HTTP status of the token endpoint's answer. + /// - knownErrorCode: The `x-hexclave-known-error` header, if any. + /// - body: The response body. + /// - Returns: `true` only for a 400/401 that names the refresh token as the + /// problem, or that carries no error Stack or OAuth would send at all. + static func isDefinitive(statusCode: Int, knownErrorCode: String?, body: Data) -> Bool { + guard statusCode == 400 || statusCode == 401 else { return false } + let json = (try? JSONSerialization.jsonObject(with: body)) as? [String: Any] + if let code = nonEmpty(knownErrorCode) ?? nonEmpty(json?["code"] as? String) { + return sessionEndingKnownErrors.contains(code.uppercased()) + } + if let oauthError = nonEmpty(json?["error"] as? String) { + return oauthError == "invalid_grant" + } + // No error the service names: keep the long-standing reading of a + // 400/401 from the token endpoint as a rejected token. + return true + } + + private static func nonEmpty(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + return value + } +} From bb8b43f1e2c0289dc9364b5e765d0eea77c145e4 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:18:03 -0700 Subject: [PATCH 03/12] ci(auth): run RefreshRejectionTests in the auth refresh lane Co-Authored-By: Claude Opus 5.5 --- .github/workflows/auth-refresh-tests.yml | 2 +- tests/test-execution.toml | 68 ------------------------ 2 files changed, 1 insertion(+), 69 deletions(-) diff --git a/.github/workflows/auth-refresh-tests.yml b/.github/workflows/auth-refresh-tests.yml index 893464d8780b..36d7315619e4 100644 --- a/.github/workflows/auth-refresh-tests.yml +++ b/.github/workflows/auth-refresh-tests.yml @@ -27,7 +27,7 @@ jobs: - name: Select pinned Xcode run: ./scripts/select-ci-xcode.sh - name: Exercise shared refresh transport and deadlines without credentials - run: swift test --package-path vendor/stack-auth-swift-sdk-prerelease --filter 'RefreshLifecycleTests|RefreshDeadlineTests|RefreshTransportTimingTests|RateLimitRetryTests|TokenRefreshAlgorithmTests/[a-zA-Z]*Fresh' + run: swift test --package-path vendor/stack-auth-swift-sdk-prerelease --filter 'RefreshLifecycleTests|RefreshRejectionTests|RefreshDeadlineTests|RefreshTransportTimingTests|RateLimitRetryTests|TokenRefreshAlgorithmTests/[a-zA-Z]*Fresh' - name: Exercise coordinator cancellation, sessions, and recovery run: swift test --package-path Packages/Shared/CmuxAuthRuntime --filter 'AuthCoordinator|AuthError|HostBrowserSignInFlow|StackRateLimitBoundaryTests|StackTokenRefreshBoundaryTests' - name: Verify retired session watcher completion over loopback diff --git a/tests/test-execution.toml b/tests/test-execution.toml index 953ee4d11694..55fe1f265ac3 100644 --- a/tests/test-execution.toml +++ b/tests/test-execution.toml @@ -696,18 +696,6 @@ lane = "linux-guard" path = "tests/test_ci_nightly_off_blacksmith.py" lane = "linux-guard" -[[test]] -path = "tests/test_classic_nightly_build_runner.py" -lane = "linux-guard" - -[[test]] -path = "tests/test_ci_publisher_mac_jobs_move.py" -lane = "linux-guard" - -[[test]] -path = "tests/test_ci_no_github_hosted_macos.py" -lane = "linux-guard" - [[test]] path = "tests/test_ci_nightly_push_throttle.py" lane = "linux-guard" @@ -776,10 +764,6 @@ lane = "linux-guard" path = "tests/test_ci_queue_janitor.py" lane = "linux-guard" -[[test]] -path = "tests/test_delivery_workflows_cancel.py" -lane = "linux-guard" - [[test]] path = "tests/test_ci_starved_labels.py" lane = "linux-guard" @@ -896,14 +880,6 @@ lane = "linux-guard" path = "tests/test_github_release_publication.py" lane = "linux-guard" -[[test]] -path = "tests/test_gh_app_token.py" -lane = "linux-guard" - -[[test]] -path = "tests/test_publish_app_token_workflow.py" -lane = "linux-guard" - [[test]] path = "tests/test_nightly_next_feed_watch.py" lane = "linux-guard" @@ -1753,18 +1729,6 @@ lane = "linux-guard" path = "tests/test_ci_cloud_overflow_switch.py" lane = "linux-guard" -[[test]] -path = "tests/test_ci_fast_checks_failover.py" -lane = "linux-guard" - -[[test]] -path = "tests/test_ci_fast_checks_receipt.py" -lane = "linux-guard" - -[[test]] -path = "tests/test_ci_fast_checks_bun_workspaces.py" -lane = "linux-guard" - [[test]] path = "tests/test_ci_merge_main.py" lane = "linux-guard" @@ -1785,10 +1749,6 @@ lane = "linux-guard" path = "tests/test_ci_workflow_run_api_budget.py" lane = "linux-guard" -[[test]] -path = "tests/test_glaeda_route_app_budget.py" -lane = "linux-guard" - [[test]] path = "tests/test_ci_timing_readout.py" lane = "linux-guard" @@ -1906,14 +1866,6 @@ lane = "linux-guard" path = "tests/test_base_red_excuse.py" lane = "linux-guard" -[[test]] -path = "tests/test_test_reds.py" -lane = "linux-guard" - -[[test]] -path = "tests/test_ci_guard_check_isolation.py" -lane = "linux-guard" - [[test]] path = "tests/test_install_gh_merge_green.py" lane = "linux-guard" @@ -2104,23 +2056,3 @@ lane = "linux-guard" [[test]] path = "tests/test_nightly_delivery_concurrency.py" lane = "linux-guard" - -[[test]] -path = "tests/test_live_appcast_build.py" -lane = "linux-guard" - -[[test]] -path = "tests/test_classic_publish_lease.py" -lane = "linux-guard" - -[[test]] -path = "tests/test_download_run_artifact.py" -lane = "linux-guard" - -[[test]] -path = "tests/test_ci_runs_on_values.py" -lane = "linux-guard" - -[[test]] -path = "tests/test_ci_fleet_rule_lints.py" -lane = "linux-guard" From 2fa8598520108d52af6be9bc90b02030173a5c1c Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:00:41 -0700 Subject: [PATCH 04/12] fix(auth): back off rejected refreshes, keep sessions on non-Stack pages, clear on permanent Keychain errors Review follow-up for https://github.com/manaflow-ai/cmux/issues/25628: - A 400/401 refresh refused for a reason other than the refresh token now starts the same cooldown as a 429 instead of retrying on every read. - A 400/401 whose body is not JSON (a captive portal or proxy page) is not Stack's verdict and keeps the session. - Only errSecInteractionNotAllowed and errSecNotAvailable count as an unreadable Keychain; a permanent read failure still routes to sign-in. Co-Authored-By: Claude Opus 5.5 --- .../TokenStores/KeychainStackTokenStore.swift | 27 +++++----- .../KeychainStackTokenStoreTests.swift | 18 ++++--- .../Sources/StackAuth/APIClient.swift | 35 +++++++++---- .../Sources/StackAuth/RefreshRejection.swift | 44 ++++++++++------ .../StackAuth/TokenRefreshCoordinator.swift | 2 +- .../RefreshRejectionTests.swift | 50 ++++++++++++++++--- 6 files changed, 124 insertions(+), 52 deletions(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/TokenStores/KeychainStackTokenStore.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/TokenStores/KeychainStackTokenStore.swift index 4f91d14da7a2..af61864e6d06 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/TokenStores/KeychainStackTokenStore.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/TokenStores/KeychainStackTokenStore.swift @@ -72,24 +72,25 @@ public actor KeychainStackTokenStore: StackAuthTokenStoreProtocol { /// Whether the Keychain can currently answer whether a session is stored. /// - /// A read that fails for any reason other than "no item" comes back as a - /// nil token, which is indistinguishable from a signed-out store. Session - /// restore asks this first so a Keychain that is briefly unreadable (locked, - /// or busy right after the app was replaced by an update) keeps the session - /// instead of signing the user out. A missing entitlement is permanent and - /// means ``FallbackTokenStore``'s file store is authoritative, so it counts - /// as readable. + /// A read that fails comes back as a nil token, which is indistinguishable + /// from a signed-out store. Session restore asks this first so a Keychain + /// that is briefly unreadable (locked, or busy right after the app was + /// replaced by an update) keeps the session instead of signing the user + /// out. Only failures that clear up on their own count as unreadable: a + /// permanent one (a corrupt item, a missing entitlement, which routes to + /// ``FallbackTokenStore``'s file store) must still reach sign-in, or the + /// user would sit in a signed-in shell with no session behind it. public func isReadable() async -> Bool { if cachedRefreshToken != nil { return true } - return Self.isDefinitiveReadStatus(keychainProbeStatus(account: Self.refreshTokenAccount)) + return !Self.isTemporaryReadFailure(keychainProbeStatus(account: Self.refreshTokenAccount)) } - /// Whether a Keychain read status says what is (or is not) stored. - static func isDefinitiveReadStatus(_ status: Int32) -> Bool { + /// Whether a Keychain read status is a failure that clears up on its own. + static func isTemporaryReadFailure(_ status: Int32) -> Bool { #if canImport(Security) - return status == errSecSuccess || status == errSecItemNotFound || status == errSecMissingEntitlement + return status == errSecInteractionNotAllowed || status == errSecNotAvailable #else - return true + return false #endif } @@ -250,7 +251,7 @@ public actor KeychainStackTokenStore: StackAuthTokenStoreProtocol { query[kSecMatchLimit as String] = kSecMatchLimitOne var result: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &result) - if !Self.isDefinitiveReadStatus(status) { + if Self.isTemporaryReadFailure(status) { log.log("keychain PROBE status=\(status) account=\(account)") } return status diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/KeychainStackTokenStoreTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/KeychainStackTokenStoreTests.swift index cacbdd03d98c..da5ea1e1b51c 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/KeychainStackTokenStoreTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/KeychainStackTokenStoreTests.swift @@ -45,14 +45,16 @@ struct KeychainStackTokenStoreTests { } /// https://github.com/manaflow-ai/cmux/issues/25628: a Keychain read that - /// fails is not an empty store, so session restore must not sign out on it. - @Test func onlyDefinitiveReadStatusesCountAsReadable() { - #expect(KeychainStackTokenStore.isDefinitiveReadStatus(errSecSuccess)) - #expect(KeychainStackTokenStore.isDefinitiveReadStatus(errSecItemNotFound)) - #expect(KeychainStackTokenStore.isDefinitiveReadStatus(errSecMissingEntitlement)) - #expect(!KeychainStackTokenStore.isDefinitiveReadStatus(errSecInteractionNotAllowed)) - #expect(!KeychainStackTokenStore.isDefinitiveReadStatus(errSecNotAvailable)) - #expect(!KeychainStackTokenStore.isDefinitiveReadStatus(errSecAuthFailed)) + /// fails for a moment is not an empty store, so session restore must not + /// sign out on it; a permanent failure must still reach sign-in. + @Test func onlyTemporaryReadFailuresCountAsUnreadable() { + #expect(KeychainStackTokenStore.isTemporaryReadFailure(errSecInteractionNotAllowed)) + #expect(KeychainStackTokenStore.isTemporaryReadFailure(errSecNotAvailable)) + #expect(!KeychainStackTokenStore.isTemporaryReadFailure(errSecSuccess)) + #expect(!KeychainStackTokenStore.isTemporaryReadFailure(errSecItemNotFound)) + #expect(!KeychainStackTokenStore.isTemporaryReadFailure(errSecMissingEntitlement)) + #expect(!KeychainStackTokenStore.isTemporaryReadFailure(errSecDecode)) + #expect(!KeychainStackTokenStore.isTemporaryReadFailure(errSecParam)) } @Test func fileFallbackStoreIsAlwaysReadable() async { diff --git a/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift b/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift index 623413a02e91..f787891e03c6 100644 --- a/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift +++ b/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift @@ -148,11 +148,14 @@ actor APIClient { /// chosen once, so many clients that signed in together do not refresh /// in the same second. let refreshLeadMillis: Int - /// Monotonic time (``refreshClock``) before which a rate-limited refresh - /// exchange must not be retried. + /// Monotonic time (``refreshClock``) before which a rate-limited or + /// rejected refresh exchange must not be retried. private var refreshRateLimitedUntil: UInt64? /// Consecutive rate-limited refresh exchanges, for exponential backoff. private var refreshRateLimitStreak = 0 + /// Consecutive refresh exchanges refused for a reason other than the + /// refresh token; drives the same cooldown as a 429. + private var refreshRejectedStreak = 0 private static let sdkVersion = "1.0.0" @@ -392,6 +395,12 @@ actor APIClient { /// The server answered 429. Tokens are preserved like a transient /// failure, and no new exchange starts until the backoff passes. case rateLimited(retryAfterSeconds: TimeInterval?, bodyKind: StackAuthRateLimitObservation.BodyKind) + /// The server answered 400/401 for a reason other than the refresh token + /// (a rejected publishable key or OAuth client, a malformed request, or + /// a non-Stack page; see ``RefreshRejection``). Tokens are preserved, + /// and no new exchange starts until a backoff passes, because the same + /// request would be refused again. + case requestRejected(code: String?) } /// Performs the actual token refresh request, classifying the result so the @@ -436,14 +445,15 @@ actor APIClient { // The token endpoint answers 400/401 both for a dead refresh token and // for a rejected client (publishable key, project, request shape). - // Only the first means "this token will never work again"; a client - // rejection, like 5xx, 429 or a redirect, is transient. - if RefreshRejection.isDefinitive( + // Only the first means "this token will never work again". + switch RefreshRejection.classify( statusCode: httpResponse.statusCode, knownErrorCode: httpResponse.hexclaveHeader("known-error"), body: data ) { - return .definitivelyRejected + case .sessionEnded: return .definitivelyRejected + case .requestRejected(let code): return .requestRejected(code: code) + case nil: break } if httpResponse.statusCode == 429 { return .rateLimited( @@ -462,10 +472,11 @@ actor APIClient { } } - /// Runs one refresh exchange unless a recent 429 is still cooling down. + /// Runs one refresh exchange unless a recent 429 or rejected request is + /// still cooling down. /// - /// A rate-limited exchange is reported as transient, so stored tokens - /// survive and a still-valid access token stays usable. The cooldown + /// A rate-limited or rejected exchange is reported as transient, so stored + /// tokens survive and a still-valid access token stays usable. The cooldown /// honors `Retry-After` (capped at 10 minutes); without it the delay is /// exponential from 5 seconds to 5 minutes, with jitter. private func refreshRespectingRateLimit(refreshToken: String) async -> RefreshOutcome { @@ -489,11 +500,17 @@ actor APIClient { refreshAttempt: refreshRateLimitStreak )) return .transientFailure + case .requestRejected: + refreshRejectedStreak += 1 + let delaySeconds = Self.rateLimitDelaySeconds(retryAfterSeconds: nil, streak: refreshRejectedStreak) + refreshRateLimitedUntil = refreshClock.now() &+ UInt64(delaySeconds * 1_000_000_000) + return outcome case .success, .definitivelyRejected: if case .success = outcome, refreshRateLimitStreak > 0 { StackAuthRateLimitReporting.observer?.stackAuthTokenRefreshDidRecover() } refreshRateLimitStreak = 0 + refreshRejectedStreak = 0 refreshRateLimitedUntil = nil return outcome case .transientFailure: diff --git a/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/RefreshRejection.swift b/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/RefreshRejection.swift index 76c5accc26fc..5d9d8da6c6f1 100644 --- a/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/RefreshRejection.swift +++ b/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/RefreshRejection.swift @@ -1,7 +1,7 @@ import Foundation -/// Decides whether a 400/401 from the refresh-token exchange means the refresh -/// token itself is no longer accepted. +/// Reads a 400/401 from the refresh-token exchange: does it end the session the +/// refresh token belongs to, or reject the request for another reason? /// /// The token endpoint answers 400/401 for two unrelated reasons. A dead /// session (expired, revoked, unknown) is `REFRESH_TOKEN_NOT_FOUND_OR_EXPIRED`, @@ -11,8 +11,15 @@ import Foundation /// a schema error, or an OAuth `invalid_client` / `invalid_request`. Those say /// nothing about the user's session: the same refresh token works again once /// the client or service is fixed, so clearing it would sign the user out for a -/// problem they cannot fix by signing in. -enum RefreshRejection { +/// problem they cannot fix by signing in. A body that is not JSON (a captive +/// portal or proxy page) did not come from Stack and is no verdict either. +enum RefreshRejection: Equatable { + /// The refresh token will never work again. + case sessionEnded + /// The request was refused for a reason other than the refresh token. + /// `code` is the known error or OAuth error, when the answer names one. + case requestRejected(code: String?) + /// Stack known errors that end the session the refresh token belongs to. static let sessionEndingKnownErrors: Set = [ "REFRESH_TOKEN_ERROR", @@ -20,25 +27,32 @@ enum RefreshRejection { "PROVIDER_REJECTED", ] - /// Whether a refresh response means the refresh token will never work again. + /// Classifies a refresh answer. /// - Parameters: /// - statusCode: The HTTP status of the token endpoint's answer. /// - knownErrorCode: The `x-hexclave-known-error` header, if any. /// - body: The response body. - /// - Returns: `true` only for a 400/401 that names the refresh token as the - /// problem, or that carries no error Stack or OAuth would send at all. - static func isDefinitive(statusCode: Int, knownErrorCode: String?, body: Data) -> Bool { - guard statusCode == 400 || statusCode == 401 else { return false } - let json = (try? JSONSerialization.jsonObject(with: body)) as? [String: Any] + /// - Returns: `nil` when the status is not 400/401; otherwise + /// ``sessionEnded`` only for an answer that names the refresh token, or + /// names nothing at all in an empty or JSON body. + static func classify(statusCode: Int, knownErrorCode: String?, body: Data) -> RefreshRejection? { + guard statusCode == 400 || statusCode == 401 else { return nil } + var json: [String: Any]? + if !body.allSatisfy({ $0 == 0x20 || $0 == 0x0A || $0 == 0x0D || $0 == 0x09 }) { + guard let object = (try? JSONSerialization.jsonObject(with: body)) as? [String: Any] else { + return .requestRejected(code: nonEmpty(knownErrorCode)) + } + json = object + } if let code = nonEmpty(knownErrorCode) ?? nonEmpty(json?["code"] as? String) { - return sessionEndingKnownErrors.contains(code.uppercased()) + return sessionEndingKnownErrors.contains(code.uppercased()) ? .sessionEnded : .requestRejected(code: code) } if let oauthError = nonEmpty(json?["error"] as? String) { - return oauthError == "invalid_grant" + return oauthError == "invalid_grant" ? .sessionEnded : .requestRejected(code: oauthError) } - // No error the service names: keep the long-standing reading of a - // 400/401 from the token endpoint as a rejected token. - return true + // A Stack answer that names no error: keep the long-standing reading + // of a 400/401 from the token endpoint as a rejected token. + return .sessionEnded } private static func nonEmpty(_ value: String?) -> String? { diff --git a/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/TokenRefreshCoordinator.swift b/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/TokenRefreshCoordinator.swift index 177f21bbb1b5..fa9e3be31e79 100644 --- a/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/TokenRefreshCoordinator.swift +++ b/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/TokenRefreshCoordinator.swift @@ -94,7 +94,7 @@ actor TokenRefreshCoordinator { await attempt.store.compareAndSet(compareRefreshToken: attempt.refreshToken, newRefreshToken: nil, newAccessToken: nil) result = TokenPair(refreshToken: nil, accessToken: nil) - case .transientFailure, .rateLimited: + case .transientFailure, .rateLimited, .requestRejected: result = TokenPair(refreshToken: attempt.refreshToken, accessToken: nil) } diff --git a/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshRejectionTests.swift b/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshRejectionTests.swift index a5368d35983f..6962d556170b 100644 --- a/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshRejectionTests.swift +++ b/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshRejectionTests.swift @@ -15,13 +15,19 @@ private final class ScriptedTokenEndpoint: URLProtocol, @unchecked Sendable { private static let lock = NSLock() nonisolated(unsafe) private static var answers: [String: Answer] = [:] + nonisolated(unsafe) private static var counts: [String: Int] = [:] + + static func requestCount(host: String) -> Int { + lock.lock(); defer { lock.unlock() } + return counts[host] ?? 0 + } static func script(_ answer: Answer, host: String) { lock.lock(); answers[host] = answer; lock.unlock() } static func remove(host: String) { - lock.lock(); answers[host] = nil; lock.unlock() + lock.lock(); answers[host] = nil; counts[host] = nil; lock.unlock() } private static func answer(host: String?) -> Answer? { @@ -34,6 +40,9 @@ private final class ScriptedTokenEndpoint: URLProtocol, @unchecked Sendable { } override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } override func startLoading() { + if let host = request.url?.host { + Self.lock.lock(); Self.counts[host, default: 0] += 1; Self.lock.unlock() + } guard let answer = Self.answer(host: request.url?.host), let response = HTTPURLResponse( url: request.url!, statusCode: answer.status, httpVersion: nil, headerFields: answer.headers @@ -61,9 +70,13 @@ private final class ScriptedTokenEndpoint: URLProtocol, @unchecked Sendable { return "eyJhbGciOiJIUzI1NiJ9.\(claims).synthetic" }() - /// Runs one refresh against `answer` and returns the refresh token the - /// store still holds afterwards. - private func refreshTokenAfterRefresh(answering answer: ScriptedTokenEndpoint.Answer) async -> String? { + /// Runs `refreshes` refreshes against `answer` and returns the refresh + /// token the store still holds afterwards, and how many exchanges reached + /// the server. + private func refresh( + answering answer: ScriptedTokenEndpoint.Answer, + refreshes: Int = 1 + ) async -> (refreshToken: String?, exchanges: Int) { let host = UUID().uuidString.lowercased() + ".invalid" ScriptedTokenEndpoint.script(answer, host: host) defer { ScriptedTokenEndpoint.remove(host: host) } @@ -77,8 +90,14 @@ private final class ScriptedTokenEndpoint: URLProtocol, @unchecked Sendable { baseUrl: "https://" + host, projectId: "fixture", publishableClientKey: "synthetic", tokenStore: store, session: session ) - _ = await client.getOrFetchLikelyValidTokens() - return await store.getStoredRefreshToken() + for _ in 0.. String? { + await refresh(answering: answer).refreshToken } @Test func rejectedPublishableKeyKeepsTheSession() async { @@ -114,6 +133,25 @@ private final class ScriptedTokenEndpoint: URLProtocol, @unchecked Sendable { #expect(kept == "session") } + @Test func nonStackPageKeepsTheSession() async { + let kept = await refreshTokenAfterRefresh(answering: .init( + status: 401, + headers: ["Content-Type": "text/html"], + body: "Sign in to the network" + )) + #expect(kept == "session") + } + + @Test func rejectedRequestIsNotRetriedDuringCooldown() async { + let result = await refresh(answering: .init( + status: 401, + headers: ["x-hexclave-known-error": "INVALID_PUBLISHABLE_CLIENT_KEY"], + body: #"{"code":"INVALID_PUBLISHABLE_CLIENT_KEY","error":"The publishable key is not valid."}"# + ), refreshes: 3) + #expect(result.refreshToken == "session") + #expect(result.exchanges == 1) + } + @Test func expiredRefreshTokenClearsTheSession() async { let kept = await refreshTokenAfterRefresh(answering: .init( status: 401, From 39842e474b8e06bfa0a83e4b029f02292be33a42 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 26 Oct 2026 16:26:34 -0700 Subject: [PATCH 05/12] fix(auth): close remaining refresh and keychain review gaps --- .../TokenStores/KeychainStackTokenStore.swift | 39 ++++-- .../KeychainStackTokenStoreTests.swift | 22 ++-- tests/test-execution.toml | 68 ++++++++++ .../Sources/StackAuth/APIClient.swift | 122 +++++++++++++++++- .../Sources/StackAuth/RefreshRejection.swift | 64 --------- .../RefreshRejectionTests.swift | 46 ++++++- 6 files changed, 266 insertions(+), 95 deletions(-) delete mode 100644 vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/RefreshRejection.swift diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/TokenStores/KeychainStackTokenStore.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/TokenStores/KeychainStackTokenStore.swift index af61864e6d06..eedc0b727231 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/TokenStores/KeychainStackTokenStore.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/TokenStores/KeychainStackTokenStore.swift @@ -76,21 +76,34 @@ public actor KeychainStackTokenStore: StackAuthTokenStoreProtocol { /// from a signed-out store. Session restore asks this first so a Keychain /// that is briefly unreadable (locked, or busy right after the app was /// replaced by an update) keeps the session instead of signing the user - /// out. Only failures that clear up on their own count as unreadable: a - /// permanent one (a corrupt item, a missing entitlement, which routes to - /// ``FallbackTokenStore``'s file store) must still reach sign-in, or the - /// user would sit in a signed-in shell with no session behind it. + /// out. A missing entitlement is also unavailable: the fallback file store + /// may contain the only copy of the session, and a legacy account can fail + /// independently of the current account. public func isReadable() async -> Bool { if cachedRefreshToken != nil { return true } - return !Self.isTemporaryReadFailure(keychainProbeStatus(account: Self.refreshTokenAccount)) +#if canImport(Security) + let currentStatus = keychainProbeStatus(account: Self.refreshTokenAccount) + guard Self.isDefinitiveReadStatus(currentStatus) else { return false } + guard currentStatus == errSecItemNotFound, + accessGroup != nil, + let legacyProjectID else { + return true + } + return Self.isDefinitiveReadStatus(keychainProbeStatus( + account: "stack-auth-refresh-\(legacyProjectID)", + legacy: true + )) +#else + return true +#endif } - /// Whether a Keychain read status is a failure that clears up on its own. - static func isTemporaryReadFailure(_ status: Int32) -> Bool { + /// Whether a Keychain read status definitively says whether an item exists. + static func isDefinitiveReadStatus(_ status: Int32) -> Bool { #if canImport(Security) - return status == errSecInteractionNotAllowed || status == errSecNotAvailable + return status == errSecSuccess || status == errSecItemNotFound #else - return false + return true #endif } @@ -243,15 +256,15 @@ public actor KeychainStackTokenStore: StackAuthTokenStoreProtocol { return String(data: data, encoding: .utf8) } - private func keychainProbeStatus(account: String) -> Int32 { - var query = baseQuery(account: account) + private func keychainProbeStatus(account: String, legacy: Bool = false) -> Int32 { + var query = legacy ? legacyBaseQuery(account: account) : baseQuery(account: account) // Ask for the data, like a real read: a protected item's data can be // unavailable while its attributes are not. query[kSecReturnData as String] = true query[kSecMatchLimit as String] = kSecMatchLimitOne var result: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &result) - if Self.isTemporaryReadFailure(status) { + if !Self.isDefinitiveReadStatus(status) { log.log("keychain PROBE status=\(status) account=\(account)") } return status @@ -305,7 +318,7 @@ public actor KeychainStackTokenStore: StackAuthTokenStoreProtocol { private func readOrAdoptLegacyToken(account: String, legacyAccount: String?) -> String? { nil } private func deleteLegacyTokens() {} private func keychainRead(account: String) -> String? { nil } - private func keychainProbeStatus(account: String) -> Int32 { 0 } + private func keychainProbeStatus(account: String, legacy: Bool = false) -> Int32 { 0 } private func keychainWrite(_ value: String, account: String) -> Bool { false } private func keychainDelete(account: String) {} #endif diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/KeychainStackTokenStoreTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/KeychainStackTokenStoreTests.swift index da5ea1e1b51c..5b6e89fcf488 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/KeychainStackTokenStoreTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/KeychainStackTokenStoreTests.swift @@ -45,16 +45,18 @@ struct KeychainStackTokenStoreTests { } /// https://github.com/manaflow-ai/cmux/issues/25628: a Keychain read that - /// fails for a moment is not an empty store, so session restore must not - /// sign out on it; a permanent failure must still reach sign-in. - @Test func onlyTemporaryReadFailuresCountAsUnreadable() { - #expect(KeychainStackTokenStore.isTemporaryReadFailure(errSecInteractionNotAllowed)) - #expect(KeychainStackTokenStore.isTemporaryReadFailure(errSecNotAvailable)) - #expect(!KeychainStackTokenStore.isTemporaryReadFailure(errSecSuccess)) - #expect(!KeychainStackTokenStore.isTemporaryReadFailure(errSecItemNotFound)) - #expect(!KeychainStackTokenStore.isTemporaryReadFailure(errSecMissingEntitlement)) - #expect(!KeychainStackTokenStore.isTemporaryReadFailure(errSecDecode)) - #expect(!KeychainStackTokenStore.isTemporaryReadFailure(errSecParam)) + /// A status other than success or item-not-found is not an empty store, so + /// session restore must preserve the cached session until the Keychain can + /// answer definitively. This includes a missing entitlement: the file + /// fallback may contain the only copy of the session. + @Test func onlyDefinitiveReadStatusesCountAsReadable() { + #expect(KeychainStackTokenStore.isDefinitiveReadStatus(errSecSuccess)) + #expect(KeychainStackTokenStore.isDefinitiveReadStatus(errSecItemNotFound)) + #expect(!KeychainStackTokenStore.isDefinitiveReadStatus(errSecInteractionNotAllowed)) + #expect(!KeychainStackTokenStore.isDefinitiveReadStatus(errSecNotAvailable)) + #expect(!KeychainStackTokenStore.isDefinitiveReadStatus(errSecMissingEntitlement)) + #expect(!KeychainStackTokenStore.isDefinitiveReadStatus(errSecDecode)) + #expect(!KeychainStackTokenStore.isDefinitiveReadStatus(errSecParam)) } @Test func fileFallbackStoreIsAlwaysReadable() async { diff --git a/tests/test-execution.toml b/tests/test-execution.toml index 55fe1f265ac3..953ee4d11694 100644 --- a/tests/test-execution.toml +++ b/tests/test-execution.toml @@ -696,6 +696,18 @@ lane = "linux-guard" path = "tests/test_ci_nightly_off_blacksmith.py" lane = "linux-guard" +[[test]] +path = "tests/test_classic_nightly_build_runner.py" +lane = "linux-guard" + +[[test]] +path = "tests/test_ci_publisher_mac_jobs_move.py" +lane = "linux-guard" + +[[test]] +path = "tests/test_ci_no_github_hosted_macos.py" +lane = "linux-guard" + [[test]] path = "tests/test_ci_nightly_push_throttle.py" lane = "linux-guard" @@ -764,6 +776,10 @@ lane = "linux-guard" path = "tests/test_ci_queue_janitor.py" lane = "linux-guard" +[[test]] +path = "tests/test_delivery_workflows_cancel.py" +lane = "linux-guard" + [[test]] path = "tests/test_ci_starved_labels.py" lane = "linux-guard" @@ -880,6 +896,14 @@ lane = "linux-guard" path = "tests/test_github_release_publication.py" lane = "linux-guard" +[[test]] +path = "tests/test_gh_app_token.py" +lane = "linux-guard" + +[[test]] +path = "tests/test_publish_app_token_workflow.py" +lane = "linux-guard" + [[test]] path = "tests/test_nightly_next_feed_watch.py" lane = "linux-guard" @@ -1729,6 +1753,18 @@ lane = "linux-guard" path = "tests/test_ci_cloud_overflow_switch.py" lane = "linux-guard" +[[test]] +path = "tests/test_ci_fast_checks_failover.py" +lane = "linux-guard" + +[[test]] +path = "tests/test_ci_fast_checks_receipt.py" +lane = "linux-guard" + +[[test]] +path = "tests/test_ci_fast_checks_bun_workspaces.py" +lane = "linux-guard" + [[test]] path = "tests/test_ci_merge_main.py" lane = "linux-guard" @@ -1749,6 +1785,10 @@ lane = "linux-guard" path = "tests/test_ci_workflow_run_api_budget.py" lane = "linux-guard" +[[test]] +path = "tests/test_glaeda_route_app_budget.py" +lane = "linux-guard" + [[test]] path = "tests/test_ci_timing_readout.py" lane = "linux-guard" @@ -1866,6 +1906,14 @@ lane = "linux-guard" path = "tests/test_base_red_excuse.py" lane = "linux-guard" +[[test]] +path = "tests/test_test_reds.py" +lane = "linux-guard" + +[[test]] +path = "tests/test_ci_guard_check_isolation.py" +lane = "linux-guard" + [[test]] path = "tests/test_install_gh_merge_green.py" lane = "linux-guard" @@ -2056,3 +2104,23 @@ lane = "linux-guard" [[test]] path = "tests/test_nightly_delivery_concurrency.py" lane = "linux-guard" + +[[test]] +path = "tests/test_live_appcast_build.py" +lane = "linux-guard" + +[[test]] +path = "tests/test_classic_publish_lease.py" +lane = "linux-guard" + +[[test]] +path = "tests/test_download_run_artifact.py" +lane = "linux-guard" + +[[test]] +path = "tests/test_ci_runs_on_values.py" +lane = "linux-guard" + +[[test]] +path = "tests/test_ci_fleet_rule_lints.py" +lane = "linux-guard" diff --git a/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift b/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift index f787891e03c6..53ba31230424 100644 --- a/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift +++ b/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift @@ -148,14 +148,18 @@ actor APIClient { /// chosen once, so many clients that signed in together do not refresh /// in the same second. let refreshLeadMillis: Int - /// Monotonic time (``refreshClock``) before which a rate-limited or - /// rejected refresh exchange must not be retried. + /// Monotonic time (``refreshClock``) before which a rate-limited refresh + /// exchange must not be retried. private var refreshRateLimitedUntil: UInt64? /// Consecutive rate-limited refresh exchanges, for exponential backoff. private var refreshRateLimitStreak = 0 /// Consecutive refresh exchanges refused for a reason other than the /// refresh token; drives the same cooldown as a 429. private var refreshRejectedStreak = 0 + /// Whether a rejected request is in its cancellation-aware cooldown. + private var refreshRejectedCooldownActive = false + private var refreshRejectedCooldownID: UUID? + private var refreshRejectedCooldownTask: Task? private static let sdkVersion = "1.0.0" @@ -373,6 +377,51 @@ actor APIClient { // MARK: - Token Refresh + /// Distinguishes a dead refresh token from a request the client can fix. + private enum RefreshRejection: Equatable { + case sessionEnded + case requestRejected(code: String?) + + private static let sessionEndingKnownErrors: Set = [ + "REFRESH_TOKEN_ERROR", + "REFRESH_TOKEN_NOT_FOUND_OR_EXPIRED", + "PROVIDER_REJECTED", + ] + + static func classify( + statusCode: Int, + knownErrorCode: String?, + body: Data + ) -> Self? { + guard statusCode == 400 || statusCode == 401 else { return nil } + var json: [String: Any]? + if !body.allSatisfy({ $0 == 0x20 || $0 == 0x0A || $0 == 0x0D || $0 == 0x09 }) { + guard let object = (try? JSONSerialization.jsonObject(with: body)) as? [String: Any] else { + return .requestRejected(code: nonEmpty(knownErrorCode)) + } + json = object + } + if let code = nonEmpty(knownErrorCode) ?? nonEmpty(json?["code"] as? String) { + return sessionEndingKnownErrors.contains(code.uppercased()) + ? .sessionEnded + : .requestRejected(code: code) + } + if let oauthError = nonEmpty(json?["error"] as? String) { + return oauthError == "invalid_grant" + ? .sessionEnded + : .requestRejected(code: oauthError) + } + return .sessionEnded + } + + private static func nonEmpty(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + return value + } + } + /// Classified outcome of a refresh-token exchange. /// /// The distinction is load-bearing: only ``definitivelyRejected`` means the @@ -385,8 +434,8 @@ actor APIClient { /// The server minted a new access token. case success(accessToken: String) /// The server rejected the refresh token itself (HTTP 400/401 naming the - /// token, e.g. `REFRESH_TOKEN_NOT_FOUND_OR_EXPIRED` or `invalid_grant`; - /// see ``RefreshRejection``). The refresh token will never work again; + /// token, e.g. `REFRESH_TOKEN_NOT_FOUND_OR_EXPIRED` or `invalid_grant`. + /// The refresh token will never work again; /// clearing is correct. case definitivelyRejected /// The refresh could not be completed for a reason that is not the token's @@ -397,7 +446,8 @@ actor APIClient { case rateLimited(retryAfterSeconds: TimeInterval?, bodyKind: StackAuthRateLimitObservation.BodyKind) /// The server answered 400/401 for a reason other than the refresh token /// (a rejected publishable key or OAuth client, a malformed request, or - /// a non-Stack page; see ``RefreshRejection``). Tokens are preserved, + /// a non-Stack page; see the private refresh rejection classifier. + /// Tokens are preserved, /// and no new exchange starts until a backoff passes, because the same /// request would be refused again. case requestRejected(code: String?) @@ -480,6 +530,9 @@ actor APIClient { /// honors `Retry-After` (capped at 10 minutes); without it the delay is /// exponential from 5 seconds to 5 minutes, with jitter. private func refreshRespectingRateLimit(refreshToken: String) async -> RefreshOutcome { + if refreshRejectedCooldownActive { + return .transientFailure + } if let until = refreshRateLimitedUntil, refreshClock.now() < until { return .transientFailure } @@ -503,7 +556,7 @@ actor APIClient { case .requestRejected: refreshRejectedStreak += 1 let delaySeconds = Self.rateLimitDelaySeconds(retryAfterSeconds: nil, streak: refreshRejectedStreak) - refreshRateLimitedUntil = refreshClock.now() &+ UInt64(delaySeconds * 1_000_000_000) + startRejectedRefreshCooldown(delaySeconds: delaySeconds) return outcome case .success, .definitivelyRejected: if case .success = outcome, refreshRateLimitStreak > 0 { @@ -512,12 +565,53 @@ actor APIClient { refreshRateLimitStreak = 0 refreshRejectedStreak = 0 refreshRateLimitedUntil = nil + clearRejectedRefreshCooldown() return outcome case .transientFailure: return outcome } } + /// Starts a one-shot, cancellation-aware cooldown for a rejected request. + private func startRejectedRefreshCooldown(delaySeconds: TimeInterval) { + refreshRejectedCooldownTask?.cancel() + let id = UUID() + refreshRejectedCooldownID = id + refreshRejectedCooldownActive = true + let deadline = refreshClock.now() &+ UInt64(delaySeconds * 1_000_000_000) + let clock = refreshClock + refreshRejectedCooldownTask = Task { [weak self] in + do { + try await clock.sleepUntil(deadline) + } catch { + return + } + guard !Task.isCancelled else { return } + await self?.finishRejectedRefreshCooldown(id: id) + } + } + + private func finishRejectedRefreshCooldown(id: UUID) { + guard refreshRejectedCooldownID == id else { return } + refreshRejectedCooldownID = nil + refreshRejectedCooldownActive = false + refreshRejectedCooldownTask = nil + } + + private func clearRejectedRefreshCooldown() { + refreshRejectedCooldownTask?.cancel() + refreshRejectedCooldownTask = nil + refreshRejectedCooldownID = nil + refreshRejectedCooldownActive = false + } + + private func resetRefreshCooldownState() { + refreshRateLimitedUntil = nil + refreshRateLimitStreak = 0 + refreshRejectedStreak = 0 + clearRejectedRefreshCooldown() + } + /// Backoff for a rate-limited refresh exchange, in seconds. static func rateLimitDelaySeconds(retryAfterSeconds: TimeInterval?, streak: Int) -> TimeInterval { if let retryAfterSeconds { @@ -531,22 +625,32 @@ actor APIClient { // MARK: - Token Management func setTokens(accessToken: String?, refreshToken: String?) async { + let previousRefreshToken = await tokenStore.getStoredRefreshToken() await tokenStore.setTokens(accessToken: accessToken, refreshToken: refreshToken) + if previousRefreshToken != refreshToken { + resetRefreshCooldownState() + } await refreshCoordinator.tokensDidChange(store: tokenStore, refreshToken: refreshToken) } func setTokens(accessToken: String?, refreshToken: String?, tokenStoreOverride: any TokenStoreProtocol) async { + let previousRefreshToken = await tokenStoreOverride.getStoredRefreshToken() await tokenStoreOverride.setTokens(accessToken: accessToken, refreshToken: refreshToken) + if previousRefreshToken != refreshToken { + resetRefreshCooldownState() + } await refreshCoordinator.tokensDidChange(store: tokenStoreOverride, refreshToken: refreshToken) } func clearTokens() async { await tokenStore.clearTokens() + resetRefreshCooldownState() await refreshCoordinator.tokensDidChange(store: tokenStore, refreshToken: nil) } func clearTokens(tokenStoreOverride: any TokenStoreProtocol) async { await tokenStoreOverride.clearTokens() + resetRefreshCooldownState() await refreshCoordinator.tokensDidChange(store: tokenStoreOverride, refreshToken: nil) } @@ -559,9 +663,13 @@ actor APIClient { newRefreshToken: nil, newAccessToken: nil ) + let remainingRefreshToken = await tokenStore.getStoredRefreshToken() await refreshCoordinator.tokensDidChange( - store: tokenStore, refreshToken: await tokenStore.getStoredRefreshToken() + store: tokenStore, refreshToken: remainingRefreshToken ) + if remainingRefreshToken == nil { + resetRefreshCooldownState() + } } /// Gets tokens, refreshing if needed. See spec for algorithm. diff --git a/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/RefreshRejection.swift b/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/RefreshRejection.swift deleted file mode 100644 index 5d9d8da6c6f1..000000000000 --- a/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/RefreshRejection.swift +++ /dev/null @@ -1,64 +0,0 @@ -import Foundation - -/// Reads a 400/401 from the refresh-token exchange: does it end the session the -/// refresh token belongs to, or reject the request for another reason? -/// -/// The token endpoint answers 400/401 for two unrelated reasons. A dead -/// session (expired, revoked, unknown) is `REFRESH_TOKEN_NOT_FOUND_OR_EXPIRED`, -/// or a raw OAuth `invalid_grant`. A rejected client is a different known error -/// on the same statuses: `INVALID_PUBLISHABLE_CLIENT_KEY`, -/// `INVALID_OAUTH_CLIENT_ID_OR_SECRET`, `PUBLISHABLE_CLIENT_KEY_REQUIRED_FOR_PROJECT`, -/// a schema error, or an OAuth `invalid_client` / `invalid_request`. Those say -/// nothing about the user's session: the same refresh token works again once -/// the client or service is fixed, so clearing it would sign the user out for a -/// problem they cannot fix by signing in. A body that is not JSON (a captive -/// portal or proxy page) did not come from Stack and is no verdict either. -enum RefreshRejection: Equatable { - /// The refresh token will never work again. - case sessionEnded - /// The request was refused for a reason other than the refresh token. - /// `code` is the known error or OAuth error, when the answer names one. - case requestRejected(code: String?) - - /// Stack known errors that end the session the refresh token belongs to. - static let sessionEndingKnownErrors: Set = [ - "REFRESH_TOKEN_ERROR", - "REFRESH_TOKEN_NOT_FOUND_OR_EXPIRED", - "PROVIDER_REJECTED", - ] - - /// Classifies a refresh answer. - /// - Parameters: - /// - statusCode: The HTTP status of the token endpoint's answer. - /// - knownErrorCode: The `x-hexclave-known-error` header, if any. - /// - body: The response body. - /// - Returns: `nil` when the status is not 400/401; otherwise - /// ``sessionEnded`` only for an answer that names the refresh token, or - /// names nothing at all in an empty or JSON body. - static func classify(statusCode: Int, knownErrorCode: String?, body: Data) -> RefreshRejection? { - guard statusCode == 400 || statusCode == 401 else { return nil } - var json: [String: Any]? - if !body.allSatisfy({ $0 == 0x20 || $0 == 0x0A || $0 == 0x0D || $0 == 0x09 }) { - guard let object = (try? JSONSerialization.jsonObject(with: body)) as? [String: Any] else { - return .requestRejected(code: nonEmpty(knownErrorCode)) - } - json = object - } - if let code = nonEmpty(knownErrorCode) ?? nonEmpty(json?["code"] as? String) { - return sessionEndingKnownErrors.contains(code.uppercased()) ? .sessionEnded : .requestRejected(code: code) - } - if let oauthError = nonEmpty(json?["error"] as? String) { - return oauthError == "invalid_grant" ? .sessionEnded : .requestRejected(code: oauthError) - } - // A Stack answer that names no error: keep the long-standing reading - // of a 400/401 from the token endpoint as a rejected token. - return .sessionEnded - } - - private static func nonEmpty(_ value: String?) -> String? { - guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { - return nil - } - return value - } -} diff --git a/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshRejectionTests.swift b/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshRejectionTests.swift index 6962d556170b..74fbc76ab38f 100644 --- a/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshRejectionTests.swift +++ b/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshRejectionTests.swift @@ -84,11 +84,12 @@ private final class ScriptedTokenEndpoint: URLProtocol, @unchecked Sendable { config.protocolClasses = [ScriptedTokenEndpoint.self] let session = URLSession(configuration: config) defer { session.invalidateAndCancel() } + let clock = RefreshTestClock() let store = MemoryTokenStore() await store.setTokens(accessToken: Self.expiredAccessToken, refreshToken: "session") let client = APIClient( baseUrl: "https://" + host, projectId: "fixture", publishableClientKey: "synthetic", - tokenStore: store, session: session + tokenStore: store, session: session, refreshClock: testClock(clock) ) for _ in 0.. TokenRefreshClock { + TokenRefreshClock(now: { + let parts = clock.now.offset.components + return UInt64(parts.seconds) * 1_000_000_000 + + UInt64(parts.attoseconds / 1_000_000_000) + }, sleepUntil: { + try await clock.sleep(until: .init(offset: .nanoseconds(Int64($0))), tolerance: nil) + }) + } } From 0d35028103019d8a05f39eca3e58492ed981d6b0 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 28 Oct 2026 11:11:26 -0700 Subject: [PATCH 06/12] fix(auth): close final refresh and storage review gaps --- .../AuthCoordinator+SessionPriming.swift | 6 +- .../TokenStores/FallbackTokenStore.swift | 14 ++++ .../TokenStores/KeychainStackTokenStore.swift | 16 ++++ .../AuthCoordinatorLockedStorageTests.swift | 25 +++++++ .../Sources/StackAuth/APIClient.swift | 27 ++++--- .../RefreshRejectionTests.swift | 73 ++++++++++++++++++- 6 files changed, 149 insertions(+), 12 deletions(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator+SessionPriming.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator+SessionPriming.swift index 6694543eef2d..c6c57c33050a 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator+SessionPriming.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator+SessionPriming.swift @@ -149,6 +149,10 @@ extension AuthCoordinator { preserveCachedSessionAfterValidationFailure() return } + // The pre-read probe can race with the Keychain becoming unreadable. + // Ask the store whether the actual token reads were definitive before + // treating an empty result as a signed-out session. + let tokenStorageReadsWereAvailable = await isTokenStorageAvailable() guard generation == sessionGeneration else { return } #if DEBUG @@ -190,7 +194,7 @@ extension AuthCoordinator { return } - if !tokenStorageWasAvailable { + if !tokenStorageWasAvailable || !tokenStorageReadsWereAvailable { authLog.info("Token storage unavailable during session restore; preserving cached session") preserveCachedSessionAfterValidationFailure() if retryOnStorageAvailable { diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/TokenStores/FallbackTokenStore.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/TokenStores/FallbackTokenStore.swift index 10989114779e..66ec11907de3 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/TokenStores/FallbackTokenStore.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/TokenStores/FallbackTokenStore.swift @@ -11,6 +11,7 @@ public actor FallbackTokenStore: StackAuthTokenStoreProtocol { private let file: FileStackTokenStore private let log = AuthDebugLog() private var keychainWorks: Bool = true + private var tokenReadOccurred = false /// Creates the composite store. /// - Parameters: @@ -22,6 +23,8 @@ public actor FallbackTokenStore: StackAuthTokenStoreProtocol { } public func getStoredAccessToken() async -> String? { + if !tokenReadOccurred { await keychain.resetLastReadStatus() } + tokenReadOccurred = true if keychainWorks, let value = await keychain.getStoredAccessToken() { return value } @@ -34,6 +37,8 @@ public actor FallbackTokenStore: StackAuthTokenStoreProtocol { } public func getStoredRefreshToken() async -> String? { + if !tokenReadOccurred { await keychain.resetLastReadStatus() } + tokenReadOccurred = true if keychainWorks, let value = await keychain.getStoredRefreshToken() { return value } @@ -52,6 +57,15 @@ public actor FallbackTokenStore: StackAuthTokenStoreProtocol { /// ``AuthCoordinator``'s `isTokenStorageAvailable`. public func isTokenStorageAvailable() async -> Bool { guard keychainWorks else { return true } + if tokenReadOccurred { + tokenReadOccurred = false + let readWasDefinitive = await keychain.wasLastReadDefinitive() + if !readWasDefinitive { + await keychain.resetLastReadStatus() + return false + } + return true + } return await keychain.isReadable() } diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/TokenStores/KeychainStackTokenStore.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/TokenStores/KeychainStackTokenStore.swift index eedc0b727231..232af298b113 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/TokenStores/KeychainStackTokenStore.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/TokenStores/KeychainStackTokenStore.swift @@ -25,6 +25,7 @@ public actor KeychainStackTokenStore: StackAuthTokenStoreProtocol { private var cachedAccessToken: String? private var cachedRefreshToken: String? + private var lastReadWasDefinitive = true /// Creates a Keychain store writing under one exact signed access group. /// @@ -81,6 +82,7 @@ public actor KeychainStackTokenStore: StackAuthTokenStoreProtocol { /// independently of the current account. public func isReadable() async -> Bool { if cachedRefreshToken != nil { return true } + lastReadWasDefinitive = true #if canImport(Security) let currentStatus = keychainProbeStatus(account: Self.refreshTokenAccount) guard Self.isDefinitiveReadStatus(currentStatus) else { return false } @@ -98,6 +100,18 @@ public actor KeychainStackTokenStore: StackAuthTokenStoreProtocol { #endif } + /// Whether the most recent token read returned a definitive Keychain status. + /// This preserves a temporary read failure across the availability check + /// that follows session restore's token reads. + public func wasLastReadDefinitive() -> Bool { + lastReadWasDefinitive + } + + /// Forget the consumed token-read status before retrying availability. + public func resetLastReadStatus() { + lastReadWasDefinitive = true + } + /// Whether a Keychain read status definitively says whether an item exists. static func isDefinitiveReadStatus(_ status: Int32) -> Bool { #if canImport(Security) @@ -247,6 +261,7 @@ public actor KeychainStackTokenStore: StackAuthTokenStoreProtocol { query[kSecMatchLimit as String] = kSecMatchLimitOne var result: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &result) + lastReadWasDefinitive = lastReadWasDefinitive && Self.isDefinitiveReadStatus(status) guard status == errSecSuccess, let data = result as? Data else { if status != errSecItemNotFound { log.log("keychain READ status=\(status) account=\(account)") @@ -302,6 +317,7 @@ public actor KeychainStackTokenStore: StackAuthTokenStoreProtocol { query[kSecMatchLimit as String] = kSecMatchLimitOne var result: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &result) + lastReadWasDefinitive = lastReadWasDefinitive && Self.isDefinitiveReadStatus(status) guard status == errSecSuccess, let data = result as? Data else { if status != errSecItemNotFound { log.log("keychain legacy READ status=\(status) account=\(account)") diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorLockedStorageTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorLockedStorageTests.swift index 4bb9caa6d225..3d06d9d1d17d 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorLockedStorageTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorLockedStorageTests.swift @@ -209,6 +209,31 @@ actor TokenStorageAvailabilityProbe { #expect(await client.clearLocalSessionCallCount == 0) } + @Test func readBecomingUnavailableAfterSuccessfulProbePreservesCachedSession() async throws { + let cachedUser = CMUXAuthUser(id: "cached", primaryEmail: "cached@example.com", displayName: "Cached") + let client = LockedStorageAuthClient( + access: "access", + refresh: "refresh", + user: nil, + locked: false, + locksFirstReads: 2 + ) + let availability = TokenStorageAvailabilityProbe(available: true) + let (coordinator, store) = try makeCoordinator( + client: client, + cachedUser: cachedUser, + availability: availability + ) + + coordinator.start() + await coordinator.awaitBootstrapped() + + #expect(coordinator.isAuthenticated == true) + #expect(coordinator.currentUser == cachedUser) + #expect(store.bool(forKey: "has_tokens") == true) + #expect(await client.clearLocalSessionCallCount == 0) + } + @Test func availableEmptyStorageClearsSeededCache() async throws { let cachedUser = CMUXAuthUser(id: "cached", primaryEmail: "cached@example.com", displayName: "Cached") let client = LockedStorageAuthClient(access: nil, refresh: nil, user: nil, locked: false) diff --git a/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift b/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift index 53ba31230424..d2cb82eb7b5b 100644 --- a/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift +++ b/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift @@ -160,6 +160,7 @@ actor APIClient { private var refreshRejectedCooldownActive = false private var refreshRejectedCooldownID: UUID? private var refreshRejectedCooldownTask: Task? + private var refreshCooldownEpoch: UInt64 = 0 private static let sdkVersion = "1.0.0" @@ -394,24 +395,27 @@ actor APIClient { body: Data ) -> Self? { guard statusCode == 400 || statusCode == 401 else { return nil } - var json: [String: Any]? - if !body.allSatisfy({ $0 == 0x20 || $0 == 0x0A || $0 == 0x0D || $0 == 0x09 }) { - guard let object = (try? JSONSerialization.jsonObject(with: body)) as? [String: Any] else { - return .requestRejected(code: nonEmpty(knownErrorCode)) - } - json = object + if let knownErrorCode = nonEmpty(knownErrorCode) { + return sessionEndingKnownErrors.contains(knownErrorCode.uppercased()) + ? .sessionEnded + : .requestRejected(code: knownErrorCode) + } + guard let bodyText = String(data: body, encoding: .utf8), + !bodyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let json = (try? JSONSerialization.jsonObject(with: body)) as? [String: Any] else { + return .requestRejected(code: nil) } - if let code = nonEmpty(knownErrorCode) ?? nonEmpty(json?["code"] as? String) { + if let code = nonEmpty(json["code"] as? String) { return sessionEndingKnownErrors.contains(code.uppercased()) ? .sessionEnded : .requestRejected(code: code) } - if let oauthError = nonEmpty(json?["error"] as? String) { + if let oauthError = nonEmpty(json["error"] as? String) { return oauthError == "invalid_grant" ? .sessionEnded : .requestRejected(code: oauthError) } - return .sessionEnded + return .requestRejected(code: nil) } private static func nonEmpty(_ value: String?) -> String? { @@ -536,7 +540,11 @@ actor APIClient { if let until = refreshRateLimitedUntil, refreshClock.now() < until { return .transientFailure } + let epoch = refreshCooldownEpoch let outcome = await refresh(refreshToken: refreshToken) + guard epoch == refreshCooldownEpoch else { + return .transientFailure + } switch outcome { case .rateLimited(let retryAfter, let bodyKind): refreshRateLimitStreak += 1 @@ -606,6 +614,7 @@ actor APIClient { } private func resetRefreshCooldownState() { + refreshCooldownEpoch &+= 1 refreshRateLimitedUntil = nil refreshRateLimitStreak = 0 refreshRejectedStreak = 0 diff --git a/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshRejectionTests.swift b/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshRejectionTests.swift index 74fbc76ab38f..74cf0f686e4d 100644 --- a/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshRejectionTests.swift +++ b/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshRejectionTests.swift @@ -6,6 +6,8 @@ import Testing @testable import StackAuth /// Answers every request to its synthetic host with one scripted response. +/// URLProtocol callbacks may run concurrently; the lock guards all mutable +/// static scripts and request counters. private final class ScriptedTokenEndpoint: URLProtocol, @unchecked Sendable { struct Answer: Sendable { var status: Int @@ -153,6 +155,38 @@ private final class ScriptedTokenEndpoint: URLProtocol, @unchecked Sendable { #expect(result.exchanges == 1) } + @Test func rejectedRequestCooldownExpires() async { + let host = UUID().uuidString.lowercased() + ".invalid" + ScriptedTokenEndpoint.script(.init( + status: 401, + headers: ["x-hexclave-known-error": "INVALID_PUBLISHABLE_CLIENT_KEY"], + body: #"{"code":"INVALID_PUBLISHABLE_CLIENT_KEY"}"# + ), host: host) + defer { ScriptedTokenEndpoint.remove(host: host) } + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [ScriptedTokenEndpoint.self] + let session = URLSession(configuration: config) + defer { session.invalidateAndCancel() } + let clock = RefreshTestClock() + let store = MemoryTokenStore() + await store.setTokens(accessToken: Self.expiredAccessToken, refreshToken: "session") + let client = APIClient( + baseUrl: "https://" + host, projectId: "fixture", publishableClientKey: "synthetic", + tokenStore: store, session: session, refreshClock: testClock(clock) + ) + + _ = await client.getOrFetchLikelyValidTokens() + clock.advance(by: .seconds(6)) + ScriptedTokenEndpoint.script(.init( + status: 200, + body: #"{"access_token":"eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjk5OTk5OTk5OTl9.synthetic"}"# + ), host: host) + let result = await client.getOrFetchLikelyValidTokens() + #expect(result.accessToken == RefreshLifecycleTests.fresh) + #expect(result.refreshToken == "session") + #expect(ScriptedTokenEndpoint.requestCount(host: host) == 2) + } + @Test func newSessionClearsRejectedRequestCooldown() async { let host = UUID().uuidString.lowercased() + ".invalid" ScriptedTokenEndpoint.script(.init( @@ -186,6 +220,31 @@ private final class ScriptedTokenEndpoint: URLProtocol, @unchecked Sendable { #expect(ScriptedTokenEndpoint.requestCount(host: host) == 2) } + @Test func lateRejectedRefreshCannotCoolDownANewSession() async { + let fixture = RefreshTransportFixture() + let session = await fixture.session() + let clock = RefreshTestClock() + let store = MemoryTokenStore() + await store.setTokens(accessToken: Self.expiredAccessToken, refreshToken: "old-session") + let client = APIClient( + baseUrl: "https://" + fixture.host, projectId: "fixture", publishableClientKey: "synthetic", + tokenStore: store, session: session, refreshClock: testClock(clock) + ) + let oldRefresh = Task { await client.getOrFetchLikelyValidTokens() } + await fixture.waitForRequest() + await client.setTokens(accessToken: Self.expiredAccessToken, refreshToken: "new-session") + await fixture.release(status: 401, token: "unused") + _ = await oldRefresh.value + await fixture.release(status: 200, token: RefreshLifecycleTests.fresh) + + let result = await client.getOrFetchLikelyValidTokens() + #expect(result.accessToken == RefreshLifecycleTests.fresh) + #expect(result.refreshToken == "new-session") + #expect(await fixture.count == 2) + session.invalidateAndCancel() + await fixture.close() + } + @Test func expiredRefreshTokenClearsTheSession() async { let kept = await refreshTokenAfterRefresh(answering: .init( status: 401, @@ -195,6 +254,16 @@ private final class ScriptedTokenEndpoint: URLProtocol, @unchecked Sendable { #expect(kept == nil) } + @Test(arguments: ["REFRESH_TOKEN_ERROR", "PROVIDER_REJECTED"]) + func sessionEndingKnownErrorHeaderClearsEvenWithMalformedBody(_ code: String) async { + let kept = await refreshTokenAfterRefresh(answering: .init( + status: 401, + headers: ["x-hexclave-known-error": code], + body: "proxy error" + )) + #expect(kept == nil) + } + @Test func invalidGrantClearsTheSession() async { let kept = await refreshTokenAfterRefresh(answering: .init( status: 400, body: #"{"error":"invalid_grant"}"# @@ -202,9 +271,9 @@ private final class ScriptedTokenEndpoint: URLProtocol, @unchecked Sendable { #expect(kept == nil) } - @Test func unnamedRejectionStillClearsTheSession() async { + @Test func unnamedRejectionKeepsTheSession() async { let kept = await refreshTokenAfterRefresh(answering: .init(status: 401, body: "")) - #expect(kept == nil) + #expect(kept == "session") } private func testClock(_ clock: RefreshTestClock) -> TokenRefreshClock { From 6b6d54676f7a93ba37d3b74a854c5658c742876d Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 28 Oct 2026 11:22:43 -0700 Subject: [PATCH 07/12] test(auth): cover refresh classification and storage races --- .../AuthCoordinatorLockedStorageTests.swift | 9 +++++++-- .../Tests/StackAuthTests/RefreshDeadlineTests.swift | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorLockedStorageTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorLockedStorageTests.swift index 3d06d9d1d17d..44d6d51980a6 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorLockedStorageTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorLockedStorageTests.swift @@ -107,10 +107,12 @@ actor LockedStorageAuthClient: AuthClient { actor TokenStorageAvailabilityProbe { private var available: Bool private var unavailableReadsRemaining: Int + private var scriptedResults: [Bool] - init(available: Bool, unavailableFirstReads: Int = 0) { + init(available: Bool, unavailableFirstReads: Int = 0, scriptedResults: [Bool] = []) { self.available = available self.unavailableReadsRemaining = unavailableFirstReads + self.scriptedResults = scriptedResults } func setAvailable(_ available: Bool) { @@ -118,6 +120,9 @@ actor TokenStorageAvailabilityProbe { } func isAvailable() -> Bool { + if !scriptedResults.isEmpty { + return scriptedResults.removeFirst() + } if unavailableReadsRemaining > 0 { unavailableReadsRemaining -= 1 return false @@ -218,7 +223,7 @@ actor TokenStorageAvailabilityProbe { locked: false, locksFirstReads: 2 ) - let availability = TokenStorageAvailabilityProbe(available: true) + let availability = TokenStorageAvailabilityProbe(available: true, scriptedResults: [true, false]) let (coordinator, store) = try makeCoordinator( client: client, cachedUser: cachedUser, diff --git a/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshDeadlineTests.swift b/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshDeadlineTests.swift index 04427df9f3fb..6c3190cf6f0a 100644 --- a/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshDeadlineTests.swift +++ b/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshDeadlineTests.swift @@ -62,7 +62,7 @@ import Testing await fixture.release(status: status, token: RefreshLifecycleTests.fresh) let pair = await client.getOrFetchLikelyValidTokens() #expect(pair.accessToken == (status == 200 ? RefreshLifecycleTests.fresh : nil)) - #expect(await store.getStoredRefreshToken() == (status == 401 ? nil : "session")) + #expect(await store.getStoredRefreshToken() == "session") #expect(await fixture.count == 1) session.invalidateAndCancel() await fixture.close() From ca1cd0873dd546326d2b46c89b11d9195e61ef95 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 28 Oct 2026 11:29:01 -0700 Subject: [PATCH 08/12] test(auth): keep locked restore from retrying early --- .../AuthCoordinatorLockedStorageTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorLockedStorageTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorLockedStorageTests.swift index 44d6d51980a6..8350fffb7cf0 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorLockedStorageTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorLockedStorageTests.swift @@ -223,7 +223,7 @@ actor TokenStorageAvailabilityProbe { locked: false, locksFirstReads: 2 ) - let availability = TokenStorageAvailabilityProbe(available: true, scriptedResults: [true, false]) + let availability = TokenStorageAvailabilityProbe(available: true, scriptedResults: [true, false, false]) let (coordinator, store) = try makeCoordinator( client: client, cachedUser: cachedUser, From 4dad49902ff4882669a943f69711f160d6d64b3e Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 28 Oct 2026 11:33:12 -0700 Subject: [PATCH 09/12] fix(auth): preserve sessions through partial storage reads --- .../AuthCoordinator+SessionPriming.swift | 18 +++++------ .../RefreshRejectionTests.swift | 2 ++ .../StackAuthTests/RefreshTestClock.swift | 31 +++++++++++++++++++ 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator+SessionPriming.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator+SessionPriming.swift index c6c57c33050a..f72234f5fddc 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator+SessionPriming.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator+SessionPriming.swift @@ -185,15 +185,6 @@ extension AuthCoordinator { } #endif - if hasStoredTokens { - sessionCache.setHasTokens(true) - if currentUser == nil, let cachedUser { - currentUser = cachedUser - } - await validateCachedSession(generation: generation, storeWriteHighWater: storeWriteHighWater) - return - } - if !tokenStorageWasAvailable || !tokenStorageReadsWereAvailable { authLog.info("Token storage unavailable during session restore; preserving cached session") preserveCachedSessionAfterValidationFailure() @@ -212,6 +203,15 @@ extension AuthCoordinator { return } + if hasStoredTokens { + sessionCache.setHasTokens(true) + if currentUser == nil, let cachedUser { + currentUser = cachedUser + } + await validateCachedSession(generation: generation, storeWriteHighWater: storeWriteHighWater) + return + } + if launch.includesDevAuth, let creds = debugCredentials { authLog.debug("Auto-login with persisted debug credentials") await performAutoLogin(creds, generation: generation, storeWriteHighWater: storeWriteHighWater) diff --git a/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshRejectionTests.swift b/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshRejectionTests.swift index 74cf0f686e4d..4435a5de208d 100644 --- a/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshRejectionTests.swift +++ b/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshRejectionTests.swift @@ -176,7 +176,9 @@ private final class ScriptedTokenEndpoint: URLProtocol, @unchecked Sendable { ) _ = await client.getOrFetchLikelyValidTokens() + await clock.waitUntilSleepers() clock.advance(by: .seconds(6)) + await clock.waitUntilSleepsFinished() ScriptedTokenEndpoint.script(.init( status: 200, body: #"{"access_token":"eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjk5OTk5OTk5OTl9.synthetic"}"# diff --git a/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshTestClock.swift b/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshTestClock.swift index 62a4ad229555..0e78ce58f1a0 100644 --- a/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshTestClock.swift +++ b/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshTestClock.swift @@ -23,10 +23,12 @@ final class RefreshTestClock: Clock, @unchecked Sendable { private let lock = NSLock() private var currentInstant = Instant(offset: .zero) private var sleepers: [UUID: Sleeper] = [:] + private var activeSleeps = 0 /// Sleeps cancelled before their continuation was parked (the /// `withTaskCancellationHandler` handler can run first). private var cancelledSleeperIDs: Set = [] private var parkWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private var drainedWaiters: [CheckedContinuation] = [] var now: Instant { lock.lock() @@ -38,6 +40,7 @@ final class RefreshTestClock: Clock, @unchecked Sendable { func sleep(until deadline: Instant, tolerance: Duration?) async throws { let id = UUID() + defer { markSleepFinished() } try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in lock.lock() @@ -52,6 +55,7 @@ final class RefreshTestClock: Clock, @unchecked Sendable { return } sleepers[id] = Sleeper(deadline: deadline, continuation: continuation) + activeSleeps += 1 let satisfied = takeSatisfiedParkWaitersLocked() lock.unlock() for waiter in satisfied { waiter.resume() } @@ -65,6 +69,20 @@ final class RefreshTestClock: Clock, @unchecked Sendable { } } + /// Suspends until all virtual sleepers have returned to their callers. + func waitUntilSleepsFinished() async { + await withCheckedContinuation { continuation in + lock.lock() + if activeSleeps == 0 { + lock.unlock() + continuation.resume() + return + } + drainedWaiters.append(continuation) + lock.unlock() + } + } + /// Suspends until at least `count` sleepers are parked, so a test advances /// time only once the deadline under test is actually waiting (instead of /// racing its start). @@ -106,4 +124,17 @@ final class RefreshTestClock: Clock, @unchecked Sendable { } return satisfied } + + private func markSleepFinished() { + lock.lock() + activeSleeps = max(0, activeSleeps - 1) + guard activeSleeps == 0 else { + lock.unlock() + return + } + let waiters = drainedWaiters + drainedWaiters = [] + lock.unlock() + for waiter in waiters { waiter.resume() } + } } From 3ebcb1f54d52715b22c7127394a31d89585a87c6 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 28 Oct 2026 15:49:25 -0700 Subject: [PATCH 10/12] fix(auth): end dead sessions on permanent Keychain failures and unnamed rejections Only a locked or busy Keychain (errSecInteractionNotAllowed, errSecNotAvailable) now counts as unreadable at restore. A failure that never clears read as unavailable forever, keeping a signed-in shell with no usable token. A 400/401 refresh rejection that names no error again ends the session; only a non-JSON page or a named client rejection keeps it. Co-Authored-By: Claude Opus 5.5 --- .../TokenStores/KeychainStackTokenStore.swift | 13 ++++++++----- .../KeychainStackTokenStoreTests.swift | 19 ++++++++++--------- .../Sources/StackAuth/APIClient.swift | 17 +++++++++++------ .../StackAuthTests/RefreshDeadlineTests.swift | 2 +- .../RefreshFixtureURLProtocol.swift | 4 ++-- .../RefreshRejectionTests.swift | 15 +++++++++++---- .../RefreshTransportFixture.swift | 10 +++++----- 7 files changed, 48 insertions(+), 32 deletions(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/TokenStores/KeychainStackTokenStore.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/TokenStores/KeychainStackTokenStore.swift index 232af298b113..b638191f6489 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/TokenStores/KeychainStackTokenStore.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/TokenStores/KeychainStackTokenStore.swift @@ -77,9 +77,11 @@ public actor KeychainStackTokenStore: StackAuthTokenStoreProtocol { /// from a signed-out store. Session restore asks this first so a Keychain /// that is briefly unreadable (locked, or busy right after the app was /// replaced by an update) keeps the session instead of signing the user - /// out. A missing entitlement is also unavailable: the fallback file store - /// may contain the only copy of the session, and a legacy account can fail - /// independently of the current account. + /// out. Only temporary failures count: a status that never clears (a + /// missing entitlement, a corrupt item, a failed access check) would keep + /// a session with no usable token forever, so it reads as an empty store + /// and ``FallbackTokenStore`` consults the file fallback. A legacy account + /// can fail independently of the current account. public func isReadable() async -> Bool { if cachedRefreshToken != nil { return true } lastReadWasDefinitive = true @@ -112,10 +114,11 @@ public actor KeychainStackTokenStore: StackAuthTokenStoreProtocol { lastReadWasDefinitive = true } - /// Whether a Keychain read status definitively says whether an item exists. + /// Whether a Keychain read status is final. Only a temporary failure (the + /// Keychain is locked or busy) is not; it clears without user action. static func isDefinitiveReadStatus(_ status: Int32) -> Bool { #if canImport(Security) - return status == errSecSuccess || status == errSecItemNotFound + return status != errSecInteractionNotAllowed && status != errSecNotAvailable #else return true #endif diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/KeychainStackTokenStoreTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/KeychainStackTokenStoreTests.swift index 5b6e89fcf488..acb234ba22c2 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/KeychainStackTokenStoreTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/KeychainStackTokenStoreTests.swift @@ -44,19 +44,20 @@ struct KeychainStackTokenStoreTests { #expect(result as? Data == unrelatedToken) } - /// https://github.com/manaflow-ai/cmux/issues/25628: a Keychain read that - /// A status other than success or item-not-found is not an empty store, so - /// session restore must preserve the cached session until the Keychain can - /// answer definitively. This includes a missing entitlement: the file - /// fallback may contain the only copy of the session. - @Test func onlyDefinitiveReadStatusesCountAsReadable() { + /// https://github.com/manaflow-ai/cmux/issues/25628: a locked or busy + /// Keychain is not an empty store, so session restore preserves the cached + /// session until it can answer. A failure that never clears (a missing + /// entitlement, a corrupt item, a failed access check) is final: treating + /// it as unavailable would keep a session with no usable token forever. + @Test func onlyTemporaryReadFailuresCountAsUnreadable() { #expect(KeychainStackTokenStore.isDefinitiveReadStatus(errSecSuccess)) #expect(KeychainStackTokenStore.isDefinitiveReadStatus(errSecItemNotFound)) #expect(!KeychainStackTokenStore.isDefinitiveReadStatus(errSecInteractionNotAllowed)) #expect(!KeychainStackTokenStore.isDefinitiveReadStatus(errSecNotAvailable)) - #expect(!KeychainStackTokenStore.isDefinitiveReadStatus(errSecMissingEntitlement)) - #expect(!KeychainStackTokenStore.isDefinitiveReadStatus(errSecDecode)) - #expect(!KeychainStackTokenStore.isDefinitiveReadStatus(errSecParam)) + #expect(KeychainStackTokenStore.isDefinitiveReadStatus(errSecMissingEntitlement)) + #expect(KeychainStackTokenStore.isDefinitiveReadStatus(errSecDecode)) + #expect(KeychainStackTokenStore.isDefinitiveReadStatus(errSecAuthFailed)) + #expect(KeychainStackTokenStore.isDefinitiveReadStatus(errSecParam)) } @Test func fileFallbackStoreIsAlwaysReadable() async { diff --git a/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift b/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift index d2cb82eb7b5b..1b582c94dfed 100644 --- a/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift +++ b/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift @@ -400,9 +400,14 @@ actor APIClient { ? .sessionEnded : .requestRejected(code: knownErrorCode) } + // An empty rejection names nothing the client could fix, so the + // token is dead. A body that is not JSON (a captive portal or proxy + // page) did not come from Stack and must not end the session. guard let bodyText = String(data: body, encoding: .utf8), - !bodyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - let json = (try? JSONSerialization.jsonObject(with: body)) as? [String: Any] else { + !bodyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return .sessionEnded + } + guard let json = (try? JSONSerialization.jsonObject(with: body)) as? [String: Any] else { return .requestRejected(code: nil) } if let code = nonEmpty(json["code"] as? String) { @@ -415,7 +420,7 @@ actor APIClient { ? .sessionEnded : .requestRejected(code: oauthError) } - return .requestRejected(code: nil) + return .sessionEnded } private static func nonEmpty(_ value: String?) -> String? { @@ -437,9 +442,9 @@ actor APIClient { enum RefreshOutcome { /// The server minted a new access token. case success(accessToken: String) - /// The server rejected the refresh token itself (HTTP 400/401 naming the - /// token, e.g. `REFRESH_TOKEN_NOT_FOUND_OR_EXPIRED` or `invalid_grant`. - /// The refresh token will never work again; + /// The server rejected the refresh token itself: HTTP 400/401 naming the + /// token (`REFRESH_TOKEN_NOT_FOUND_OR_EXPIRED`, `invalid_grant`) or + /// naming no error at all. The refresh token will never work again; /// clearing is correct. case definitivelyRejected /// The refresh could not be completed for a reason that is not the token's diff --git a/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshDeadlineTests.swift b/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshDeadlineTests.swift index 6c3190cf6f0a..04427df9f3fb 100644 --- a/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshDeadlineTests.swift +++ b/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshDeadlineTests.swift @@ -62,7 +62,7 @@ import Testing await fixture.release(status: status, token: RefreshLifecycleTests.fresh) let pair = await client.getOrFetchLikelyValidTokens() #expect(pair.accessToken == (status == 200 ? RefreshLifecycleTests.fresh : nil)) - #expect(await store.getStoredRefreshToken() == "session") + #expect(await store.getStoredRefreshToken() == (status == 401 ? nil : "session")) #expect(await fixture.count == 1) session.invalidateAndCancel() await fixture.close() diff --git a/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshFixtureURLProtocol.swift b/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshFixtureURLProtocol.swift index 7e8e0b04cc1d..317979dcd40f 100644 --- a/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshFixtureURLProtocol.swift +++ b/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshFixtureURLProtocol.swift @@ -6,8 +6,8 @@ final class RefreshFixtureURLProtocol: URLProtocol, @unchecked Sendable { override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } override func startLoading() { Task { await RefreshTransportFixture.routes.route(self) } } override func stopLoading() {} - func respond(status: Int, token: String) { - let response = HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)! + func respond(status: Int, token: String, headers: [String: String] = [:]) { + let response = HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: headers)! client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) client?.urlProtocol(self, didLoad: Data("{\"access_token\":\"\(token)\"}".utf8)) client?.urlProtocolDidFinishLoading(self) diff --git a/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshRejectionTests.swift b/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshRejectionTests.swift index 4435a5de208d..b6d472f0bae6 100644 --- a/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshRejectionTests.swift +++ b/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshRejectionTests.swift @@ -235,7 +235,10 @@ private final class ScriptedTokenEndpoint: URLProtocol, @unchecked Sendable { let oldRefresh = Task { await client.getOrFetchLikelyValidTokens() } await fixture.waitForRequest() await client.setTokens(accessToken: Self.expiredAccessToken, refreshToken: "new-session") - await fixture.release(status: 401, token: "unused") + await fixture.release( + status: 401, token: "unused", + headers: ["x-stack-known-error": "INVALID_PUBLISHABLE_CLIENT_KEY"] + ) _ = await oldRefresh.value await fixture.release(status: 200, token: RefreshLifecycleTests.fresh) @@ -273,9 +276,13 @@ private final class ScriptedTokenEndpoint: URLProtocol, @unchecked Sendable { #expect(kept == nil) } - @Test func unnamedRejectionKeepsTheSession() async { - let kept = await refreshTokenAfterRefresh(answering: .init(status: 401, body: "")) - #expect(kept == "session") + /// A Stack rejection that names no error still ends a dead session; + /// keeping it would leave the user signed in with a token that never + /// refreshes. + @Test(arguments: ["", "{}", #"{"message":"Unauthorized"}"#]) + func unnamedRejectionClearsTheSession(_ body: String) async { + let kept = await refreshTokenAfterRefresh(answering: .init(status: 401, body: body)) + #expect(kept == nil) } private func testClock(_ clock: RefreshTestClock) -> TokenRefreshClock { diff --git a/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshTransportFixture.swift b/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshTransportFixture.swift index 9e2a5125aa28..e109ac5803cd 100644 --- a/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshTransportFixture.swift +++ b/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshTransportFixture.swift @@ -10,7 +10,7 @@ actor RefreshTransportFixture { private var requests: [RefreshFixtureURLProtocol] = [] private var started: [CheckedContinuation] = [] private(set) var count = 0 - private var response: (Int, String)? + private var response: (status: Int, token: String, headers: [String: String])? func session() async -> URLSession { await Self.routes.register(self, host: host) @@ -21,7 +21,7 @@ actor RefreshTransportFixture { func receive(_ request: RefreshFixtureURLProtocol) { count += 1 - if let response { request.respond(status: response.0, token: response.1) } + if let response { request.respond(status: response.status, token: response.token, headers: response.headers) } else { requests.append(request) } let waiters = started started = [] @@ -33,11 +33,11 @@ actor RefreshTransportFixture { await withCheckedContinuation { started.append($0) } } - func release(status: Int = 200, token: String) { - response = (status, token) + func release(status: Int = 200, token: String, headers: [String: String] = [:]) { + response = (status, token, headers) let pending = requests requests = [] - for request in pending { request.respond(status: status, token: token) } + for request in pending { request.respond(status: status, token: token, headers: headers) } } func close() async { await Self.routes.remove(host: host) } From b7f17d8c71951f17af2b1b4e9544354c4d6d3ee9 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 28 Oct 2026 16:17:24 -0700 Subject: [PATCH 11/12] test(auth): a non-UTF-8 proxy page must keep the session A 401 whose body is not valid UTF-8 (a proxy page in a legacy encoding) is not a Stack rejection, but classify treats it like an empty body and ends the session. Co-Authored-By: Claude Opus 5.5 --- .../StackAuthTests/RefreshRejectionTests.swift | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshRejectionTests.swift b/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshRejectionTests.swift index b6d472f0bae6..b0d1ef4a5271 100644 --- a/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshRejectionTests.swift +++ b/vendor/stack-auth-swift-sdk-prerelease/Tests/StackAuthTests/RefreshRejectionTests.swift @@ -13,6 +13,7 @@ private final class ScriptedTokenEndpoint: URLProtocol, @unchecked Sendable { var status: Int var headers: [String: String] = [:] var body: String + var rawBody: Data? = nil } private static let lock = NSLock() @@ -53,7 +54,7 @@ private final class ScriptedTokenEndpoint: URLProtocol, @unchecked Sendable { return } client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: Data(answer.body.utf8)) + client?.urlProtocol(self, didLoad: answer.rawBody ?? Data(answer.body.utf8)) client?.urlProtocolDidFinishLoading(self) } override func stopLoading() {} @@ -145,6 +146,18 @@ private final class ScriptedTokenEndpoint: URLProtocol, @unchecked Sendable { #expect(kept == "session") } + /// A proxy page in a legacy encoding is not UTF-8, but it is still not + /// a Stack rejection. + @Test func nonUTF8PageKeepsTheSession() async { + let kept = await refreshTokenAfterRefresh(answering: .init( + status: 401, + headers: ["Content-Type": "text/html; charset=iso-8859-1"], + body: "", + rawBody: Data("Acc".utf8) + Data([0xE8]) + Data("s refus".utf8) + )) + #expect(kept == "session") + } + @Test func rejectedRequestIsNotRetriedDuringCooldown() async { let result = await refresh(answering: .init( status: 401, From ed2924392f49985d6deac388fb506e941fbb6951 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 28 Oct 2026 16:17:35 -0700 Subject: [PATCH 12/12] fix(auth): decide an empty rejection body on its raw bytes A 401 page that is not valid UTF-8 failed the String conversion and was treated as empty, ending the session. Only an empty or whitespace body now ends it; anything else that is not Stack JSON keeps the session. Co-Authored-By: Claude Opus 5.5 --- .../Sources/StackAuth/APIClient.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift b/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift index 1b582c94dfed..5d6d1cacea62 100644 --- a/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift +++ b/vendor/stack-auth-swift-sdk-prerelease/Sources/StackAuth/APIClient.swift @@ -403,8 +403,9 @@ actor APIClient { // An empty rejection names nothing the client could fix, so the // token is dead. A body that is not JSON (a captive portal or proxy // page) did not come from Stack and must not end the session. - guard let bodyText = String(data: body, encoding: .utf8), - !bodyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + // Check the raw bytes: a page in another encoding is not UTF-8 + // but is still not empty. + guard body.contains(where: { !asciiWhitespace.contains($0) }) else { return .sessionEnded } guard let json = (try? JSONSerialization.jsonObject(with: body)) as? [String: Any] else { @@ -423,6 +424,8 @@ actor APIClient { return .sessionEnded } + private static let asciiWhitespace: Set = [0x09, 0x0A, 0x0D, 0x20] + private static func nonEmpty(_ value: String?) -> String? { guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil