From 623875aacf0ab700b4010b73aaeeac500483af88 Mon Sep 17 00:00:00 2001 From: Dalton Claybrook Date: Fri, 27 Feb 2026 16:42:17 -0500 Subject: [PATCH 1/6] Initial implementation of federated auth --- Sources/AppleAPI/Client.swift | 91 ++++++++++++++++- Sources/AppleAPI/URLRequest+Apple.swift | 32 ++++++ Sources/XcodesKit/AppleSessionService.swift | 103 ++++++++++++++------ Sources/XcodesKit/Environment.swift | 56 +++++++++++ Tests/XcodesKitTests/Environment+Mock.swift | 9 +- 5 files changed, 258 insertions(+), 33 deletions(-) diff --git a/Sources/AppleAPI/Client.swift b/Sources/AppleAPI/Client.swift index 06386cbe..9d5d1d0b 100644 --- a/Sources/AppleAPI/Client.swift +++ b/Sources/AppleAPI/Client.swift @@ -26,7 +26,8 @@ public class Client { case accountUsesHardwareKey case srpInvalidPublicKey case srpError(String) - + case federatedAuthenticationRequired + public var errorDescription: String? { switch self { case .invalidUsernameOrPassword(let username): @@ -47,6 +48,8 @@ public class Client { return "Expected security code info but didn't receive any." case .accountUsesHardwareKey: return "Account uses a hardware key for authentication but this is not supported yet." + case .federatedAuthenticationRequired: + return "This account uses federated authentication (e.g. Apple Business Manager). Browser-based login is required." default: return String(describing: self) } @@ -81,10 +84,10 @@ public class Client { struct ServiceKeyResponse: Decodable { let authServiceKey: String? } - + let response = try JSONDecoder().decode(ServiceKeyResponse.self, from: data) serviceKey = response.authServiceKey - + /// Load a hashcash of the account name return self.loadHashcash(accountName: accountName, serviceKey: serviceKey).map { (serviceKey, $0) } } @@ -348,6 +351,50 @@ public class Client { } } + public func checkFederation(accountName: String, serviceKey: String) -> Promise { + return Current.network.dataTask(with: URLRequest.checkFederation(serviceKey: serviceKey, accountName: accountName)) + .map { data, _ in + try JSONDecoder().decode(FederationResponse.self, from: data) + } + } + + /// Fetches the service key and checks whether the account uses federated authentication. + /// Call this before prompting for a password to avoid unnecessary password prompts for federated accounts. + public func checkIsFederated(accountName: String) -> Promise { + return firstly { () -> Promise<(data: Data, response: URLResponse)> in + Current.network.dataTask(with: URLRequest.itcServiceKey) + } + .then { (data, _) -> Promise in + struct ServiceKeyResponse: Decodable { + let authServiceKey: String? + } + let response = try JSONDecoder().decode(ServiceKeyResponse.self, from: data) + guard let serviceKey = response.authServiceKey else { + return .value(FederationResponse(federated: false)) + } + return self.checkFederation(accountName: accountName, serviceKey: serviceKey) + } + } + + /// Validates a federated authentication token by calling Apple's /federate/validate endpoint. + /// This exchanges the token and relay state from the IdP callback URL for session cookies. + public func validateFederatedToken(widgetKey: String, token: String, relayState: String) -> Promise { + return Current.network.dataTask(with: URLRequest.federateValidate(widgetKey: widgetKey, token: token, relayState: relayState)) + .then { (data, response) -> Promise in + let httpResponse = response as! HTTPURLResponse + switch httpResponse.statusCode { + case 200...299: + return Current.network.dataTask(with: URLRequest.olympusSession).asVoid() + case 409: + // May need 2FA even after federated login + let serviceKey = widgetKey + return self.handleTwoStepOrFactor(data: data, response: response, serviceKey: serviceKey) + default: + throw Error.unexpectedSignInResponse(statusCode: httpResponse.statusCode, message: nil) + } + } + } + // 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 @@ -516,3 +563,41 @@ public struct ServerSRPInitResponse: Decodable { let b: String let c: String } + +public struct FederationResponse: Decodable, Equatable { + public let federated: Bool + public let showFederatedIdpConfirmation: Bool? + public let federatedIdpRequest: FederatedIdpRequest? + public let federatedAuthIntro: FederatedAuthIntro? + + public init(federated: Bool, showFederatedIdpConfirmation: Bool? = nil, federatedIdpRequest: FederatedIdpRequest? = nil, federatedAuthIntro: FederatedAuthIntro? = nil) { + self.federated = federated + self.showFederatedIdpConfirmation = showFederatedIdpConfirmation + self.federatedIdpRequest = federatedIdpRequest + self.federatedAuthIntro = federatedAuthIntro + } + + /// Builds the full IdP URL with request parameters as query items. + public var idpURL: URL? { + guard let idpRequest = federatedIdpRequest else { return nil } + var components = URLComponents(string: idpRequest.idPUrl) + components?.queryItems = idpRequest.requestParams.map { key, value in + URLQueryItem(name: key, value: value) + } + return components?.url + } +} + +public struct FederatedIdpRequest: Decodable, Equatable { + public let idPUrl: String + public let requestParams: [String: String] + public let httpMethod: String? +} + +public struct FederatedAuthIntro: Decodable, Equatable { + public let orgName: String? + public let idpName: String? + public let idpUrl: String? + public let orgType: String? + public let accountManagementUrl: String? +} diff --git a/Sources/AppleAPI/URLRequest+Apple.swift b/Sources/AppleAPI/URLRequest+Apple.swift index 47704efa..afd22793 100644 --- a/Sources/AppleAPI/URLRequest+Apple.swift +++ b/Sources/AppleAPI/URLRequest+Apple.swift @@ -12,6 +12,8 @@ extension URL { static let srpInit = URL(string: "https://idmsa.apple.com/appleauth/auth/signin/init")! static let srpComplete = URL(string: "https://idmsa.apple.com/appleauth/auth/signin/complete?isRememberMeEnabled=false")! + static let federateCheck = URL(string: "https://idmsa.apple.com/appleauth/auth/federate")! + static let federateValidate = URL(string: "https://idmsa.apple.com/appleauth/auth/federate/validate")! } extension URLRequest { @@ -133,6 +135,36 @@ extension URLRequest { return request } + static func checkFederation(serviceKey: String, accountName: String) -> URLRequest { + struct Body: Encodable { + let accountName: String + let rememberMe = true + } + + var request = URLRequest(url: .federateCheck) + request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:] + request.allHTTPHeaderFields?["Content-Type"] = "application/json" + request.allHTTPHeaderFields?["X-Requested-With"] = "XMLHttpRequest" + request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey + request.allHTTPHeaderFields?["Accept"] = "application/json" + request.httpMethod = "POST" + request.httpBody = try! JSONEncoder().encode(Body(accountName: accountName)) + return request + } + + static func federateValidate(widgetKey: String, token: String, relayState: String) -> URLRequest { + var components = URLComponents(url: .federateValidate, resolvingAgainstBaseURL: false)! + components.queryItems = [ + URLQueryItem(name: "widgetKey", value: widgetKey), + URLQueryItem(name: "token", value: token), + URLQueryItem(name: "relayState", value: relayState), + ] + var request = URLRequest(url: components.url!) + request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:] + request.allHTTPHeaderFields?["Accept"] = "application/json" + return request + } + static func SRPInit(serviceKey: String, a: String, accountName: String) -> URLRequest { struct ServerSRPInitRequest: Encodable { public let a: String diff --git a/Sources/XcodesKit/AppleSessionService.swift b/Sources/XcodesKit/AppleSessionService.swift index 6fe57668..dce916b2 100644 --- a/Sources/XcodesKit/AppleSessionService.swift +++ b/Sources/XcodesKit/AppleSessionService.swift @@ -51,34 +51,41 @@ public class AppleSessionService { } guard let username = possibleUsername else { throw Error.missingUsernameOrPassword } - let passwordPrompt: String - if hasPromptedForUsername { - passwordPrompt = "Apple ID Password: " - } else { - // If the user wasn't prompted for their username, also explain which Apple ID password they need to enter - passwordPrompt = "Apple ID Password (\(username)): " - } - var possiblePassword = self.findPassword(withUsername: username) - if possiblePassword == nil || shouldPromptForPassword { - possiblePassword = Current.shell.readSecureLine(prompt: passwordPrompt) - } - guard let password = possiblePassword else { throw Error.missingUsernameOrPassword } - - return firstly { () -> Promise in - self.login(username, password: password) - } - .recover { error -> Promise in - Current.logging.log(error.legibleLocalizedDescription.red) - - if case Client.Error.invalidUsernameOrPassword = error { - Current.logging.log("Try entering your password again") - // Prompt for the password next time to avoid being stuck in a loop of using an incorrect XCODES_PASSWORD environment variable - return self.loginIfNeeded(withUsername: username, shouldPromptForPassword: true) + // Check if this account uses federated authentication before prompting for a password + return Current.network.checkIsFederated(accountName: username) + .then { federationResponse -> Promise in + if federationResponse.federated { + return self.handleFederatedLogin(username: username, federationResponse: federationResponse) + } + + // Not federated — proceed with normal password-based login + let passwordPrompt: String + if hasPromptedForUsername { + passwordPrompt = "Apple ID Password: " + } else { + passwordPrompt = "Apple ID Password (\(username)): " + } + var possiblePassword = self.findPassword(withUsername: username) + if possiblePassword == nil || shouldPromptForPassword { + possiblePassword = Current.shell.readSecureLine(prompt: passwordPrompt) + } + guard let password = possiblePassword else { throw Error.missingUsernameOrPassword } + + return firstly { () -> Promise in + self.login(username, password: password) + } + .recover { error -> Promise in + Current.logging.log(error.legibleLocalizedDescription.red) + + if case Client.Error.invalidUsernameOrPassword = error { + Current.logging.log("Try entering your password again") + return self.loginIfNeeded(withUsername: username, shouldPromptForPassword: true) + } + else { + return Promise(error: error) + } + } } - else { - return Promise(error: error) - } - } } } @@ -89,7 +96,7 @@ public class AppleSessionService { .recover { error -> Promise in if let error = error as? Client.Error { - switch error { + switch error { case .invalidUsernameOrPassword(_): // remove any keychain password if we fail to log with an invalid username or password so it doesn't try again. try? Current.keychain.remove(username) @@ -110,6 +117,46 @@ public class AppleSessionService { } } + private func handleFederatedLogin(username: String, federationResponse: FederationResponse) -> Promise { + let orgName = federationResponse.federatedAuthIntro?.orgName ?? "your organization" + let idpName = federationResponse.federatedAuthIntro?.idpName ?? "your Identity Provider" + Current.logging.log("This account uses federated authentication via \(orgName) (\(idpName)).") + Current.logging.log("Opening your browser to sign in...") + + guard let idpURL = federationResponse.idpURL else { + return Promise(error: Client.Error.federatedAuthenticationRequired) + } + + Current.shell.openURL(idpURL) + + Current.logging.log("") + Current.logging.log("After signing in, your browser will redirect to a blank page.") + let callbackURLString = Current.shell.readLongLine(prompt: "Paste the URL from your browser's address bar here: ") + guard let callbackURLString = callbackURLString, + let callbackURL = URL(string: callbackURLString), + let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems else { + return Promise(error: Error.missingUsernameOrPassword) + } + + let widgetKey = queryItems.first(where: { $0.name == "widgetKey" })?.value + let token = queryItems.first(where: { $0.name == "token" })?.value + let relayState = queryItems.first(where: { $0.name == "relayState" })?.value + + guard let widgetKey = widgetKey, let token = token, let relayState = relayState else { + Current.logging.log("The URL is missing required parameters (widgetKey, token, relayState).") + return Promise(error: Client.Error.invalidSession) + } + + return Current.network.validateFederatedToken(widgetKey: widgetKey, token: token, relayState: relayState) + .done { + if self.configuration.defaultUsername != username { + self.configuration.defaultUsername = username + try? self.configuration.save() + } + } + } + public func logout() -> Promise { guard let username = findUsername() else { return Promise(error: Client.Error.notAuthenticated) } diff --git a/Sources/XcodesKit/Environment.swift b/Sources/XcodesKit/Environment.swift index 393e19c9..8fe99966 100644 --- a/Sources/XcodesKit/Environment.swift +++ b/Sources/XcodesKit/Environment.swift @@ -1,3 +1,9 @@ +#if canImport(Darwin) +import Darwin +#else +import Glibc +#endif + import Foundation import PromiseKit import PMKFoundation @@ -174,6 +180,39 @@ public struct Shell { readLine(prompt) } + /// Reads a line from stdin using non-canonical terminal mode, which avoids + /// the `MAX_CANON` line buffer limit (~1024 bytes) that prevents pasting long strings. + public var readLongLine: (String) -> String? = { prompt in + print(prompt, terminator: "") + fflush(stdout) + + let fd = fileno(stdin) + var original = termios() + tcgetattr(fd, &original) + + var raw = original + raw.c_lflag &= ~UInt(ICANON) // Disable canonical mode (removes line buffer limit) + raw.c_lflag |= UInt(ECHO) // Keep echo on so user sees what they paste + raw.c_cc.4 = 1 // VMIN: return after at least 1 byte + raw.c_cc.5 = 0 // VTIME: no timeout + tcsetattr(fd, TCSANOW, &raw) + defer { tcsetattr(fd, TCSANOW, &original) } + + var result = Data() + var byte: UInt8 = 0 + while read(fd, &byte, 1) == 1 { + if byte == 0x0A || byte == 0x0D { // newline or carriage return + break + } + result.append(byte) + } + print("") // Move to next line after input + return String(data: result, encoding: .utf8) + } + public func readLongLine(prompt: String) -> String? { + readLongLine(prompt) + } + public var readSecureLine: (String, Int) -> String? = { prompt, maximumLength in let buffer = UnsafeMutablePointer.allocate(capacity: maximumLength) buffer.initialize(repeating: 0, count: maximumLength) @@ -216,6 +255,13 @@ public struct Shell { public var exit: (Int32) -> Void = { Darwin.exit($0) } public var isatty: () -> Bool = { Foundation.isatty(fileno(stdout)) != 0 } + + public var openURL: (URL) -> Void = { url in + Process.launchedProcess(launchPath: "/usr/bin/open", arguments: [url.absoluteString]) + } + public func openURL(_ url: URL) { + openURL(url) + } } public struct Files { @@ -304,6 +350,16 @@ public struct Network { public func login(accountName: String, password: String) -> Promise { login(accountName, password) } + + public var checkIsFederated: (String) -> Promise = { client.checkIsFederated(accountName: $0) } + public func checkIsFederated(accountName: String) -> Promise { + checkIsFederated(accountName) + } + + public var validateFederatedToken: (String, String, String) -> Promise = { client.validateFederatedToken(widgetKey: $0, token: $1, relayState: $2) } + public func validateFederatedToken(widgetKey: String, token: String, relayState: String) -> Promise { + validateFederatedToken(widgetKey, token, relayState) + } } public struct Logging { diff --git a/Tests/XcodesKitTests/Environment+Mock.swift b/Tests/XcodesKitTests/Environment+Mock.swift index 067f9cec..e256fdb4 100644 --- a/Tests/XcodesKitTests/Environment+Mock.swift +++ b/Tests/XcodesKitTests/Environment+Mock.swift @@ -1,6 +1,7 @@ @testable import XcodesKit import Foundation import PromiseKit +import struct AppleAPI.FederationResponse extension Environment { static var mock = Environment( @@ -40,10 +41,12 @@ extension Shell { xcodeSelectSwitch: { _, _ in return Promise.value(Shell.processOutputMock) }, isRoot: { true }, readLine: { _ in return nil }, + readLongLine: { _ in return nil }, readSecureLine: { _, _ in return nil }, env: { _ in nil }, exit: { _ in }, - isatty: { true } + isatty: { true }, + openURL: { _ in } ) } @@ -80,7 +83,9 @@ extension Network { dataTask: { url in return Promise.value((data: Data(), response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) }, downloadTask: { url, saveLocation, _ in return (Progress(), Promise.value((saveLocation, HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!))) }, validateSession: { Promise() }, - login: { _, _ in Promise() } + login: { _, _ in Promise() }, + checkIsFederated: { _ in Promise.value(AppleAPI.FederationResponse(federated: false)) }, + validateFederatedToken: { _, _, _ in Promise() } ) } From 19edd94a76529d289af5c54cde0ee8e9494546f7 Mon Sep 17 00:00:00 2001 From: Dalton Claybrook Date: Fri, 27 Feb 2026 17:10:17 -0500 Subject: [PATCH 2/6] Improve ergonomics --- Sources/XcodesKit/AppleSessionService.swift | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/Sources/XcodesKit/AppleSessionService.swift b/Sources/XcodesKit/AppleSessionService.swift index dce916b2..36d65e63 100644 --- a/Sources/XcodesKit/AppleSessionService.swift +++ b/Sources/XcodesKit/AppleSessionService.swift @@ -118,20 +118,24 @@ public class AppleSessionService { } private func handleFederatedLogin(username: String, federationResponse: FederationResponse) -> Promise { - let orgName = federationResponse.federatedAuthIntro?.orgName ?? "your organization" - let idpName = federationResponse.federatedAuthIntro?.idpName ?? "your Identity Provider" - Current.logging.log("This account uses federated authentication via \(orgName) (\(idpName)).") - Current.logging.log("Opening your browser to sign in...") - guard let idpURL = federationResponse.idpURL else { return Promise(error: Client.Error.federatedAuthenticationRequired) } + let orgName = federationResponse.federatedAuthIntro?.orgName ?? "your organization" + let idpName = federationResponse.federatedAuthIntro?.idpName + let orgNameWithIdp = idpName.map { "\(orgName) (\($0))" } ?? orgName + + Current.logging.log("\n- This account uses federated authentication via \(orgNameWithIdp).") + Current.logging.log("- Your browser will open to complete sign-in.") + Current.logging.log("- After signing in, you will be redirected to a blank page.") + Current.logging.log("- Copy the URL from your browser's address bar, then return here and paste it.") + + _ = Current.shell.readLine(prompt: "\nPress Return to open your browser... ") + Current.shell.openURL(idpURL) - Current.logging.log("") - Current.logging.log("After signing in, your browser will redirect to a blank page.") - let callbackURLString = Current.shell.readLongLine(prompt: "Paste the URL from your browser's address bar here: ") + let callbackURLString = Current.shell.readLongLine(prompt: "\nPaste the URL here: ") guard let callbackURLString = callbackURLString, let callbackURL = URL(string: callbackURLString), let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false), From 55880a028fbba442dfea31d0841d672eace66d91 Mon Sep 17 00:00:00 2001 From: Dalton Claybrook Date: Fri, 27 Feb 2026 21:17:12 -0500 Subject: [PATCH 3/6] Support any key --- Sources/XcodesKit/AppleSessionService.swift | 11 ++-- Sources/XcodesKit/Environment.swift | 68 ++++++++++++++------- Tests/XcodesKitTests/Environment+Mock.swift | 1 + 3 files changed, 53 insertions(+), 27 deletions(-) diff --git a/Sources/XcodesKit/AppleSessionService.swift b/Sources/XcodesKit/AppleSessionService.swift index 36d65e63..30bac212 100644 --- a/Sources/XcodesKit/AppleSessionService.swift +++ b/Sources/XcodesKit/AppleSessionService.swift @@ -126,13 +126,12 @@ public class AppleSessionService { let idpName = federationResponse.federatedAuthIntro?.idpName let orgNameWithIdp = idpName.map { "\(orgName) (\($0))" } ?? orgName - Current.logging.log("\n- This account uses federated authentication via \(orgNameWithIdp).") - Current.logging.log("- Your browser will open to complete sign-in.") - Current.logging.log("- After signing in, you will be redirected to a blank page.") - Current.logging.log("- Copy the URL from your browser's address bar, then return here and paste it.") - - _ = Current.shell.readLine(prompt: "\nPress Return to open your browser... ") + Current.logging.log("\n- This account uses federated authentication via \(orgNameWithIdp)") + Current.logging.log("- Your browser will open to complete sign-in") + Current.logging.log("- After signing in, you will be redirected to a blank page") + Current.logging.log("- Copy the URL from your browser's address bar, then return here and paste it") + Current.shell.waitForKeypress(prompt: "\nPress any key to open your browser...") Current.shell.openURL(idpURL) let callbackURLString = Current.shell.readLongLine(prompt: "\nPaste the URL here: ") diff --git a/Sources/XcodesKit/Environment.swift b/Sources/XcodesKit/Environment.swift index 8fe99966..a5717e0e 100644 --- a/Sources/XcodesKit/Environment.swift +++ b/Sources/XcodesKit/Environment.swift @@ -182,32 +182,36 @@ public struct Shell { /// Reads a line from stdin using non-canonical terminal mode, which avoids /// the `MAX_CANON` line buffer limit (~1024 bytes) that prevents pasting long strings. - public var readLongLine: (String) -> String? = { prompt in + /// Waits for a single keypress without requiring Return. + public var waitForKeypress: (String) -> Void = { prompt in print(prompt, terminator: "") fflush(stdout) + withRawTerminalMode(echo: false) { + var byte: UInt8 = 0 + _ = read(fileno(stdin), &byte, 1) + print("") + } + } + public func waitForKeypress(prompt: String) { + waitForKeypress(prompt) + } - let fd = fileno(stdin) - var original = termios() - tcgetattr(fd, &original) - - var raw = original - raw.c_lflag &= ~UInt(ICANON) // Disable canonical mode (removes line buffer limit) - raw.c_lflag |= UInt(ECHO) // Keep echo on so user sees what they paste - raw.c_cc.4 = 1 // VMIN: return after at least 1 byte - raw.c_cc.5 = 0 // VTIME: no timeout - tcsetattr(fd, TCSANOW, &raw) - defer { tcsetattr(fd, TCSANOW, &original) } - - var result = Data() - var byte: UInt8 = 0 - while read(fd, &byte, 1) == 1 { - if byte == 0x0A || byte == 0x0D { // newline or carriage return - break + /// Reads a line from stdin using non-canonical terminal mode, which avoids + /// the `MAX_CANON` line buffer limit (~1024 bytes) that prevents pasting long strings. + public var readLongLine: (String) -> String? = { prompt in + print(prompt, terminator: "") + fflush(stdout) + return withRawTerminalMode(echo: true) { + var result = Data() + var byte: UInt8 = 0 + let fd = fileno(stdin) + while read(fd, &byte, 1) == 1 { + if byte == 0x0A || byte == 0x0D { break } + result.append(byte) } - result.append(byte) + print("") + return String(data: result, encoding: .utf8) } - print("") // Move to next line after input - return String(data: result, encoding: .utf8) } public func readLongLine(prompt: String) -> String? { readLongLine(prompt) @@ -264,6 +268,28 @@ public struct Shell { } } +/// Switches the terminal to non-canonical mode, executes the closure, then restores the original settings. +/// Non-canonical mode removes the `MAX_CANON` line buffer limit and allows reading input byte-by-byte. +private func withRawTerminalMode(echo: Bool, _ body: () -> T) -> T { + let fd = fileno(stdin) + var original = termios() + tcgetattr(fd, &original) + + var raw = original + raw.c_lflag &= ~UInt(ICANON) + if echo { + raw.c_lflag |= UInt(ECHO) + } else { + raw.c_lflag &= ~UInt(ECHO) + } + raw.c_cc.4 = 1 // VMIN: return after at least 1 byte + raw.c_cc.5 = 0 // VTIME: no timeout + tcsetattr(fd, TCSANOW, &raw) + defer { tcsetattr(fd, TCSANOW, &original) } + + return body() +} + public struct Files { public var fileExistsAtPath: (String) -> Bool = { FileManager.default.fileExists(atPath: $0) } diff --git a/Tests/XcodesKitTests/Environment+Mock.swift b/Tests/XcodesKitTests/Environment+Mock.swift index e256fdb4..7ca94b93 100644 --- a/Tests/XcodesKitTests/Environment+Mock.swift +++ b/Tests/XcodesKitTests/Environment+Mock.swift @@ -41,6 +41,7 @@ extension Shell { xcodeSelectSwitch: { _, _ in return Promise.value(Shell.processOutputMock) }, isRoot: { true }, readLine: { _ in return nil }, + waitForKeypress: { _ in }, readLongLine: { _ in return nil }, readSecureLine: { _, _ in return nil }, env: { _ in nil }, From 8728751b88a33670d7da577971fb2afa30add5ab Mon Sep 17 00:00:00 2001 From: Dalton Claybrook Date: Fri, 27 Feb 2026 21:22:39 -0500 Subject: [PATCH 4/6] Save and restore cookies --- Sources/XcodesKit/AppleSessionService.swift | 68 +++++++++++++++++++++ Sources/XcodesKit/Path+.swift | 1 + 2 files changed, 69 insertions(+) diff --git a/Sources/XcodesKit/AppleSessionService.swift b/Sources/XcodesKit/AppleSessionService.swift index 30bac212..47162213 100644 --- a/Sources/XcodesKit/AppleSessionService.swift +++ b/Sources/XcodesKit/AppleSessionService.swift @@ -1,6 +1,7 @@ import PromiseKit import Foundation import AppleAPI +import Path public class AppleSessionService { @@ -38,6 +39,9 @@ public class AppleSessionService { } func loginIfNeeded(withUsername providedUsername: String? = nil, shouldPromptForPassword: Bool = false) -> Promise { + // Restore any previously saved session cookies before validating + loadSessionCookies() + return firstly { () -> Promise in return Current.network.validateSession() } @@ -153,6 +157,8 @@ public class AppleSessionService { return Current.network.validateFederatedToken(widgetKey: widgetKey, token: token, relayState: relayState) .done { + self.saveSessionCookies() + if self.configuration.defaultUsername != username { self.configuration.defaultUsername = username try? self.configuration.save() @@ -160,6 +166,65 @@ public class AppleSessionService { } } + // MARK: - Session Cookie Persistence + + private struct SerializableCookie: Codable { + let name: String + let value: String + let domain: String + let path: String + let isSecure: Bool + let expiresDate: Date? + + init(cookie: HTTPCookie) { + self.name = cookie.name + self.value = cookie.value + self.domain = cookie.domain + self.path = cookie.path + self.isSecure = cookie.isSecure + self.expiresDate = cookie.expiresDate + } + + var httpCookie: HTTPCookie? { + var properties: [HTTPCookiePropertyKey: Any] = [ + .name: name, + .value: value, + .domain: domain, + .path: path, + .secure: isSecure ? "TRUE" : "FALSE", + ] + if let expiresDate = expiresDate { + properties[.expires] = expiresDate + } + return HTTPCookie(properties: properties) + } + } + + private func saveSessionCookies() { + let appleDomains = [".apple.com", ".idmsa.apple.com", "appstoreconnect.apple.com"] + let cookies = AppleAPI.Current.network.session.configuration.httpCookieStorage?.cookies ?? [] + let relevantCookies = cookies.filter { cookie in + appleDomains.contains(where: { cookie.domain.hasSuffix($0) }) + } + guard !relevantCookies.isEmpty else { return } + + let serializable = relevantCookies.map(SerializableCookie.init) + if let data = try? JSONEncoder().encode(serializable) { + try? Current.files.write(data, Path.sessionCookiesFile.url) + } + } + + private func loadSessionCookies() { + guard let data = Current.files.contents(atPath: Path.sessionCookiesFile.string) else { return } + guard let serialized = try? JSONDecoder().decode([SerializableCookie].self, from: data) else { return } + + let cookieStorage = AppleAPI.Current.network.session.configuration.httpCookieStorage + for cookie in serialized { + if let expired = cookie.expiresDate, expired < Date() { continue } + cookie.httpCookie.map { cookieStorage?.setCookie($0) } + } + } + public func logout() -> Promise { guard let username = findUsername() else { return Promise(error: Client.Error.notAuthenticated) } @@ -173,6 +238,9 @@ public class AppleSessionService { // Remove all keychain items try Current.keychain.remove(username) + // Remove saved session cookies + try? Current.files.removeItem(Path.sessionCookiesFile.url) + // Set `defaultUsername` in Configuration to nil self.configuration.defaultUsername = nil try self.configuration.save() diff --git a/Sources/XcodesKit/Path+.swift b/Sources/XcodesKit/Path+.swift index eba6dd73..d73d650a 100644 --- a/Sources/XcodesKit/Path+.swift +++ b/Sources/XcodesKit/Path+.swift @@ -13,6 +13,7 @@ extension Path { static let xcodesCaches = environmentCaches/"com.robotsandpencils.xcodes" static let cacheFile = xcodesApplicationSupport/"available-xcodes.json" static let configurationFile = xcodesApplicationSupport/"configuration.json" + static let sessionCookiesFile = xcodesApplicationSupport/"session-cookies.json" @discardableResult func setCurrentUserAsOwner() -> Path { From a2759eb301d5a94218f60f8196c4ad0cdeb7be37 Mon Sep 17 00:00:00 2001 From: Dalton Claybrook Date: Fri, 27 Feb 2026 22:31:15 -0500 Subject: [PATCH 5/6] Add tests for federated login --- Sources/AppleAPI/Client.swift | 14 ++ Tests/AppleAPITests/AppleAPITests.swift | 144 ++++++++++++ .../FederateCheck.json | 24 ++ .../FederateCheckNonFederated.json | 3 + .../ITCServiceKey.json | 4 + .../OlympusSession.json | 8 + Tests/XcodesKitTests/XcodesKitTests.swift | 207 ++++++++++++++++++ 7 files changed, 404 insertions(+) create mode 100644 Tests/AppleAPITests/Fixtures/Login_Federated_Succeeds/FederateCheck.json create mode 100644 Tests/AppleAPITests/Fixtures/Login_Federated_Succeeds/FederateCheckNonFederated.json create mode 100644 Tests/AppleAPITests/Fixtures/Login_Federated_Succeeds/ITCServiceKey.json create mode 100644 Tests/AppleAPITests/Fixtures/Login_Federated_Succeeds/OlympusSession.json diff --git a/Sources/AppleAPI/Client.swift b/Sources/AppleAPI/Client.swift index 9d5d1d0b..94e23c2c 100644 --- a/Sources/AppleAPI/Client.swift +++ b/Sources/AppleAPI/Client.swift @@ -592,6 +592,12 @@ public struct FederatedIdpRequest: Decodable, Equatable { public let idPUrl: String public let requestParams: [String: String] public let httpMethod: String? + + public init(idPUrl: String, requestParams: [String: String], httpMethod: String?) { + self.idPUrl = idPUrl + self.requestParams = requestParams + self.httpMethod = httpMethod + } } public struct FederatedAuthIntro: Decodable, Equatable { @@ -600,4 +606,12 @@ public struct FederatedAuthIntro: Decodable, Equatable { public let idpUrl: String? public let orgType: String? public let accountManagementUrl: String? + + public init(orgName: String?, idpName: String?, idpUrl: String?, orgType: String?, accountManagementUrl: String?) { + self.orgName = orgName + self.idpName = idpName + self.idpUrl = idpUrl + self.orgType = orgType + self.accountManagementUrl = accountManagementUrl + } } diff --git a/Tests/AppleAPITests/AppleAPITests.swift b/Tests/AppleAPITests/AppleAPITests.swift index 06c7f36f..561d2d2c 100644 --- a/Tests/AppleAPITests/AppleAPITests.swift +++ b/Tests/AppleAPITests/AppleAPITests.swift @@ -751,6 +751,150 @@ final class AppleAPITests: XCTestCase { } + func test_CheckFederation_FederatedAccount() { + Current.network.dataTask = { convertible in + switch convertible.pmkRequest.url! { + case .itcServiceKey: + return fixture(for: .itcServiceKey, + fileURL: Bundle.module.url(forResource: "ITCServiceKey", withExtension: "json", subdirectory: "Fixtures/Login_Federated_Succeeds")!, + statusCode: 200, + headers: ["Content-Type": "application/json"]) + case .federateCheck: + return fixture(for: .federateCheck, + fileURL: Bundle.module.url(forResource: "FederateCheck", withExtension: "json", subdirectory: "Fixtures/Login_Federated_Succeeds")!, + statusCode: 200, + headers: ["Content-Type": "application/json"]) + default: + XCTFail("Unexpected request to \(convertible.pmkRequest.url!)") + return .init(error: PMKError.invalidCallingConvention) + } + } + + let expectation = self.expectation(description: "promise fulfills") + + let client = Client() + client.checkIsFederated(accountName: "test@company.com") + .tap { result in + guard case .fulfilled(let response) = result else { + XCTFail("checkIsFederated rejected") + return + } + XCTAssertTrue(response.federated) + XCTAssertEqual(response.federatedAuthIntro?.orgName, "Test Corp") + XCTAssertEqual(response.federatedAuthIntro?.idpName, "Microsoft Entra") + XCTAssertEqual(response.federatedIdpRequest?.idPUrl, "https://login.microsoftonline.com/test-tenant/oauth2/authorize") + XCTAssertEqual(response.federatedIdpRequest?.requestParams["login_hint"], "test@company.com") + XCTAssertNotNil(response.idpURL) + expectation.fulfill() + } + .cauterize() + + wait(for: [expectation], timeout: 1.0) + } + + func test_CheckFederation_NonFederatedAccount() { + Current.network.dataTask = { convertible in + switch convertible.pmkRequest.url! { + case .itcServiceKey: + return fixture(for: .itcServiceKey, + fileURL: Bundle.module.url(forResource: "ITCServiceKey", withExtension: "json", subdirectory: "Fixtures/Login_Federated_Succeeds")!, + statusCode: 200, + headers: ["Content-Type": "application/json"]) + case .federateCheck: + return fixture(for: .federateCheck, + fileURL: Bundle.module.url(forResource: "FederateCheckNonFederated", withExtension: "json", subdirectory: "Fixtures/Login_Federated_Succeeds")!, + statusCode: 200, + headers: ["Content-Type": "application/json"]) + default: + XCTFail("Unexpected request to \(convertible.pmkRequest.url!)") + return .init(error: PMKError.invalidCallingConvention) + } + } + + let expectation = self.expectation(description: "promise fulfills") + + let client = Client() + client.checkIsFederated(accountName: "test@example.com") + .tap { result in + guard case .fulfilled(let response) = result else { + XCTFail("checkIsFederated rejected") + return + } + XCTAssertFalse(response.federated) + XCTAssertNil(response.federatedIdpRequest) + XCTAssertNil(response.federatedAuthIntro) + XCTAssertNil(response.idpURL) + expectation.fulfill() + } + .cauterize() + + wait(for: [expectation], timeout: 1.0) + } + + func test_ValidateFederatedToken_Succeeds() { + Current.network.dataTask = { convertible in + let url = convertible.pmkRequest.url! + if url.absoluteString.contains("federate/validate") { + return fixture(for: url, + statusCode: 200, + headers: ["Content-Type": "application/json"]) + } else if url == .olympusSession { + return fixture(for: .olympusSession, + fileURL: Bundle.module.url(forResource: "OlympusSession", withExtension: "json", subdirectory: "Fixtures/Login_Federated_Succeeds")!, + statusCode: 200, + headers: ["Content-Type": "application/json"]) + } else { + XCTFail("Unexpected request to \(url)") + return .init(error: PMKError.invalidCallingConvention) + } + } + + let expectation = self.expectation(description: "promise fulfills") + + let client = Client() + client.validateFederatedToken(widgetKey: "test-widget-key", token: "test-token", relayState: "test-relay-state") + .tap { result in + guard case .fulfilled = result else { + XCTFail("validateFederatedToken rejected") + return + } + expectation.fulfill() + } + .cauterize() + + wait(for: [expectation], timeout: 1.0) + } + + func test_ValidateFederatedToken_UnexpectedStatusCode() { + Current.network.dataTask = { convertible in + let url = convertible.pmkRequest.url! + if url.absoluteString.contains("federate/validate") { + return fixture(for: url, + statusCode: 401, + headers: ["Content-Type": "application/json"]) + } else { + XCTFail("Unexpected request to \(url)") + return .init(error: PMKError.invalidCallingConvention) + } + } + + let expectation = self.expectation(description: "promise rejects") + + let client = Client() + client.validateFederatedToken(widgetKey: "test-widget-key", token: "test-token", relayState: "test-relay-state") + .tap { result in + guard case .rejected(let error as AppleAPI.Client.Error) = result else { + XCTFail("validateFederatedToken fulfilled, but should have rejected") + return + } + XCTAssertEqual(error, AppleAPI.Client.Error.unexpectedSignInResponse(statusCode: 401, message: nil)) + expectation.fulfill() + } + .cauterize() + + wait(for: [expectation], timeout: 1.0) + } + func testValidHashCashMint() { let bits: UInt = 11 let resource = "4d74fb15eb23f465f1f6fcbf534e5877" diff --git a/Tests/AppleAPITests/Fixtures/Login_Federated_Succeeds/FederateCheck.json b/Tests/AppleAPITests/Fixtures/Login_Federated_Succeeds/FederateCheck.json new file mode 100644 index 00000000..5fa30f79 --- /dev/null +++ b/Tests/AppleAPITests/Fixtures/Login_Federated_Succeeds/FederateCheck.json @@ -0,0 +1,24 @@ +{ + "federated" : true, + "showFederatedIdpConfirmation" : false, + "federatedIdpRequest" : { + "idPUrl" : "https://login.microsoftonline.com/test-tenant/oauth2/authorize", + "requestParams" : { + "login_hint" : "test@company.com", + "scope" : "openid", + "response_type" : "code", + "redirect_uri" : "https://idmsa.apple.com/IDMSWebAuth/federate/oidc/callback", + "state" : "test-relay-state", + "nonce" : "test-nonce", + "client_id" : "test-client-id" + }, + "httpMethod" : "GET" + }, + "federatedAuthIntro" : { + "orgName" : "Test Corp", + "idpName" : "Microsoft Entra", + "idpUrl" : "https://login.microsoftonline.com", + "orgType" : "enterprise", + "accountManagementUrl" : "https://business.apple.com" + } +} diff --git a/Tests/AppleAPITests/Fixtures/Login_Federated_Succeeds/FederateCheckNonFederated.json b/Tests/AppleAPITests/Fixtures/Login_Federated_Succeeds/FederateCheckNonFederated.json new file mode 100644 index 00000000..56991d0a --- /dev/null +++ b/Tests/AppleAPITests/Fixtures/Login_Federated_Succeeds/FederateCheckNonFederated.json @@ -0,0 +1,3 @@ +{ + "federated" : false +} diff --git a/Tests/AppleAPITests/Fixtures/Login_Federated_Succeeds/ITCServiceKey.json b/Tests/AppleAPITests/Fixtures/Login_Federated_Succeeds/ITCServiceKey.json new file mode 100644 index 00000000..33c00bf8 --- /dev/null +++ b/Tests/AppleAPITests/Fixtures/Login_Federated_Succeeds/ITCServiceKey.json @@ -0,0 +1,4 @@ +{ + "authServiceUrl" : "https://idmsa.apple.com/appleauth", + "authServiceKey" : "NNNNN" +} diff --git a/Tests/AppleAPITests/Fixtures/Login_Federated_Succeeds/OlympusSession.json b/Tests/AppleAPITests/Fixtures/Login_Federated_Succeeds/OlympusSession.json new file mode 100644 index 00000000..3c8e2231 --- /dev/null +++ b/Tests/AppleAPITests/Fixtures/Login_Federated_Succeeds/OlympusSession.json @@ -0,0 +1,8 @@ +{ + "user" : { + "fullName" : "Test User", + "firstName" : "Test", + "lastName" : "User", + "emailAddress" : "test@company.com" + } +} diff --git a/Tests/XcodesKitTests/XcodesKitTests.swift b/Tests/XcodesKitTests/XcodesKitTests.swift index 1282d926..49c79e9e 100644 --- a/Tests/XcodesKitTests/XcodesKitTests.swift +++ b/Tests/XcodesKitTests/XcodesKitTests.swift @@ -1400,6 +1400,213 @@ final class XcodesKitTests: XCTestCase { XCTAssertEqual(capturedError as? Client.Error, Client.Error.notAuthenticated) } + // MARK: - Federated Authentication Tests + + func test_LoginIfNeeded_FederatedAccount_SkipsPasswordPrompt() { + var log = "" + XcodesKit.Current.logging.log = { log.append($0 + "\n") } + + var passwordPrompted = false + XcodesKit.Current.shell.readSecureLine = { _, _ in + passwordPrompted = true + return "password" + } + + var openedURL: URL? + XcodesKit.Current.shell.openURL = { url in + openedURL = url + } + + XcodesKit.Current.shell.waitForKeypress = { _ in } + + // Simulate pasting the callback URL + XcodesKit.Current.shell.readLongLine = { _ in + return "https://idmsa.apple.com/IDMSWebAuth/federate/oidc/callback?widgetKey=test-widget-key&token=test-token&relayState=test-relay-state" + } + + // Session validation should fail (not logged in) + XcodesKit.Current.network.validateSession = { Promise(error: AppleSessionService.Error.missingUsernameOrPassword) } + + // Federation check returns federated + XcodesKit.Current.network.checkIsFederated = { _ in + Promise.value(AppleAPI.FederationResponse( + federated: true, + federatedIdpRequest: AppleAPI.FederatedIdpRequest( + idPUrl: "https://login.microsoftonline.com/test-tenant/oauth2/authorize", + requestParams: ["login_hint": "test@company.com"], + httpMethod: "GET" + ), + federatedAuthIntro: AppleAPI.FederatedAuthIntro( + orgName: "Test Corp", + idpName: "Microsoft Entra", + idpUrl: nil, + orgType: nil, + accountManagementUrl: nil + ) + )) + } + + XcodesKit.Current.network.validateFederatedToken = { _, _, _ in Promise() } + + var customConfig = Configuration() + customConfig.defaultUsername = "test@company.com" + let customService = AppleSessionService(configuration: customConfig) + + let expectation = self.expectation(description: "Login completes") + + customService.loginIfNeeded() + .tap { result in + guard case .fulfilled = result else { + XCTFail("loginIfNeeded rejected: \(result)") + return + } + expectation.fulfill() + } + .cauterize() + + wait(for: [expectation], timeout: 1.0) + + XCTAssertFalse(passwordPrompted, "Password should not be prompted for federated accounts") + XCTAssertNotNil(openedURL, "Browser should be opened for federated auth") + XCTAssertTrue(log.contains("federated authentication")) + XCTAssertTrue(log.contains("Test Corp")) + XCTAssertTrue(log.contains("Microsoft Entra")) + } + + func test_LoginIfNeeded_NonFederatedAccount_PromptsPassword() { + var passwordPrompted = false + XcodesKit.Current.shell.readSecureLine = { _, _ in + passwordPrompted = true + return "password123" + } + + var openedURL: URL? + XcodesKit.Current.shell.openURL = { url in + openedURL = url + } + + // Session validation should fail (not logged in) + XcodesKit.Current.network.validateSession = { Promise(error: AppleSessionService.Error.missingUsernameOrPassword) } + + // Federation check returns non-federated + XcodesKit.Current.network.checkIsFederated = { _ in + Promise.value(AppleAPI.FederationResponse(federated: false)) + } + + // Login succeeds + XcodesKit.Current.network.login = { _, _ in Promise() } + + var customConfig = Configuration() + customConfig.defaultUsername = "test@example.com" + let customService = AppleSessionService(configuration: customConfig) + + let expectation = self.expectation(description: "Login completes") + + customService.loginIfNeeded() + .tap { result in + guard case .fulfilled = result else { + XCTFail("loginIfNeeded rejected: \(result)") + return + } + expectation.fulfill() + } + .cauterize() + + wait(for: [expectation], timeout: 1.0) + + XCTAssertTrue(passwordPrompted, "Password should be prompted for non-federated accounts") + XCTAssertNil(openedURL, "Browser should not be opened for non-federated accounts") + } + + func test_LoginIfNeeded_FederatedAccount_InvalidCallbackURL() { + XcodesKit.Current.logging.log = { _ in } + + XcodesKit.Current.shell.waitForKeypress = { _ in } + XcodesKit.Current.shell.openURL = { _ in } + + // Simulate pasting an invalid URL + XcodesKit.Current.shell.readLongLine = { _ in + return "not-a-valid-url" + } + + XcodesKit.Current.network.validateSession = { Promise(error: AppleSessionService.Error.missingUsernameOrPassword) } + + XcodesKit.Current.network.checkIsFederated = { _ in + Promise.value(AppleAPI.FederationResponse( + federated: true, + federatedIdpRequest: AppleAPI.FederatedIdpRequest( + idPUrl: "https://login.microsoftonline.com/test-tenant/oauth2/authorize", + requestParams: ["login_hint": "test@company.com"], + httpMethod: "GET" + ) + )) + } + + var customConfig = Configuration() + customConfig.defaultUsername = "test@company.com" + let customService = AppleSessionService(configuration: customConfig) + + let expectation = self.expectation(description: "Login fails") + + customService.loginIfNeeded() + .tap { result in + guard case .rejected = result else { + XCTFail("loginIfNeeded should have rejected for invalid URL") + return + } + expectation.fulfill() + } + .cauterize() + + wait(for: [expectation], timeout: 1.0) + } + + func test_LoginIfNeeded_FederatedAccount_MissingCallbackParams() { + var log = "" + XcodesKit.Current.logging.log = { log.append($0 + "\n") } + + XcodesKit.Current.shell.waitForKeypress = { _ in } + XcodesKit.Current.shell.openURL = { _ in } + + // URL with missing required params + XcodesKit.Current.shell.readLongLine = { _ in + return "https://idmsa.apple.com/callback?widgetKey=key-only" + } + + XcodesKit.Current.network.validateSession = { Promise(error: AppleSessionService.Error.missingUsernameOrPassword) } + + XcodesKit.Current.network.checkIsFederated = { _ in + Promise.value(AppleAPI.FederationResponse( + federated: true, + federatedIdpRequest: AppleAPI.FederatedIdpRequest( + idPUrl: "https://login.microsoftonline.com/test-tenant/oauth2/authorize", + requestParams: ["login_hint": "test@company.com"], + httpMethod: "GET" + ) + )) + } + + var customConfig = Configuration() + customConfig.defaultUsername = "test@company.com" + let customService = AppleSessionService(configuration: customConfig) + + let expectation = self.expectation(description: "Login fails") + + customService.loginIfNeeded() + .tap { result in + guard case .rejected = result else { + XCTFail("loginIfNeeded should have rejected for missing params") + return + } + expectation.fulfill() + } + .cauterize() + + wait(for: [expectation], timeout: 1.0) + + XCTAssertTrue(log.contains("missing required parameters")) + } + func test_XcodeList_ShouldUpdate_NotWhenCacheFileIsRecent() { Current.files.contentsAtPath = { _ in try! JSONEncoder().encode([Self.mockXcode]) } Current.files.attributesOfItemAtPath = { _ in [.modificationDate: Date(timeIntervalSinceNow: -3600*12)] } From 4c33dfd35f655d9e546c2016616489e87111599a Mon Sep 17 00:00:00 2001 From: Dalton Claybrook Date: Fri, 27 Feb 2026 23:19:56 -0500 Subject: [PATCH 6/6] Remove custom cookie persistence --- Sources/XcodesKit/AppleSessionService.swift | 86 +++++++-------------- Sources/XcodesKit/Path+.swift | 1 - 2 files changed, 27 insertions(+), 60 deletions(-) diff --git a/Sources/XcodesKit/AppleSessionService.swift b/Sources/XcodesKit/AppleSessionService.swift index 47162213..6ebf7428 100644 --- a/Sources/XcodesKit/AppleSessionService.swift +++ b/Sources/XcodesKit/AppleSessionService.swift @@ -1,7 +1,6 @@ import PromiseKit import Foundation import AppleAPI -import Path public class AppleSessionService { @@ -39,9 +38,6 @@ public class AppleSessionService { } func loginIfNeeded(withUsername providedUsername: String? = nil, shouldPromptForPassword: Bool = false) -> Promise { - // Restore any previously saved session cookies before validating - loadSessionCookies() - return firstly { () -> Promise in return Current.network.validateSession() } @@ -157,7 +153,11 @@ public class AppleSessionService { return Current.network.validateFederatedToken(widgetKey: widgetKey, token: token, relayState: relayState) .done { - self.saveSessionCookies() + // Apple's auth cookies (e.g. myacinfo) are session-only, so HTTPCookieStorage + // discards them when the process exits. Since federated accounts have no password + // in the keychain to re-authenticate silently, we replace session-only cookies + // with persistent copies so the session survives across runs. + self.persistSessionOnlyCookies() if self.configuration.defaultUsername != username { self.configuration.defaultUsername = username @@ -166,62 +166,33 @@ public class AppleSessionService { } } - // MARK: - Session Cookie Persistence - - private struct SerializableCookie: Codable { - let name: String - let value: String - let domain: String - let path: String - let isSecure: Bool - let expiresDate: Date? - - init(cookie: HTTPCookie) { - self.name = cookie.name - self.value = cookie.value - self.domain = cookie.domain - self.path = cookie.path - self.isSecure = cookie.isSecure - self.expiresDate = cookie.expiresDate - } + /// Replaces session-only Apple cookies with persistent copies by adding an expiry date. + /// This ensures HTTPCookieStorage keeps them across process launches. + private func persistSessionOnlyCookies() { + let appleDomains = [".apple.com", ".idmsa.apple.com", "appstoreconnect.apple.com"] + guard let cookieStorage = AppleAPI.Current.network.session.configuration.httpCookieStorage else { return } + let cookies = cookieStorage.cookies ?? [] + let expiry = Date(timeIntervalSinceNow: 24 * 60 * 60) // 1 day + + for cookie in cookies where cookie.isSessionOnly { + guard appleDomains.contains(where: { cookie.domain.hasSuffix($0) }) else { continue } - var httpCookie: HTTPCookie? { var properties: [HTTPCookiePropertyKey: Any] = [ - .name: name, - .value: value, - .domain: domain, - .path: path, - .secure: isSecure ? "TRUE" : "FALSE", + .name: cookie.name, + .value: cookie.value, + .domain: cookie.domain, + .path: cookie.path, + .secure: cookie.isSecure, + .expires: expiry, ] - if let expiresDate = expiresDate { - properties[.expires] = expiresDate + if let version = cookie.properties?[.version] { + properties[.version] = version } - return HTTPCookie(properties: properties) - } - } - private func saveSessionCookies() { - let appleDomains = [".apple.com", ".idmsa.apple.com", "appstoreconnect.apple.com"] - let cookies = AppleAPI.Current.network.session.configuration.httpCookieStorage?.cookies ?? [] - let relevantCookies = cookies.filter { cookie in - appleDomains.contains(where: { cookie.domain.hasSuffix($0) }) - } - guard !relevantCookies.isEmpty else { return } - - let serializable = relevantCookies.map(SerializableCookie.init) - if let data = try? JSONEncoder().encode(serializable) { - try? Current.files.write(data, Path.sessionCookiesFile.url) - } - } - - private func loadSessionCookies() { - guard let data = Current.files.contents(atPath: Path.sessionCookiesFile.string) else { return } - guard let serialized = try? JSONDecoder().decode([SerializableCookie].self, from: data) else { return } - - let cookieStorage = AppleAPI.Current.network.session.configuration.httpCookieStorage - for cookie in serialized { - if let expired = cookie.expiresDate, expired < Date() { continue } - cookie.httpCookie.map { cookieStorage?.setCookie($0) } + cookieStorage.deleteCookie(cookie) + if let persistent = HTTPCookie(properties: properties) { + cookieStorage.setCookie(persistent) + } } } @@ -238,9 +209,6 @@ public class AppleSessionService { // Remove all keychain items try Current.keychain.remove(username) - // Remove saved session cookies - try? Current.files.removeItem(Path.sessionCookiesFile.url) - // Set `defaultUsername` in Configuration to nil self.configuration.defaultUsername = nil try self.configuration.save() diff --git a/Sources/XcodesKit/Path+.swift b/Sources/XcodesKit/Path+.swift index d73d650a..eba6dd73 100644 --- a/Sources/XcodesKit/Path+.swift +++ b/Sources/XcodesKit/Path+.swift @@ -13,7 +13,6 @@ extension Path { static let xcodesCaches = environmentCaches/"com.robotsandpencils.xcodes" static let cacheFile = xcodesApplicationSupport/"available-xcodes.json" static let configurationFile = xcodesApplicationSupport/"configuration.json" - static let sessionCookiesFile = xcodesApplicationSupport/"session-cookies.json" @discardableResult func setCurrentUserAsOwner() -> Path {