Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions Sources/XcodesLoginKit/Client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: [])
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions Sources/XcodesLoginKit/URLRequest+Apple.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")!
Expand All @@ -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
Expand Down
83 changes: 83 additions & 0 deletions Tests/XcodesLoginKitTests/XcodesLoginKitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -327,6 +383,21 @@ private extension XcodesLoginKitTests {
)
}

static let signInPageWidgetKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"

static func signInPageResponse(for request: URLRequest) throws -> (Data, HTTPURLResponse) {
let html = #"<html><head><script>var config = {"widgetKey":"\#(signInPageWidgetKey)","rv":1};</script></head></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(),
Expand Down Expand Up @@ -391,6 +462,18 @@ private final class URLRecorder: Sendable {
}
}

private final class HeaderRecorder: Sendable {
private let storedValue = OSAllocatedUnfairLock<String?>(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
Expand Down