From e80646f7591ad76878d173ea9fb703c944d5be45 Mon Sep 17 00:00:00 2001 From: sakiyamaK Date: Fri, 11 Sep 2026 18:21:10 +0900 Subject: [PATCH] fix: fall back to the sign-in page when Apple's service key endpoint returns 404 Apple stopped serving olympus/v1/app/config on 2026-09-10. Client uses it to obtain the X-Apple-Widget-Key that every subsequent authentication request needs, and fetchServiceKey had no fallback, so the 404 surfaced to the user before any credentials were sent. The App Store Connect endpoint is still tried first, so a restored endpoint needs no change here. When it fails, the key is read from the widgetKey value embedded in the Developer portal sign-in page. Co-Authored-By: Claude Opus 5 (1M context) --- Sources/XcodesLoginKit/Client.swift | 26 +++++- Sources/XcodesLoginKit/URLRequest+Apple.swift | 5 ++ .../XcodesLoginKitTests.swift | 83 +++++++++++++++++++ 3 files changed, 110 insertions(+), 4 deletions(-) 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(initialState: nil) + + var value: String? { + storedValue.withLock { $0 } + } + + func record(_ value: String?) { + storedValue.withLock { $0 = value } + } +} + private final class AppleSessionRecorder: Sendable { enum LoginOutcome: Sendable { case success