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..58dd8cf 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,38 @@ case .unauthenticated, .notAppleDeveloper: } ``` +`Client` resolves Apple's public sign-in widget key in this order: + +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. + +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")) +``` + +For dynamic configuration, supply an asynchronous, `Sendable` loader: + +```swift +let client = Client( + serviceKeyProvider: AppleServiceKeyProvider { + try await configuration.appleServiceKey() + } +) +``` + +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 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..a7e875c --- /dev/null +++ b/Sources/XcodesLoginKit/AppleServiceKeyProvider.swift @@ -0,0 +1,78 @@ +/// Supplies an explicit Apple widget key for Apple ID authentication requests. +/// +/// 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 + + 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 } + } +} + +/// Sources XcodesLoginKit can use when resolving Apple's public sign-in service key. +public enum AppleServiceKeySource: String, Equatable, Sendable { + /// A key supplied explicitly by the application. + case supplied + /// 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 e2139b8..8f31a7f 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 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? { @@ -90,6 +92,48 @@ 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(attempts): + let details = attempts + .map { "\($0.source.displayName): \($0.failure.displayName)" } + .joined(separator: "; ") + return "Could not retrieve Apple's sign-in service key. \(details)" + } + } +} + +private extension AppleServiceKeySource { + var displayName: String { + switch self { + case .supplied: + 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/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..d63bb59 100644 --- a/Sources/XcodesLoginKit/Client.swift +++ b/Sources/XcodesLoginKit/Client.swift @@ -21,15 +21,33 @@ public final class Client: Sendable { private static let authTypes = ["sa", "hsa", "non-sa", "hsa2"] private let networkService: AsyncHTTPNetworkService + private let serviceKeyResolver: AppleServiceKeyResolver /// 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 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.serviceKeyResolver = serviceKeyResolver } /// The URL session used by the client. @@ -53,7 +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 federationResponse = try await checkIsFederated(accountName: accountName) + let serviceKey = try await serviceKeyResolver.serviceKey() + let federationResponse = try await checkFederation(accountName: accountName, serviceKey: serviceKey) if federationResponse.federated { return .waitingForFederatedAuthentication(federationResponse) } @@ -62,7 +81,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 +93,15 @@ 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 serviceKeyResolver.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 +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 serviceKeyResponse: ServiceKeyResponse = try await networkService.requestObject(URLRequest.itcServiceKey) - return try await checkFederation(accountName: accountName, serviceKey: serviceKeyResponse.authServiceKey) + 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. diff --git a/Sources/XcodesLoginKit/URLRequest+Apple.swift b/Sources/XcodesLoginKit/URLRequest+Apple.swift index 6db1e53..478dc2b 100644 --- a/Sources/XcodesLoginKit/URLRequest+Apple.swift +++ b/Sources/XcodesLoginKit/URLRequest+Apple.swift @@ -8,7 +8,9 @@ import Foundation public extension URL { + @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")! @@ -25,8 +27,20 @@ public extension URL { } public extension URLRequest { + @available(*, deprecated, message: "This endpoint is unreliable and is retained only as a fallback.") static var itcServiceKey: URLRequest { - return URLRequest(url: .itcServiceKey) + URLRequest(url: .itcServiceKey) + } + + 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 { @@ -146,20 +160,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 +246,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..5a45f6c 100644 --- a/Tests/XcodesLoginKitTests/XcodesLoginKitTests.swift +++ b/Tests/XcodesLoginKitTests/XcodesLoginKitTests.swift @@ -161,25 +161,26 @@ 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: - 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") @@ -192,25 +193,24 @@ 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 client = Client( + urlSession: MockURLProtocol.session { request in + switch request.url { + case .federate: + let serviceKey = try XCTUnwrap(request.value(forHTTPHeaderField: "X-Apple-Widget-Key")) + XCTAssertEqual(serviceKey, "override-widget-key") + 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) + } + }, + serviceKeyProvider: .fixed("override-widget-key") + ) let response = try await client.checkIsFederated(accountName: "test@example.com") @@ -220,6 +220,378 @@ final class XcodesLoginKitTests: XCTestCase { XCTAssertNil(response.idpURL) } + func testServiceKeyResolverReadsKeyFromSignOutRedirect() async throws { + let steps = StepRecorder() + 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 key = try await resolver.serviceKey() + + XCTAssertEqual(key, "redirect-widget-key") + XCTAssertEqual(steps.values, ["signout"]) + } + + func testServiceKeyResolverFallsBackToOlympusWhenRedirectHasNoLocation() async throws { + let steps = StepRecorder() + let resolver = AppleServiceKeyResolver( + cache: .disabled, + loadSignOutResponse: { + steps.record("signout") + return Self.response(url: .appStoreConnectLogout, statusCode: 302) + }, + 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") + 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 key = try await resolver.serviceKey() + + 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 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 + Self.emptyResponse(for: request, statusCode: 401) + }, + serviceKeyProvider: .fixed("test-widget-key") + ) + + do { + _ = try await client.checkIsFederated(accountName: "test@example.com") + 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)) + + XCTAssertEqual(components.queryItems?.first(where: { $0.name == "widgetKey" })?.value, "test-widget-key") + } + + func testAuthenticationStateLoadsExplicitProviderOnlyOnce() 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") + } + _ = try await client.authenticationState(accountName: "test@company.com", password: nil) + let invocationCount = await invocationCounter.value + XCTAssertEqual(invocationCount, 1) + } + func testClientValidateFederatedTokenSucceeds() async throws { let client = Client(urlSession: MockURLProtocol.session { request in if request.url?.absoluteString.contains("federate/validate") == true { @@ -313,6 +685,42 @@ final class XcodesLoginKitTests: XCTestCase { } private extension XcodesLoginKitTests { + 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 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: headers + )! + ) + } + 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 +799,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