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.
@@ -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