Skip to content

Commit 82f70f2

Browse files
authored
Implement auth command (#55)
1 parent 9611546 commit 82f70f2

File tree

10 files changed

+344
-147
lines changed

10 files changed

+344
-147
lines changed

.github/workflows/integration-tests.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ jobs:
3030
fail-fast: false
3131
matrix:
3232
macos: [macos-10.15, macos-11, macos-12]
33-
command: [download, search]
33+
command: [auth, download, search]
3434
steps:
3535
- name: Download binary
3636
uses: actions/download-artifact@v2

.swiftpm/xcode/xcshareddata/xcschemes/IPATool-Package.xcscheme

+14
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,20 @@
7676
ReferencedContainer = "container:">
7777
</BuildableReference>
7878
</BuildActionEntry>
79+
<BuildActionEntry
80+
buildForTesting = "YES"
81+
buildForRunning = "YES"
82+
buildForProfiling = "YES"
83+
buildForArchiving = "YES"
84+
buildForAnalyzing = "YES">
85+
<BuildableReference
86+
BuildableIdentifier = "primary"
87+
BlueprintIdentifier = "Persistence"
88+
BuildableName = "Persistence"
89+
BlueprintName = "Persistence"
90+
ReferencedContainer = "container:">
91+
</BuildableReference>
92+
</BuildActionEntry>
7993
</BuildActionEntries>
8094
</BuildAction>
8195
<TestAction

Package.resolved

+9
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
{
22
"object": {
33
"pins": [
4+
{
5+
"package": "KeychainAccess",
6+
"repositoryURL": "https://github.com/kishikawakatsumi/KeychainAccess",
7+
"state": {
8+
"branch": "v4.2.2",
9+
"revision": "84e546727d66f1adc5439debad16270d0fdd04e7",
10+
"version": null
11+
}
12+
},
413
{
514
"package": "swift-argument-parser",
615
"repositoryURL": "https://github.com/apple/swift-argument-parser",

Package.swift

+10-4
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,22 @@ let package = Package(
88
products: [
99
.executable(name: "ipatool", targets: ["CLI"]),
1010
.library(name: "StoreAPI", targets: ["StoreAPI"]),
11-
.library(name: "Networking", targets: ["Networking"])
11+
.library(name: "Networking", targets: ["Networking"]),
12+
.library(name: "Persistence", targets: ["Persistence"])
1213
],
1314
dependencies: [
1415
.package(url: "https://github.com/apple/swift-argument-parser", revision: "1.0.2"),
15-
.package(url: "https://github.com/weichsel/ZIPFoundation", revision: "0.9.14")
16+
.package(url: "https://github.com/weichsel/ZIPFoundation", revision: "0.9.14"),
17+
.package(url: "https://github.com/kishikawakatsumi/KeychainAccess", revision: "v4.2.2")
1618
],
1719
targets: [
1820
.executableTarget(
1921
name: "CLI",
2022
dependencies: [
2123
.product(name: "ArgumentParser", package: "swift-argument-parser"),
2224
.byName(name: "Networking"),
23-
.byName(name: "StoreAPI")
25+
.byName(name: "StoreAPI"),
26+
.byName(name: "Persistence")
2427
]
2528
),
2629
.target(
@@ -32,6 +35,9 @@ let package = Package(
3235
]
3336
),
3437
.target(name: "Networking", dependencies: []),
35-
.testTarget(name: "NetworkingTests", dependencies: ["Networking"])
38+
.testTarget(name: "NetworkingTests", dependencies: ["Networking"]),
39+
.target(name: "Persistence", dependencies: [
40+
.byName(name: "KeychainAccess")
41+
]),
3642
]
3743
)

README.md

+41-23
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# IPATool
2+
23
[![Release](https://img.shields.io/github/release/majd/ipatool.svg?label=Release)](https://GitHub.com/majd/ipatool/releases/)
34
[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/majd/ipatool/blob/main/LICENSE)
45
[![Unit Tests](https://github.com/majd/ipatool/actions/workflows/unit-tests.yml/badge.svg)](https://github.com/majd/ipatool/actions/workflows/unit-tests.yml)
@@ -10,17 +11,18 @@
1011

1112
![Demo](./demo.gif)
1213

13-
* [Requirements](#requirements)
14-
* [Installation](#installation)
15-
* [Manual](#manual)
16-
* [Homebrew](#homebrew)
17-
* [Usage](#usage)
18-
* [FAQ](https://github.com/majd/ipatool/wiki/FAQ)
19-
* [License](#license)
14+
- [Requirements](#requirements)
15+
- [Installation](#installation)
16+
- [Manual](#manual)
17+
- [Homebrew](#homebrew)
18+
- [Usage](#usage)
19+
- [FAQ](https://github.com/majd/ipatool/wiki/FAQ)
20+
- [License](#license)
2021

2122
## Requirements
22-
* macOS 10.15 or later.
23-
* Apple ID set up to use the App Store.
23+
24+
- macOS 10.15 or later.
25+
- Apple ID set up to use the App Store.
2426

2527
## Installation
2628

@@ -39,6 +41,24 @@ $ brew install ipatool
3941

4042
## Usage
4143

44+
To authenticate with the App Store, use the `auth` command.
45+
46+
```
47+
OVERVIEW: Authenticate with the App Store.
48+
49+
USAGE: ipatool auth <subcommand>
50+
51+
OPTIONS:
52+
--version Show the version.
53+
-h, --help Show help information.
54+
55+
SUBCOMMANDS:
56+
login Login to the App Store.
57+
revoke Revoke your App Store credentials.
58+
59+
See 'ipatool help auth <subcommand>' for detailed help.
60+
```
61+
4262
To search for apps on the App Store, use the `search` command.
4363

4464
```
@@ -50,11 +70,13 @@ ARGUMENTS:
5070
<term> The term to search for.
5171
5272
OPTIONS:
53-
-l, --limit <limit> The maximum amount of search results to retrieve. (default: 5)
54-
-c, --country <country> The two-letter (ISO 3166-1 alpha-2) country code for the
55-
iTunes Store. (default: US)
73+
-l, --limit <limit> The maximum amount of search results to retrieve.
74+
(default: 5)
75+
-c, --country <country> The two-letter (ISO 3166-1 alpha-2) country code for
76+
the iTunes Store. (default: US)
5677
-d, --device-family <device-family>
57-
The device family to limit the search query to. (default: iPhone)
78+
The device family to limit the search query to.
79+
(default: iPhone)
5880
--log-level <log-level> The log level. (default: info)
5981
--version Show the version.
6082
-h, --help Show help information.
@@ -65,28 +87,24 @@ To download a copy of the ipa file, use the `download` command.
6587
```
6688
OVERVIEW: Download (encrypted) iOS app packages from the App Store.
6789
68-
USAGE: ipatool download --bundle-identifier <bundle-identifier> [--email <email>] [--password <password>] [--auth-code <auth-code>] [--country <country>] [--device-family <device-family>] [--output <output>] [--log-level <log-level>]
90+
USAGE: ipatool download --bundle-identifier <bundle-identifier> [--country <country>] [--device-family <device-family>] [--output <output>] [--log-level <log-level>]
6991
7092
OPTIONS:
7193
-b, --bundle-identifier <bundle-identifier>
7294
The bundle identifier of the target iOS app.
73-
-e, --email <email> The email address for the Apple ID.
74-
-p, --password <password>
75-
The password for the Apple ID.
76-
--auth-code <auth-code> The 2FA code for the Apple ID.
77-
-c, --country <country> The two-letter (ISO 3166-1 alpha-2)
78-
country code for the iTunes Store. (default: US)
95+
-c, --country <country> The two-letter (ISO 3166-1 alpha-2) country code for
96+
the iTunes Store. (default: US)
7997
-d, --device-family <device-family>
80-
The device family to limit the search query to. (default: iPhone)
98+
The device family to limit the search query to.
99+
(default: iPhone)
81100
-o, --output <output> The destination path of the downloaded app package.
82101
--log-level <log-level> The log level. (default: info)
83102
--version Show the version.
84103
-h, --help Show help information.
85-
86104
```
87105

88106
**Note:** You can specify the Apple ID email address and username as arguments when using the tool or by setting them as environment variables (`IPATOOL_EMAIL` and `IPATOOL_PASSWORD`). If you do not specify this information using either of those methods, the tool will prompt for user input in an interactive session. Similarly, you can supply the 2FA code interactively or using the environment variable `IPATOOL_2FA_CODE`.
89107

90108
## License
91109

92-
IPATool is released under the [MIT license](https://github.com/majd/ipatool/blob/main/LICENSE).
110+
IPATool is released under the [MIT license](https://github.com/majd/ipatool/blob/main/LICENSE).

Sources/CLI/Commands/Auth.swift

+201
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
//
2+
// Auth.swift
3+
// IPATool
4+
//
5+
// Created by Majd Alfhaily on 21.03.22.
6+
//
7+
8+
import ArgumentParser
9+
import Foundation
10+
import Networking
11+
import StoreAPI
12+
import Persistence
13+
14+
struct Auth: AsyncParsableCommand {
15+
static var configuration: CommandConfiguration {
16+
return .init(
17+
commandName: "auth",
18+
abstract: "Authenticate with the App Store.",
19+
subcommands: [Login.self, Revoke.self],
20+
defaultSubcommand: nil
21+
)
22+
}
23+
}
24+
25+
extension Auth {
26+
struct Login: AsyncParsableCommand {
27+
static var configuration: CommandConfiguration {
28+
return .init(abstract: "Login to the App Store.")
29+
}
30+
31+
@Option(name: [.short, .customLong("email")], help: "The email address for the Apple ID.")
32+
private var emailArgument: String?
33+
34+
@Option(name: [.short, .customLong("password")], help: "The password for the Apple ID.")
35+
private var passwordArgument: String?
36+
37+
@Option(name: [.customLong("auth-code")], help: "The 2FA code for the Apple ID.")
38+
private var authCodeArgument: String?
39+
40+
@Option(name: [.long], help: "The log level.")
41+
private var logLevel: LogLevel = .info
42+
43+
lazy var logger = ConsoleLogger(level: logLevel)
44+
}
45+
46+
struct Revoke: AsyncParsableCommand {
47+
static var configuration: CommandConfiguration {
48+
return .init(abstract: "Revoke your App Store credentials.")
49+
}
50+
51+
@Option(name: [.long], help: "The log level.")
52+
private var logLevel: LogLevel = .info
53+
54+
lazy var logger = ConsoleLogger(level: logLevel)
55+
}
56+
}
57+
58+
extension Auth.Login {
59+
private mutating func email() -> String {
60+
if let email = emailArgument {
61+
return email
62+
} else if let email = ProcessInfo.processInfo.environment["IPATOOL_EMAIL"] {
63+
return email
64+
} else if let email = String(validatingUTF8: UnsafePointer<CChar>(getpass(logger.compile("Enter Apple ID email: ", level: .warning)))) {
65+
return email
66+
} else {
67+
logger.log("An Apple ID email address is required.", level: .error)
68+
_exit(1)
69+
}
70+
}
71+
72+
private mutating func password() -> String {
73+
if let password = passwordArgument {
74+
return password
75+
} else if let password = ProcessInfo.processInfo.environment["IPATOOL_PASSWORD"] {
76+
return password
77+
} else if let password = String(validatingUTF8: UnsafePointer<CChar>(getpass(logger.compile("Enter Apple ID password: ", level: .warning)))) {
78+
return password
79+
} else {
80+
logger.log("An Apple ID password is required.", level: .error)
81+
_exit(1)
82+
}
83+
}
84+
85+
private mutating func authCode() -> String {
86+
if let authCode = authCodeArgument {
87+
return authCode
88+
} else if let authCode = ProcessInfo.processInfo.environment["IPATOOL_2FA_CODE"] {
89+
return authCode
90+
} else if let authCode = String(validatingUTF8: UnsafePointer<CChar>(getpass(logger.compile("Enter 2FA code: ", level: .warning)))) {
91+
return authCode
92+
} else {
93+
logger.log("A 2FA auth-code is required.", level: .error)
94+
_exit(1)
95+
}
96+
}
97+
98+
private mutating func authenticate(email: String, password: String) async -> Account {
99+
logger.log("Creating HTTP client...", level: .debug)
100+
let httpClient = HTTPClient(session: URLSession.shared)
101+
102+
logger.log("Creating App Store client...", level: .debug)
103+
let storeClient = StoreClient(httpClient: httpClient)
104+
105+
do {
106+
logger.log("Authenticating with the App Store...", level: .info)
107+
let account = try await storeClient.authenticate(email: email, password: password, code: nil)
108+
return Account(
109+
name: "\(account.firstName) \(account.lastName)",
110+
email: email,
111+
passwordToken: account.passwordToken,
112+
directoryServicesIdentifier: account.directoryServicesIdentifier
113+
)
114+
} catch {
115+
switch error {
116+
case StoreResponse.Error.codeRequired:
117+
do {
118+
let account = try await storeClient.authenticate(email: email, password: password, code: authCode())
119+
return Account(
120+
name: "\(account.firstName) \(account.lastName)",
121+
email: email,
122+
passwordToken: account.passwordToken,
123+
directoryServicesIdentifier: account.directoryServicesIdentifier
124+
)
125+
} catch {
126+
logger.log("\(error)", level: .debug)
127+
128+
switch error {
129+
case StoreClient.Error.invalidResponse:
130+
logger.log("Received invalid response.", level: .error)
131+
case StoreResponse.Error.invalidAccount:
132+
logger.log("This Apple ID has not been set up to use the App Store.", level: .error)
133+
case StoreResponse.Error.invalidCredentials:
134+
logger.log("Invalid credentials.", level: .error)
135+
case StoreResponse.Error.lockedAccount:
136+
logger.log("This Apple ID has been disabled for security reasons.", level: .error)
137+
default:
138+
logger.log("An unknown error has occurred.", level: .error)
139+
}
140+
141+
_exit(1)
142+
}
143+
default:
144+
logger.log("\(error)", level: .debug)
145+
146+
switch error {
147+
case StoreClient.Error.invalidResponse:
148+
logger.log("Received invalid response.", level: .error)
149+
case StoreResponse.Error.invalidAccount:
150+
logger.log("This Apple ID has not been set up to use the App Store.", level: .error)
151+
case StoreResponse.Error.invalidCredentials:
152+
logger.log("Invalid credentials.", level: .error)
153+
case StoreResponse.Error.lockedAccount:
154+
logger.log("This Apple ID has been disabled for security reasons.", level: .error)
155+
default:
156+
logger.log("An unknown error has occurred.", level: .error)
157+
}
158+
159+
_exit(1)
160+
}
161+
}
162+
}
163+
164+
mutating func run() async throws {
165+
// Get Apple ID email
166+
let email: String = email()
167+
168+
// Get Apple ID password
169+
let password: String = password()
170+
171+
// Authenticate with the App Store
172+
let account: Account = await authenticate(email: email, password: password)
173+
174+
// Store data in keychain
175+
do {
176+
let keychainStore = KeychainStore(service: "ipatool.service")
177+
try keychainStore.setValue(account, forKey: "account")
178+
179+
logger.log("Authenticated as '\(account.name)'.", level: .info)
180+
} catch {
181+
logger.log("Failed to save account data in keychain.", level: .error)
182+
logger.log("\(error)", level: .debug)
183+
184+
_exit(1)
185+
}
186+
}
187+
}
188+
189+
extension Auth.Revoke {
190+
mutating func run() async throws {
191+
let keychainStore = KeychainStore(service: "ipatool.service")
192+
193+
guard let account: Account = try keychainStore.value(forKey: "account") else {
194+
logger.log("No credentials available to revoke.", level: .error)
195+
_exit(1)
196+
}
197+
198+
try keychainStore.remove("account")
199+
logger.log("Revoked credentials for '\(account.name)'.", level: .info)
200+
}
201+
}

0 commit comments

Comments
 (0)