diff --git a/Sources/XcodesLoginKit/Client.swift b/Sources/XcodesLoginKit/Client.swift index bed1be4..8c78c9d 100644 --- a/Sources/XcodesLoginKit/Client.swift +++ b/Sources/XcodesLoginKit/Client.swift @@ -79,8 +79,7 @@ public final class Client: Sendable { let a = clientKeys.public - let serviceKeyResponse: ServiceKeyResponse = try await networkService.requestObject(URLRequest.itcServiceKey) - let serviceKey = serviceKeyResponse.authServiceKey + let serviceKey = try await fetchServiceKey() // Fixes issue https://github.com/RobotsAndPencils/XcodesApp/issues/360 // On 2023-02-23, Apple added a custom implementation of hashcash to their auth flow @@ -179,6 +178,25 @@ public final class Client: Sendable { return AuthenticationState.waitingForSecondFactor(option, authOptions, sessionData) } + /// Fetches the service key Apple requires as `X-Apple-Widget-Key` on every auth request. + /// + /// Apple stopped serving ``URL/itcServiceKey`` on 2026-09-10. That endpoint is still tried + /// first, so a restored endpoint needs no change here; otherwise the key is read from the + /// `widgetKey` value embedded in the Developer portal sign-in page. + private func fetchServiceKey() async throws -> String { + if let response: ServiceKeyResponse = try? await networkService.requestObject(URLRequest.itcServiceKey) { + return response.authServiceKey + } + + let result: (Data, URLResponse) = try await networkService.requestData(URLRequest.developerPortalSignInPage, validators: []) + + guard let html = String(data: result.0, encoding: .utf8), + let match = html.firstMatch(of: /"widgetKey"\s*:\s*"([0-9a-f]{32,64})"/) else { + throw AuthenticationError.invalidResult(resultString: "Could not determine Apple's authentication service key.") + } + return String(match.1) + } + private func loadHashcash(accountName: String, serviceKey: String) async throws -> String { let result: (Data, URLResponse) = try await networkService.requestData(URLRequest.federate(account: accountName, serviceKey: serviceKey), validators: []) @@ -217,8 +235,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 fetchServiceKey() + 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..e47cdbc 100644 --- a/Sources/XcodesLoginKit/URLRequest+Apple.swift +++ b/Sources/XcodesLoginKit/URLRequest+Apple.swift @@ -17,6 +17,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")! @@ -29,6 +30,10 @@ public extension URLRequest { return URLRequest(url: .itcServiceKey) } + static var developerPortalSignInPage: URLRequest { + return URLRequest(url: .developerPortalSignInPage) + } + static func signIn(serviceKey: String, accountName: String, password: String, hashcash: String) -> URLRequest { struct Body: Encodable { let accountName: String diff --git a/Tests/XcodesLoginKitTests/XcodesLoginKitTests.swift b/Tests/XcodesLoginKitTests/XcodesLoginKitTests.swift index a356692..71a4a80 100644 --- a/Tests/XcodesLoginKitTests/XcodesLoginKitTests.swift +++ b/Tests/XcodesLoginKitTests/XcodesLoginKitTests.swift @@ -220,6 +220,62 @@ final class XcodesLoginKitTests: XCTestCase { XCTAssertNil(response.idpURL) } + func testClientFallsBackToSignInPageWhenServiceKeyEndpointFails() async throws { + let widgetKeyRecorder = HeaderRecorder() + let client = Client(urlSession: MockURLProtocol.session { request in + switch request.url { + case .itcServiceKey: + return Self.emptyResponse(for: request, statusCode: 404) + case .developerPortalSignInPage: + return try Self.signInPageResponse(for: request) + case .federate: + widgetKeyRecorder.record(request.value(forHTTPHeaderField: "X-Apple-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) + } + }) + + let response = try await client.checkIsFederated(accountName: "test@example.com") + + XCTAssertFalse(response.federated) + XCTAssertEqual(widgetKeyRecorder.value, Self.signInPageWidgetKey) + } + + func testClientPrefersServiceKeyEndpointWhenAvailable() 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 .developerPortalSignInPage: + XCTFail("Should not fall back while the service key endpoint works") + return Self.emptyResponse(for: request, statusCode: 500) + 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 response = try await client.checkIsFederated(accountName: "test@example.com") + + XCTAssertFalse(response.federated) + } + func testClientValidateFederatedTokenSucceeds() async throws { let client = Client(urlSession: MockURLProtocol.session { request in if request.url?.absoluteString.contains("federate/validate") == true { @@ -327,6 +383,21 @@ 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 emptyResponse(for request: URLRequest, statusCode: Int) -> (Data, HTTPURLResponse) { ( Data(), @@ -391,6 +462,18 @@ private final class URLRecorder: Sendable { } } +private final class HeaderRecorder: Sendable { + private let storedValue = OSAllocatedUnfairLock