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")
+}