Skip to content

Commit 362e92b

Browse files
committed
support federated authentication
1 parent b8037a7 commit 362e92b

10 files changed

Lines changed: 593 additions & 2 deletions

File tree

Sources/XcodesLoginKit/AppleSessionService.swift

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,13 @@ public actor AppleSessionService {
88
public typealias KeychainSet = @Sendable (String, String) throws -> Void
99
public typealias KeychainRemove = @Sendable (String) throws -> Void
1010
public typealias ReadLine = @Sendable (String) -> String?
11+
public typealias ReadLongLine = @Sendable (String) -> String?
1112
public typealias ReadSecureLine = @Sendable (String) -> String?
1213
public typealias ValidateSession = @Sendable () async throws -> Void
1314
public typealias Login = @Sendable (String, String) async throws -> Void
15+
public typealias CheckIsFederated = @Sendable (String) async throws -> FederationResponse
16+
public typealias ValidateFederatedCallbackURL = @Sendable (String) async throws -> Void
17+
public typealias OpenURL = @Sendable (URL) -> Void
1418
public typealias Signout = @Sendable () async -> Void
1519
public typealias LoadData = @Sendable (URLRequest) async throws -> (Data, URLResponse)
1620
public typealias Log = @Sendable (String) -> Void
@@ -23,9 +27,13 @@ public actor AppleSessionService {
2327
public var keychainSet: KeychainSet
2428
public var keychainRemove: KeychainRemove
2529
public var readLine: ReadLine
30+
public var readLongLine: ReadLongLine
2631
public var readSecureLine: ReadSecureLine
2732
public var validateSession: ValidateSession
2833
public var login: Login
34+
public var checkIsFederated: CheckIsFederated
35+
public var validateFederatedCallbackURL: ValidateFederatedCallbackURL
36+
public var openURL: OpenURL
2937
public var signout: Signout
3038
public var loadData: LoadData
3139
public var log: Log
@@ -38,9 +46,13 @@ public actor AppleSessionService {
3846
keychainSet: @escaping KeychainSet,
3947
keychainRemove: @escaping KeychainRemove,
4048
readLine: @escaping ReadLine,
49+
readLongLine: @escaping ReadLongLine,
4150
readSecureLine: @escaping ReadSecureLine,
4251
validateSession: @escaping ValidateSession,
4352
login: @escaping Login,
53+
checkIsFederated: @escaping CheckIsFederated,
54+
validateFederatedCallbackURL: @escaping ValidateFederatedCallbackURL,
55+
openURL: @escaping OpenURL,
4456
signout: @escaping Signout,
4557
loadData: @escaping LoadData,
4658
log: @escaping Log = { _ in }
@@ -52,9 +64,13 @@ public actor AppleSessionService {
5264
self.keychainSet = keychainSet
5365
self.keychainRemove = keychainRemove
5466
self.readLine = readLine
67+
self.readLongLine = readLongLine
5568
self.readSecureLine = readSecureLine
5669
self.validateSession = validateSession
5770
self.login = login
71+
self.checkIsFederated = checkIsFederated
72+
self.validateFederatedCallbackURL = validateFederatedCallbackURL
73+
self.openURL = openURL
5874
self.signout = signout
5975
self.loadData = loadData
6076
self.log = log
@@ -106,6 +122,12 @@ public actor AppleSessionService {
106122
}
107123
guard let username = possibleUsername else { throw Error.missingUsernameOrPassword }
108124

125+
let federationResponse = try await dependencies.checkIsFederated(username)
126+
if federationResponse.federated {
127+
try await handleFederatedLogin(username: username, federationResponse: federationResponse)
128+
return
129+
}
130+
109131
let passwordPrompt: String
110132
if hasPromptedForUsername {
111133
passwordPrompt = "Apple ID Password: "
@@ -131,6 +153,33 @@ public actor AppleSessionService {
131153
}
132154
}
133155

156+
private func handleFederatedLogin(username: String, federationResponse: FederationResponse) async throws {
157+
guard let idpURL = federationResponse.idpURL else {
158+
throw AuthenticationError.federatedAuthenticationRequired
159+
}
160+
161+
let orgName = federationResponse.federatedAuthIntro?.orgName ?? "your organization"
162+
let idpName = federationResponse.federatedAuthIntro?.idpName
163+
let orgNameWithIdp = idpName.map { "\(orgName) (\($0))" } ?? orgName
164+
165+
dependencies.log("\n- This account uses federated authentication via \(orgNameWithIdp)")
166+
dependencies.log("- Your browser will open to complete sign-in")
167+
dependencies.log("- After signing in, you will be redirected to a blank page")
168+
dependencies.log("- Copy the URL from your browser's address bar, then return here and paste it")
169+
dependencies.log("\nOpening your browser...")
170+
dependencies.openURL(idpURL)
171+
172+
guard let callbackURLString = dependencies.readLongLine("\nPaste the URL here: ") else {
173+
throw Error.missingUsernameOrPassword
174+
}
175+
176+
try await dependencies.validateFederatedCallbackURL(callbackURLString)
177+
178+
if dependencies.defaultUsername() != username {
179+
try? dependencies.setDefaultUsername(username)
180+
}
181+
}
182+
134183
public func login(_ username: String, password: String) async throws {
135184
do {
136185
try await dependencies.login(username, password)

Sources/XcodesLoginKit/AuthenticationError.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ public enum AuthenticationError: Swift.Error, LocalizedError, Equatable, Sendabl
2323
case invalidResult(resultString: String?)
2424
case srpInvalidPublicKey
2525
case userCancelledSecurityKeyAuthentication
26+
case federatedAuthenticationRequired
27+
case invalidFederatedAuthenticationCallback
28+
case missingPasswordForNonFederatedAccount
2629

2730
public var errorDescription: String? {
2831
switch self {
@@ -61,6 +64,12 @@ public enum AuthenticationError: Swift.Error, LocalizedError, Equatable, Sendabl
6164
return "Invalid Key"
6265
case .userCancelledSecurityKeyAuthentication:
6366
return "User cancelled security key authorization"
67+
case .federatedAuthenticationRequired:
68+
return "This account uses federated authentication. Browser-based sign in is required."
69+
case .invalidFederatedAuthenticationCallback:
70+
return "The federated authentication callback URL is missing required parameters."
71+
case .missingPasswordForNonFederatedAccount:
72+
return "This Apple ID does not use federated authentication. Enter your password to continue."
6473
}
6574
}
6675
}

Sources/XcodesLoginKit/AuthenticationState.swift

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@
66
//
77

88

9+
import Foundation
10+
911
public enum AuthenticationState: Equatable, Sendable {
1012
case unauthenticated
13+
case waitingForFederatedAuthentication(FederationResponse)
1114
case waitingForSecondFactor(TwoFactorOption, AuthOptionsResponse, AppleSessionData)
1215
case authenticated(AppleSession)
1316
case notAppleDeveloper
@@ -188,3 +191,90 @@ public struct AppleSession: Decodable, Sendable, Equatable {
188191
public struct AppleSessionUser: Decodable, Sendable, Equatable {
189192
public let fullName: String?
190193
}
194+
195+
public struct FederationResponse: Decodable, Equatable, Sendable {
196+
public let federated: Bool
197+
public let showFederatedIdpConfirmation: Bool?
198+
public let federatedIdpRequest: FederatedIdpRequest?
199+
public let federatedAuthIntro: FederatedAuthIntro?
200+
201+
public init(
202+
federated: Bool,
203+
showFederatedIdpConfirmation: Bool? = nil,
204+
federatedIdpRequest: FederatedIdpRequest? = nil,
205+
federatedAuthIntro: FederatedAuthIntro? = nil
206+
) {
207+
self.federated = federated
208+
self.showFederatedIdpConfirmation = showFederatedIdpConfirmation
209+
self.federatedIdpRequest = federatedIdpRequest
210+
self.federatedAuthIntro = federatedAuthIntro
211+
}
212+
213+
public var idpURL: URL? {
214+
guard let idpRequest = federatedIdpRequest else { return nil }
215+
var components = URLComponents(string: idpRequest.idPUrl)
216+
components?.queryItems = idpRequest.requestParams.map { key, value in
217+
URLQueryItem(name: key, value: value)
218+
}
219+
return components?.url
220+
}
221+
}
222+
223+
public struct FederatedIdpRequest: Decodable, Equatable, Sendable {
224+
public let idPUrl: String
225+
public let requestParams: [String: String]
226+
public let httpMethod: String?
227+
228+
public init(idPUrl: String, requestParams: [String: String], httpMethod: String?) {
229+
self.idPUrl = idPUrl
230+
self.requestParams = requestParams
231+
self.httpMethod = httpMethod
232+
}
233+
}
234+
235+
public struct FederatedAuthIntro: Decodable, Equatable, Sendable {
236+
public let orgName: String?
237+
public let idpName: String?
238+
public let idpUrl: String?
239+
public let orgType: String?
240+
public let accountManagementUrl: String?
241+
242+
public init(orgName: String?, idpName: String?, idpUrl: String?, orgType: String?, accountManagementUrl: String?) {
243+
self.orgName = orgName
244+
self.idpName = idpName
245+
self.idpUrl = idpUrl
246+
self.orgType = orgType
247+
self.accountManagementUrl = accountManagementUrl
248+
}
249+
}
250+
251+
public struct FederatedAuthenticationCallback: Equatable, Sendable {
252+
public let widgetKey: String
253+
public let token: String
254+
public let relayState: String
255+
256+
public init(callbackURL: URL) throws {
257+
guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false),
258+
let queryItems = components.queryItems else {
259+
throw AuthenticationError.invalidFederatedAuthenticationCallback
260+
}
261+
262+
guard let widgetKey = queryItems.first(where: { $0.name == "widgetKey" })?.value,
263+
let token = queryItems.first(where: { $0.name == "token" })?.value,
264+
let relayState = queryItems.first(where: { $0.name == "relayState" })?.value else {
265+
throw AuthenticationError.invalidFederatedAuthenticationCallback
266+
}
267+
268+
self.widgetKey = widgetKey
269+
self.token = token
270+
self.relayState = relayState
271+
}
272+
273+
public init(callbackURLString: String) throws {
274+
guard let callbackURL = URL(string: callbackURLString) else {
275+
throw AuthenticationError.invalidFederatedAuthenticationCallback
276+
}
277+
278+
try self.init(callbackURL: callbackURL)
279+
}
280+
}

Sources/XcodesLoginKit/Client.swift

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,19 @@ public final class Client: Sendable {
2525
}
2626

2727
// MARK: Login
28+
29+
public func authenticationState(accountName: String, password: String?) async throws -> AuthenticationState {
30+
let federationResponse = try await checkIsFederated(accountName: accountName)
31+
if federationResponse.federated {
32+
return .waitingForFederatedAuthentication(federationResponse)
33+
}
34+
35+
guard let password, !password.isEmpty else {
36+
throw AuthenticationError.missingPasswordForNonFederatedAccount
37+
}
38+
39+
return try await srpLogin(accountName: accountName, password: password)
40+
}
2841

2942
public func srpLogin(accountName: String, password: String) async throws -> AuthenticationState {
3043
let client = SRPClient(configuration: SRPConfiguration<SHA256>(.N2048))
@@ -159,6 +172,83 @@ public final class Client: Sendable {
159172
throw AuthenticationError.badStatusCode(statusCode: code, data: nil, response: response)
160173
}
161174
}
175+
176+
public func checkFederation(accountName: String, serviceKey: String) async throws -> FederationResponse {
177+
try await networkService.requestObject(URLRequest.checkFederation(serviceKey: serviceKey, accountName: accountName))
178+
}
179+
180+
public func checkIsFederated(accountName: String) async throws -> FederationResponse {
181+
let serviceKeyResponse: ServiceKeyResponse = try await networkService.requestObject(URLRequest.itcServiceKey)
182+
return try await checkFederation(accountName: accountName, serviceKey: serviceKeyResponse.authServiceKey)
183+
}
184+
185+
@discardableResult
186+
public func validateFederatedToken(widgetKey: String, token: String, relayState: String) async throws -> AuthenticationState {
187+
let result = try await networkService.requestData(
188+
URLRequest.federateValidate(widgetKey: widgetKey, token: token, relayState: relayState),
189+
validators: []
190+
)
191+
192+
guard let response = result.1 as? HTTPURLResponse else {
193+
throw NetworkError.invalidResponseFormat
194+
}
195+
196+
switch response.statusCode {
197+
case 200..<300:
198+
persistSessionOnlyAppleCookies()
199+
return try await validateSession()
200+
case 409:
201+
return try await handleTwoStepOrFactor(data: result.0, response: response, serviceKey: widgetKey)
202+
default:
203+
throw AuthenticationError.unexpectedSignInResponse(statusCode: response.statusCode, message: nil)
204+
}
205+
}
206+
207+
@discardableResult
208+
public func validateFederatedCallbackURL(_ callbackURL: URL) async throws -> AuthenticationState {
209+
let callback = try FederatedAuthenticationCallback(callbackURL: callbackURL)
210+
return try await validateFederatedToken(
211+
widgetKey: callback.widgetKey,
212+
token: callback.token,
213+
relayState: callback.relayState
214+
)
215+
}
216+
217+
@discardableResult
218+
public func validateFederatedCallbackURLString(_ callbackURLString: String) async throws -> AuthenticationState {
219+
let callback = try FederatedAuthenticationCallback(callbackURLString: callbackURLString)
220+
return try await validateFederatedToken(
221+
widgetKey: callback.widgetKey,
222+
token: callback.token,
223+
relayState: callback.relayState
224+
)
225+
}
226+
227+
public func persistSessionOnlyAppleCookies(expiring expirationDate: Date = Date(timeIntervalSinceNow: 24 * 60 * 60)) {
228+
let appleDomains = [".apple.com", ".idmsa.apple.com", "appstoreconnect.apple.com"]
229+
guard let cookieStorage = networkService.urlSession.configuration.httpCookieStorage else { return }
230+
231+
for cookie in cookieStorage.cookies ?? [] where cookie.isSessionOnly {
232+
guard appleDomains.contains(where: { cookie.domain.hasSuffix($0) }) else { continue }
233+
234+
var properties: [HTTPCookiePropertyKey: Any] = [
235+
.name: cookie.name,
236+
.value: cookie.value,
237+
.domain: cookie.domain,
238+
.path: cookie.path,
239+
.secure: cookie.isSecure,
240+
.expires: expirationDate
241+
]
242+
if let version = cookie.properties?[.version] {
243+
properties[.version] = version
244+
}
245+
246+
cookieStorage.deleteCookie(cookie)
247+
if let persistentCookie = HTTPCookie(properties: properties) {
248+
cookieStorage.setCookie(persistentCookie)
249+
}
250+
}
251+
}
162252

163253
private func sha256(data : Data) -> Data {
164254
var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))

Sources/XcodesLoginKit/URLRequest+Apple.swift

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ public extension URL {
1515
static func submitSecurityCode(_ code: SecurityCode) -> URL { URL(string: "https://idmsa.apple.com/appleauth/auth/verify/\(code.urlPathComponent)/securitycode")! }
1616
static let trust = URL(string: "https://idmsa.apple.com/appleauth/auth/2sv/trust")!
1717
static let federate = URL(string: "https://idmsa.apple.com/appleauth/auth/federate")!
18+
static let federateValidate = URL(string: "https://idmsa.apple.com/appleauth/auth/federate/validate")!
1819
static let olympusSession = URL(string: "https://appstoreconnect.apple.com/olympus/v1/session")!
1920
static let keyAuth = URL(string: "https://idmsa.apple.com/appleauth/auth/verify/security/key")!
2021

@@ -161,6 +162,36 @@ public extension URLRequest {
161162

162163
return request
163164
}
165+
166+
static func checkFederation(serviceKey: String, accountName: String) -> URLRequest {
167+
struct Body: Encodable {
168+
let accountName: String
169+
let rememberMe = true
170+
}
171+
172+
var request = URLRequest(url: .federate)
173+
request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:]
174+
request.allHTTPHeaderFields?["Content-Type"] = "application/json"
175+
request.allHTTPHeaderFields?["X-Requested-With"] = "XMLHttpRequest"
176+
request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey
177+
request.allHTTPHeaderFields?["Accept"] = "application/json"
178+
request.httpMethod = "POST"
179+
request.httpBody = try? JSONEncoder().encode(Body(accountName: accountName))
180+
return request
181+
}
182+
183+
static func federateValidate(widgetKey: String, token: String, relayState: String) -> URLRequest {
184+
var components = URLComponents(url: .federateValidate, resolvingAgainstBaseURL: false)!
185+
components.queryItems = [
186+
URLQueryItem(name: "widgetKey", value: widgetKey),
187+
URLQueryItem(name: "token", value: token),
188+
URLQueryItem(name: "relayState", value: relayState)
189+
]
190+
var request = URLRequest(url: components.url!)
191+
request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:]
192+
request.allHTTPHeaderFields?["Accept"] = "application/json"
193+
return request
194+
}
164195

165196
static func SRPInit(serviceKey: String, a: String, accountName: String) -> URLRequest {
166197
struct ServerSRPInitRequest: Encodable {
@@ -208,4 +239,3 @@ public enum SRPProtocol: String, Codable, Sendable {
208239
case s2k, s2k_fo
209240
}
210241

211-

0 commit comments

Comments
 (0)