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
7 changes: 6 additions & 1 deletion Sources/DeveloperPortal/Authentication.swift
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,12 @@ public extension DeveloperPortal {

let (data, response): (Data, URLResponse)
do {
(data, response) = try await session.data(for: request)
if let operation = requestParameters["o"] as? String,
["init", "complete", "apptokens"].contains(operation) {
(data, response) = try await GrandSlamTransport.data(for: request)
} else {
(data, response) = try await session.data(for: request)
}
} catch {
debugLog("[SideSign] sendAuthenticationRequest network error: \(error)")
throw error
Expand Down
55 changes: 55 additions & 0 deletions Sources/DeveloperPortal/GrandSlamTransport.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

/// Transport for the three GrandSlam authentication exchanges, not general portal requests.
enum GrandSlamTransport {
static func data(
for request: URLRequest,
makeSession: @Sendable (URLSessionConfiguration) -> URLSession = { URLSession(configuration: $0) },
sleep: @Sendable (UInt64) async throws -> Void = { try await Task.sleep(nanoseconds: $0) }
) async throws -> (Data, URLResponse) {
let retryDelays: [UInt64] = [1, 2, 4, 8]
var attempt = 0

while true {
try Task.checkCancellation()
// Apple's GSA edge can pin a persistent connection to a failing backend.
// Each attempt needs its own connection pool, including the initial request.
let configuration = URLSessionConfiguration.ephemeral
configuration.urlCache = nil
configuration.httpCookieStorage = nil
configuration.urlCredentialStorage = nil
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
let session = makeSession(configuration)
let result: (Data, URLResponse)
do {
// Scope invalidation to this attempt, before any backoff or return.
defer { session.finishTasksAndInvalidate() }
result = try await session.data(for: request)
}
try Task.checkCancellation()

guard let response = result.1 as? HTTPURLResponse,
(500...599).contains(response.statusCode) else {
// Keep existing plist/JSON and Apple error handling for all other responses.
return result
}

guard attempt < retryDelays.count else {
// Never pass an exhausted 5xx HTML body to the plist parser. Do not expose
// response bodies: authentication responses can contain account secrets.
throw NSError(domain: NSURLErrorDomain, code: NSURLErrorBadServerResponse, userInfo: [
NSLocalizedDescriptionKey: "Apple's authentication server returned HTTP \(response.statusCode) after \(attempt + 1) attempts. Please try again later.",
"HTTPStatusCode": response.statusCode,
"ContentType": response.value(forHTTPHeaderField: "Content-Type") ?? "unknown"
])
}

// Only received 5xx responses are retried, never transport errors or cancellation.
try await sleep(retryDelays[attempt] * 1_000_000_000)
attempt += 1
}
}
}
155 changes: 155 additions & 0 deletions Tests/SideSignTests/GrandSlamTransportTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
import Testing
@testable import SideSign

private final class Responses: @unchecked Sendable {
private let lock = NSLock()
private var statuses: [Int] = []
private var sessions: [URLSession] = []
private var delays: [UInt64] = []
private var requests: [URLRequest] = []
private var failure: URLError?

func reset(_ statuses: [Int], failure: URLError? = nil) {
lock.lock(); defer { lock.unlock() }
self.statuses = statuses
self.failure = failure
sessions = []; delays = []; requests = []
}
func next(_ request: URLRequest) -> (Int, URLError?) {
lock.lock(); defer { lock.unlock() }
requests.append(request)
return (statuses.isEmpty ? 503 : statuses.removeFirst(), failure)
}
func session(_ configuration: URLSessionConfiguration) -> URLSession {
#expect(configuration.urlCache == nil)
#expect(configuration.httpCookieStorage == nil)
#expect(configuration.urlCredentialStorage == nil)
#expect(configuration.requestCachePolicy == .reloadIgnoringLocalCacheData)
configuration.protocolClasses = [GrandSlamProtocol.self]
let session = URLSession(configuration: configuration)
lock.lock(); defer { lock.unlock() }
sessions.append(session)
return session
}
func delay(_ value: UInt64) {
lock.lock(); defer { lock.unlock() }
delays.append(value)
}
func verify(attempts: Int, seconds: [UInt64]) {
lock.lock(); defer { lock.unlock() }
#expect(sessions.count == attempts)
#expect(Set(sessions.map(ObjectIdentifier.init)).count == attempts)
#expect(requests.count == attempts)
#expect(delays == seconds.map { $0 * 1_000_000_000 })
#expect(requests.allSatisfy { $0.httpMethod == "POST" })
#expect(requests.allSatisfy { $0.httpBody == requests.first?.httpBody })
#expect(requests.allSatisfy { $0.value(forHTTPHeaderField: "User-Agent") == "AuthKit/test" })
}
}

private final class GrandSlamProtocol: URLProtocol, @unchecked Sendable {
static let responses = Responses()
static let plist = Data("""
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0"><dict><key>Response</key><dict><key>Status</key><dict><key>ec</key><integer>0</integer></dict></dict></dict></plist>
""".utf8)

override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
let (status, failure) = Self.responses.next(request)
if let failure {
client?.urlProtocol(self, didFailWithError: failure)
return
}
let html = (500...599).contains(status)
let response = HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": html ? "text/html" : "text/x-xml-plist"])!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: html ? Data("<html>503 Service Temporarily Unavailable</html>".utf8) : Self.plist)
client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
}

@Suite(.serialized)
struct GrandSlamTransportTests {
private func request() -> URLRequest {
var request = URLRequest(url: URL(string: "https://gsa.apple.com/grandslam/GsService2")!)
request.httpMethod = "POST"
request.httpBody = Data("test auth request".utf8)
request.setValue("AuthKit/test", forHTTPHeaderField: "User-Agent")
return request
}
private func send() async throws -> (Data, URLResponse) {
try await GrandSlamTransport.data(for: request(), makeSession: { GrandSlamProtocol.responses.session($0) },
sleep: { GrandSlamProtocol.responses.delay($0) })
}
@Test func successfulPlistIsUnchanged() async throws {
GrandSlamProtocol.responses.reset([200])
let (data, response) = try await send()
#expect((response as? HTTPURLResponse)?.statusCode == 200)
#expect(data == GrandSlamProtocol.plist)
let plist = try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any]
#expect(plist?["Response"] != nil)
GrandSlamProtocol.responses.verify(attempts: 1, seconds: [])
}
@Test(arguments: [1, 2, 4]) func html503ThenSuccess(failures: Int) async throws {
GrandSlamProtocol.responses.reset(Array(repeating: 503, count: failures) + [200])
let (data, response) = try await send()
#expect(data == GrandSlamProtocol.plist)
#expect((response as? HTTPURLResponse)?.statusCode == 200)
GrandSlamProtocol.responses.verify(attempts: failures + 1, seconds: Array([1, 2, 4, 8].prefix(failures)))
}
@Test(arguments: [500, 502, 503, 504, 599]) func boundedServerFailure(status: Int) async throws {
GrandSlamProtocol.responses.reset(Array(repeating: status, count: 6))
do {
_ = try await send()
Issue.record("Expected HTTP server error")
} catch {
let error = error as NSError
#expect(error.domain == NSURLErrorDomain)
#expect(error.code == NSURLErrorBadServerResponse)
#expect(error.userInfo["HTTPStatusCode"] as? Int == status)
#expect(error.userInfo["ContentType"] as? String == "text/html")
#expect(error.localizedDescription.contains("HTTP \(status)"))
#expect(error.localizedDescription.contains("5 attempts"))
#expect(!error.localizedDescription.contains("<html>"))
}
GrandSlamProtocol.responses.verify(attempts: 5, seconds: [1, 2, 4, 8])
}
@Test(arguments: [400, 401, 403, 429]) func clientErrorsAreNotRetried(status: Int) async throws {
GrandSlamProtocol.responses.reset([status])
let (data, response) = try await send()
#expect((response as? HTTPURLResponse)?.statusCode == status)
#expect(data == GrandSlamProtocol.plist) // Retain structured Apple errors for existing parser.
GrandSlamProtocol.responses.verify(attempts: 1, seconds: [])
}
@Test(arguments: [URLError.Code.timedOut, .notConnectedToInternet, .cancelled])
func transportErrorsAreNotRetried(code: URLError.Code) async throws {
GrandSlamProtocol.responses.reset([200], failure: URLError(code))
do {
_ = try await send()
Issue.record("Expected transport error")
} catch {
#expect((error as NSError).code == code.rawValue)
}
GrandSlamProtocol.responses.verify(attempts: 1, seconds: [])
}
@Test func cancellationDuringBackoffStopsRetries() async throws {
GrandSlamProtocol.responses.reset([503, 200])
do {
_ = try await GrandSlamTransport.data(for: request(),
makeSession: { GrandSlamProtocol.responses.session($0) },
sleep: { _ in throw CancellationError() })
Issue.record("Expected cancellation")
} catch {
#expect(error is CancellationError)
}
GrandSlamProtocol.responses.verify(attempts: 1, seconds: [])
}
}