Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}

Expand All @@ -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
}
}

Expand Down
38 changes: 38 additions & 0 deletions Sources/XKit/GrandSlam/Requests/BuddyMLAlert.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")!)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
46 changes: 46 additions & 0 deletions Tests/XToolTests/BuddyMLAlertParserTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import Foundation
import Testing
@testable import XKit

@Test func parsesAlertFromIssue283Fixture() throws {
let xml = """
<xmlui action="showModal">
<alert title="No Trusted Phone Numbers" \
message="You do not have any trusted phone numbers associated with your Apple Account. \
You can add a trusted phone number by verifying your identity.">
<button name="ak-button" ak-action="cancel"></button>
<button httpMethod="GET" url="https://gsa.apple.com/iforgot/phone/add"></button>
</alert>
</xmlui>
"""
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("<xmlui action=\"showModal\"></xmlui>".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 = """
<xmlui>
<alert title="First" message="first-message"/>
<alert title="Second" message="second-message"/>
</xmlui>
"""
let alert = BuddyMLAlertParser.parse(Data(xml.utf8))
#expect(alert?.title == "First")
#expect(alert?.message == "first-message")
}