From e2dde218398c02fdeaf884ed25f0304ab9f54a94 Mon Sep 17 00:00:00 2001 From: Matt Kiazyk Date: Fri, 11 Sep 2026 12:00:50 -0500 Subject: [PATCH 1/2] Fix where apple is setting the service key now --- .../LoginKitExample.xcodeproj/project.pbxproj | 17 +- .../xcshareddata/swiftpm/Package.resolved | 13 +- .../LoginKitExample/AppState.swift | 13 +- README.md | 27 ++ .../AppleServiceKeyProvider.swift | 42 +++ .../XcodesLoginKit/AuthenticationError.swift | 18 ++ .../XcodesLoginKit/AuthenticationState.swift | 4 - Sources/XcodesLoginKit/Client.swift | 92 ++++++- Sources/XcodesLoginKit/URLRequest+Apple.swift | 26 +- .../ITCServiceKey.json | 4 - .../XcodesLoginKitTests.swift | 239 ++++++++++++++++-- 11 files changed, 420 insertions(+), 75 deletions(-) create mode 100644 Sources/XcodesLoginKit/AppleServiceKeyProvider.swift delete mode 100644 Tests/XcodesLoginKitTests/Fixtures/Login_Federated_Succeeds/ITCServiceKey.json diff --git a/LoginKitExample/LoginKitExample.xcodeproj/project.pbxproj b/LoginKitExample/LoginKitExample.xcodeproj/project.pbxproj index fd7ebfa..c452c57 100644 --- a/LoginKitExample/LoginKitExample.xcodeproj/project.pbxproj +++ b/LoginKitExample/LoginKitExample.xcodeproj/project.pbxproj @@ -8,8 +8,7 @@ /* Begin PBXBuildFile section */ E85C97D82D4D7B190062D747 /* XcodesLoginKit in Frameworks */ = {isa = PBXBuildFile; productRef = E85C97D72D4D7B190062D747 /* XcodesLoginKit */; }; - E8937CD82D4D71AD007C96DE /* XcodesLoginKit in Frameworks */ = {isa = PBXBuildFile; productRef = E8937CD72D4D71AD007C96DE /* XcodesLoginKit */; }; - E8937CE02D4D7A34007C96DE /* XcodesLoginKit in Frameworks */ = {isa = PBXBuildFile; productRef = E8937CDF2D4D7A34007C96DE /* XcodesLoginKit */; }; + E8937CD82D4D71AD007C96DE /* XcodesLoginKitSecurityKey in Frameworks */ = {isa = PBXBuildFile; productRef = E8937CD72D4D71AD007C96DE /* XcodesLoginKitSecurityKey */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -58,9 +57,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - E8937CD82D4D71AD007C96DE /* XcodesLoginKit in Frameworks */, + E8937CD82D4D71AD007C96DE /* XcodesLoginKitSecurityKey in Frameworks */, E85C97D82D4D7B190062D747 /* XcodesLoginKit in Frameworks */, - E8937CE02D4D7A34007C96DE /* XcodesLoginKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -121,8 +119,7 @@ ); name = LoginKitExample; packageProductDependencies = ( - E8937CD72D4D71AD007C96DE /* XcodesLoginKit */, - E8937CDF2D4D7A34007C96DE /* XcodesLoginKit */, + E8937CD72D4D71AD007C96DE /* XcodesLoginKitSecurityKey */, E85C97D72D4D7B190062D747 /* XcodesLoginKit */, ); productName = LoginKitExample; @@ -577,13 +574,9 @@ isa = XCSwiftPackageProductDependency; productName = XcodesLoginKit; }; - E8937CD72D4D71AD007C96DE /* XcodesLoginKit */ = { + E8937CD72D4D71AD007C96DE /* XcodesLoginKitSecurityKey */ = { isa = XCSwiftPackageProductDependency; - productName = XcodesLoginKit; - }; - E8937CDF2D4D7A34007C96DE /* XcodesLoginKit */ = { - isa = XCSwiftPackageProductDependency; - productName = XcodesLoginKit; + productName = XcodesLoginKitSecurityKey; }; /* End XCSwiftPackageProductDependency section */ }; diff --git a/LoginKitExample/LoginKitExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/LoginKitExample/LoginKitExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3b2e490..355c99c 100644 --- a/LoginKitExample/LoginKitExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/LoginKitExample/LoginKitExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/kinoroy/LibFido2Swift", "state" : { - "revision" : "94d496d6f850dcbb3e8c4a27cd7eeabfad9f14e3", - "version" : "0.1.4" + "revision" : "ac8596a852e2b008c5902a521cf4d9c7f0f05fba", + "version" : "0.1.6" } }, { @@ -45,6 +45,15 @@ "branch" : "main", "revision" : "543aa0122a0257b992f6c7d62d18a26e3dffb8fe" } + }, + { + "identity" : "yams", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jpsim/Yams", + "state" : { + "revision" : "0d9ee7ea8c4ebd4a489ad7a73d5c6cad55d6fed3", + "version" : "5.0.6" + } } ], "version" : 3 diff --git a/LoginKitExample/LoginKitExample/AppState.swift b/LoginKitExample/LoginKitExample/AppState.swift index 9808f22..2d5940a 100644 --- a/LoginKitExample/LoginKitExample/AppState.swift +++ b/LoginKitExample/LoginKitExample/AppState.swift @@ -7,6 +7,7 @@ import Foundation import XcodesLoginKit +import XcodesLoginKitSecurityKey import SwiftUI @Observable @@ -23,8 +24,8 @@ class AppState { Task { do { - let autheticationState = try await client.srpLogin(accountName: username, password: password) - handleAuthenticationFlowCompletion(autheticationState) + let authenticationState = try await client.authenticationState(accountName: username, password: password) + handleAuthenticationFlowCompletion(authenticationState) isProcessingAuthRequest = false } catch { @@ -39,6 +40,8 @@ class AppState { switch authenticationState { case .unauthenticated: authError = AuthenticationError.notAuthorized + case .waitingForFederatedAuthentication: + authError = AuthenticationError.federatedAuthenticationRequired case let .waitingForSecondFactor(twoFactorOption, authOptionsResponse, appleSessionData): self.presentedSheet = .twoFactor(.init( option: twoFactorOption, @@ -46,7 +49,7 @@ class AppState { sessionData: AppleSessionData(serviceKey: appleSessionData.serviceKey, sessionID: appleSessionData.sessionID, scnt: appleSessionData.scnt) )) case .authenticated(let appleSession): - print("SUCCESSFULLY LOGGED IN - WELCOME: \(appleSession.user.fullName)") + print("SUCCESSFULLY LOGGED IN - WELCOME: \(appleSession.user.fullName ?? "Apple Developer")") self.presentedSheet = nil break case .notAppleDeveloper: @@ -114,9 +117,7 @@ class AppState { } func cancelSecurityKeyAssertationRequest() { - Task { - await client.cancelSecurityKeyAssertationRequest() - } + client.cancelSecurityKeyAssertationRequest() } } enum XcodesSheet: Identifiable { diff --git a/README.md b/README.md index c36f8a6..1722517 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,33 @@ case .unauthenticated, .notAppleDeveloper: } ``` +`Client` resolves Apple's public sign-in widget key in this order: + +1. The key bundled with XcodesLoginKit. +2. An optional key supplied by the application. +3. The latest key discovered from Apple's Developer Portal sign-in page. + +Supply a known fallback key without changing the library: + +```swift +let client = Client(serviceKeyProvider: .fixed("current-public-widget-key")) +``` + +For dynamic configuration, supply an asynchronous, `Sendable` loader: + +```swift +let client = Client( + serviceKeyProvider: AppleServiceKeyProvider { + try await configuration.appleServiceKey() + } +) +``` + +The supplied provider and live discovery are lazy: they are only used when the earlier key cannot +start authentication. If every source fails, the client throws +`AuthenticationError.serviceKeyResolutionFailed(attemptedSources:)`, whose localized description +lists the attempted sources. + ### Main flow 1. Create a `Client`. Pass a custom `URLSession` if you want isolated cookie storage. diff --git a/Sources/XcodesLoginKit/AppleServiceKeyProvider.swift b/Sources/XcodesLoginKit/AppleServiceKeyProvider.swift new file mode 100644 index 0000000..9179ef9 --- /dev/null +++ b/Sources/XcodesLoginKit/AppleServiceKeyProvider.swift @@ -0,0 +1,42 @@ +/// Supplies the public Apple widget key used by Apple ID authentication requests. +/// +/// Apple does not publish a stable endpoint for discovering this value. XcodesLoginKit tries its +/// bundled App Store Connect key first, then a key supplied by this provider, and finally attempts +/// to discover the latest key from Apple's Developer Portal sign-in page. +public struct AppleServiceKeyProvider: Sendable { + public typealias Loader = @Sendable () async throws -> String + + private let load: Loader + + /// Creates a provider backed by an asynchronous loader. + public init(load: @escaping Loader) { + self.load = load + } + + /// Loads the service key. + public func serviceKey() async throws -> String { + try await load() + } + + /// Returns a provider that always supplies the given service key. + public static func fixed(_ serviceKey: String) -> Self { + Self { serviceKey } + } + + /// The public widget key bundled with this version of XcodesLoginKit. + public static let bundledAppStoreConnectServiceKey = + "e0b80c3bf78523bfe80974d320935bfa30add02e1bff88ec2166c6bd5a706c42" + + /// A provider for the public widget key bundled with this version of XcodesLoginKit. + public static let appStoreConnect = fixed(bundledAppStoreConnectServiceKey) +} + +/// Sources XcodesLoginKit can try when resolving Apple's public sign-in service key. +public enum AppleServiceKeySource: String, Equatable, Sendable { + /// The key bundled with the installed XcodesLoginKit version. + case bundled + /// A key supplied by the application through ``AppleServiceKeyProvider``. + case supplied + /// A key discovered from Apple's current Developer Portal sign-in page. + case developerPortal +} diff --git a/Sources/XcodesLoginKit/AuthenticationError.swift b/Sources/XcodesLoginKit/AuthenticationError.swift index e2139b8..727a5fd 100644 --- a/Sources/XcodesLoginKit/AuthenticationError.swift +++ b/Sources/XcodesLoginKit/AuthenticationError.swift @@ -45,6 +45,8 @@ public enum AuthenticationError: Swift.Error, LocalizedError, Equatable, Sendabl case invalidFederatedAuthenticationCallback /// A password is required because the account is not federated. case missingPasswordForNonFederatedAccount + /// None of the available Apple sign-in service keys could start authentication. + case serviceKeyResolutionFailed(attemptedSources: [AppleServiceKeySource]) /// A user-visible error description. public var errorDescription: String? { @@ -90,6 +92,22 @@ public enum AuthenticationError: Swift.Error, LocalizedError, Equatable, Sendabl return "The federated authentication callback URL is missing required parameters." case .missingPasswordForNonFederatedAccount: return "This Apple ID does not use federated authentication. Enter your password to continue." + case let .serviceKeyResolutionFailed(attemptedSources): + let sources = attemptedSources.map(\.displayName).joined(separator: ", ") + return "Could not establish Apple sign-in using these service-key sources: \(sources). Apple may have changed its authentication flow." + } + } +} + +private extension AppleServiceKeySource { + var displayName: String { + switch self { + case .bundled: + return "the bundled key" + case .supplied: + return "the supplied key" + case .developerPortal: + return "Apple's Developer Portal" } } } diff --git a/Sources/XcodesLoginKit/AuthenticationState.swift b/Sources/XcodesLoginKit/AuthenticationState.swift index 199c758..2dbcd15 100644 --- a/Sources/XcodesLoginKit/AuthenticationState.swift +++ b/Sources/XcodesLoginKit/AuthenticationState.swift @@ -210,10 +210,6 @@ public enum SecurityCode: Sendable { } } -struct ServiceKeyResponse: Decodable, Sendable { - let authServiceKey: String -} - struct SignInResponse: Decodable, Sendable { let authType: String? let serviceErrors: [ServiceError]? diff --git a/Sources/XcodesLoginKit/Client.swift b/Sources/XcodesLoginKit/Client.swift index bed1be4..0921224 100644 --- a/Sources/XcodesLoginKit/Client.swift +++ b/Sources/XcodesLoginKit/Client.swift @@ -21,15 +21,23 @@ public final class Client: Sendable { private static let authTypes = ["sa", "hsa", "non-sa", "hsa2"] private let networkService: AsyncHTTPNetworkService + private let serviceKeyProvider: AppleServiceKeyProvider? /// Creates a client that uses the supplied URL session for all Apple authentication requests. /// /// Use a custom session when you need isolated cookie storage, imported fastlane cookies, or /// test-controlled networking. The session's cookie storage is where authenticated Apple cookies /// are read and written. - /// - Parameter urlSession: The session used for requests and cookie persistence. - public init(urlSession: URLSession = .shared) { + /// - Parameters: + /// - urlSession: The session used for requests and cookie persistence. + /// - serviceKeyProvider: Optionally supplies a fallback sign-in widget key. The bundled key is + /// tried first, followed by this provider, then automatic discovery from Apple's sign-in page. + public init( + urlSession: URLSession = .shared, + serviceKeyProvider: AppleServiceKeyProvider? = nil + ) { self.networkService = AsyncHTTPNetworkService(urlSession: urlSession) + self.serviceKeyProvider = serviceKeyProvider } /// The URL session used by the client. @@ -53,7 +61,9 @@ public final class Client: Sendable { /// - password: The Apple ID password, or `nil` when checking for federated authentication first. /// - Returns: The current authentication state. public func authenticationState(accountName: String, password: String?) async throws -> AuthenticationState { - let federationResponse = try await checkIsFederated(accountName: accountName) + let (serviceKey, federationResponse) = try await withResolvedServiceKey { serviceKey in + try await checkFederation(accountName: accountName, serviceKey: serviceKey) + } if federationResponse.federated { return .waitingForFederatedAuthentication(federationResponse) } @@ -62,7 +72,7 @@ public final class Client: Sendable { throw AuthenticationError.missingPasswordForNonFederatedAccount } - return try await srpLogin(accountName: accountName, password: password) + return try await srpLogin(accountName: accountName, password: password, serviceKey: serviceKey) } /// Signs in a non-federated Apple ID with Secure Remote Password authentication. @@ -74,14 +84,17 @@ public final class Client: Sendable { /// - password: The Apple ID password. /// - Returns: `.authenticated` when no additional verification is needed, or a second-factor state. public func srpLogin(accountName: String, password: String) async throws -> AuthenticationState { + let (serviceKey, _) = try await withResolvedServiceKey { serviceKey in + try await checkFederation(accountName: accountName, serviceKey: serviceKey) + } + return try await srpLogin(accountName: accountName, password: password, serviceKey: serviceKey) + } + + private func srpLogin(accountName: String, password: String, serviceKey: String) async throws -> AuthenticationState { let client = SRPClient(configuration: SRPConfiguration(.N2048)) let clientKeys = client.generateKeys() let a = clientKeys.public - - let serviceKeyResponse: ServiceKeyResponse = try await networkService.requestObject(URLRequest.itcServiceKey) - let serviceKey = serviceKeyResponse.authServiceKey - // Fixes issue https://github.com/RobotsAndPencils/XcodesApp/issues/360 // On 2023-02-23, Apple added a custom implementation of hashcash to their auth flow // Without this addition, Apple ID's would get set to locked @@ -217,8 +230,62 @@ public final class Client: Sendable { /// Checks whether an Apple ID is federated and, when it is, returns identity-provider details. public func checkIsFederated(accountName: String) async throws -> FederationResponse { - let serviceKeyResponse: ServiceKeyResponse = try await networkService.requestObject(URLRequest.itcServiceKey) - return try await checkFederation(accountName: accountName, serviceKey: serviceKeyResponse.authServiceKey) + let (_, federationResponse) = try await withResolvedServiceKey { serviceKey in + try await checkFederation(accountName: accountName, serviceKey: serviceKey) + } + return federationResponse + } + + private func withResolvedServiceKey( + operation: (String) async throws -> Result + ) async throws -> (serviceKey: String, result: Result) { + var attemptedSources: [AppleServiceKeySource] = [] + + do { + let serviceKey = AppleServiceKeyProvider.bundledAppStoreConnectServiceKey + return (serviceKey, try await operation(serviceKey)) + } catch { + try Task.checkCancellation() + attemptedSources.append(.bundled) + } + + if let serviceKeyProvider { + do { + let serviceKey = try await normalizedServiceKey(awaiting: serviceKeyProvider) + return (serviceKey, try await operation(serviceKey)) + } catch { + try Task.checkCancellation() + attemptedSources.append(.supplied) + } + } + + do { + let serviceKey = try await discoverLatestServiceKey() + return (serviceKey, try await operation(serviceKey)) + } catch { + try Task.checkCancellation() + attemptedSources.append(.developerPortal) + } + + throw AuthenticationError.serviceKeyResolutionFailed(attemptedSources: attemptedSources) + } + + private func normalizedServiceKey(awaiting provider: AppleServiceKeyProvider) async throws -> String { + let serviceKey = try await provider.serviceKey().trimmingCharacters(in: .whitespacesAndNewlines) + guard !serviceKey.isEmpty else { throw ServiceKeyError.empty } + return serviceKey + } + + private func discoverLatestServiceKey() async throws -> String { + let (data, _) = try await networkService.requestData( + URLRequest.developerPortalSignInPage, + validators: [statusCodeIsIn200s] + ) + guard let html = String(data: data, encoding: .utf8), + let match = html.firstMatch(of: /"widgetKey"\s*:\s*"([0-9a-f]{32,64})"/) else { + throw ServiceKeyError.notFoundOnDeveloperPortal + } + return String(match.1) } /// Completes a federated sign-in after the identity provider redirects back with a token. @@ -440,3 +507,8 @@ extension Data { return map { String(format: "%02hhx", $0) }.joined() } } + +private enum ServiceKeyError: Error { + case empty + case notFoundOnDeveloperPortal +} diff --git a/Sources/XcodesLoginKit/URLRequest+Apple.swift b/Sources/XcodesLoginKit/URLRequest+Apple.swift index 6db1e53..1942396 100644 --- a/Sources/XcodesLoginKit/URLRequest+Apple.swift +++ b/Sources/XcodesLoginKit/URLRequest+Apple.swift @@ -8,6 +8,7 @@ import Foundation public extension URL { + @available(*, deprecated, message: "Apple removed this endpoint. Use AppleServiceKeyProvider instead.") static let itcServiceKey = URL(string: "https://appstoreconnect.apple.com/olympus/v1/app/config?hostname=itunesconnect.apple.com")! static let signIn = URL(string: "https://idmsa.apple.com/appleauth/auth/signin")! static let authOptions = URL(string: "https://idmsa.apple.com/appleauth/auth")! @@ -17,6 +18,7 @@ public extension URL { static let federate = URL(string: "https://idmsa.apple.com/appleauth/auth/federate")! static let federateValidate = URL(string: "https://idmsa.apple.com/appleauth/auth/federate/validate")! static let olympusSession = URL(string: "https://appstoreconnect.apple.com/olympus/v1/session")! + static let developerPortalSignInPage = URL(string: "https://developer.apple.com/account")! static let keyAuth = URL(string: "https://idmsa.apple.com/appleauth/auth/verify/security/key")! static let srpInit = URL(string: "https://idmsa.apple.com/appleauth/auth/signin/init")! @@ -25,8 +27,13 @@ public extension URL { } public extension URLRequest { + @available(*, deprecated, message: "Apple removed this endpoint. Use AppleServiceKeyProvider instead.") static var itcServiceKey: URLRequest { - return URLRequest(url: .itcServiceKey) + URLRequest(url: .itcServiceKey) + } + + static var developerPortalSignInPage: URLRequest { + URLRequest(url: .developerPortalSignInPage) } static func signIn(serviceKey: String, accountName: String, password: String, hashcash: String) -> URLRequest { @@ -146,20 +153,14 @@ public extension URLRequest { return URLRequest(url: .olympusSession) } - static func federate(account: String, serviceKey: String) throws -> URLRequest { - struct FederateRequest: Encodable { - let accountName: String - let rememberMe: Bool - } - var request = URLRequest(url: .signIn) + static func federate(account _: String, serviceKey: String) throws -> URLRequest { + var components = URLComponents(url: .signIn, resolvingAgainstBaseURL: false)! + components.queryItems = [URLQueryItem(name: "widgetKey", value: serviceKey)] + var request = URLRequest(url: components.url!) + request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:] request.allHTTPHeaderFields?["Accept"] = "application/json" request.allHTTPHeaderFields?["Content-Type"] = "application/json" request.httpMethod = "GET" - -// let encoder = JSONEncoder() -// encoder.outputFormatting = .withoutEscapingSlashes -// request.httpBody = try encoder.encode(FederateRequest(accountName: account, rememberMe: true)) - return request } @@ -238,4 +239,3 @@ public extension URLRequest { public enum SRPProtocol: String, Codable, Sendable { case s2k, s2k_fo } - diff --git a/Tests/XcodesLoginKitTests/Fixtures/Login_Federated_Succeeds/ITCServiceKey.json b/Tests/XcodesLoginKitTests/Fixtures/Login_Federated_Succeeds/ITCServiceKey.json deleted file mode 100644 index 33c00bf..0000000 --- a/Tests/XcodesLoginKitTests/Fixtures/Login_Federated_Succeeds/ITCServiceKey.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "authServiceUrl" : "https://idmsa.apple.com/appleauth", - "authServiceKey" : "NNNNN" -} diff --git a/Tests/XcodesLoginKitTests/XcodesLoginKitTests.swift b/Tests/XcodesLoginKitTests/XcodesLoginKitTests.swift index a356692..fd07888 100644 --- a/Tests/XcodesLoginKitTests/XcodesLoginKitTests.swift +++ b/Tests/XcodesLoginKitTests/XcodesLoginKitTests.swift @@ -163,13 +163,11 @@ final class XcodesLoginKitTests: XCTestCase { func testClientCheckIsFederatedReturnsFederatedResponse() async throws { let client = Client(urlSession: MockURLProtocol.session { request in switch request.url { - case .itcServiceKey: - return try Self.fixtureResponse( - for: request, - resource: "ITCServiceKey", - subdirectory: "Fixtures/Login_Federated_Succeeds" - ) case .federate: + XCTAssertEqual( + request.value(forHTTPHeaderField: "X-Apple-Widget-Key"), + "e0b80c3bf78523bfe80974d320935bfa30add02e1bff88ec2166c6bd5a706c42" + ) return try Self.fixtureResponse( for: request, resource: "FederateCheck", @@ -192,25 +190,34 @@ final class XcodesLoginKitTests: XCTestCase { } func testClientCheckIsFederatedReturnsNonFederatedResponse() async throws { - let client = Client(urlSession: MockURLProtocol.session { request in - switch request.url { - case .itcServiceKey: - return try Self.fixtureResponse( - for: request, - resource: "ITCServiceKey", - subdirectory: "Fixtures/Login_Federated_Succeeds" - ) - case .federate: - return try Self.fixtureResponse( - for: request, - resource: "FederateCheckNonFederated", - subdirectory: "Fixtures/Login_Federated_Succeeds" - ) - default: - XCTFail("Unexpected request to \(String(describing: request.url))") - return Self.emptyResponse(for: request, statusCode: 500) + let steps = StepRecorder() + let client = Client( + urlSession: MockURLProtocol.session { request in + switch request.url { + case .federate: + let serviceKey = try XCTUnwrap(request.value(forHTTPHeaderField: "X-Apple-Widget-Key")) + steps.record("request:\(serviceKey)") + guard serviceKey == "override-widget-key" else { + return Self.emptyResponse(for: request, statusCode: 401) + } + return try Self.fixtureResponse( + for: request, + resource: "FederateCheckNonFederated", + subdirectory: "Fixtures/Login_Federated_Succeeds" + ) + case .developerPortalSignInPage: + XCTFail("Should not discover a key after the supplied key succeeds") + return Self.emptyResponse(for: request, statusCode: 500) + default: + XCTFail("Unexpected request to \(String(describing: request.url))") + return Self.emptyResponse(for: request, statusCode: 500) + } + }, + serviceKeyProvider: AppleServiceKeyProvider { + steps.record("provider") + return "override-widget-key" } - }) + ) let response = try await client.checkIsFederated(accountName: "test@example.com") @@ -218,6 +225,154 @@ final class XcodesLoginKitTests: XCTestCase { XCTAssertNil(response.federatedIdpRequest) XCTAssertNil(response.federatedAuthIntro) XCTAssertNil(response.idpURL) + XCTAssertEqual(steps.values, [ + "request:\(AppleServiceKeyProvider.bundledAppStoreConnectServiceKey)", + "provider", + "request:override-widget-key" + ]) + } + + func testClientDiscoversLatestServiceKeyWhenBundledKeyFailsAndNoKeyIsSupplied() async throws { + let steps = StepRecorder() + let client = Client( + urlSession: MockURLProtocol.session { request in + switch request.url { + case .federate: + let serviceKey = try XCTUnwrap(request.value(forHTTPHeaderField: "X-Apple-Widget-Key")) + steps.record("request:\(serviceKey)") + guard serviceKey == Self.signInPageWidgetKey else { + return Self.emptyResponse(for: request, statusCode: 401) + } + return try Self.fixtureResponse( + for: request, + resource: "FederateCheckNonFederated", + subdirectory: "Fixtures/Login_Federated_Succeeds" + ) + case .developerPortalSignInPage: + steps.record("discover") + return try Self.signInPageResponse(for: request) + default: + XCTFail("Unexpected request to \(String(describing: request.url))") + return Self.emptyResponse(for: request, statusCode: 500) + } + } + ) + + let response = try await client.checkIsFederated(accountName: "test@example.com") + + XCTAssertFalse(response.federated) + XCTAssertEqual(steps.values, [ + "request:\(AppleServiceKeyProvider.bundledAppStoreConnectServiceKey)", + "discover", + "request:\(Self.signInPageWidgetKey)" + ]) + } + + func testClientDiscoversLatestServiceKeyAfterSuppliedKeyFails() async throws { + let steps = StepRecorder() + let client = Client( + urlSession: MockURLProtocol.session { request in + switch request.url { + case .federate: + let serviceKey = try XCTUnwrap(request.value(forHTTPHeaderField: "X-Apple-Widget-Key")) + steps.record("request:\(serviceKey)") + guard serviceKey == Self.signInPageWidgetKey else { + return Self.emptyResponse(for: request, statusCode: 401) + } + return try Self.fixtureResponse( + for: request, + resource: "FederateCheckNonFederated", + subdirectory: "Fixtures/Login_Federated_Succeeds" + ) + case .developerPortalSignInPage: + steps.record("discover") + return try Self.signInPageResponse(for: request) + default: + XCTFail("Unexpected request to \(String(describing: request.url))") + return Self.emptyResponse(for: request, statusCode: 500) + } + }, + serviceKeyProvider: AppleServiceKeyProvider { + steps.record("provider") + return "stale-supplied-key" + } + ) + + let response = try await client.checkIsFederated(accountName: "test@example.com") + + XCTAssertFalse(response.federated) + XCTAssertEqual(steps.values, [ + "request:\(AppleServiceKeyProvider.bundledAppStoreConnectServiceKey)", + "provider", + "request:stale-supplied-key", + "discover", + "request:\(Self.signInPageWidgetKey)" + ]) + } + + func testClientReturnsTypedErrorWhenEveryServiceKeySourceFails() async throws { + let client = Client( + urlSession: MockURLProtocol.session { request in + switch request.url { + case .federate: + return Self.emptyResponse(for: request, statusCode: 401) + case .developerPortalSignInPage: + return try Self.signInPageResponse(for: request) + default: + XCTFail("Unexpected request to \(String(describing: request.url))") + return Self.emptyResponse(for: request, statusCode: 500) + } + }, + serviceKeyProvider: .fixed("stale-supplied-key") + ) + + do { + _ = try await client.checkIsFederated(accountName: "test@example.com") + XCTFail("Expected service-key resolution to fail") + } catch AuthenticationError.serviceKeyResolutionFailed(let attemptedSources) { + XCTAssertEqual(attemptedSources, [.bundled, .supplied, .developerPortal]) + let message = AuthenticationError + .serviceKeyResolutionFailed(attemptedSources: attemptedSources) + .localizedDescription + XCTAssertTrue(message.contains("the bundled key")) + XCTAssertTrue(message.contains("the supplied key")) + XCTAssertTrue(message.contains("Apple's Developer Portal")) + } + } + + func testHashcashRequestIncludesWidgetKey() throws { + let request = try URLRequest.federate(account: "test@example.com", serviceKey: "test-widget-key") + let components = try XCTUnwrap(URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)) + + XCTAssertEqual(components.queryItems?.first(where: { $0.name == "widgetKey" })?.value, "test-widget-key") + } + + func testAuthenticationStateDoesNotLoadFallbackWhileBundledKeyWorks() async throws { + let invocationCounter = InvocationCounter() + let client = Client( + urlSession: MockURLProtocol.session { request in + guard request.url == .federate else { + XCTFail("Unexpected request to \(String(describing: request.url))") + return Self.emptyResponse(for: request, statusCode: 500) + } + return try Self.fixtureResponse( + for: request, + resource: "FederateCheck", + subdirectory: "Fixtures/Login_Federated_Succeeds" + ) + }, + serviceKeyProvider: AppleServiceKeyProvider { + await invocationCounter.increment() + return "test-widget-key" + } + ) + + let state = try await client.authenticationState(accountName: "test@company.com", password: nil) + guard case .waitingForFederatedAuthentication = state else { + return XCTFail("Expected federated authentication") + } + let invocationCount = await invocationCounter.value + XCTAssertEqual(invocationCount, 0) } func testClientValidateFederatedTokenSucceeds() async throws { @@ -313,6 +468,22 @@ final class XcodesLoginKitTests: XCTestCase { } private extension XcodesLoginKitTests { + static let signInPageWidgetKey = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + + static func signInPageResponse(for request: URLRequest) throws -> (Data, HTTPURLResponse) { + let html = #""# + return ( + Data(html.utf8), + try XCTUnwrap(HTTPURLResponse( + url: try XCTUnwrap(request.url), + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "text/html"] + )) + ) + } + static func fixtureResponse(for request: URLRequest, resource: String, subdirectory: String) throws -> (Data, HTTPURLResponse) { let url = try XCTUnwrap(Bundle.module.url(forResource: resource, withExtension: "json", subdirectory: subdirectory)) let data = try Data(contentsOf: url) @@ -391,6 +562,26 @@ private final class URLRecorder: Sendable { } } +private final class StepRecorder: Sendable { + private let recordedValues = OSAllocatedUnfairLock<[String]>(initialState: []) + + var values: [String] { + recordedValues.withLock { $0 } + } + + func record(_ value: String) { + recordedValues.withLock { $0.append(value) } + } +} + +private actor InvocationCounter { + private(set) var value = 0 + + func increment() { + value += 1 + } +} + private final class AppleSessionRecorder: Sendable { enum LoginOutcome: Sendable { case success From 2052fea895e83686bf8e07ce05873eb8fc4a4748 Mon Sep 17 00:00:00 2001 From: Matt Kiazyk Date: Fri, 11 Sep 2026 13:19:29 -0500 Subject: [PATCH 2/2] update based on similar to fastlane --- README.md | 21 +- .../AppleServiceKeyProvider.swift | 70 ++- .../AppleServiceKeyResolver.swift | 293 +++++++++++ .../XcodesLoginKit/AuthenticationError.swift | 46 +- Sources/XcodesLoginKit/Client.swift | 92 +--- Sources/XcodesLoginKit/URLRequest+Apple.swift | 17 +- .../XcodesLoginKitTests.swift | 495 +++++++++++++----- 7 files changed, 793 insertions(+), 241 deletions(-) create mode 100644 Sources/XcodesLoginKit/AppleServiceKeyResolver.swift diff --git a/README.md b/README.md index 1722517..58dd8cf 100644 --- a/README.md +++ b/README.md @@ -79,11 +79,16 @@ case .unauthenticated, .notAppleDeveloper: `Client` resolves Apple's public sign-in widget key in this order: -1. The key bundled with XcodesLoginKit. -2. An optional key supplied by the application. -3. The latest key discovered from Apple's Developer Portal sign-in page. +1. An explicit key supplied by the application, when present. +2. A key previously saved in XcodesLoginKit's in-memory or on-disk cache. +3. The `widgetKey` in App Store Connect's unauthenticated `/logout` redirect. +4. The legacy App Store Connect Olympus configuration endpoint as a final fallback. -Supply a known fallback key without changing the library: +The sign-out lookup uses a separate cookie-free session and does not follow the redirect. Following +that redirect would perform a real sign-out, so the authentication session is never used for this +request. + +Supply an explicit key without changing the library: ```swift let client = Client(serviceKeyProvider: .fixed("current-public-widget-key")) @@ -99,10 +104,10 @@ let client = Client( ) ``` -The supplied provider and live discovery are lazy: they are only used when the earlier key cannot -start authentication. If every source fails, the client throws -`AuthenticationError.serviceKeyResolutionFailed(attemptedSources:)`, whose localized description -lists the attempted sources. +Successful automatic lookups are cached on a best-effort basis. Cache read or write failures do not +block authentication. If every network source fails, the client throws +`AuthenticationError.serviceKeyResolutionFailed(attempts:)`, whose localized description includes +the source-specific failures and HTTP status codes when available. ### Main flow diff --git a/Sources/XcodesLoginKit/AppleServiceKeyProvider.swift b/Sources/XcodesLoginKit/AppleServiceKeyProvider.swift index 9179ef9..a7e875c 100644 --- a/Sources/XcodesLoginKit/AppleServiceKeyProvider.swift +++ b/Sources/XcodesLoginKit/AppleServiceKeyProvider.swift @@ -1,8 +1,8 @@ -/// Supplies the public Apple widget key used by Apple ID authentication requests. +/// Supplies an explicit Apple widget key for Apple ID authentication requests. /// -/// Apple does not publish a stable endpoint for discovering this value. XcodesLoginKit tries its -/// bundled App Store Connect key first, then a key supplied by this provider, and finally attempts -/// to discover the latest key from Apple's Developer Portal sign-in page. +/// When a provider is present, XcodesLoginKit tries it before consulting its cache or Apple's +/// current App Store Connect key sources. The loader is asynchronous so applications can source +/// the value from their own configuration service. public struct AppleServiceKeyProvider: Sendable { public typealias Loader = @Sendable () async throws -> String @@ -22,21 +22,57 @@ public struct AppleServiceKeyProvider: Sendable { public static func fixed(_ serviceKey: String) -> Self { Self { serviceKey } } - - /// The public widget key bundled with this version of XcodesLoginKit. - public static let bundledAppStoreConnectServiceKey = - "e0b80c3bf78523bfe80974d320935bfa30add02e1bff88ec2166c6bd5a706c42" - - /// A provider for the public widget key bundled with this version of XcodesLoginKit. - public static let appStoreConnect = fixed(bundledAppStoreConnectServiceKey) } -/// Sources XcodesLoginKit can try when resolving Apple's public sign-in service key. +/// Sources XcodesLoginKit can use when resolving Apple's public sign-in service key. public enum AppleServiceKeySource: String, Equatable, Sendable { - /// The key bundled with the installed XcodesLoginKit version. - case bundled - /// A key supplied by the application through ``AppleServiceKeyProvider``. + /// A key supplied explicitly by the application. case supplied - /// A key discovered from Apple's current Developer Portal sign-in page. - case developerPortal + /// A key read from XcodesLoginKit's best-effort cache. + case cache + /// A key read from App Store Connect's unauthenticated sign-out redirect. + case appStoreConnectSignOut + /// A key read from App Store Connect's legacy Olympus configuration endpoint. + case olympus +} + +/// Why an Apple service-key source did not produce a usable key. +public enum AppleServiceKeyFailure: Swift.Error, Equatable, Sendable { + /// The source could not be reached. + case network(description: String) + /// The response was not an HTTP response. + case invalidResponse + /// The source returned an HTTP error. + case httpStatus(code: Int, bodyPreview: String?) + /// The App Store Connect sign-out response did not contain a redirect. + case missingRedirect + /// The sign-out redirect could not be parsed. + case invalidRedirect + /// The source returned a response without a service key. + case missingKey + + /// Whether retrying this failure later may succeed without an application update. + public var isRetryable: Bool { + switch self { + case .network: + return true + case let .httpStatus(code, _): + return code == 429 || code >= 500 + case .invalidResponse, .missingRedirect, .invalidRedirect, .missingKey: + return false + } + } +} + +/// A failed attempt to resolve Apple's public sign-in service key. +public struct AppleServiceKeyAttempt: Equatable, Sendable { + /// The source that was attempted. + public let source: AppleServiceKeySource + /// The reason the source did not produce a usable key. + public let failure: AppleServiceKeyFailure + + public init(source: AppleServiceKeySource, failure: AppleServiceKeyFailure) { + self.source = source + self.failure = failure + } } diff --git a/Sources/XcodesLoginKit/AppleServiceKeyResolver.swift b/Sources/XcodesLoginKit/AppleServiceKeyResolver.swift new file mode 100644 index 0000000..205ec8e --- /dev/null +++ b/Sources/XcodesLoginKit/AppleServiceKeyResolver.swift @@ -0,0 +1,293 @@ +import Foundation +import os + +/// Resolves and caches Apple's public App Store Connect sign-in service key. +actor AppleServiceKeyResolver { + typealias ResponseLoader = @Sendable () async throws -> (Data, HTTPURLResponse) + + private static let logger = Logger( + subsystem: "org.xcodes.XcodesLoginKit", + category: "AppleServiceKey" + ) + + private let provider: AppleServiceKeyProvider? + private let cache: AppleServiceKeyCache + private let loadSignOutResponse: ResponseLoader + private let loadOlympusResponse: ResponseLoader + private var memoryKey: String? + + init( + provider: AppleServiceKeyProvider? = nil, + cache: AppleServiceKeyCache = .default, + loadSignOutResponse: @escaping ResponseLoader, + loadOlympusResponse: @escaping ResponseLoader + ) { + self.provider = provider + self.cache = cache + self.loadSignOutResponse = loadSignOutResponse + self.loadOlympusResponse = loadOlympusResponse + } + + static func live( + provider: AppleServiceKeyProvider?, + authenticationSession: URLSession + ) -> AppleServiceKeyResolver { + let signOutClient = AppleServiceKeyHTTPClient(session: .appleServiceKeySignOut) + let olympusClient = AppleServiceKeyHTTPClient(session: authenticationSession) + + return AppleServiceKeyResolver( + provider: provider, + loadSignOutResponse: { + try await signOutClient.response(for: .appStoreConnectLogoutServiceKey) + }, + loadOlympusResponse: { + try await olympusClient.response(for: .olympusServiceKeyFallback) + } + ) + } + + func serviceKey() async throws -> String { + var attempts: [AppleServiceKeyAttempt] = [] + + if let memoryKey { + return memoryKey + } + + if let provider { + do { + let suppliedKey = try await provider.serviceKey() + try Task.checkCancellation() + + if let key = normalized(suppliedKey) { + memoryKey = key + return key + } + attempts.append(.init(source: .supplied, failure: .missingKey)) + } catch { + try Task.checkCancellation() + if let failure = Self.expectedFailure(from: error) { + attempts.append(.init(source: .supplied, failure: failure)) + } else { + throw error + } + } + } + + do { + if let cachedKey = try cache.load().flatMap(normalized) { + memoryKey = cachedKey + return cachedKey + } + } catch { + Self.logger.warning("Could not read the cached Apple service key: \(error.localizedDescription, privacy: .public)") + } + + do { + let response = try await loadSignOutResponse() + try Task.checkCancellation() + + switch Self.keyFromSignOutResponse(response) { + case let .success(key): + return store(key) + case let .failure(failure): + attempts.append(.init(source: .appStoreConnectSignOut, failure: failure)) + } + } catch { + try Task.checkCancellation() + if let failure = Self.expectedFailure(from: error) { + attempts.append(.init(source: .appStoreConnectSignOut, failure: failure)) + } else { + throw error + } + } + + do { + let response = try await loadOlympusResponse() + try Task.checkCancellation() + + switch Self.keyFromOlympusResponse(response) { + case let .success(key): + return store(key) + case let .failure(failure): + attempts.append(.init(source: .olympus, failure: failure)) + } + } catch { + try Task.checkCancellation() + if let failure = Self.expectedFailure(from: error) { + attempts.append(.init(source: .olympus, failure: failure)) + } else { + throw error + } + } + + throw AuthenticationError.serviceKeyResolutionFailed(attempts: attempts) + } + + private func store(_ key: String) -> String { + memoryKey = key + do { + try cache.save(key) + } catch { + Self.logger.warning("Could not cache the Apple service key: \(error.localizedDescription, privacy: .public)") + } + return key + } + + private func normalized(_ key: String) -> String? { + Self.normalizedStatic(key) + } + + private static func keyFromSignOutResponse( + _ response: (Data, HTTPURLResponse) + ) -> Result { + let (_, httpResponse) = response + guard 300..<400 ~= httpResponse.statusCode else { + return .failure(.httpStatus(code: httpResponse.statusCode, bodyPreview: nil)) + } + guard let location = httpResponse.value(forHTTPHeaderField: "Location"), !location.isEmpty else { + return .failure(.missingRedirect) + } + guard let components = URLComponents(string: location), + let redirectURL = components.url, + redirectURL.scheme != nil, + redirectURL.host != nil else { + return .failure(.invalidRedirect) + } + guard let key = components.queryItems?.first(where: { $0.name == "widgetKey" })?.value, + let key = normalizedStatic(key) else { + return .failure(.missingKey) + } + return .success(key) + } + + private static func keyFromOlympusResponse( + _ response: (Data, HTTPURLResponse) + ) -> Result { + let (data, httpResponse) = response + guard 200..<300 ~= httpResponse.statusCode else { + return .failure(.httpStatus( + code: httpResponse.statusCode, + bodyPreview: bodyPreview(data) + )) + } + + guard let response = try? JSONDecoder().decode(ServiceKeyResponse.self, from: data), + let key = normalizedStatic(response.authServiceKey) else { + return .failure(.missingKey) + } + return .success(key) + } + + private static func expectedFailure(from error: Error) -> AppleServiceKeyFailure? { + if let error = error as? URLError { + return .network(description: error.localizedDescription) + } + if error is AppleServiceKeyTransportError { + return .invalidResponse + } + return nil + } + + private static func normalizedStatic(_ key: String) -> String? { + let key = key.trimmingCharacters(in: .whitespacesAndNewlines) + return key.isEmpty ? nil : key + } + + private static func bodyPreview(_ data: Data) -> String? { + guard let body = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !body.isEmpty else { + return nil + } + return String(body.prefix(200)) + } +} + +struct AppleServiceKeyCache: Sendable { + typealias Load = @Sendable () throws -> String? + typealias Save = @Sendable (String) throws -> Void + + let load: Load + let save: Save + + static let disabled = AppleServiceKeyCache(load: { nil }, save: { _ in }) + + static let `default`: AppleServiceKeyCache = { + guard let cachesDirectory = FileManager.default.urls( + for: .cachesDirectory, + in: .userDomainMask + ).first else { + return .disabled + } + + let directory = cachesDirectory.appendingPathComponent( + "org.xcodes.XcodesLoginKit", + isDirectory: true + ) + let fileURL = directory.appendingPathComponent("apple-service-key.txt") + + return AppleServiceKeyCache( + load: { + guard FileManager.default.fileExists(atPath: fileURL.path) else { return nil } + return try String(contentsOf: fileURL, encoding: .utf8) + }, + save: { key in + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + try key.write(to: fileURL, atomically: true, encoding: .utf8) + } + ) + }() +} + +struct AppleServiceKeyHTTPClient: Sendable { + let session: URLSession + + func response(for request: URLRequest) async throws -> (Data, HTTPURLResponse) { + let (data, response) = try await session.data(for: request) + guard let response = response as? HTTPURLResponse else { + throw AppleServiceKeyTransportError.invalidResponse + } + return (data, response) + } +} + +private enum AppleServiceKeyTransportError: Error { + case invalidResponse +} + +/// Stateless; `@unchecked Sendable` is required because `NSObject` does not provide a checked +/// `Sendable` conformance for Foundation delegate implementations. +final class NoRedirectURLSessionDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping @Sendable (URLRequest?) -> Void + ) { + completionHandler(nil) + } +} + +extension URLSession { + static let appleServiceKeySignOut: URLSession = { + let configuration = URLSessionConfiguration.ephemeral + configuration.httpCookieStorage = nil + configuration.httpShouldSetCookies = false + configuration.httpCookieAcceptPolicy = .never + configuration.urlCache = nil + configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData + return URLSession( + configuration: configuration, + delegate: NoRedirectURLSessionDelegate(), + delegateQueue: nil + ) + }() +} + +private struct ServiceKeyResponse: Decodable, Sendable { + let authServiceKey: String +} diff --git a/Sources/XcodesLoginKit/AuthenticationError.swift b/Sources/XcodesLoginKit/AuthenticationError.swift index 727a5fd..8f31a7f 100644 --- a/Sources/XcodesLoginKit/AuthenticationError.swift +++ b/Sources/XcodesLoginKit/AuthenticationError.swift @@ -45,8 +45,8 @@ public enum AuthenticationError: Swift.Error, LocalizedError, Equatable, Sendabl case invalidFederatedAuthenticationCallback /// A password is required because the account is not federated. case missingPasswordForNonFederatedAccount - /// None of the available Apple sign-in service keys could start authentication. - case serviceKeyResolutionFailed(attemptedSources: [AppleServiceKeySource]) + /// None of Apple's available sign-in service-key sources returned a usable key. + case serviceKeyResolutionFailed(attempts: [AppleServiceKeyAttempt]) /// A user-visible error description. public var errorDescription: String? { @@ -92,9 +92,11 @@ public enum AuthenticationError: Swift.Error, LocalizedError, Equatable, Sendabl return "The federated authentication callback URL is missing required parameters." case .missingPasswordForNonFederatedAccount: return "This Apple ID does not use federated authentication. Enter your password to continue." - case let .serviceKeyResolutionFailed(attemptedSources): - let sources = attemptedSources.map(\.displayName).joined(separator: ", ") - return "Could not establish Apple sign-in using these service-key sources: \(sources). Apple may have changed its authentication flow." + case let .serviceKeyResolutionFailed(attempts): + let details = attempts + .map { "\($0.source.displayName): \($0.failure.displayName)" } + .joined(separator: "; ") + return "Could not retrieve Apple's sign-in service key. \(details)" } } } @@ -102,12 +104,36 @@ public enum AuthenticationError: Swift.Error, LocalizedError, Equatable, Sendabl private extension AppleServiceKeySource { var displayName: String { switch self { - case .bundled: - return "the bundled key" case .supplied: - return "the supplied key" - case .developerPortal: - return "Apple's Developer Portal" + return "Supplied key" + case .cache: + return "Cached key" + case .appStoreConnectSignOut: + return "App Store Connect sign-out redirect" + case .olympus: + return "App Store Connect Olympus endpoint" + } + } +} + +private extension AppleServiceKeyFailure { + var displayName: String { + switch self { + case let .network(description): + return "network request failed (\(description))" + case .invalidResponse: + return "returned a non-HTTP response" + case let .httpStatus(code, bodyPreview): + if let bodyPreview { + return "returned HTTP \(code) (\(bodyPreview))" + } + return "returned HTTP \(code)" + case .missingRedirect: + return "did not return a Location header" + case .invalidRedirect: + return "returned an invalid Location header" + case .missingKey: + return "did not contain a service key" } } } diff --git a/Sources/XcodesLoginKit/Client.swift b/Sources/XcodesLoginKit/Client.swift index 0921224..d63bb59 100644 --- a/Sources/XcodesLoginKit/Client.swift +++ b/Sources/XcodesLoginKit/Client.swift @@ -21,7 +21,7 @@ public final class Client: Sendable { private static let authTypes = ["sa", "hsa", "non-sa", "hsa2"] private let networkService: AsyncHTTPNetworkService - private let serviceKeyProvider: AppleServiceKeyProvider? + private let serviceKeyResolver: AppleServiceKeyResolver /// Creates a client that uses the supplied URL session for all Apple authentication requests. /// @@ -30,14 +30,24 @@ public final class Client: Sendable { /// are read and written. /// - Parameters: /// - urlSession: The session used for requests and cookie persistence. - /// - serviceKeyProvider: Optionally supplies a fallback sign-in widget key. The bundled key is - /// tried first, followed by this provider, then automatic discovery from Apple's sign-in page. - public init( + /// - serviceKeyProvider: Optionally supplies an explicit sign-in widget key. Without one, the + /// client uses its cache, App Store Connect's sign-out redirect, then the legacy Olympus endpoint. + public convenience init( urlSession: URLSession = .shared, serviceKeyProvider: AppleServiceKeyProvider? = nil ) { + self.init( + urlSession: urlSession, + serviceKeyResolver: .live( + provider: serviceKeyProvider, + authenticationSession: urlSession + ) + ) + } + + init(urlSession: URLSession, serviceKeyResolver: AppleServiceKeyResolver) { self.networkService = AsyncHTTPNetworkService(urlSession: urlSession) - self.serviceKeyProvider = serviceKeyProvider + self.serviceKeyResolver = serviceKeyResolver } /// The URL session used by the client. @@ -61,9 +71,8 @@ public final class Client: Sendable { /// - password: The Apple ID password, or `nil` when checking for federated authentication first. /// - Returns: The current authentication state. public func authenticationState(accountName: String, password: String?) async throws -> AuthenticationState { - let (serviceKey, federationResponse) = try await withResolvedServiceKey { serviceKey in - try await checkFederation(accountName: accountName, serviceKey: serviceKey) - } + let serviceKey = try await serviceKeyResolver.serviceKey() + let federationResponse = try await checkFederation(accountName: accountName, serviceKey: serviceKey) if federationResponse.federated { return .waitingForFederatedAuthentication(federationResponse) } @@ -84,9 +93,7 @@ public final class Client: Sendable { /// - password: The Apple ID password. /// - Returns: `.authenticated` when no additional verification is needed, or a second-factor state. public func srpLogin(accountName: String, password: String) async throws -> AuthenticationState { - let (serviceKey, _) = try await withResolvedServiceKey { serviceKey in - try await checkFederation(accountName: accountName, serviceKey: serviceKey) - } + let serviceKey = try await serviceKeyResolver.serviceKey() return try await srpLogin(accountName: accountName, password: password, serviceKey: serviceKey) } @@ -230,62 +237,8 @@ public final class Client: Sendable { /// Checks whether an Apple ID is federated and, when it is, returns identity-provider details. public func checkIsFederated(accountName: String) async throws -> FederationResponse { - let (_, federationResponse) = try await withResolvedServiceKey { serviceKey in - try await checkFederation(accountName: accountName, serviceKey: serviceKey) - } - return federationResponse - } - - private func withResolvedServiceKey( - operation: (String) async throws -> Result - ) async throws -> (serviceKey: String, result: Result) { - var attemptedSources: [AppleServiceKeySource] = [] - - do { - let serviceKey = AppleServiceKeyProvider.bundledAppStoreConnectServiceKey - return (serviceKey, try await operation(serviceKey)) - } catch { - try Task.checkCancellation() - attemptedSources.append(.bundled) - } - - if let serviceKeyProvider { - do { - let serviceKey = try await normalizedServiceKey(awaiting: serviceKeyProvider) - return (serviceKey, try await operation(serviceKey)) - } catch { - try Task.checkCancellation() - attemptedSources.append(.supplied) - } - } - - do { - let serviceKey = try await discoverLatestServiceKey() - return (serviceKey, try await operation(serviceKey)) - } catch { - try Task.checkCancellation() - attemptedSources.append(.developerPortal) - } - - throw AuthenticationError.serviceKeyResolutionFailed(attemptedSources: attemptedSources) - } - - private func normalizedServiceKey(awaiting provider: AppleServiceKeyProvider) async throws -> String { - let serviceKey = try await provider.serviceKey().trimmingCharacters(in: .whitespacesAndNewlines) - guard !serviceKey.isEmpty else { throw ServiceKeyError.empty } - return serviceKey - } - - private func discoverLatestServiceKey() async throws -> String { - let (data, _) = try await networkService.requestData( - URLRequest.developerPortalSignInPage, - validators: [statusCodeIsIn200s] - ) - guard let html = String(data: data, encoding: .utf8), - let match = html.firstMatch(of: /"widgetKey"\s*:\s*"([0-9a-f]{32,64})"/) else { - throw ServiceKeyError.notFoundOnDeveloperPortal - } - return String(match.1) + let serviceKey = try await serviceKeyResolver.serviceKey() + return try await checkFederation(accountName: accountName, serviceKey: serviceKey) } /// Completes a federated sign-in after the identity provider redirects back with a token. @@ -507,8 +460,3 @@ extension Data { return map { String(format: "%02hhx", $0) }.joined() } } - -private enum ServiceKeyError: Error { - case empty - case notFoundOnDeveloperPortal -} diff --git a/Sources/XcodesLoginKit/URLRequest+Apple.swift b/Sources/XcodesLoginKit/URLRequest+Apple.swift index 1942396..478dc2b 100644 --- a/Sources/XcodesLoginKit/URLRequest+Apple.swift +++ b/Sources/XcodesLoginKit/URLRequest+Apple.swift @@ -8,8 +8,9 @@ import Foundation public extension URL { - @available(*, deprecated, message: "Apple removed this endpoint. Use AppleServiceKeyProvider instead.") + @available(*, deprecated, message: "This endpoint is unreliable and is retained only as a fallback.") static let itcServiceKey = URL(string: "https://appstoreconnect.apple.com/olympus/v1/app/config?hostname=itunesconnect.apple.com")! + static let appStoreConnectLogout = URL(string: "https://appstoreconnect.apple.com/logout")! static let signIn = URL(string: "https://idmsa.apple.com/appleauth/auth/signin")! static let authOptions = URL(string: "https://idmsa.apple.com/appleauth/auth")! static let requestSecurityCode = URL(string: "https://idmsa.apple.com/appleauth/auth/verify/phone")! @@ -18,7 +19,6 @@ public extension URL { static let federate = URL(string: "https://idmsa.apple.com/appleauth/auth/federate")! static let federateValidate = URL(string: "https://idmsa.apple.com/appleauth/auth/federate/validate")! static let olympusSession = URL(string: "https://appstoreconnect.apple.com/olympus/v1/session")! - static let developerPortalSignInPage = URL(string: "https://developer.apple.com/account")! static let keyAuth = URL(string: "https://idmsa.apple.com/appleauth/auth/verify/security/key")! static let srpInit = URL(string: "https://idmsa.apple.com/appleauth/auth/signin/init")! @@ -27,13 +27,20 @@ public extension URL { } public extension URLRequest { - @available(*, deprecated, message: "Apple removed this endpoint. Use AppleServiceKeyProvider instead.") + @available(*, deprecated, message: "This endpoint is unreliable and is retained only as a fallback.") static var itcServiceKey: URLRequest { URLRequest(url: .itcServiceKey) } - static var developerPortalSignInPage: URLRequest { - URLRequest(url: .developerPortalSignInPage) + static var olympusServiceKeyFallback: URLRequest { + URLRequest(url: URL(string: "https://appstoreconnect.apple.com/olympus/v1/app/config?hostname=itunesconnect.apple.com")!) + } + + static var appStoreConnectLogoutServiceKey: URLRequest { + var request = URLRequest(url: .appStoreConnectLogout) + request.httpMethod = "HEAD" + request.httpShouldHandleCookies = false + return request } static func signIn(serviceKey: String, accountName: String, password: String, hashcash: String) -> URLRequest { diff --git a/Tests/XcodesLoginKitTests/XcodesLoginKitTests.swift b/Tests/XcodesLoginKitTests/XcodesLoginKitTests.swift index fd07888..5a45f6c 100644 --- a/Tests/XcodesLoginKitTests/XcodesLoginKitTests.swift +++ b/Tests/XcodesLoginKitTests/XcodesLoginKitTests.swift @@ -161,23 +161,26 @@ final class XcodesLoginKitTests: XCTestCase { } func testClientCheckIsFederatedReturnsFederatedResponse() async throws { - let client = Client(urlSession: MockURLProtocol.session { request in - switch request.url { - case .federate: - XCTAssertEqual( - request.value(forHTTPHeaderField: "X-Apple-Widget-Key"), - "e0b80c3bf78523bfe80974d320935bfa30add02e1bff88ec2166c6bd5a706c42" - ) - return try Self.fixtureResponse( - for: request, - resource: "FederateCheck", - subdirectory: "Fixtures/Login_Federated_Succeeds" - ) - default: - XCTFail("Unexpected request to \(String(describing: request.url))") - return Self.emptyResponse(for: request, statusCode: 500) - } - }) + let client = Client( + urlSession: MockURLProtocol.session { request in + switch request.url { + case .federate: + XCTAssertEqual( + request.value(forHTTPHeaderField: "X-Apple-Widget-Key"), + "test-widget-key" + ) + return try Self.fixtureResponse( + for: request, + resource: "FederateCheck", + subdirectory: "Fixtures/Login_Federated_Succeeds" + ) + default: + XCTFail("Unexpected request to \(String(describing: request.url))") + return Self.emptyResponse(for: request, statusCode: 500) + } + }, + serviceKeyProvider: .fixed("test-widget-key") + ) let response = try await client.checkIsFederated(accountName: "test@company.com") @@ -190,33 +193,23 @@ final class XcodesLoginKitTests: XCTestCase { } func testClientCheckIsFederatedReturnsNonFederatedResponse() async throws { - let steps = StepRecorder() let client = Client( urlSession: MockURLProtocol.session { request in switch request.url { case .federate: let serviceKey = try XCTUnwrap(request.value(forHTTPHeaderField: "X-Apple-Widget-Key")) - steps.record("request:\(serviceKey)") - guard serviceKey == "override-widget-key" else { - return Self.emptyResponse(for: request, statusCode: 401) - } + XCTAssertEqual(serviceKey, "override-widget-key") return try Self.fixtureResponse( for: request, resource: "FederateCheckNonFederated", subdirectory: "Fixtures/Login_Federated_Succeeds" ) - case .developerPortalSignInPage: - XCTFail("Should not discover a key after the supplied key succeeds") - return Self.emptyResponse(for: request, statusCode: 500) default: XCTFail("Unexpected request to \(String(describing: request.url))") return Self.emptyResponse(for: request, statusCode: 500) } }, - serviceKeyProvider: AppleServiceKeyProvider { - steps.record("provider") - return "override-widget-key" - } + serviceKeyProvider: .fixed("override-widget-key") ) let response = try await client.checkIsFederated(accountName: "test@example.com") @@ -225,121 +218,344 @@ final class XcodesLoginKitTests: XCTestCase { XCTAssertNil(response.federatedIdpRequest) XCTAssertNil(response.federatedAuthIntro) XCTAssertNil(response.idpURL) - XCTAssertEqual(steps.values, [ - "request:\(AppleServiceKeyProvider.bundledAppStoreConnectServiceKey)", - "provider", - "request:override-widget-key" - ]) } - func testClientDiscoversLatestServiceKeyWhenBundledKeyFailsAndNoKeyIsSupplied() async throws { + func testServiceKeyResolverReadsKeyFromSignOutRedirect() async throws { let steps = StepRecorder() - let client = Client( - urlSession: MockURLProtocol.session { request in - switch request.url { - case .federate: - let serviceKey = try XCTUnwrap(request.value(forHTTPHeaderField: "X-Apple-Widget-Key")) - steps.record("request:\(serviceKey)") - guard serviceKey == Self.signInPageWidgetKey else { - return Self.emptyResponse(for: request, statusCode: 401) - } - return try Self.fixtureResponse( - for: request, - resource: "FederateCheckNonFederated", - subdirectory: "Fixtures/Login_Federated_Succeeds" - ) - case .developerPortalSignInPage: - steps.record("discover") - return try Self.signInPageResponse(for: request) - default: - XCTFail("Unexpected request to \(String(describing: request.url))") - return Self.emptyResponse(for: request, statusCode: 500) - } + let resolver = AppleServiceKeyResolver( + cache: .disabled, + loadSignOutResponse: { + steps.record("signout") + return Self.signOutResponse(widgetKey: "redirect-widget-key") + }, + loadOlympusResponse: { + steps.record("olympus") + return Self.olympusResponse(widgetKey: "olympus-widget-key") } ) - let response = try await client.checkIsFederated(accountName: "test@example.com") + let key = try await resolver.serviceKey() - XCTAssertFalse(response.federated) - XCTAssertEqual(steps.values, [ - "request:\(AppleServiceKeyProvider.bundledAppStoreConnectServiceKey)", - "discover", - "request:\(Self.signInPageWidgetKey)" - ]) + XCTAssertEqual(key, "redirect-widget-key") + XCTAssertEqual(steps.values, ["signout"]) } - func testClientDiscoversLatestServiceKeyAfterSuppliedKeyFails() async throws { + func testServiceKeyResolverFallsBackToOlympusWhenRedirectHasNoLocation() async throws { let steps = StepRecorder() - let client = Client( - urlSession: MockURLProtocol.session { request in - switch request.url { - case .federate: - let serviceKey = try XCTUnwrap(request.value(forHTTPHeaderField: "X-Apple-Widget-Key")) - steps.record("request:\(serviceKey)") - guard serviceKey == Self.signInPageWidgetKey else { - return Self.emptyResponse(for: request, statusCode: 401) - } - return try Self.fixtureResponse( - for: request, - resource: "FederateCheckNonFederated", - subdirectory: "Fixtures/Login_Federated_Succeeds" - ) - case .developerPortalSignInPage: - steps.record("discover") - return try Self.signInPageResponse(for: request) - default: - XCTFail("Unexpected request to \(String(describing: request.url))") - return Self.emptyResponse(for: request, statusCode: 500) - } + let resolver = AppleServiceKeyResolver( + cache: .disabled, + loadSignOutResponse: { + steps.record("signout") + return Self.response(url: .appStoreConnectLogout, statusCode: 302) }, - serviceKeyProvider: AppleServiceKeyProvider { + loadOlympusResponse: { + steps.record("olympus") + return Self.olympusResponse(widgetKey: "olympus-widget-key") + } + ) + + let key = try await resolver.serviceKey() + + XCTAssertEqual(key, "olympus-widget-key") + XCTAssertEqual(steps.values, ["signout", "olympus"]) + } + + func testServiceKeyResolverFallsBackToOlympusWhenSignOutRequestFails() async throws { + let resolver = AppleServiceKeyResolver( + cache: .disabled, + loadSignOutResponse: { + throw URLError(.cannotConnectToHost) + }, + loadOlympusResponse: { + Self.olympusResponse(widgetKey: "olympus-widget-key") + } + ) + + let key = try await resolver.serviceKey() + XCTAssertEqual(key, "olympus-widget-key") + } + + func testServiceKeyResolverFallsBackWhenSuppliedProviderCannotConnect() async throws { + let steps = StepRecorder() + let resolver = AppleServiceKeyResolver( + provider: AppleServiceKeyProvider { steps.record("provider") - return "stale-supplied-key" + throw URLError(.cannotConnectToHost) + }, + cache: .disabled, + loadSignOutResponse: { + steps.record("signout") + return Self.signOutResponse(widgetKey: "redirect-widget-key") + }, + loadOlympusResponse: { + steps.record("olympus") + return Self.olympusResponse(widgetKey: "olympus-widget-key") } ) - let response = try await client.checkIsFederated(accountName: "test@example.com") + let key = try await resolver.serviceKey() - XCTAssertFalse(response.federated) - XCTAssertEqual(steps.values, [ - "request:\(AppleServiceKeyProvider.bundledAppStoreConnectServiceKey)", - "provider", - "request:stale-supplied-key", - "discover", - "request:\(Self.signInPageWidgetKey)" - ]) + XCTAssertEqual(key, "redirect-widget-key") + XCTAssertEqual(steps.values, ["provider", "signout"]) + } + + func testServiceKeyResolverReturnsDetailedFailureForSignOutAndOlympus() async throws { + let resolver = AppleServiceKeyResolver( + cache: .disabled, + loadSignOutResponse: { + Self.response(url: .appStoreConnectLogout, statusCode: 302) + }, + loadOlympusResponse: { + Self.response( + url: URLRequest.olympusServiceKeyFallback.url!, + statusCode: 404, + body: Data("Not Found".utf8) + ) + } + ) + + do { + _ = try await resolver.serviceKey() + XCTFail("Expected service-key resolution to fail") + } catch AuthenticationError.serviceKeyResolutionFailed(let attempts) { + XCTAssertEqual(attempts, [ + .init(source: .appStoreConnectSignOut, failure: .missingRedirect), + .init(source: .olympus, failure: .httpStatus(code: 404, bodyPreview: "Not Found")) + ]) + let message = AuthenticationError.serviceKeyResolutionFailed(attempts: attempts).localizedDescription + XCTAssertTrue(message.contains("sign-out redirect")) + XCTAssertTrue(message.contains("HTTP 404")) + XCTAssertTrue(message.contains("Not Found")) + } + } + + func testServiceKeyResolverReportsMalformedRedirectAndMissingOlympusKey() async throws { + let resolver = AppleServiceKeyResolver( + cache: .disabled, + loadSignOutResponse: { + Self.response( + url: .appStoreConnectLogout, + statusCode: 302, + headers: ["Location": "%"] + ) + }, + loadOlympusResponse: { + Self.response( + url: URLRequest.olympusServiceKeyFallback.url!, + statusCode: 200, + headers: ["Content-Type": "application/json"], + body: Data(#"{"unrelated":"value"}"#.utf8) + ) + } + ) + + do { + _ = try await resolver.serviceKey() + XCTFail("Expected service-key resolution to fail") + } catch AuthenticationError.serviceKeyResolutionFailed(let attempts) { + XCTAssertEqual(attempts, [ + .init(source: .appStoreConnectSignOut, failure: .invalidRedirect), + .init(source: .olympus, failure: .missingKey) + ]) + } + } + + func testServiceKeyFailureIdentifiesRetryableResponses() { + XCTAssertTrue(AppleServiceKeyFailure.network(description: "offline").isRetryable) + XCTAssertTrue(AppleServiceKeyFailure.httpStatus(code: 429, bodyPreview: nil).isRetryable) + XCTAssertTrue(AppleServiceKeyFailure.httpStatus(code: 503, bodyPreview: nil).isRetryable) + XCTAssertFalse(AppleServiceKeyFailure.httpStatus(code: 404, bodyPreview: nil).isRetryable) + XCTAssertFalse(AppleServiceKeyFailure.missingKey.isRetryable) + } + + func testServiceKeyResolverUsesCachedKeyWithoutNetworking() async throws { + let steps = StepRecorder() + let cache = AppleServiceKeyCache( + load: { + steps.record("cache") + return " cached-widget-key\n" + }, + save: { _ in XCTFail("A cached key should not be written again") } + ) + let resolver = AppleServiceKeyResolver( + cache: cache, + loadSignOutResponse: { + steps.record("signout") + return Self.signOutResponse(widgetKey: "redirect-widget-key") + }, + loadOlympusResponse: { + steps.record("olympus") + return Self.olympusResponse(widgetKey: "olympus-widget-key") + } + ) + + let key = try await resolver.serviceKey() + XCTAssertEqual(key, "cached-widget-key") + XCTAssertEqual(steps.values, ["cache"]) + } + + func testServiceKeyResolverCachesFetchedKeyAndReusesItInMemory() async throws { + let steps = StepRecorder() + let cache = AppleServiceKeyCache( + load: { nil }, + save: { key in steps.record("save:\(key)") } + ) + let resolver = AppleServiceKeyResolver( + cache: cache, + loadSignOutResponse: { + steps.record("signout") + return Self.signOutResponse(widgetKey: "redirect-widget-key") + }, + loadOlympusResponse: { + XCTFail("Olympus should not be used") + return Self.olympusResponse(widgetKey: "olympus-widget-key") + } + ) + + let first = try await resolver.serviceKey() + let second = try await resolver.serviceKey() + + XCTAssertEqual(first, "redirect-widget-key") + XCTAssertEqual(second, "redirect-widget-key") + XCTAssertEqual(steps.values, ["signout", "save:redirect-widget-key"]) + } + + func testServiceKeyResolverDoesNotFailWhenCacheCannotBeReadOrWritten() async throws { + enum CacheError: Error { case unavailable } + + let resolver = AppleServiceKeyResolver( + cache: AppleServiceKeyCache( + load: { throw CacheError.unavailable }, + save: { _ in throw CacheError.unavailable } + ), + loadSignOutResponse: { + Self.signOutResponse(widgetKey: "redirect-widget-key") + }, + loadOlympusResponse: { + XCTFail("Olympus should not be used") + return Self.olympusResponse(widgetKey: "olympus-widget-key") + } + ) + + let key = try await resolver.serviceKey() + XCTAssertEqual(key, "redirect-widget-key") + } + + func testServiceKeyResolverDoesNotSwallowUnexpectedErrors() async throws { + enum LocalError: Error, Equatable { case unavailableFile } + + let resolver = AppleServiceKeyResolver( + cache: .disabled, + loadSignOutResponse: { throw LocalError.unavailableFile }, + loadOlympusResponse: { + XCTFail("Unexpected errors must not fall through to Olympus") + return Self.olympusResponse(widgetKey: "olympus-widget-key") + } + ) + + do { + _ = try await resolver.serviceKey() + XCTFail("Expected the original error") + } catch let error as LocalError { + XCTAssertEqual(error, .unavailableFile) + } } - func testClientReturnsTypedErrorWhenEveryServiceKeySourceFails() async throws { + func testServiceKeyResolverPropagatesCancellationWithoutTryingOlympus() async throws { + let steps = StepRecorder() + let started = AsyncStream.makeStream() + let release = AsyncStream.makeStream() + let resolver = AppleServiceKeyResolver( + cache: .disabled, + loadSignOutResponse: { + steps.record("signout") + started.continuation.yield() + for await _ in release.stream { + break + } + return Self.signOutResponse(widgetKey: "redirect-widget-key") + }, + loadOlympusResponse: { + steps.record("olympus") + return Self.olympusResponse(widgetKey: "olympus-widget-key") + } + ) + let task = Task { + try await resolver.serviceKey() + } + var startedIterator = started.stream.makeAsyncIterator() + _ = await startedIterator.next() + + task.cancel() + release.continuation.yield() + + do { + _ = try await task.value + XCTFail("Expected cancellation") + } catch is CancellationError { + XCTAssertEqual(steps.values, ["signout"]) + } + started.continuation.finish() + release.continuation.finish() + } + + func testAuthenticationFailureIsNotConvertedIntoServiceKeyResolutionFailure() async throws { let client = Client( urlSession: MockURLProtocol.session { request in - switch request.url { - case .federate: - return Self.emptyResponse(for: request, statusCode: 401) - case .developerPortalSignInPage: - return try Self.signInPageResponse(for: request) - default: - XCTFail("Unexpected request to \(String(describing: request.url))") - return Self.emptyResponse(for: request, statusCode: 500) - } + Self.emptyResponse(for: request, statusCode: 401) }, - serviceKeyProvider: .fixed("stale-supplied-key") + serviceKeyProvider: .fixed("test-widget-key") ) do { _ = try await client.checkIsFederated(accountName: "test@example.com") - XCTFail("Expected service-key resolution to fail") - } catch AuthenticationError.serviceKeyResolutionFailed(let attemptedSources) { - XCTAssertEqual(attemptedSources, [.bundled, .supplied, .developerPortal]) - let message = AuthenticationError - .serviceKeyResolutionFailed(attemptedSources: attemptedSources) - .localizedDescription - XCTAssertTrue(message.contains("the bundled key")) - XCTAssertTrue(message.contains("the supplied key")) - XCTAssertTrue(message.contains("Apple's Developer Portal")) + XCTFail("Expected federation to fail") + } catch is AuthenticationError { + XCTFail("The federation error must not be converted into an authentication error") + } catch { + XCTAssertFalse(error is CancellationError) } } + func testSignOutServiceKeyRequestIsHeadAndDoesNotHandleCookies() { + let request = URLRequest.appStoreConnectLogoutServiceKey + + XCTAssertEqual(request.httpMethod, "HEAD") + XCTAssertEqual(request.httpShouldHandleCookies, false) + XCTAssertNil(request.value(forHTTPHeaderField: "Cookie")) + } + + func testSignOutServiceKeySessionHasNoCookieStoreAndDoesNotFollowRedirects() { + let session = URLSession.appleServiceKeySignOut + + XCTAssertNil(session.configuration.httpCookieStorage) + XCTAssertFalse(session.configuration.httpShouldSetCookies) + XCTAssertEqual(session.configuration.httpCookieAcceptPolicy, .never) + XCTAssertTrue(session.delegate is NoRedirectURLSessionDelegate) + } + + func testLiveAppStoreConnectSignOutRedirectContainsServiceKey() async throws { + guard ProcessInfo.processInfo.environment["XCODES_LOGIN_KIT_LIVE_SERVICE_KEY_TEST"] == "1" else { + throw XCTSkip("Set XCODES_LOGIN_KIT_LIVE_SERVICE_KEY_TEST=1 to contact App Store Connect") + } + + let httpClient = AppleServiceKeyHTTPClient(session: .appleServiceKeySignOut) + let resolver = AppleServiceKeyResolver( + cache: .disabled, + loadSignOutResponse: { + try await httpClient.response(for: .appStoreConnectLogoutServiceKey) + }, + loadOlympusResponse: { + XCTFail("The live sign-out redirect should contain the key") + return Self.olympusResponse(widgetKey: "unexpected-fallback-key") + } + ) + + let key = try await resolver.serviceKey() + + XCTAssertNotNil(key.wholeMatch(of: /[0-9a-f]{32,64}/)) + } + func testHashcashRequestIncludesWidgetKey() throws { let request = try URLRequest.federate(account: "test@example.com", serviceKey: "test-widget-key") let components = try XCTUnwrap(URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)) @@ -347,7 +563,7 @@ final class XcodesLoginKitTests: XCTestCase { XCTAssertEqual(components.queryItems?.first(where: { $0.name == "widgetKey" })?.value, "test-widget-key") } - func testAuthenticationStateDoesNotLoadFallbackWhileBundledKeyWorks() async throws { + func testAuthenticationStateLoadsExplicitProviderOnlyOnce() async throws { let invocationCounter = InvocationCounter() let client = Client( urlSession: MockURLProtocol.session { request in @@ -371,8 +587,9 @@ final class XcodesLoginKitTests: XCTestCase { guard case .waitingForFederatedAuthentication = state else { return XCTFail("Expected federated authentication") } + _ = try await client.authenticationState(accountName: "test@company.com", password: nil) let invocationCount = await invocationCounter.value - XCTAssertEqual(invocationCount, 0) + XCTAssertEqual(invocationCount, 1) } func testClientValidateFederatedTokenSucceeds() async throws { @@ -468,19 +685,39 @@ final class XcodesLoginKitTests: XCTestCase { } private extension XcodesLoginKitTests { - static let signInPageWidgetKey = - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + static func signOutResponse(widgetKey: String) -> (Data, HTTPURLResponse) { + response( + url: .appStoreConnectLogout, + statusCode: 302, + headers: [ + "Location": "https://idmsa.apple.com/appleauth/signout?widgetKey=\(widgetKey)&asop=destroy-session&asoc=/&rv=3" + ] + ) + } - static func signInPageResponse(for request: URLRequest) throws -> (Data, HTTPURLResponse) { - let html = #""# - return ( - Data(html.utf8), - try XCTUnwrap(HTTPURLResponse( - url: try XCTUnwrap(request.url), - statusCode: 200, + static func olympusResponse(widgetKey: String) -> (Data, HTTPURLResponse) { + response( + url: URLRequest.olympusServiceKeyFallback.url!, + statusCode: 200, + headers: ["Content-Type": "application/json"], + body: Data(#"{"authServiceKey":"\#(widgetKey)"}"#.utf8) + ) + } + + static func response( + url: URL, + statusCode: Int, + headers: [String: String] = [:], + body: Data = Data() + ) -> (Data, HTTPURLResponse) { + ( + body, + HTTPURLResponse( + url: url, + statusCode: statusCode, httpVersion: nil, - headerFields: ["Content-Type": "text/html"] - )) + headerFields: headers + )! ) }