Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,12 @@ extension CmxIrohHostRuntime {
CmxIrohBrokerBindingMetadata(binding: confirmedBinding) != localBinding {
throw CmxIrohHostRuntimeError.invalidLocalBinding
}
// Availability-only: an authenticated denial must not unlock the
// cached host policy, mirroring the client-side rule that auth
// rejections never consult offline stores. (Teardown-avoidance below
// still uses the wider preserve set.)
guard allowFallback,
CmxIrohTrustBrokerClientError
.preservesVerifiedPolicyDuringRefresh(error),
CmxIrohTrustBrokerClientError.isAvailabilityFailure(error),
let cached = configuration.cachedHostPolicy else {
throw error
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,11 @@ public actor CmxIrohRegistryContextProvider: CmxIrohClientContextProvider {
}

private static func isConnectivity(_ error: any Error) -> Bool {
CmxIrohTrustBrokerClientError.preservesVerifiedPolicyDuringRefresh(error)
// Dial-time cached-policy fallbacks key on availability-only failures.
// Deliberately NOT `preservesVerifiedPolicyDuringRefresh`: that set now
// includes auth rejections (which preserve in-memory state during a
// refresh), but an authenticated denial must never unlock the cached
// grant store for a dial — revocation takes effect at the next dial.
CmxIrohTrustBrokerClientError.isAvailabilityFailure(error)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,26 @@ public struct CmxIrohBrokerTokenSource: Sendable {
/// Both tokens from ONE snapshot, so a request can never mix an old access
/// token with a rotated refresh token.
public let credentialPair: @Sendable () async -> CmxIrohBrokerCredentials?
/// Replaces a pair the broker just rejected as unauthorized.
///
/// A pair that was coherent at capture can still be rejected when another
/// lane rotates the session between capture and server validation (the
/// wake-time RPC force refresh, most commonly). Live sources force-mint
/// through their session owner and return the replacement pair; frozen
/// pinned sources (sign-out revocation) return nil so a destructive flow
/// never silently switches credentials. The client retries the rejected
/// request at most once with the recovered pair.
public let recoveredCredentialPair:
@Sendable (_ rejected: CmxIrohBrokerCredentials) async -> CmxIrohBrokerCredentials?

public init(
credentialPair: @escaping @Sendable () async -> CmxIrohBrokerCredentials?
credentialPair: @escaping @Sendable () async -> CmxIrohBrokerCredentials?,
recoveredCredentialPair: @escaping @Sendable (
_ rejected: CmxIrohBrokerCredentials
) async -> CmxIrohBrokerCredentials? = { _ in nil }
) {
self.credentialPair = credentialPair
self.recoveredCredentialPair = recoveredCredentialPair
self.accessToken = { await credentialPair()?.accessToken }
self.refreshToken = { await credentialPair()?.refreshToken }
}
Expand Down Expand Up @@ -451,8 +466,49 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
guard let pair = await tokenSource.credentialPair() else {
throw CmxIrohTrustBrokerClientError.missingAuthentication
}
let accessToken = pair.accessToken
let refreshToken = pair.refreshToken
do {
return try await performAuthenticatedRequest(
path: path,
method: method,
body: body,
queryItems: queryItems,
credentials: pair
)
} catch let error as CmxIrohTrustBrokerClientError
where Self.isUnauthorizedRejection(error) {
// A pair that was coherent at capture can be rejected when another
// lane rotated the session before the server validated it. Recover
// ONCE with a pair minted after the rejection; a second rejection
// is authoritative and propagates.
guard let recovered = await tokenSource.recoveredCredentialPair(pair) else {
throw error
}
return try await performAuthenticatedRequest(
path: path,
method: method,
body: body,
queryItems: queryItems,
credentials: recovered
)
}
}

private static func isUnauthorizedRejection(
_ error: CmxIrohTrustBrokerClientError
) -> Bool {
guard case let .rejected(statusCode, _) = error else { return false }
return statusCode == 401
}

private func performAuthenticatedRequest<Response: Decodable & Sendable>(
path: String,
method: String,
body: Data?,
queryItems: [URLQueryItem],
credentials: CmxIrohBrokerCredentials
) async throws -> Response {
let accessToken = credentials.accessToken
let refreshToken = credentials.refreshToken
guard Self.isSafeHeaderValue(accessToken), Self.isSafeHeaderValue(refreshToken) else {
throw CmxIrohTrustBrokerClientError.invalidAuthentication
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,18 @@ public enum CmxIrohTrustBrokerClientError:
case .rateLimited:
return true
case let .rejected(statusCode, _):
return statusCode == 408
// 401/403: an unauthorized rejection here already survived the
// broker client's single force-refresh retry, so it is a session
// transition still settling (rotation race, locked token store) or
// a server-side availability condition — not a trust change. The
// cached policy was verified when stored; tearing the runtime down
// buys nothing and turns a seconds-long auth blip into a full
// endpoint rebuild. A genuinely dead session clears auth state
// through the coordinator, which stops the runtime through the
// lifecycle owner instead.
return statusCode == 401
|| statusCode == 403
|| statusCode == 408
|| statusCode == 425
|| statusCode == 429
|| (500...599).contains(statusCode)
Expand All @@ -53,6 +64,43 @@ public enum CmxIrohTrustBrokerClientError:
case let .rejected(statusCode, _):
// A server failure cannot establish trust, so retrying the request
// is safe while the lifecycle-owned start task remains current.
// 401 joins the retriable set: it already survived the broker
// client's single force-refresh retry, so it is a session
// transition still settling; a dead session exits through the auth
// coordinator's state clear, not through this loop.
return statusCode == 401
|| statusCode == 408
|| statusCode == 425
|| statusCode == 429
|| (500...599).contains(statusCode)
case .invalidBaseURL,
.missingAuthentication,
.invalidAuthentication,
.nonHTTPResponse,
.invalidResponse:
return false
}
}

/// Availability-only failures: the broker could not answer, as opposed to
/// an authenticated denial.
///
/// Dial-time cached-policy fallbacks key on this set so an authoritative
/// rejection — 401/403 INCLUDED — never unlocks cached grants or the
/// offline policy store: a revoked account or binding must stop dialing at
/// the next dial, not at grant expiry. Contrast with
/// ``preservesVerifiedPolicyDuringRefresh(_:)``, which additionally
/// accepts auth rejections because keeping already-verified IN-MEMORY
/// state during a refresh grants nothing new.
static func isAvailabilityFailure(_ error: any Error) -> Bool {
if (error as? any CmxRetryAfterProviding)?.retryAfterSeconds != nil {
return true
}
guard let brokerError = error as? Self else { return false }
switch brokerError {
case .connectivity, .rateLimited:
return true
case let .rejected(statusCode, _):
return statusCode == 408
|| statusCode == 425
|| statusCode == 429
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -443,9 +443,15 @@ struct CmxIrohClientRuntimeTests {
}
)
try await runtime.start()
// 400 is a genuinely terminal rejection. A 401 is deliberately NOT
// terminal anymore: at wake the pair captured coherently a moment
// earlier can be rejected after another lane rotates the session, and
// revoking the verified local policy for that race turned a
// seconds-long token refresh into a full endpoint rebuild (and, with
// the offline cache deleted, an outage until the broker succeeded).
let terminal = CmxIrohTrustBrokerClientError.rejected(
statusCode: 401,
code: "unauthorized"
statusCode: 400,
code: "bad_request"
)
await broker.setRegistrationError(terminal)

Expand Down Expand Up @@ -506,6 +512,11 @@ struct CmxIrohClientRuntimeTests {
code: "challenge_rate_limited"
),
.rejected(statusCode: 503, code: "unavailable"),
// Auth rejections joined the preserved set: they already survived the
// broker client's single force-refresh retry, so they are a session
// transition still settling, not a trust revocation.
.rejected(statusCode: 401, code: "unauthorized"),
.rejected(statusCode: 403, code: "forbidden"),
])
func foregroundAvailabilityFailureKeepsLastVerifiedPolicy(
_ failure: CmxIrohTrustBrokerClientError
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Foundation
import Testing
@testable import CmuxIrohTransport

/// Regression coverage for the wake-time authorization outage: a broker 401
/// used to tear down the whole verified runtime (endpoint, routes, offline
/// cache) and nap for 30s+ of backoff, turning a seconds-long token rotation
/// race into a multi-minute connectivity gap on every app foreground.
struct CmxIrohTrustBrokerClientAuthClassifierTests {
@Test
func unauthorizedRejectionPreservesVerifiedPolicyDuringRefresh() {
#expect(CmxIrohTrustBrokerClientError.preservesVerifiedPolicyDuringRefresh(
CmxIrohTrustBrokerClientError.rejected(
statusCode: 401,
code: "unauthorized"
)
))
#expect(CmxIrohTrustBrokerClientError.preservesVerifiedPolicyDuringRefresh(
CmxIrohTrustBrokerClientError.rejected(statusCode: 403, code: nil)
))
}

@Test
func unauthorizedRejectionRetriesInitialActivation() {
#expect(CmxIrohTrustBrokerClientError.retriesInitialActivation(
CmxIrohTrustBrokerClientError.rejected(
statusCode: 401,
code: "unauthorized"
)
))
// 403 can be a durable permission denial; initial activation must not
// spin on it.
#expect(!CmxIrohTrustBrokerClientError.retriesInitialActivation(
CmxIrohTrustBrokerClientError.rejected(statusCode: 403, code: nil)
))
}
}
Loading