From aae2f6c0ed31c4acd25b6a87a9ca946b8ecfad23 Mon Sep 17 00:00:00 2001 From: Nick-800 Date: Sun, 11 Oct 2026 11:19:58 +0200 Subject: [PATCH 1/2] auth: fall back to trusted-device 2FA when Apple rejects SMS (#283) Parse the body Apple returns from `/auth/verify/phone/put?mode=sms` (previously discarded by an empty decoder) and surface it as a typed `smsRejected` error. In `performSMSAuth`, catch this error and transparently retry via the trusted-device flow, logging a one-line notice. Previously `xtool setup` hung forever on accounts that received the "No Trusted Phone Numbers" alert, because the code silently prompted for a verification code that Apple never sent. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...ndSlamTwoFactorAuthenticateOperation.swift | 35 ++++++++++++-- .../GrandSlam/Requests/BuddyMLAlert.swift | 38 +++++++++++++++ .../GrandSlamTrustedDeviceRequest.swift | 8 +++- .../Concrete/GrandSlamValidateRequest.swift | 8 +++- .../XToolTests/BuddyMLAlertParserTests.swift | 46 +++++++++++++++++++ 5 files changed, 130 insertions(+), 5 deletions(-) create mode 100644 Sources/XKit/GrandSlam/Requests/BuddyMLAlert.swift create mode 100644 Tests/XToolTests/BuddyMLAlertParserTests.swift diff --git a/Sources/XKit/GrandSlam/Operations/GrandSlamTwoFactorAuthenticateOperation.swift b/Sources/XKit/GrandSlam/Operations/GrandSlamTwoFactorAuthenticateOperation.swift index a3a39d98..894a1d4a 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,18 @@ 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 } + print("SMS 2FA unavailable (\(title)). Falling back to trusted-device verification.") + try await performTrustedDeviceAuth() + return + } try await validateCode(phoneNumberID: phoneNumberID) } 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") +} From 1fdec3e9aeb880a20a9a41fc04216d0ee513d0f8 Mon Sep 17 00:00:00 2001 From: Nick-800 Date: Thu, 15 Oct 2026 10:38:06 +0200 Subject: [PATCH 2/2] auth: tighten 2FA SMS error semantics per review (#283) Addresses the two soft suggestions from @rahul05ranjan on PR #315: - `validateCode(phoneNumberID:)` now catches `.smsRejected` thrown by `GrandSlamValidateSMSRequest.Decoder` and rethrows as `.incorrectVerificationCode`. Previously a buddyML alert returned by Apple's `/securitycode` endpoint would surface as a fatal alert title; during the validate step the overwhelmingly likely cause is a wrong code, so this matches runtime intent. - The SMS-fallback notice in `performSMSAuth()` now writes to stderr via `FileHandle.standardError.write`, so it no longer interleaves with the stdout `Code:` prompt from `Console.prompt`. Co-Authored-By: Claude Opus 4.7 --- .../GrandSlamTwoFactorAuthenticateOperation.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Sources/XKit/GrandSlam/Operations/GrandSlamTwoFactorAuthenticateOperation.swift b/Sources/XKit/GrandSlam/Operations/GrandSlamTwoFactorAuthenticateOperation.swift index 894a1d4a..e23ebf88 100644 --- a/Sources/XKit/GrandSlam/Operations/GrandSlamTwoFactorAuthenticateOperation.swift +++ b/Sources/XKit/GrandSlam/Operations/GrandSlamTwoFactorAuthenticateOperation.swift @@ -53,7 +53,9 @@ struct GrandSlamTwoFactorAuthenticateOperation { try await client.send(GrandSlamSMSAuthRequest(loginData: loginData, phoneNumberID: phoneNumberID)) } catch let error as Error { guard case .smsRejected(let title, _) = error else { throw error } - print("SMS 2FA unavailable (\(title)). Falling back to trusted-device verification.") + FileHandle.standardError.write( + Data("SMS 2FA unavailable (\(title)). Falling back to trusted-device verification.\n".utf8) + ) try await performTrustedDeviceAuth() return } @@ -75,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 } }