Skip to content

Commit 4de8e5c

Browse files
committed
documentation
1 parent 34bcac9 commit 4de8e5c

10 files changed

Lines changed: 422 additions & 6 deletions

README.md

Lines changed: 127 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,130 @@
11
## XcodesLoginKit
22

3-
An Swift API to log in to to Apple's developer website.
3+
A Swift API for signing in to Apple's developer services.
44

5-
More to come.
5+
XcodesLoginKit handles Apple's SRP password login, federated Apple IDs, two-factor
6+
authentication, session validation, logout, and optional fastlane/Spaceship cookie import.
7+
8+
### Getting started
9+
10+
Add the package to your `Package.swift`:
11+
12+
```swift
13+
.package(url: "https://github.com/XcodesOrg/XcodesLoginKit", branch: "main")
14+
```
15+
16+
Then add the product you need:
17+
18+
```swift
19+
.product(name: "XcodesLoginKit", package: "XcodesLoginKit")
20+
```
21+
22+
Use `Client` when your app wants to drive its own sign-in UI:
23+
24+
```swift
25+
import Foundation
26+
import XcodesLoginKit
27+
28+
let client = Client()
29+
30+
let state = try await client.authenticationState(
31+
accountName: "name@example.com",
32+
password: password
33+
)
34+
35+
switch state {
36+
case .authenticated(let session):
37+
print("Signed in as \(session.user.fullName ?? "Apple Developer")")
38+
39+
case .waitingForFederatedAuthentication(let federation):
40+
guard let idpURL = federation.idpURL else {
41+
throw AuthenticationError.federatedAuthenticationRequired
42+
}
43+
44+
// Open idpURL in a browser. After the user signs in, collect the redirected URL.
45+
let callbackURLString = "... pasted or received callback URL ..."
46+
try await client.validateFederatedCallbackURLString(callbackURLString)
47+
48+
case .waitingForSecondFactor(let option, let authOptions, let sessionData):
49+
switch option {
50+
case .codeSent:
51+
let code = "... code from trusted device ..."
52+
try await client.submitSecurityCode(.device(code: code), sessionData: sessionData)
53+
54+
case .smsPendingChoice:
55+
guard let phoneNumber = authOptions.trustedPhoneNumbers?.first else { return }
56+
try await client.requestSMSSecurityCode(
57+
to: phoneNumber,
58+
authOptions: authOptions,
59+
sessionData: sessionData
60+
)
61+
62+
case .smsSent(let phoneNumber):
63+
let code = "... code from SMS ..."
64+
try await client.submitSecurityCode(
65+
.sms(code: code, phoneNumberId: phoneNumber.id),
66+
sessionData: sessionData
67+
)
68+
69+
case .securityKey:
70+
// Add the XcodesLoginKitSecurityKey product and call
71+
// submitSecurityKeyPinCode(_:sessionData:authOptions:).
72+
break
73+
}
74+
75+
case .unauthenticated, .notAppleDeveloper:
76+
break
77+
}
78+
```
79+
80+
### Main flow
81+
82+
1. Create a `Client`. Pass a custom `URLSession` if you want isolated cookie storage.
83+
2. Call `authenticationState(accountName:password:)`.
84+
3. Switch on `AuthenticationState`.
85+
4. Complete any required federated authentication or second-factor challenge.
86+
5. Call `validateSession()` later to check whether stored cookies are still valid.
87+
6. Call `signout()` to clear the client's cookies.
88+
89+
### Federated Apple IDs
90+
91+
Federated accounts return `.waitingForFederatedAuthentication`. Open `FederationResponse.idpURL`
92+
in a browser, let the user finish identity-provider sign-in, then pass the final callback URL to
93+
`validateFederatedCallbackURL(_:)` or `validateFederatedCallbackURLString(_:)`.
94+
95+
### Reusing fastlane sessions
96+
97+
If the user already has a fastlane session, load its cookies into a session and pass that session
98+
to `Client`:
99+
100+
```swift
101+
let loader = FastlaneSessionLoader()
102+
let session = try loader.session(
103+
fastlaneUser: "name@example.com",
104+
environmentValue: { ProcessInfo.processInfo.environment[$0] }
105+
)
106+
107+
let client = Client(urlSession: session)
108+
let state = try await client.validateSession()
109+
```
110+
111+
Use `FastlaneSessionLoader.Constants.fastlaneSessionEnvVarName` as `fastlaneUser` to load cookies
112+
from `FASTLANE_SESSION` instead of `~/.fastlane/spaceship/<apple-id>/cookie`.
113+
114+
### Higher-level session management
115+
116+
`AppleSessionService` wraps the low-level client with dependency-injected environment lookup,
117+
keychain storage, prompts, browser opening, validation, login, and logout. It is a good fit for
118+
command-line tools that want a "login if needed" workflow while still controlling where credentials
119+
are stored and how users are prompted.
120+
121+
### Security keys
122+
123+
Add the `XcodesLoginKitSecurityKey` product when you need FIDO2 security-key support:
124+
125+
```swift
126+
.product(name: "XcodesLoginKitSecurityKey", package: "XcodesLoginKit")
127+
```
128+
129+
When the login state is `.waitingForSecondFactor(.securityKey, authOptions, sessionData)`, call
130+
`submitSecurityKeyPinCode(_:sessionData:authOptions:)`.

Sources/XcodesLoginKit/AppleSessionService.swift

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import Foundation
22

3+
/// Coordinates credential lookup, prompting, login, validation, and logout for Apple Developer sessions.
4+
///
5+
/// `AppleSessionService` is the high-level API for command-line tools and apps that want an injectable
6+
/// workflow around ``Client``. It can read credentials from environment variables or a keychain, prompt
7+
/// when credentials are missing, open a browser for federated accounts, and remember the default username
8+
/// after a successful login.
39
public actor AppleSessionService {
410
public typealias EnvironmentValue = @Sendable (String) -> String?
511
public typealias DefaultUsername = @Sendable () -> String?
@@ -19,25 +25,48 @@ public actor AppleSessionService {
1925
public typealias LoadData = @Sendable (URLRequest) async throws -> (Data, URLResponse)
2026
public typealias Log = @Sendable (String) -> Void
2127

28+
/// Dependencies used by ``AppleSessionService`` to interact with the host app and storage.
29+
///
30+
/// Supply closures for environment lookup, keychain access, prompting, browser opening, networking,
31+
/// and the underlying login operations. This keeps the service testable and lets each app decide how
32+
/// credentials and user interaction should work.
2233
public struct Dependencies: Sendable {
34+
/// Reads a process environment value.
2335
public var environmentValue: EnvironmentValue
36+
/// Returns the remembered default Apple ID, if any.
2437
public var defaultUsername: DefaultUsername
38+
/// Stores or clears the remembered default Apple ID.
2539
public var setDefaultUsername: SetDefaultUsername
40+
/// Reads a string from secure storage for a username.
2641
public var keychainString: KeychainString
42+
/// Stores a string in secure storage for a username.
2743
public var keychainSet: KeychainSet
44+
/// Removes a username's stored secret.
2845
public var keychainRemove: KeychainRemove
46+
/// Prompts for a single line of input.
2947
public var readLine: ReadLine
48+
/// Prompts for a long line of input, such as a pasted browser callback URL.
3049
public var readLongLine: ReadLongLine
50+
/// Prompts for hidden input, such as a password.
3151
public var readSecureLine: ReadSecureLine
52+
/// Validates the current Apple session.
3253
public var validateSession: ValidateSession
54+
/// Performs username and password login.
3355
public var login: Login
56+
/// Checks whether an Apple ID uses federated authentication.
3457
public var checkIsFederated: CheckIsFederated
58+
/// Completes federated login from a pasted callback URL string.
3559
public var validateFederatedCallbackURL: ValidateFederatedCallbackURL
60+
/// Opens a URL in the host app or system browser.
3661
public var openURL: OpenURL
62+
/// Signs out from the underlying Apple session.
3763
public var signout: Signout
64+
/// Loads data for a URL request, used by developer-portal validation.
3865
public var loadData: LoadData
66+
/// Receives user-visible progress or recovery messages.
3967
public var log: Log
4068

69+
/// Creates a dependency container for ``AppleSessionService``.
4170
public init(
4271
environmentValue: @escaping EnvironmentValue,
4372
defaultUsername: @escaping DefaultUsername,
@@ -81,6 +110,7 @@ public actor AppleSessionService {
81110
private let xcodesPassword = "XCODES_PASSWORD"
82111
private let dependencies: Dependencies
83112

113+
/// Creates a service with the host-provided dependencies.
84114
public init(dependencies: Dependencies) {
85115
self.dependencies = dependencies
86116
}
@@ -103,12 +133,24 @@ public actor AppleSessionService {
103133
return nil
104134
}
105135

136+
/// Validates that the current session is authorized to access an Apple Developer download path.
137+
///
138+
/// - Parameter path: The developer download path to validate, such as a path from an Xcode release.
106139
public func validateADCSession(path: String) async throws {
107140
try await DeveloperPortalSessionService(
108141
loadData: dependencies.loadData
109142
).validateADCSession(path: path)
110143
}
111144

145+
/// Ensures that a valid Apple session exists, prompting or signing in only when needed.
146+
///
147+
/// The service first calls `validateSession`. If that fails, it looks for a username from the
148+
/// provided argument, `XCODES_USERNAME`, or the remembered default username. Passwords are read from
149+
/// `XCODES_PASSWORD`, secure storage, or `readSecureLine`. Federated accounts open the identity
150+
/// provider URL and ask the user to paste the callback URL.
151+
/// - Parameters:
152+
/// - providedUsername: A username to try before environment or default values.
153+
/// - shouldPromptForPassword: Pass `true` to ignore saved passwords and force a password prompt.
112154
public func loginIfNeeded(withUsername providedUsername: String? = nil, shouldPromptForPassword: Bool = false) async throws {
113155
do {
114156
try await dependencies.validateSession()
@@ -180,6 +222,9 @@ public actor AppleSessionService {
180222
}
181223
}
182224

225+
/// Logs in with an explicit username and password, then stores successful credentials.
226+
///
227+
/// If Apple reports invalid credentials, the stored password for that username is removed.
183228
public func login(_ username: String, password: String) async throws {
184229
do {
185230
try await dependencies.login(username, password)
@@ -198,6 +243,7 @@ public actor AppleSessionService {
198243
}
199244
}
200245

246+
/// Signs out, removes the stored password, and clears the remembered default username.
201247
public func logout() async throws {
202248
guard let username = findUsername() else { throw Error.notAuthenticated }
203249

@@ -208,10 +254,14 @@ public actor AppleSessionService {
208254
}
209255

210256
public extension AppleSessionService {
257+
/// Errors raised by the high-level session service before or after Apple authentication.
211258
enum Error: LocalizedError, Equatable {
259+
/// No username or password was available from dependencies or prompting.
212260
case missingUsernameOrPassword
261+
/// Logout was requested when no username could be found.
213262
case notAuthenticated
214263

264+
/// A user-visible description of the service error.
215265
public var errorDescription: String? {
216266
switch self {
217267
case .missingUsernameOrPassword:

Sources/XcodesLoginKit/AuthenticationError.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,26 +7,46 @@
77

88
import Foundation
99

10+
/// Errors returned by XcodesLoginKit's Apple authentication flow.
1011
public enum AuthenticationError: Swift.Error, LocalizedError, Equatable, Sendable {
12+
/// The current cookies do not represent a valid Apple session.
1113
case invalidSession
14+
/// Apple's hashcash challenge could not be solved or parsed.
1215
case invalidHashcash
16+
/// Apple rejected the username and password combination.
1317
case invalidUsernameOrPassword(username: String)
18+
/// Apple rejected the submitted two-factor verification code.
1419
case incorrectSecurityCode
20+
/// Apple returned a sign-in response that XcodesLoginKit does not recognize.
1521
case unexpectedSignInResponse(statusCode: Int, message: String?)
22+
/// The Apple ID & Privacy agreement must be acknowledged in App Store Connect.
1623
case appleIDAndPrivacyAcknowledgementRequired
24+
/// The account uses legacy two-step authentication, which is not supported.
1725
case accountUsesTwoStepAuthentication
26+
/// Apple reported an authentication kind that XcodesLoginKit cannot classify.
1827
case accountUsesUnknownAuthenticationKind(String?)
28+
/// Apple reports that the account is locked.
1929
case accountLocked(String)
30+
/// Apple returned an unexpected HTTP status code.
2031
case badStatusCode(statusCode: Int, data: Data?, response: HTTPURLResponse)
32+
/// The Apple ID is not registered as an Apple Developer.
2133
case notDeveloperAppleId
34+
/// The current session is not authorized for the requested operation.
2235
case notAuthorized
36+
/// Apple returned an invalid or unexpected result payload.
2337
case invalidResult(resultString: String?)
38+
/// The SRP public key or salt returned by Apple could not be decoded.
2439
case srpInvalidPublicKey
40+
/// The user cancelled security-key authentication.
2541
case userCancelledSecurityKeyAuthentication
42+
/// The account requires federated authentication via a browser.
2643
case federatedAuthenticationRequired
44+
/// The federated callback URL did not include the required query parameters.
2745
case invalidFederatedAuthenticationCallback
46+
/// A password is required because the account is not federated.
2847
case missingPasswordForNonFederatedAccount
2948

49+
/// A user-visible error description.
3050
public var errorDescription: String? {
3151
switch self {
3252
case .invalidSession:

0 commit comments

Comments
 (0)