Skip to content

Commit b8037a7

Browse files
committed
Add concurrency-ready login services
1 parent e31b43c commit b8037a7

14 files changed

Lines changed: 736 additions & 79 deletions

Package.resolved

Lines changed: 11 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,15 @@ let package = Package(
1111
.library(
1212
name: "XcodesLoginKit",
1313
targets: ["XcodesLoginKit"]),
14+
.library(
15+
name: "XcodesLoginKitSecurityKey",
16+
targets: ["XcodesLoginKitSecurityKey"]),
1417
],
1518
dependencies: [
1619
.package(url: "https://github.com/XcodesOrg/swift-srp", branch: "main"),
1720
.package(url: "https://github.com/XcodesOrg/AsyncHTTPNetworkService", branch: "main"),
18-
.package(url: "https://github.com/kinoroy/LibFido2Swift", from: "0.1.4")
21+
.package(url: "https://github.com/kinoroy/LibFido2Swift", from: "0.1.4"),
22+
.package(url: "https://github.com/jpsim/Yams", .upToNextMinor(from: "5.0.1")),
1923
],
2024
targets: [
2125
// Targets are the basic building blocks of a package, defining a module or a test suite.
@@ -25,13 +29,24 @@ let package = Package(
2529
dependencies: [
2630
.product(name: "SRP", package: "swift-srp"),
2731
.product(name: "AsyncNetworkService", package: "AsyncHTTPNetworkService"),
28-
.product(name: "LibFido2Swift", package: "libfido2swift")
32+
"Yams",
2933
],
3034
path: "./Sources")
3135
,
36+
.target(
37+
name: "XcodesLoginKitSecurityKey",
38+
dependencies: [
39+
"XcodesLoginKit",
40+
.product(name: "LibFido2Swift", package: "libfido2swift")
41+
],
42+
path: "./SourcesSecurityKey"
43+
),
3244
.testTarget(
3345
name: "XcodesLoginKitTests",
34-
dependencies: ["XcodesLoginKit"]
46+
dependencies: ["XcodesLoginKit"],
47+
resources: [
48+
.copy("Fixtures"),
49+
]
3550
),
3651
]
3752
)
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import Foundation
2+
3+
public actor AppleSessionService {
4+
public typealias EnvironmentValue = @Sendable (String) -> String?
5+
public typealias DefaultUsername = @Sendable () -> String?
6+
public typealias SetDefaultUsername = @Sendable (String?) throws -> Void
7+
public typealias KeychainString = @Sendable (String) throws -> String?
8+
public typealias KeychainSet = @Sendable (String, String) throws -> Void
9+
public typealias KeychainRemove = @Sendable (String) throws -> Void
10+
public typealias ReadLine = @Sendable (String) -> String?
11+
public typealias ReadSecureLine = @Sendable (String) -> String?
12+
public typealias ValidateSession = @Sendable () async throws -> Void
13+
public typealias Login = @Sendable (String, String) async throws -> Void
14+
public typealias Signout = @Sendable () async -> Void
15+
public typealias LoadData = @Sendable (URLRequest) async throws -> (Data, URLResponse)
16+
public typealias Log = @Sendable (String) -> Void
17+
18+
public struct Dependencies: Sendable {
19+
public var environmentValue: EnvironmentValue
20+
public var defaultUsername: DefaultUsername
21+
public var setDefaultUsername: SetDefaultUsername
22+
public var keychainString: KeychainString
23+
public var keychainSet: KeychainSet
24+
public var keychainRemove: KeychainRemove
25+
public var readLine: ReadLine
26+
public var readSecureLine: ReadSecureLine
27+
public var validateSession: ValidateSession
28+
public var login: Login
29+
public var signout: Signout
30+
public var loadData: LoadData
31+
public var log: Log
32+
33+
public init(
34+
environmentValue: @escaping EnvironmentValue,
35+
defaultUsername: @escaping DefaultUsername,
36+
setDefaultUsername: @escaping SetDefaultUsername,
37+
keychainString: @escaping KeychainString,
38+
keychainSet: @escaping KeychainSet,
39+
keychainRemove: @escaping KeychainRemove,
40+
readLine: @escaping ReadLine,
41+
readSecureLine: @escaping ReadSecureLine,
42+
validateSession: @escaping ValidateSession,
43+
login: @escaping Login,
44+
signout: @escaping Signout,
45+
loadData: @escaping LoadData,
46+
log: @escaping Log = { _ in }
47+
) {
48+
self.environmentValue = environmentValue
49+
self.defaultUsername = defaultUsername
50+
self.setDefaultUsername = setDefaultUsername
51+
self.keychainString = keychainString
52+
self.keychainSet = keychainSet
53+
self.keychainRemove = keychainRemove
54+
self.readLine = readLine
55+
self.readSecureLine = readSecureLine
56+
self.validateSession = validateSession
57+
self.login = login
58+
self.signout = signout
59+
self.loadData = loadData
60+
self.log = log
61+
}
62+
}
63+
64+
private let xcodesUsername = "XCODES_USERNAME"
65+
private let xcodesPassword = "XCODES_PASSWORD"
66+
private let dependencies: Dependencies
67+
68+
public init(dependencies: Dependencies) {
69+
self.dependencies = dependencies
70+
}
71+
72+
private func findUsername() -> String? {
73+
if let username = dependencies.environmentValue(xcodesUsername) {
74+
return username
75+
} else if let username = dependencies.defaultUsername() {
76+
return username
77+
}
78+
return nil
79+
}
80+
81+
private func findPassword(withUsername username: String) -> String? {
82+
if let password = dependencies.environmentValue(xcodesPassword) {
83+
return password
84+
} else if let password = try? dependencies.keychainString(username) {
85+
return password
86+
}
87+
return nil
88+
}
89+
90+
public func validateADCSession(path: String) async throws {
91+
try await DeveloperPortalSessionService(
92+
loadData: dependencies.loadData
93+
).validateADCSession(path: path)
94+
}
95+
96+
public func loginIfNeeded(withUsername providedUsername: String? = nil, shouldPromptForPassword: Bool = false) async throws {
97+
do {
98+
try await dependencies.validateSession()
99+
return
100+
} catch {
101+
var possibleUsername = providedUsername ?? findUsername()
102+
var hasPromptedForUsername = false
103+
if possibleUsername == nil {
104+
possibleUsername = dependencies.readLine("Apple ID: ")
105+
hasPromptedForUsername = true
106+
}
107+
guard let username = possibleUsername else { throw Error.missingUsernameOrPassword }
108+
109+
let passwordPrompt: String
110+
if hasPromptedForUsername {
111+
passwordPrompt = "Apple ID Password: "
112+
} else {
113+
passwordPrompt = "Apple ID Password (\(username)): "
114+
}
115+
var possiblePassword = findPassword(withUsername: username)
116+
if possiblePassword == nil || shouldPromptForPassword {
117+
possiblePassword = dependencies.readSecureLine(passwordPrompt)
118+
}
119+
guard let password = possiblePassword else { throw Error.missingUsernameOrPassword }
120+
121+
do {
122+
try await login(username, password: password)
123+
} catch {
124+
dependencies.log(error.localizedDescription)
125+
126+
guard case AuthenticationError.invalidUsernameOrPassword = error else { throw error }
127+
128+
dependencies.log("Try entering your password again")
129+
try await loginIfNeeded(withUsername: username, shouldPromptForPassword: true)
130+
}
131+
}
132+
}
133+
134+
public func login(_ username: String, password: String) async throws {
135+
do {
136+
try await dependencies.login(username, password)
137+
} catch {
138+
if case AuthenticationError.invalidUsernameOrPassword = error {
139+
try? dependencies.keychainRemove(username)
140+
}
141+
142+
throw error
143+
}
144+
145+
try? dependencies.keychainSet(password, username)
146+
147+
if dependencies.defaultUsername() != username {
148+
try? dependencies.setDefaultUsername(username)
149+
}
150+
}
151+
152+
public func logout() async throws {
153+
guard let username = findUsername() else { throw Error.notAuthenticated }
154+
155+
await dependencies.signout()
156+
try dependencies.keychainRemove(username)
157+
try dependencies.setDefaultUsername(nil)
158+
}
159+
}
160+
161+
public extension AppleSessionService {
162+
enum Error: LocalizedError, Equatable {
163+
case missingUsernameOrPassword
164+
case notAuthenticated
165+
166+
public var errorDescription: String? {
167+
switch self {
168+
case .missingUsernameOrPassword:
169+
return "Missing username or a password. Please try again."
170+
case .notAuthenticated:
171+
return "You are already signed out"
172+
}
173+
}
174+
}
175+
}

Sources/XcodesLoginKit/AuthenticationError.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import Foundation
99

10-
public enum AuthenticationError: Swift.Error, LocalizedError, Equatable {
10+
public enum AuthenticationError: Swift.Error, LocalizedError, Equatable, Sendable {
1111
case invalidSession
1212
case invalidHashcash
1313
case invalidUsernameOrPassword(username: String)
@@ -30,8 +30,8 @@ public enum AuthenticationError: Swift.Error, LocalizedError, Equatable {
3030
return "Your authentication session is invalid. Try signing in again."
3131
case .invalidHashcash:
3232
return "Could not create a hashcash for the session."
33-
case .invalidUsernameOrPassword:
34-
return "Invalid username and password combination."
33+
case let .invalidUsernameOrPassword(username):
34+
return "Invalid username and password combination. Attempted to sign in with username \(username)."
3535
case .incorrectSecurityCode:
3636
return "The code that was entered is incorrect."
3737
case let .unexpectedSignInResponse(statusCode, message):

Sources/XcodesLoginKit/AuthenticationState.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ public struct AuthOptionsResponse: Equatable, Decodable, Sendable {
113113
}
114114
}
115115

116-
public enum Kind: Equatable {
116+
public enum Kind: Equatable, Sendable {
117117
case twoStep, twoFactor, securityKey, unknown
118118
}
119119
}
@@ -143,7 +143,7 @@ public struct FSAChallenge: Equatable, Decodable, Sendable {
143143
public let allowedCredentials: String
144144
}
145145

146-
public enum SecurityCode {
146+
public enum SecurityCode: Sendable {
147147
case device(code: String)
148148
case sms(code: String, phoneNumberId: Int)
149149

@@ -155,15 +155,15 @@ public enum SecurityCode {
155155
}
156156
}
157157

158-
struct ServiceKeyResponse: Decodable {
158+
struct ServiceKeyResponse: Decodable, Sendable {
159159
let authServiceKey: String
160160
}
161161

162-
struct SignInResponse: Decodable {
162+
struct SignInResponse: Decodable, Sendable {
163163
let authType: String?
164164
let serviceErrors: [ServiceError]?
165165

166-
struct ServiceError: Decodable, CustomStringConvertible {
166+
struct ServiceError: Decodable, CustomStringConvertible, Sendable {
167167
let code: String
168168
let message: String
169169

0 commit comments

Comments
 (0)