diff --git a/Xcodes.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Xcodes.xcodeproj/project.xcworkspace/contents.xcworkspacedata
index 1091406..919434a 100644
--- a/Xcodes.xcodeproj/project.xcworkspace/contents.xcworkspacedata
+++ b/Xcodes.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -2,6 +2,6 @@
+ location = "self:">
diff --git a/Xcodes/AppState.swift b/Xcodes/AppState.swift
index d34df78..c6829a7 100644
--- a/Xcodes/AppState.swift
+++ b/Xcodes/AppState.swift
@@ -22,6 +22,8 @@ class AppState: ObservableObject {
case installing(Progress)
case installed
}
+
+ @Published var authenticationState: AuthenticationState = .unauthenticated
@Published var allVersions: [XcodeVersion] = []
struct AlertContent: Identifiable {
@@ -32,53 +34,184 @@ class AppState: ObservableObject {
@Published var error: AlertContent?
@Published var presentingSignInAlert = false
+ @Published var secondFactorSessionData: AppleSessionData?
+
+ private var cancellables = Set()
+ let client = AppleAPI.Client()
func load() {
// if list.shouldUpdate {
update()
- .done { _ in
- self.updateAllVersions()
- }
- .catch { error in
- self.error = AlertContent(title: "Error",
- message: error.localizedDescription)
- }
-// }
+// .done { _ in
+// self.updateAllVersions()
+// }
+// .catch { error in
+// self.error = AlertContent(title: "Error",
+// message: error.localizedDescription)
+// }
+//// }
// else {
// updateAllVersions()
// }
}
- func validateSession() -> Promise {
- return firstly { () -> Promise in
- return Current.network.validateSession()
- }
- .recover { _ in
- self.presentingSignInAlert = true
+ func validateSession() -> AnyPublisher {
+ return client.validateSession()
+ .handleEvents(receiveCompletion: { completion in
+ if case .failure = completion {
+ self.authenticationState = .unauthenticated
+ self.presentingSignInAlert = true
+ }
+ })
+ .eraseToAnyPublisher()
+ }
+
+ func login(username: String, password: String) {
+ client.login(accountName: username, password: password)
+ .receive(on: DispatchQueue.main)
+ .sink(
+ receiveCompletion: { completion in
+ if case .failure = completion {
+ // TODO: show error
+ }
+ },
+ receiveValue: { authenticationState in
+ self.authenticationState = authenticationState
+ if case let AuthenticationState.waitingForSecondFactor(option, sessionData) = authenticationState {
+ self.handleTwoFactorOption(option, serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt)
+ }
+ }
+ )
+ .store(in: &cancellables)
+ }
+
+ func handleTwoFactorOption(_ option: TwoFactorOption, serviceKey: String, sessionID: String, scnt: String) {
+// Current.logging.log("Two-factor authentication is enabled for this account.\n")
+ switch option {
+ case let .smsSent(codeLength, phoneNumber):
+ break
+// return Result {
+// let code = self.promptForSMSSecurityCode(length: codeLength, for: phoneNumber)
+// return try URLRequest.submitSecurityCode(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, code: code)
+// }
+// .publisher
+// .flatMap { request in
+// return Current.network.dataTask(with: request)
+// .validateSecurityCodeResponse()
+// .mapError { $0 as Error }
+// }
+// .flatMap { (data, response) in
+// self.updateSession(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
+// }
+// .eraseToAnyPublisher()
+ case let .smsPendingChoice(codeLength, trustedPhoneNumbers):
+ break
+// return handleWithPhoneNumberSelection(codeLength: codeLength, trustedPhoneNumbers: trustedPhoneNumbers, serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
+ case let .codeSent(codeLength):
+ self.presentingSignInAlert = false
+ self.secondFactorSessionData = AppleSessionData(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
+// let code = Current.shell.readLine("""
+// Enter "sms" without quotes to exit this prompt and choose a phone number to send an SMS security code to.
+// Enter the \(codeLength) digit code from one of your trusted devices:
+// """) ?? ""
+//
+// if code == "sms" {
+ // return handleWithPhoneNumberSelection(codeLength: codeLength, trustedPhoneNumbers: authOp, serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
+// }
}
}
- func continueLogin(username: String, password: String) -> Promise {
- firstly { () -> Promise in
- self.installer.login(username, password: password)
- }
- .recover { error -> Promise in
- XcodesKit.Current.logging.log(error.legibleLocalizedDescription)
-
- if case Client.Error.invalidUsernameOrPassword = error {
- self.presentingSignInAlert = true
- }
- return Promise(error: error)
- }
+// func selectPhoneNumberInteractively(from trustedPhoneNumbers: [AuthOptionsResponse.TrustedPhoneNumber]) -> AnyPublisher {
+// return Result {
+// Current.logging.log("Trusted phone numbers:")
+// trustedPhoneNumbers.enumerated().forEach { (index, phoneNumber) in
+// Current.logging.log("\(index + 1): \(phoneNumber.numberWithDialCode)")
+// }
+//
+// let possibleSelectionNumberString = Current.shell.readLine("Select a trusted phone number to receive a code via SMS: ")
+// guard
+// let selectionNumberString = possibleSelectionNumberString,
+// let selectionNumber = Int(selectionNumberString) ,
+// trustedPhoneNumbers.indices.contains(selectionNumber - 1)
+// else {
+// throw AuthenticationError.invalidPhoneNumberIndex(min: 1, max: trustedPhoneNumbers.count, given: possibleSelectionNumberString)
+// }
+//
+// return trustedPhoneNumbers[selectionNumber - 1]
+// }
+// .publisher
+// .catch { error -> AnyPublisher in
+// guard case AuthenticationError.invalidPhoneNumberIndex = error else {
+// return Fail(error: error).eraseToAnyPublisher()
+// }
+// Current.logging.log("\(error.localizedDescription)\n")
+// return self.selectPhoneNumberInteractively(from: trustedPhoneNumbers)
+// }
+// .eraseToAnyPublisher()
+// }
+//
+// func promptForSMSSecurityCode(length: Int, for trustedPhoneNumber: AuthOptionsResponse.TrustedPhoneNumber) -> SecurityCode {
+// let code = Current.shell.readLine("Enter the \(length) digit code sent to \(trustedPhoneNumber.numberWithDialCode): ") ?? ""
+// return .sms(code: code, phoneNumberId: trustedPhoneNumber.id)
+// }
+
+// func handleWithPhoneNumberSelection(codeLength: Int, trustedPhoneNumbers: [AuthOptionsResponse.TrustedPhoneNumber]?, serviceKey: String, sessionID: String, scnt: String) -> AnyPublisher {
+// // I don't think this should ever be nil or empty, because 2FA requires at least one trusted phone number,
+// // but if it is nil or empty it's better to inform the user so they can try to address it instead of crashing.
+// guard let trustedPhoneNumbers = trustedPhoneNumbers, trustedPhoneNumbers.isEmpty == false else {
+// return Fail(error: AuthenticationError.noTrustedPhoneNumbers)
+// .eraseToAnyPublisher()
+// }
+//
+// return selectPhoneNumberInteractively(from: trustedPhoneNumbers)
+// .flatMap { trustedPhoneNumber in
+// Current.network.dataTask(with: try URLRequest.requestSecurityCode(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, trustedPhoneID: trustedPhoneNumber.id))
+// .map { _ in
+// self.promptForSMSSecurityCode(length: codeLength, for: trustedPhoneNumber)
+// }
+// }
+// .flatMap { code in
+// Current.network.dataTask(with: try URLRequest.submitSecurityCode(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, code: code))
+// .validateSecurityCodeResponse()
+// }
+// .flatMap { (data, response) -> AnyPublisher in
+// self.updateSession(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
+// }
+// .eraseToAnyPublisher()
+// }
+
+ func submit2FACode(_ code: String, sessionData: AppleSessionData) {
+ client.submitSecurityCode(code, sessionData: sessionData)
+ .receive(on: DispatchQueue.main)
+ .sink(
+ receiveCompletion: { completion in
+ switch completion {
+ case let .failure(error):
+ self.error = AlertContent(title: "Error logging in", message: error.legibleLocalizedDescription)
+ case .finished:
+ if case .authenticated = self.authenticationState {
+ self.presentingSignInAlert = false
+ self.secondFactorSessionData = nil
+ }
+ }
+ },
+ receiveValue: { authenticationState in
+ self.authenticationState = authenticationState
+ }
+ )
+ .store(in: &cancellables)
}
- public func update() -> Promise<[Xcode]> {
- return firstly { () -> Promise in
- validateSession()
- }
- .then { () -> Promise<[Xcode]> in
- self.list.update()
- }
+ public func update() -> AnyPublisher<[Xcode], Error> {
+// return firstly { () -> Promise in
+// validateSession()
+// }
+// .then { () -> Promise<[Xcode]> in
+// self.list.update()
+// }
+ Just<[Xcode]>([])
+ .setFailureType(to: Error.self)
+ .eraseToAnyPublisher()
}
private func updateAllVersions() {
diff --git a/Xcodes/AppleAPI/Package.swift b/Xcodes/AppleAPI/Package.swift
index bfab7a9..b4c2d5c 100644
--- a/Xcodes/AppleAPI/Package.swift
+++ b/Xcodes/AppleAPI/Package.swift
@@ -5,24 +5,20 @@ import PackageDescription
let package = Package(
name: "AppleAPI",
- platforms: [.macOS(.v10_13)],
+ platforms: [.macOS(.v10_15)],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "AppleAPI",
targets: ["AppleAPI"]),
],
- dependencies: [
- // Dependencies declare other packages that this package depends on.
- .package(url: "https://github.com/mxcl/PromiseKit.git", .upToNextMajor(from: "6.8.3")),
- .package(name: "PMKFoundation", url: "https://github.com/PromiseKit/Foundation.git", .upToNextMajor(from: "3.3.1")),
- ],
+ dependencies: [],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "AppleAPI",
- dependencies: ["PromiseKit", "PMKFoundation"]),
+ dependencies: []),
.testTarget(
name: "AppleAPITests",
dependencies: ["AppleAPI"]),
diff --git a/Xcodes/AppleAPI/Sources/AppleAPI/Client.swift b/Xcodes/AppleAPI/Sources/AppleAPI/Client.swift
index 8617915..a3f1dbd 100644
--- a/Xcodes/AppleAPI/Sources/AppleAPI/Client.swift
+++ b/Xcodes/AppleAPI/Sources/AppleAPI/Client.swift
@@ -1,123 +1,84 @@
import Foundation
-import PromiseKit
-import PMKFoundation
+import Combine
public class Client {
private static let authTypes = ["sa", "hsa", "non-sa", "hsa2"]
public init() {}
- public enum Error: Swift.Error, LocalizedError, Equatable {
- case invalidSession
- case invalidUsernameOrPassword(username: String)
- case invalidPhoneNumberIndex(min: Int, max: Int, given: String?)
- case incorrectSecurityCode
- case unexpectedSignInResponse(statusCode: Int, message: String?)
- case appleIDAndPrivacyAcknowledgementRequired
- case noTrustedPhoneNumbers
+ // MARK: - Login
- public var errorDescription: String? {
- switch self {
- case .invalidUsernameOrPassword(let username):
- return "Invalid username and password combination. Attempted to sign in with username \(username)."
- case .appleIDAndPrivacyAcknowledgementRequired:
- return "You must sign in to https://appstoreconnect.apple.com and acknowledge the Apple ID & Privacy agreement."
- case .invalidPhoneNumberIndex(let min, let max, let given):
- return "Not a valid phone number index. Expecting a whole number between \(min)-\(max), but was given \(given ?? "nothing")."
- case .noTrustedPhoneNumbers:
- return "Your account doesn't have any trusted phone numbers, but they're required for two-factor authentication. See https://support.apple.com/en-ca/HT204915."
- default:
- return String(describing: self)
- }
- }
- }
-
- /// Use the olympus session endpoint to see if the existing session is still valid
- public func validateSession() -> Promise {
- return Current.network.dataTask(with: URLRequest.olympusSession)
- .done { data, response in
- guard
- let jsonObject = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
- jsonObject["provider"] != nil
- else { throw Error.invalidSession }
- }
- }
-
- public func login(accountName: String, password: String) -> Promise {
+ public func login(accountName: String, password: String) -> AnyPublisher {
var serviceKey: String!
- return firstly { () -> Promise<(data: Data, response: URLResponse)> in
- Current.network.dataTask(with: URLRequest.itcServiceKey)
- }
- .then { (data, _) -> Promise<(data: Data, response: URLResponse)> in
- struct ServiceKeyResponse: Decodable {
- let authServiceKey: String
+ return Current.network.dataTask(with: URLRequest.itcServiceKey)
+ .map(\.data)
+ .decode(type: ServiceKeyResponse.self, decoder: JSONDecoder())
+ .flatMap { serviceKeyResponse -> AnyPublisher in
+ serviceKey = serviceKeyResponse.authServiceKey
+ return Current.network.dataTask(with: URLRequest.signIn(serviceKey: serviceKey, accountName: accountName, password: password))
+ .mapError { $0 as Swift.Error }
+ .eraseToAnyPublisher()
}
-
- let response = try JSONDecoder().decode(ServiceKeyResponse.self, from: data)
- serviceKey = response.authServiceKey
-
- return Current.network.dataTask(with: URLRequest.signIn(serviceKey: serviceKey, accountName: accountName, password: password))
- }
- .then { (data, response) -> Promise in
- struct SignInResponse: Decodable {
- let authType: String?
- let serviceErrors: [ServiceError]?
-
- struct ServiceError: Decodable, CustomStringConvertible {
- let code: String
- let message: String
-
- var description: String {
- return "\(code): \(message)"
+ .flatMap { result -> AnyPublisher in
+ let (data, response) = result
+ return Just(data)
+ .decode(type: SignInResponse.self, decoder: JSONDecoder())
+ .flatMap { responseBody -> AnyPublisher in
+ let httpResponse = response as! HTTPURLResponse
+
+ switch httpResponse.statusCode {
+ case 200:
+ return Current.network.dataTask(with: URLRequest.olympusSession)
+ .map { _ in AuthenticationState.authenticated }
+ .mapError { $0 as Swift.Error }
+ .eraseToAnyPublisher()
+ case 401:
+ return Fail(error: AuthenticationError.invalidUsernameOrPassword(username: accountName))
+ .eraseToAnyPublisher()
+ case 409:
+ return self.handleTwoStepOrFactor(data: data, response: response, serviceKey: serviceKey)
+ case 412 where Client.authTypes.contains(responseBody.authType ?? ""):
+ return Fail(error: AuthenticationError.appleIDAndPrivacyAcknowledgementRequired)
+ .eraseToAnyPublisher()
+ default:
+ return Fail(error: AuthenticationError.unexpectedSignInResponse(statusCode: httpResponse.statusCode,
+ message: responseBody.serviceErrors?.map { $0.description }.joined(separator: ", ")))
+ .eraseToAnyPublisher()
+ }
}
- }
+ .eraseToAnyPublisher()
}
-
- let httpResponse = response as! HTTPURLResponse
- let responseBody = try JSONDecoder().decode(SignInResponse.self, from: data)
-
- switch httpResponse.statusCode {
- case 200:
- return Current.network.dataTask(with: URLRequest.olympusSession).asVoid()
- case 401:
- throw Error.invalidUsernameOrPassword(username: accountName)
- case 409:
- return self.handleTwoStepOrFactor(data: data, response: response, serviceKey: serviceKey)
- case 412 where Client.authTypes.contains(responseBody.authType ?? ""):
- throw Error.appleIDAndPrivacyAcknowledgementRequired
- default:
- throw Error.unexpectedSignInResponse(statusCode: httpResponse.statusCode,
- message: responseBody.serviceErrors?.map { $0.description }.joined(separator: ", "))
- }
- }
+ .mapError { $0 as Swift.Error }
+ .eraseToAnyPublisher()
}
- func handleTwoStepOrFactor(data: Data, response: URLResponse, serviceKey: String) -> Promise {
+ func handleTwoStepOrFactor(data: Data, response: URLResponse, serviceKey: String) -> AnyPublisher {
let httpResponse = response as! HTTPURLResponse
let sessionID = (httpResponse.allHeaderFields["X-Apple-ID-Session-Id"] as! String)
let scnt = (httpResponse.allHeaderFields["scnt"] as! String)
- return firstly { () -> Promise in
- return Current.network.dataTask(with: URLRequest.authOptions(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt))
- .map { try JSONDecoder().decode(AuthOptionsResponse.self, from: $0.data) }
- }
- .then { authOptions -> Promise in
- switch authOptions.kind {
- case .twoStep:
- Current.logging.log("Received a response from Apple that indicates this account has two-step authentication enabled. xcodes currently only supports the newer two-factor authentication, though. Please consider upgrading to two-factor authentication, or open an issue on GitHub explaining why this isn't an option for you here: https://github.com/RobotsAndPencils/xcodes/issues/new")
- return Promise.value(())
- case .twoFactor:
- return self.handleTwoFactor(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, authOptions: authOptions)
- case .unknown:
- Current.logging.log("Received a response from Apple that indicates this account has two-step or two-factor authentication enabled, but xcodes is unsure how to handle this response:")
- String(data: data, encoding: .utf8).map { Current.logging.log($0) }
- return Promise.value(())
+ return Current.network.dataTask(with: URLRequest.authOptions(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt))
+ .map(\.data)
+ .decode(type: AuthOptionsResponse.self, decoder: JSONDecoder())
+ .flatMap { authOptions -> AnyPublisher in
+ switch authOptions.kind {
+ case .twoStep:
+ return Fail(error: AuthenticationError.accountUsesTwoStepAuthentication)
+ .eraseToAnyPublisher()
+ case .twoFactor:
+ return self.handleTwoFactor(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, authOptions: authOptions)
+ .eraseToAnyPublisher()
+ case .unknown:
+ let possibleResponseString = String(data: data, encoding: .utf8)
+ return Fail(error: AuthenticationError.accountUsesUnknownAuthenticationKind(possibleResponseString))
+ .eraseToAnyPublisher()
+ }
}
- }
+ .eraseToAnyPublisher()
}
- func handleTwoFactor(serviceKey: String, sessionID: String, scnt: String, authOptions: AuthOptionsResponse) -> Promise {
+ func handleTwoFactor(serviceKey: String, sessionID: String, scnt: String, authOptions: AuthOptionsResponse) -> AnyPublisher {
let option: TwoFactorOption
// SMS was sent automatically
@@ -131,132 +92,153 @@ public class Client {
option = .codeSent(authOptions.securityCode.length)
}
- return handleTwoFactorOption(option, serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
+ let sessionData = AppleSessionData(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
+ return Just(AuthenticationState.waitingForSecondFactor(option, sessionData))
+ .setFailureType(to: Error.self)
+ .eraseToAnyPublisher()
+ // return handleTwoFactorOption(option, serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
}
- func handleTwoFactorOption(_ option: TwoFactorOption, serviceKey: String, sessionID: String, scnt: String) -> Promise {
- Current.logging.log("Two-factor authentication is enabled for this account.\n")
- switch option {
- case let .smsSent(codeLength, phoneNumber):
- return firstly { () throws -> Promise<(data: Data, response: URLResponse)> in
- let code = self.promptForSMSSecurityCode(length: codeLength, for: phoneNumber)
- return Current.network.dataTask(with: try URLRequest.submitSecurityCode(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, code: code))
- .validateSecurityCodeResponse()
- }
- .then { (data, response) -> Promise in
- self.updateSession(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
- }
- case let .smsPendingChoice(codeLength, trustedPhoneNumbers):
- return handleWithPhoneNumberSelection(codeLength: codeLength, trustedPhoneNumbers: trustedPhoneNumbers, serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
- case let .codeSent(codeLength):
- let code = Current.shell.readLine("""
- Enter "sms" without quotes to exit this prompt and choose a phone number to send an SMS security code to.
- Enter the \(codeLength) digit code from one of your trusted devices:
- """) ?? ""
-
- if code == "sms" {
- // return handleWithPhoneNumberSelection(codeLength: codeLength, trustedPhoneNumbers: authOp, serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
- }
-
- return firstly {
- Current.network.dataTask(with: try URLRequest.submitSecurityCode(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, code: .device(code: code)))
- .validateSecurityCodeResponse()
-
- }
- .then { (data, response) -> Promise in
- self.updateSession(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
- }
+ // MARK: - Continue 2FA
+
+ public func submitSecurityCode(_ code: String, sessionData: AppleSessionData) -> AnyPublisher {
+ Result {
+ try URLRequest.submitSecurityCode(serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt, code: .device(code: code))
}
- }
-
- func updateSession(serviceKey: String, sessionID: String, scnt: String) -> Promise {
- return Current.network.dataTask(with: URLRequest.trust(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt))
- .then { (data, response) -> Promise in
- Current.network.dataTask(with: URLRequest.olympusSession).asVoid()
- }
- }
-
- func selectPhoneNumberInteractively(from trustedPhoneNumbers: [AuthOptionsResponse.TrustedPhoneNumber]) -> Promise {
- return firstly { () throws -> Guarantee in
- Current.logging.log("Trusted phone numbers:")
- trustedPhoneNumbers.enumerated().forEach { (index, phoneNumber) in
- Current.logging.log("\(index + 1): \(phoneNumber.numberWithDialCode)")
- }
-
- let possibleSelectionNumberString = Current.shell.readLine("Select a trusted phone number to receive a code via SMS: ")
- guard
- let selectionNumberString = possibleSelectionNumberString,
- let selectionNumber = Int(selectionNumberString) ,
- trustedPhoneNumbers.indices.contains(selectionNumber - 1)
- else {
- throw Error.invalidPhoneNumberIndex(min: 1, max: trustedPhoneNumbers.count, given: possibleSelectionNumberString)
- }
-
- return .value(trustedPhoneNumbers[selectionNumber - 1])
- }
- .recover { error throws -> Promise in
- guard case Error.invalidPhoneNumberIndex = error else { throw error }
- Current.logging.log("\(error.localizedDescription)\n")
- return self.selectPhoneNumberInteractively(from: trustedPhoneNumbers)
- }
- }
-
- func promptForSMSSecurityCode(length: Int, for trustedPhoneNumber: AuthOptionsResponse.TrustedPhoneNumber) -> SecurityCode {
- let code = Current.shell.readLine("Enter the \(length) digit code sent to \(trustedPhoneNumber.numberWithDialCode): ") ?? ""
- return .sms(code: code, phoneNumberId: trustedPhoneNumber.id)
- }
-
- func handleWithPhoneNumberSelection(codeLength: Int, trustedPhoneNumbers: [AuthOptionsResponse.TrustedPhoneNumber]?, serviceKey: String, sessionID: String, scnt: String) -> Promise {
- return firstly { () throws -> Promise in
- // I don't think this should ever be nil or empty, because 2FA requires at least one trusted phone number,
- // but if it is nil or empty it's better to inform the user so they can try to address it instead of crashing.
- guard let trustedPhoneNumbers = trustedPhoneNumbers, trustedPhoneNumbers.isEmpty == false else {
- throw Error.noTrustedPhoneNumbers
- }
-
- return selectPhoneNumberInteractively(from: trustedPhoneNumbers)
- }
- .then { trustedPhoneNumber in
- Current.network.dataTask(with: try URLRequest.requestSecurityCode(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, trustedPhoneID: trustedPhoneNumber.id))
- .map { _ in
- self.promptForSMSSecurityCode(length: codeLength, for: trustedPhoneNumber)
+ .publisher
+ .flatMap { request in
+ Current.network.dataTask(with: request)
+ .mapError { $0 as Error }
+ // .validateSecurityCodeResponse()
+ .flatMap { (data, response) -> AnyPublisher in
+ self.updateSession(serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt)
}
}
- .then { code in
- Current.network.dataTask(with: try URLRequest.submitSecurityCode(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, code: code))
- .validateSecurityCodeResponse()
- }
- .then { (data, response) -> Promise in
- self.updateSession(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
+ .eraseToAnyPublisher()
+ }
+
+ // MARK: - Session
+
+ /// Use the olympus session endpoint to see if the existing session is still valid
+ public func validateSession() -> AnyPublisher {
+ return Current.network.dataTask(with: URLRequest.olympusSession)
+ .tryMap { (data, response) in
+ guard
+ let jsonObject = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
+ jsonObject["provider"] != nil
+ else { throw AuthenticationError.invalidSession }
+ }
+ .eraseToAnyPublisher()
+ }
+
+ func updateSession(serviceKey: String, sessionID: String, scnt: String) -> AnyPublisher {
+ return Current.network.dataTask(with: URLRequest.trust(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt))
+ .flatMap { (data, response) in
+ Current.network.dataTask(with: URLRequest.olympusSession)
+ .map { _ in AuthenticationState.authenticated }
+ }
+ .mapError { $0 as Error }
+ .eraseToAnyPublisher()
+ }
+}
+
+// MARK: - Types
+
+public enum AuthenticationState: Equatable {
+ case unauthenticated
+ case waitingForSecondFactor(TwoFactorOption, AppleSessionData)
+ case authenticated
+}
+
+public enum AuthenticationError: Swift.Error, LocalizedError, Equatable {
+ case invalidSession
+ case invalidUsernameOrPassword(username: String)
+ case invalidPhoneNumberIndex(min: Int, max: Int, given: String?)
+ case incorrectSecurityCode
+ case unexpectedSignInResponse(statusCode: Int, message: String?)
+ case appleIDAndPrivacyAcknowledgementRequired
+ case noTrustedPhoneNumbers
+ case accountUsesTwoStepAuthentication
+ case accountUsesUnknownAuthenticationKind(String?)
+
+ public var errorDescription: String? {
+ switch self {
+ case .invalidUsernameOrPassword(let username):
+ return "Invalid username and password combination. Attempted to sign in with username \(username)."
+ case .appleIDAndPrivacyAcknowledgementRequired:
+ return "You must sign in to https://appstoreconnect.apple.com and acknowledge the Apple ID & Privacy agreement."
+ case .invalidPhoneNumberIndex(let min, let max, let given):
+ return "Not a valid phone number index. Expecting a whole number between \(min)-\(max), but was given \(given ?? "nothing")."
+ case .noTrustedPhoneNumbers:
+ return "Your account doesn't have any trusted phone numbers, but they're required for two-factor authentication. See https://support.apple.com/en-ca/HT204915."
+ case .accountUsesTwoStepAuthentication:
+ return "Received a response from Apple that indicates this account has two-step authentication enabled. xcodes currently only supports the newer two-factor authentication, though. Please consider upgrading to two-factor authentication, or open an issue on GitHub explaining why this isn't an option for you here: https://github.com/RobotsAndPencils/xcodes/issues/new"
+ case .accountUsesUnknownAuthenticationKind:
+ return "Received a response from Apple that indicates this account has two-step or two-factor authentication enabled, but xcodes is unsure how to handle this response:"
+ default:
+ return String(describing: self)
}
}
}
-enum TwoFactorOption {
+public struct AppleSessionData: Equatable, Identifiable {
+ public let serviceKey: String
+ public let sessionID: String
+ public let scnt: String
+
+ public var id: String { sessionID }
+
+ public init(serviceKey: String, sessionID: String, scnt: String) {
+ self.serviceKey = serviceKey
+ self.sessionID = sessionID
+ self.scnt = scnt
+ }
+}
+
+struct ServiceKeyResponse: Decodable {
+ let authServiceKey: String
+}
+
+struct SignInResponse: Decodable {
+ let authType: String?
+ let serviceErrors: [ServiceError]?
+
+ struct ServiceError: Decodable, CustomStringConvertible {
+ let code: String
+ let message: String
+
+ var description: String {
+ return "\(code): \(message)"
+ }
+ }
+}
+
+public enum TwoFactorOption: Equatable {
case smsSent(Int, AuthOptionsResponse.TrustedPhoneNumber)
case codeSent(Int)
case smsPendingChoice(Int, [AuthOptionsResponse.TrustedPhoneNumber])
}
-public extension Promise where T == (data: Data, response: URLResponse) {
- func validateSecurityCodeResponse() -> Promise {
- validate()
- .recover { error -> Promise<(data: Data, response: URLResponse)> in
- switch error {
- case PMKHTTPError.badStatusCode(let code, _, _):
- if code == 401 {
- throw Client.Error.incorrectSecurityCode
- } else {
- throw error
- }
- default:
- throw error
- }
- }
+public extension Publisher where Output == (data: Data, response: URLResponse) {
+ func validateSecurityCodeResponse() -> AnyPublisher