adsedare 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +35 -0
- data/Rakefile +4 -0
- data/adsedare.gemspec +36 -0
- data/exe/adsedare +3 -0
- data/lib/adsedare/capabilities.rb +138 -0
- data/lib/adsedare/version.rb +5 -0
- data/lib/adsedare.rb +518 -0
- data/lib/appstoreconnect.rb +86 -0
- data/lib/logging.rb +26 -0
- data/lib/starship/2fa_provider.rb +40 -0
- data/lib/starship/auth_helper.rb +450 -0
- data/lib/starship.rb +293 -0
- metadata +145 -0
@@ -0,0 +1,450 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "faraday"
|
4
|
+
require "faraday-cookie_jar"
|
5
|
+
require "json"
|
6
|
+
require "base64"
|
7
|
+
require "digest"
|
8
|
+
require "fileutils"
|
9
|
+
require "openssl"
|
10
|
+
require "securerandom"
|
11
|
+
|
12
|
+
require_relative "2fa_provider"
|
13
|
+
require_relative "../logging"
|
14
|
+
|
15
|
+
module Starship
|
16
|
+
class Error < StandardError; end
|
17
|
+
|
18
|
+
# AuthHelper handles authentication with Apple"s developer portal
|
19
|
+
class AuthHelper
|
20
|
+
include Logging
|
21
|
+
|
22
|
+
attr_reader :session, :csrf, :csrf_ts, :session_data
|
23
|
+
|
24
|
+
AUTH_ENDPOINT = "https://idmsa.apple.com/appleauth/auth"
|
25
|
+
WIDGET_KEY_URL = "https://appstoreconnect.apple.com/olympus/v1/app/config?hostname=itunesconnect.apple.com"
|
26
|
+
|
27
|
+
def initialize(two_factor_provider = nil)
|
28
|
+
# Create session directory if it doesn't exist
|
29
|
+
@session_directory = File.expand_path('~/.starship')
|
30
|
+
FileUtils.mkdir_p(@session_directory)
|
31
|
+
|
32
|
+
@widget_key = nil
|
33
|
+
@csrf = nil
|
34
|
+
@csrf_ts = nil
|
35
|
+
@email = nil
|
36
|
+
@session_data = {}
|
37
|
+
@two_factor_provider = two_factor_provider || Starship::ManualTwoFactorProvider.new
|
38
|
+
|
39
|
+
# Initialize Faraday with cookie jar
|
40
|
+
@session = Faraday.new do |builder|
|
41
|
+
builder.use :cookie_jar, jar: HTTP::CookieJar.new
|
42
|
+
builder.adapter Faraday.default_adapter
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# Sign in to Apple Developer Portal
|
47
|
+
# @return [Boolean] Whether authentication was successful
|
48
|
+
def sign_in
|
49
|
+
email = ENV["APPLE_DEVELOPER_USERNAME"]
|
50
|
+
password = ENV["APPLE_DEVELOPER_PASSWORD"]
|
51
|
+
|
52
|
+
if !email || !password
|
53
|
+
raise Error, "Email and password are required. Set APPLE_ID and APPLE_PASSWORD environment variables."
|
54
|
+
end
|
55
|
+
|
56
|
+
@email = email
|
57
|
+
@client_id = generate_session_id(email)
|
58
|
+
cookie_path, session_path = get_paths(email)
|
59
|
+
|
60
|
+
# Try to load existing session
|
61
|
+
if File.exist?(session_path)
|
62
|
+
load_session
|
63
|
+
if validate_token
|
64
|
+
return true
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
logger.warn "Session invalid or expired. Starting authentication from scratch..."
|
69
|
+
@session_data = { "client_id" => @client_id, "email" => email }
|
70
|
+
|
71
|
+
# Start authentication process
|
72
|
+
auth_result = authenticate_with_srp(email, password)
|
73
|
+
|
74
|
+
if auth_result == :two_factor_required
|
75
|
+
logger.info "Two-factor authentication required. Requesting verification code..."
|
76
|
+
|
77
|
+
handle_two_factor_auth
|
78
|
+
elsif auth_result
|
79
|
+
# After successful authentication, get CSRF tokens
|
80
|
+
response = @session.get("https://developer.apple.com/account")
|
81
|
+
if response.status == 200
|
82
|
+
extract_csrf_tokens(response)
|
83
|
+
|
84
|
+
if @csrf && @csrf_ts
|
85
|
+
save_session
|
86
|
+
return true
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
return auth_result
|
92
|
+
end
|
93
|
+
|
94
|
+
# Check if the current session is valid
|
95
|
+
# @return [Boolean] Whether the session is valid
|
96
|
+
def validate_token
|
97
|
+
return false unless @session_data["session_id"] && @session_data["scnt"]
|
98
|
+
|
99
|
+
begin
|
100
|
+
headers = {
|
101
|
+
"Accept" => "application/json, text/plain, */*",
|
102
|
+
"Content-Type" => "application/vnd.api+json",
|
103
|
+
"X-Requested-With" => "XMLHttpRequest",
|
104
|
+
"X-Apple-ID-Session-Id" => @session_data["session_id"],
|
105
|
+
"scnt" => @session_data["scnt"]
|
106
|
+
}
|
107
|
+
|
108
|
+
response = @session.get(
|
109
|
+
"https://developer.apple.com/services-account/v1/certificates",
|
110
|
+
nil,
|
111
|
+
headers
|
112
|
+
)
|
113
|
+
|
114
|
+
if response.status == 403
|
115
|
+
# Fetch CSRF tokens after confirming session is valid
|
116
|
+
csrf_response = @session.get("https://developer.apple.com/account/resources/certificates/list")
|
117
|
+
|
118
|
+
if csrf_response.status == 200
|
119
|
+
extract_csrf_tokens(csrf_response)
|
120
|
+
|
121
|
+
if @csrf && @csrf_ts
|
122
|
+
return true
|
123
|
+
else
|
124
|
+
logger.error "Failed to retrieve CSRF tokens after validating session."
|
125
|
+
return false
|
126
|
+
end
|
127
|
+
end
|
128
|
+
return true
|
129
|
+
else
|
130
|
+
logger.warn "Session is invalid. Will reauthenticate."
|
131
|
+
return false
|
132
|
+
end
|
133
|
+
rescue => e
|
134
|
+
logger.error "Authentication status check failed: #{e.message}"
|
135
|
+
return false
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
def request(endpoint, method: :get, params: nil, body: nil, headers: nil)
|
140
|
+
default_headers = {
|
141
|
+
"Accept" => "application/json, text/plain, */*",
|
142
|
+
"X-Requested-With" => "XMLHttpRequest",
|
143
|
+
"X-HTTP-Method-Override" => "GET",
|
144
|
+
"csrf" => @csrf,
|
145
|
+
"csrf_ts" => @csrf_ts
|
146
|
+
}
|
147
|
+
|
148
|
+
if headers
|
149
|
+
default_headers = default_headers.merge(headers)
|
150
|
+
end
|
151
|
+
|
152
|
+
response = case method
|
153
|
+
when :get
|
154
|
+
@session.get(endpoint, params, default_headers)
|
155
|
+
when :post
|
156
|
+
@session.post(endpoint, body, default_headers)
|
157
|
+
when :put
|
158
|
+
@session.put(endpoint, body, default_headers)
|
159
|
+
when :delete
|
160
|
+
@session.delete(endpoint, default_headers)
|
161
|
+
when :patch
|
162
|
+
@session.patch(endpoint, body, default_headers)
|
163
|
+
end
|
164
|
+
|
165
|
+
if response.status == 401 || response.status == 403
|
166
|
+
logger.warn "Session invalid or expired. Starting authentication from scratch..."
|
167
|
+
|
168
|
+
self.sign_in
|
169
|
+
|
170
|
+
response = self.request(endpoint, method: method, params: params, body: body, headers: headers)
|
171
|
+
end
|
172
|
+
|
173
|
+
return response
|
174
|
+
end
|
175
|
+
|
176
|
+
private
|
177
|
+
|
178
|
+
# Generate a consistent session ID from email
|
179
|
+
# @param email [String] The email address
|
180
|
+
# @return [String] The session ID
|
181
|
+
def generate_session_id(email)
|
182
|
+
"auth-#{Digest::SHA256.hexdigest(email)[0...8]}"
|
183
|
+
end
|
184
|
+
|
185
|
+
# Get the paths for cookie and session files
|
186
|
+
# @param email [String] The email address
|
187
|
+
# @return [Array<String>] The cookie path and session path
|
188
|
+
def get_paths(email)
|
189
|
+
session_id = generate_session_id(email)
|
190
|
+
cookie_path = File.join(@session_directory, "#{session_id}.cookies")
|
191
|
+
session_path = File.join(@session_directory, "#{session_id}.session")
|
192
|
+
[cookie_path, session_path]
|
193
|
+
end
|
194
|
+
|
195
|
+
# Get the cookie jar path
|
196
|
+
# @return [String] The cookie jar path
|
197
|
+
def cookiejar_path
|
198
|
+
raise Error, "Email not set" unless @email
|
199
|
+
get_paths(@email)[0]
|
200
|
+
end
|
201
|
+
|
202
|
+
# Get the session path
|
203
|
+
# @return [String] The session path
|
204
|
+
def session_path
|
205
|
+
raise Error, "Email not set" unless @email
|
206
|
+
get_paths(@email)[1]
|
207
|
+
end
|
208
|
+
|
209
|
+
# Load session data from file
|
210
|
+
# @return [Boolean] Whether the session was loaded successfully
|
211
|
+
def load_session
|
212
|
+
begin
|
213
|
+
@session_data = JSON.parse(File.read(session_path))
|
214
|
+
if File.exist?(cookiejar_path)
|
215
|
+
begin
|
216
|
+
# Create a new cookie jar for Faraday
|
217
|
+
jar = HTTP::CookieJar.new
|
218
|
+
jar.load(cookiejar_path, format: :cookiestxt)
|
219
|
+
|
220
|
+
# Recreate the Faraday connection with the loaded cookies
|
221
|
+
@session = Faraday.new do |builder|
|
222
|
+
builder.use :cookie_jar, jar: jar
|
223
|
+
builder.adapter Faraday.default_adapter
|
224
|
+
end
|
225
|
+
rescue => e
|
226
|
+
logger.warn "Failed to load cookies from file: #{e.message}"
|
227
|
+
end
|
228
|
+
end
|
229
|
+
return true
|
230
|
+
rescue => e
|
231
|
+
logger.warn "Failed to load session from file: #{e.message}"
|
232
|
+
@session_data = {}
|
233
|
+
return false
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
237
|
+
# Save session data to file
|
238
|
+
def save_session
|
239
|
+
logger.info "Saving session to disk at #{session_path}"
|
240
|
+
File.write(session_path, JSON.pretty_generate(@session_data))
|
241
|
+
|
242
|
+
# Save cookies
|
243
|
+
cookie_path = get_paths(@email)[0]
|
244
|
+
|
245
|
+
# Get the cookie jar from Faraday
|
246
|
+
jar = @session.builder.app.instance_variable_get('@jar')
|
247
|
+
if jar
|
248
|
+
# Save all cookies, even if they're marked as discardable or expired
|
249
|
+
jar.save(cookie_path, format: :cookiestxt, session: true)
|
250
|
+
logger.info "Cookies saved to #{cookie_path}"
|
251
|
+
else
|
252
|
+
logger.info "No cookies were found to save."
|
253
|
+
end
|
254
|
+
|
255
|
+
logger.info "Session saved successfully."
|
256
|
+
end
|
257
|
+
|
258
|
+
# Get the widget key used for authentication
|
259
|
+
# @return [String] The widget key
|
260
|
+
def widget_key
|
261
|
+
unless @widget_key
|
262
|
+
response = @session.get(WIDGET_KEY_URL)
|
263
|
+
@widget_key = JSON.parse(response.body)["authServiceKey"] || ""
|
264
|
+
end
|
265
|
+
@widget_key
|
266
|
+
end
|
267
|
+
|
268
|
+
# Get a cookie value by name
|
269
|
+
# @param name [String] The cookie name
|
270
|
+
# @return [String, nil] The cookie value or nil if not found
|
271
|
+
def get_cookie_value(name)
|
272
|
+
# Access the cookie jar middleware from Faraday
|
273
|
+
jar = @session.builder.app.instance_variable_get('@jar')
|
274
|
+
return nil unless jar
|
275
|
+
|
276
|
+
# Find the cookie by name
|
277
|
+
jar.cookies.find { |cookie| cookie.name == name }&.value
|
278
|
+
end
|
279
|
+
|
280
|
+
# Extract CSRF tokens from response
|
281
|
+
# @param response [Faraday::Response] The response object
|
282
|
+
def extract_csrf_tokens(response)
|
283
|
+
# Try cookies first
|
284
|
+
@csrf = get_cookie_value("csrf")
|
285
|
+
@csrf_ts = get_cookie_value("csrf_ts")
|
286
|
+
|
287
|
+
# If not in cookies, try response headers
|
288
|
+
unless @csrf
|
289
|
+
@csrf = response.headers["csrf"]
|
290
|
+
end
|
291
|
+
unless @csrf_ts
|
292
|
+
@csrf_ts = response.headers["csrf_ts"]
|
293
|
+
end
|
294
|
+
|
295
|
+
# If still not found, try to extract from page content
|
296
|
+
if !@csrf || !@csrf_ts
|
297
|
+
if response.body =~ /csrf[""]\s*:\s*[""](.*?)[""]/
|
298
|
+
@csrf = $1
|
299
|
+
end
|
300
|
+
if response.body =~ /csrf_ts[""]\s*:\s*[""](.*?)[""]/
|
301
|
+
@csrf_ts = $1
|
302
|
+
end
|
303
|
+
end
|
304
|
+
end
|
305
|
+
|
306
|
+
# Authenticate with Secure Remote Password protocol
|
307
|
+
# @param email [String] The email address
|
308
|
+
# @param password [String] The password
|
309
|
+
# @return [Boolean, Symbol] True if successful, :two_factor_required if 2FA is needed, false otherwise
|
310
|
+
def authenticate_with_srp(email, password)
|
311
|
+
# This is a simplified SRP implementation since Ruby doesn"t have a direct equivalent to Python"s srp library
|
312
|
+
# In a real implementation, you would use a proper SRP library or implement the full protocol
|
313
|
+
|
314
|
+
headers = {
|
315
|
+
"Accept" => "application/json, text/javascript",
|
316
|
+
"Content-Type" => "application/json",
|
317
|
+
"X-Requested-With" => "XMLHttpRequest",
|
318
|
+
"X-Apple-Widget-Key" => widget_key
|
319
|
+
}
|
320
|
+
|
321
|
+
if @session_data["session_id"]
|
322
|
+
headers.update({
|
323
|
+
"X-Apple-ID-Session-Id" => @session_data["session_id"],
|
324
|
+
"scnt" => @session_data["scnt"]
|
325
|
+
})
|
326
|
+
end
|
327
|
+
|
328
|
+
# For simplicity, we"ll use a direct password-based authentication approach
|
329
|
+
# In a real implementation, you would implement the full SRP protocol
|
330
|
+
auth_data = {
|
331
|
+
"accountName" => email,
|
332
|
+
"password" => password,
|
333
|
+
"rememberMe" => true
|
334
|
+
}
|
335
|
+
|
336
|
+
logger.info "Initializing authentication request to Apple ID..."
|
337
|
+
response = @session.post(
|
338
|
+
"#{AUTH_ENDPOINT}/signin",
|
339
|
+
auth_data.to_json,
|
340
|
+
headers
|
341
|
+
)
|
342
|
+
|
343
|
+
# Handle 409 response (2FA required)
|
344
|
+
if response.status == 409
|
345
|
+
session_id = response.headers["X-Apple-ID-Session-Id"]
|
346
|
+
scnt = response.headers["scnt"]
|
347
|
+
|
348
|
+
if session_id && scnt
|
349
|
+
@session_data.update({
|
350
|
+
"session_id" => session_id,
|
351
|
+
"scnt" => scnt,
|
352
|
+
"client_id" => @client_id,
|
353
|
+
"email" => email
|
354
|
+
})
|
355
|
+
save_session
|
356
|
+
end
|
357
|
+
|
358
|
+
return :two_factor_required
|
359
|
+
end
|
360
|
+
|
361
|
+
# Handle successful authentication
|
362
|
+
if response.status == 200 || response.status == 302
|
363
|
+
session_id = response.headers["X-Apple-ID-Session-Id"]
|
364
|
+
scnt = response.headers["scnt"]
|
365
|
+
|
366
|
+
if session_id && scnt
|
367
|
+
@session_data.update({
|
368
|
+
"session_id" => session_id,
|
369
|
+
"scnt" => scnt,
|
370
|
+
"client_id" => @client_id,
|
371
|
+
"email" => email
|
372
|
+
})
|
373
|
+
save_session
|
374
|
+
logger.info "Session data saved after basic authentication."
|
375
|
+
return true
|
376
|
+
end
|
377
|
+
end
|
378
|
+
|
379
|
+
return false
|
380
|
+
end
|
381
|
+
|
382
|
+
# Handle two-factor authentication
|
383
|
+
# @return [Boolean] Whether 2FA was successful
|
384
|
+
def handle_two_factor_auth
|
385
|
+
session_id = @session_data["session_id"]
|
386
|
+
scnt = @session_data["scnt"]
|
387
|
+
|
388
|
+
unless session_id && scnt
|
389
|
+
logger.error "Missing session data. Cannot continue two-factor authentication."
|
390
|
+
return false
|
391
|
+
end
|
392
|
+
|
393
|
+
begin
|
394
|
+
# Get 2FA code from provider
|
395
|
+
code = @two_factor_provider.get_code(session_id, scnt)
|
396
|
+
|
397
|
+
verify_headers = {
|
398
|
+
"Accept" => "application/json, text/javascript",
|
399
|
+
"Content-Type" => "application/json",
|
400
|
+
"X-Requested-With" => "XMLHttpRequest",
|
401
|
+
"X-Apple-ID-Session-Id" => session_id,
|
402
|
+
"scnt" => scnt,
|
403
|
+
"X-Apple-Widget-Key" => widget_key
|
404
|
+
}
|
405
|
+
|
406
|
+
verify_data = { "securityCode" => { "code" => code.strip } }
|
407
|
+
|
408
|
+
# First verify the security code
|
409
|
+
verify_response = @session.post(
|
410
|
+
"#{AUTH_ENDPOINT}/verify/trusteddevice/securitycode",
|
411
|
+
verify_data.to_json,
|
412
|
+
verify_headers
|
413
|
+
)
|
414
|
+
|
415
|
+
if verify_response.status == 204
|
416
|
+
logger.info "Two-factor code verified successfully."
|
417
|
+
logger.info "Trusting the session after 2FA verification..."
|
418
|
+
|
419
|
+
# Then request trust for the session
|
420
|
+
trust_response = @session.get(
|
421
|
+
"#{AUTH_ENDPOINT}/2sv/trust",
|
422
|
+
nil,
|
423
|
+
verify_headers
|
424
|
+
)
|
425
|
+
|
426
|
+
|
427
|
+
if trust_response.status == 204
|
428
|
+
# Store all relevant session data
|
429
|
+
@session_data.update({
|
430
|
+
"session_id" => session_id,
|
431
|
+
"scnt" => scnt,
|
432
|
+
"client_id" => @client_id,
|
433
|
+
"email" => @email
|
434
|
+
})
|
435
|
+
logger.info "Session trusted and fully authenticated. Saving final session data."
|
436
|
+
save_session
|
437
|
+
return true
|
438
|
+
else
|
439
|
+
logger.error "Failed to trust session after 2FA verification."
|
440
|
+
return false
|
441
|
+
end
|
442
|
+
end
|
443
|
+
rescue => e
|
444
|
+
logger.error "Two-factor verification failed: #{e.message}"
|
445
|
+
end
|
446
|
+
|
447
|
+
return false
|
448
|
+
end
|
449
|
+
end
|
450
|
+
end
|