diff --git a/Sources/XKit/GrandSlam/Operations/GrandSlamTwoFactorAuthenticateOperation.swift b/Sources/XKit/GrandSlam/Operations/GrandSlamTwoFactorAuthenticateOperation.swift index a3a39d98..e23ebf88 100644 --- a/Sources/XKit/GrandSlam/Operations/GrandSlamTwoFactorAuthenticateOperation.swift +++ b/Sources/XKit/GrandSlam/Operations/GrandSlamTwoFactorAuthenticateOperation.swift @@ -14,9 +14,29 @@ public protocol TwoFactorAuthDelegate: AnyObject, Sendable { struct GrandSlamTwoFactorAuthenticateOperation { - enum Error: Swift.Error { + enum Error: Swift.Error, LocalizedError { case incorrectVerificationCode case userCancelled + case smsRejected(title: String, message: String) + + var errorDescription: String? { + switch self { + case .incorrectVerificationCode: + return NSLocalizedString( + "apple_id_2fa.error.incorrect_code", + value: "Incorrect verification code.", + comment: "" + ) + case .userCancelled: + return NSLocalizedString( + "apple_id_2fa.error.user_cancelled", + value: "Verification was cancelled.", + comment: "" + ) + case .smsRejected(let title, let message): + return "\(title): \(message)" + } + } } let client: GrandSlamClient @@ -25,9 +45,20 @@ struct GrandSlamTwoFactorAuthenticateOperation { unowned let delegate: TwoFactorAuthDelegate private func performSMSAuth() async throws { - // TODO: parse phone number list from o=complete req? + // Apple's SMS endpoint expects a phoneNumber.id that identifies which trusted phone number + // to text. We don't yet parse the list from the o=complete response, so we try "1" first; + // if Apple rejects it (e.g. "No Trusted Phone Numbers"), fall back to trusted-device 2FA. let phoneNumberID = "1" - try await client.send(GrandSlamSMSAuthRequest(loginData: loginData, phoneNumberID: phoneNumberID)) + do { + try await client.send(GrandSlamSMSAuthRequest(loginData: loginData, phoneNumberID: phoneNumberID)) + } catch let error as Error { + guard case .smsRejected(let title, _) = error else { throw error } + FileHandle.standardError.write( + Data("SMS 2FA unavailable (\(title)). Falling back to trusted-device verification.\n".utf8) + ) + try await performTrustedDeviceAuth() + return + } try await validateCode(phoneNumberID: phoneNumberID) } @@ -46,6 +77,12 @@ struct GrandSlamTwoFactorAuthenticateOperation { _ = try await client.send(request) } catch let error as GrandSlamOperationError where error.code == -21669 { throw Error.incorrectVerificationCode + } catch Error.smsRejected { + // The SMS /securitycode endpoint returned a buddyML alert. We don't have a + // captured payload distinguishing "bad code" from other failures, but during + // validate the overwhelmingly likely cause is a wrong code — surface it that + // way rather than as a fatal alert title. + throw Error.incorrectVerificationCode } } diff --git a/Sources/XKit/GrandSlam/Requests/BuddyMLAlert.swift b/Sources/XKit/GrandSlam/Requests/BuddyMLAlert.swift new file mode 100644 index 00000000..984daf06 --- /dev/null +++ b/Sources/XKit/GrandSlam/Requests/BuddyMLAlert.swift @@ -0,0 +1,38 @@ +import Foundation +#if canImport(FoundationXML) +import FoundationXML +#endif + +struct BuddyMLAlert: Equatable, Sendable { + let title: String + let message: String +} + +enum BuddyMLAlertParser { + static func parse(_ data: Data) -> BuddyMLAlert? { + guard !data.isEmpty else { return nil } + let delegate = AlertDelegate() + let parser = XMLParser(data: data) + parser.delegate = delegate + _ = parser.parse() + return delegate.alert + } +} + +private final class AlertDelegate: NSObject, XMLParserDelegate { + var alert: BuddyMLAlert? + + func parser( + _ parser: XMLParser, + didStartElement elementName: String, + namespaceURI: String?, + qualifiedName qName: String?, + attributes attributeDict: [String: String] + ) { + guard alert == nil, elementName == "alert" else { return } + let title = attributeDict["title"] ?? "" + let message = attributeDict["message"] ?? "" + alert = BuddyMLAlert(title: title, message: message) + parser.abortParsing() + } +} diff --git a/Sources/XKit/GrandSlam/Requests/Concrete/GrandSlamTrustedDeviceRequest.swift b/Sources/XKit/GrandSlam/Requests/Concrete/GrandSlamTrustedDeviceRequest.swift index 28123a9e..a87acc09 100644 --- a/Sources/XKit/GrandSlam/Requests/Concrete/GrandSlamTrustedDeviceRequest.swift +++ b/Sources/XKit/GrandSlam/Requests/Concrete/GrandSlamTrustedDeviceRequest.swift @@ -32,7 +32,13 @@ struct GrandSlamSecondaryAuthRequest: GrandSlamTwoFactorRequest { struct GrandSlamSMSAuthRequest: GrandSlamTwoFactorRequest { struct Decoder: GrandSlamDataDecoder { - static func decode(data: Data) throws {} + static func decode(data: Data) throws { + if let alert = BuddyMLAlertParser.parse(data) { + throw GrandSlamTwoFactorAuthenticateOperation.Error.smsRejected( + title: alert.title, message: alert.message + ) + } + } } static let endpoint: GrandSlamEndpoint = .url(URL(string: "https://gsa.apple.com/auth/verify/phone/put?mode=sms")!) diff --git a/Sources/XKit/GrandSlam/Requests/Concrete/GrandSlamValidateRequest.swift b/Sources/XKit/GrandSlam/Requests/Concrete/GrandSlamValidateRequest.swift index 281241fa..54c2ea11 100644 --- a/Sources/XKit/GrandSlam/Requests/Concrete/GrandSlamValidateRequest.swift +++ b/Sources/XKit/GrandSlam/Requests/Concrete/GrandSlamValidateRequest.swift @@ -25,7 +25,13 @@ struct GrandSlamValidateRequest: GrandSlamTwoFactorRequest { struct GrandSlamValidateSMSRequest: GrandSlamTwoFactorRequest { struct Decoder: GrandSlamDataDecoder { - static func decode(data: Data) throws {} + static func decode(data: Data) throws { + if let alert = BuddyMLAlertParser.parse(data) { + throw GrandSlamTwoFactorAuthenticateOperation.Error.smsRejected( + title: alert.title, message: alert.message + ) + } + } } static let endpoint: GrandSlamEndpoint = .url(URL( diff --git a/Tests/XToolTests/BuddyMLAlertParserTests.swift b/Tests/XToolTests/BuddyMLAlertParserTests.swift new file mode 100644 index 00000000..ae589aaa --- /dev/null +++ b/Tests/XToolTests/BuddyMLAlertParserTests.swift @@ -0,0 +1,46 @@ +import Foundation +import Testing +@testable import XKit + +@Test func parsesAlertFromIssue283Fixture() throws { + let xml = """ + + + + + + + """ + let data = Data(xml.utf8) + let alert = BuddyMLAlertParser.parse(data) + #expect(alert?.title == "No Trusted Phone Numbers") + #expect(alert?.message.hasPrefix("You do not have any trusted phone numbers") == true) +} + +@Test func returnsNilForBuddyMLWithoutAlert() { + let data = Data("".utf8) + #expect(BuddyMLAlertParser.parse(data) == nil) +} + +@Test func returnsNilForEmptyData() { + #expect(BuddyMLAlertParser.parse(Data()) == nil) +} + +@Test func returnsNilForMalformedXML() { + let data = Data("not xml at all <<<".utf8) + #expect(BuddyMLAlertParser.parse(data) == nil) +} + +@Test func stopsAtFirstAlert() { + let xml = """ + + + + + """ + let alert = BuddyMLAlertParser.parse(Data(xml.utf8)) + #expect(alert?.title == "First") + #expect(alert?.message == "first-message") +}