git-fit 0.10.10 → 0.10.11

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c4cd7d43e318f96dcab83874635c24e7ead0ec772f550f2fdcd9ea7c81a6a44b
4
- data.tar.gz: e106683db19d232870990bcbc59bcb35f59be4fec8c33d1141ec858632446522
3
+ metadata.gz: 49ad8e07b35c5dc5be5cd8e9067fdaa21dc1684a6d5d5bb14ed873243c926de5
4
+ data.tar.gz: 1b4057796536c438ce997cccf15268fa121f5ad28f19f7ba0af30922c374d2c5
5
5
  SHA512:
6
- metadata.gz: da0ac336bbe16839be618bb0ddbcc6db7513630231b89cd690249568908d0fbe0f20110ec2904d589e18b3bb2ff14431e7138b846b9c3dd0ed65b4018f033e29
7
- data.tar.gz: 101b8bc30d222858e2b61a592027ae701fb4563fdfeb832688929ba01cd7c87588411023cec5057b8af8fe7f81a15c18de62a3b25a2b97577a3f11496de3b672
6
+ metadata.gz: c835f47ca63aebfb8e7493fc522dca3f77768469d8b1b73d5293d882c1ccada07e03db64fae6063c5d2628ee579563e4842e75c6c5d1d2a4163a20801c337ec4
7
+ data.tar.gz: d0858df7943c64dd1ca22089be6eb8bfb09194fe3a2b8124c4b95051315edc095518388e6d76fe9a8d3f5c75b357a0f02cf0a7a9e075b6c9d68bf83a1bf27650
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../sync/garmin_base'
4
+ require 'json'
5
+ require 'fileutils'
6
+
7
+ module GitFit
8
+ module Auth
9
+ module Garmin
10
+ # Raised when MFA is required and no code is available. The login session
11
+ # (pre-auth cookie jar / storage_state) has already been persisted so the
12
+ # user can resume with `--mfa-code` without re-triggering MFA.
13
+ class MfaRequired < GitFit::Sync::AuthError; end
14
+
15
+ # Persists the pre-auth login session created during an interrupted MFA
16
+ # flow. This is a *temporary* session (30-minute TTL matching the MFA code
17
+ # validity window), distinct from the long-lived persistent session cookie
18
+ # (garmin_playwright_session_*.json) that Strategy B keeps after success.
19
+ module LoginSession
20
+ DIR = 'data/cache'
21
+ TTL_SECONDS = 30 * 60
22
+
23
+ module_function
24
+
25
+ def save(domain, state)
26
+ FileUtils.mkdir_p(DIR)
27
+ state['saved_at'] = Time.now.to_f
28
+ File.write(path(domain), JSON.generate(state))
29
+ end
30
+
31
+ def load(domain)
32
+ p = path(domain)
33
+ return nil unless File.exist?(p)
34
+
35
+ state = JSON.parse(File.read(p))
36
+ return nil if (Time.now.to_f - state['saved_at'].to_f) > TTL_SECONDS
37
+
38
+ state
39
+ rescue JSON::ParserError
40
+ nil
41
+ end
42
+
43
+ def clear(domain)
44
+ File.delete(path(domain)) if File.exist?(path(domain))
45
+ end
46
+
47
+ def path(domain)
48
+ File.join(DIR, "garmin_mfa_login_#{domain.tr('.', '_')}.json")
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'di_exchange'
4
+ require_relative 'login_session'
4
5
  require_relative '../../sync/garmin_base'
5
6
  require 'open3'
6
7
  require 'tmpdir'
@@ -43,9 +44,16 @@ module GitFit
43
44
  def call(email:, password:, domain: 'garmin.com', mfa_code: nil)
44
45
  check_curl_available!
45
46
 
47
+ code = mfa_code.to_s.strip
48
+ if !code.empty? && (saved = LoginSession.load(domain))
49
+ return resume_login(domain, code, saved)
50
+ end
51
+
46
52
  errors = []
47
53
  begin
48
54
  return finish(mobile_login(domain, email, password, mfa_code), domain)
55
+ rescue MfaRequired
56
+ raise
49
57
  rescue GitFit::Sync::AuthError => e
50
58
  errors << "mobile+cffi: #{e.message}"
51
59
  warn "[garmin_auth] #{errors.last}"
@@ -56,6 +64,8 @@ module GitFit
56
64
 
57
65
  begin
58
66
  return finish(portal_login(domain, email, password, mfa_code), domain)
67
+ rescue MfaRequired
68
+ raise
59
69
  rescue GitFit::Sync::AuthError => e
60
70
  errors << "portal+cffi: #{e.message}"
61
71
  warn "[garmin_auth] #{errors.last}"
@@ -76,11 +86,11 @@ module GitFit
76
86
  def mobile_login(domain, email, password, mfa_code)
77
87
  sso = "https://sso.#{domain}"
78
88
  with_session_files do |jar, body|
79
- mobile_login_session(sso, email, password, mfa_code, jar, body)
89
+ mobile_login_session(sso, domain, email, password, mfa_code, jar, body)
80
90
  end
81
91
  end
82
92
 
83
- def mobile_login_session(sso, email, password, mfa_code, jar, body)
93
+ def mobile_login_session(sso, domain, email, password, mfa_code, jar, body)
84
94
  random_delay('mobile+cffi')
85
95
  signin_url = "#{sso}/mobile/sso/en_US/sign-in"
86
96
  get_params = { clientId: MOBILE_CLIENT_ID, service: MOBILE_SERVICE }
@@ -130,7 +140,8 @@ module GitFit
130
140
  if resp_type == 'MFA_REQUIRED'
131
141
  method = res.dig('customerMfaInfo', 'mfaLastMethodUsed') || 'email'
132
142
  res = handle_mfa(sso, method, login_params, post_headers, mfa_code,
133
- impersonate: MOBILE_FINGERPRINT, jar: jar, output: body, email: email)
143
+ impersonate: MOBILE_FINGERPRINT, jar: jar, output: body,
144
+ email: email, domain: domain, service_url: MOBILE_SERVICE)
134
145
  resp_type = res.dig('responseStatus', 'type')
135
146
  end
136
147
 
@@ -146,19 +157,19 @@ module GitFit
146
157
  def portal_login(domain, email, password, mfa_code)
147
158
  sso = "https://sso.#{domain}"
148
159
  TLS_FINGERPRINTS.each do |imp|
149
- result = attempt_portal_fingerprint(sso, imp, email, password, mfa_code)
160
+ result = attempt_portal_fingerprint(sso, domain, imp, email, password, mfa_code)
150
161
  return result if result
151
162
  end
152
163
  raise GitFit::Sync::AuthError, 'portal+cffi: all TLS fingerprints exhausted'
153
164
  end
154
165
 
155
- def attempt_portal_fingerprint(sso, imp, email, password, mfa_code)
166
+ def attempt_portal_fingerprint(sso, domain, imp, email, password, mfa_code)
156
167
  with_session_files do |jar, body|
157
- attempt_portal_in_session(sso, imp, email, password, mfa_code, jar, body)
168
+ attempt_portal_in_session(sso, domain, imp, email, password, mfa_code, jar, body)
158
169
  end
159
170
  end
160
171
 
161
- def attempt_portal_in_session(sso, imp, email, password, mfa_code, jar, body)
172
+ def attempt_portal_in_session(sso, domain, imp, email, password, mfa_code, jar, body)
162
173
  random_delay("portal+cffi/#{imp}")
163
174
  signin_url = "#{sso}/portal/sso/en-US/sign-in"
164
175
  get_params = { clientId: PORTAL_CLIENT_ID, service: PORTAL_SERVICE }
@@ -216,7 +227,8 @@ module GitFit
216
227
  if resp_type == 'MFA_REQUIRED'
217
228
  method = res.dig('customerMfaInfo', 'mfaLastMethodUsed') || 'email'
218
229
  res = handle_mfa(sso, method, login_params, post_headers, mfa_code,
219
- impersonate: imp, jar: jar, output: body, email: email)
230
+ impersonate: imp, jar: jar, output: body,
231
+ email: email, domain: domain, service_url: PORTAL_SERVICE)
220
232
  resp_type = res.dig('responseStatus', 'type')
221
233
  end
222
234
 
@@ -231,8 +243,17 @@ module GitFit
231
243
  nil
232
244
  end
233
245
 
234
- def handle_mfa(sso, method, login_params, post_headers, mfa_code, impersonate:, jar:, output:, email:)
246
+ def handle_mfa(sso, method, login_params, post_headers, mfa_code,
247
+ impersonate:, jar:, output:, email:, domain:, service_url:)
235
248
  code = resolve_mfa_code(mfa_code, email)
249
+ if code.nil?
250
+ save_login_session(domain, sso, method, login_params, post_headers, impersonate, jar, service_url)
251
+ raise MfaRequired, mfa_required_message(email)
252
+ end
253
+ verify_mfa(sso, method, login_params, post_headers, code, impersonate:, jar:, output:)
254
+ end
255
+
256
+ def verify_mfa(sso, method, login_params, post_headers, code, impersonate:, jar:, output:)
236
257
  mfa_data = {
237
258
  'mfaMethod' => method,
238
259
  'mfaVerificationCode' => code,
@@ -267,6 +288,30 @@ module GitFit
267
288
  raise GitFit::Sync::AuthError, 'MFA verification failed on all endpoints'
268
289
  end
269
290
 
291
+ def save_login_session(domain, sso, method, login_params, post_headers, impersonate, jar, service_url)
292
+ LoginSession.save(domain, {
293
+ 'strategy' => 'A',
294
+ 'sso' => sso,
295
+ 'method' => method,
296
+ 'login_params' => login_params,
297
+ 'post_headers' => post_headers,
298
+ 'impersonate' => impersonate,
299
+ 'jar' => File.read(jar),
300
+ 'service_url' => service_url,
301
+ })
302
+ end
303
+
304
+ def resume_login(domain, mfa_code, saved)
305
+ with_session_files do |jar, body|
306
+ File.write(jar, saved['jar'])
307
+ res = verify_mfa(saved['sso'], saved['method'], saved['login_params'],
308
+ saved['post_headers'], mfa_code,
309
+ impersonate: saved['impersonate'], jar: jar, output: body)
310
+ LoginSession.clear(domain)
311
+ finish([res['serviceTicketId'], saved['service_url']], domain)
312
+ end
313
+ end
314
+
270
315
  def resolve_mfa_code(mfa_code, email)
271
316
  code = mfa_code.to_s.strip
272
317
  if code.empty?
@@ -274,15 +319,16 @@ module GitFit
274
319
  $stderr.write('Enter the 6-digit code: ') if $stdin.tty?
275
320
  code = $stdin.gets.to_s.strip
276
321
  end
277
- return code unless code.empty?
278
-
279
- raise GitFit::Sync::AuthError,
280
- "MFA required — check your email (#{email}) for the code.\n" \
281
- " This code is valid for a few minutes.\n\n" \
282
- " To continue, choose one:\n" \
283
- " echo \"<CODE>\" | git fit auth garmin #{AUTH_FLAG}\n" \
284
- " git fit auth garmin #{AUTH_FLAG} --mfa-code <CODE>\n\n" \
285
- " If the code has expired, request a new one: git fit auth garmin #{AUTH_FLAG}"
322
+ code.empty? ? nil : code
323
+ end
324
+
325
+ def mfa_required_message(email)
326
+ "MFA required check your email (#{email}) for the code.\n" \
327
+ " This code is valid for a few minutes.\n\n" \
328
+ " To continue, choose one:\n" \
329
+ " echo \"<CODE>\" | git fit auth garmin #{AUTH_FLAG}\n" \
330
+ " git fit auth garmin #{AUTH_FLAG} --mfa-code <CODE>\n\n" \
331
+ " If the code has expired, request a new one: git fit auth garmin #{AUTH_FLAG}"
286
332
  end
287
333
 
288
334
  def check_curl_available!
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'di_exchange'
4
+ require_relative 'login_session'
4
5
  require_relative '../../sync/garmin_base'
5
6
  require 'json'
6
7
  require 'fileutils'
@@ -29,6 +30,10 @@ module GitFit
29
30
  session_path = _session_file_path(domain)
30
31
  chrome_path = _find_chrome
31
32
 
33
+ if !mfa_code.empty? && (saved = LoginSession.load(domain))
34
+ return resume_login(domain, mfa_code, saved, chrome_path)
35
+ end
36
+
32
37
  _launch_playwright(chrome_path) do |browser|
33
38
  session = _load_session(session_path)
34
39
  warn "loaded session cookies (#{session[:age_days].round} days old)" if session
@@ -38,7 +43,7 @@ module GitFit
38
43
  context = browser.new_context(**opts)
39
44
 
40
45
  begin
41
- ticket, service_url = _mobile_api_login(context.request, domain, email, password, mfa_code, sso)
46
+ ticket, service_url = _mobile_api_login(context, domain, email, password, mfa_code, sso)
42
47
  token = DIExchange.call(domain, ticket, service_url)
43
48
  _save_session(context, session_path)
44
49
  token
@@ -48,7 +53,23 @@ module GitFit
48
53
  end
49
54
  end
50
55
 
51
- def _mobile_api_login(context, _domain, email, password, mfa_code, sso)
56
+ def resume_login(domain, mfa_code, saved, chrome_path)
57
+ _launch_playwright(chrome_path) do |browser|
58
+ context = browser.new_context(storageState: saved['storage_state'])
59
+ begin
60
+ ticket, = verify_mfa(context, saved['sso'], saved['method'], mfa_code)
61
+ token = DIExchange.call(domain, ticket, MOBILE_SERVICE)
62
+ _save_session(context, _session_file_path(domain))
63
+ LoginSession.clear(domain)
64
+ token
65
+ ensure
66
+ context.close
67
+ end
68
+ end
69
+ end
70
+
71
+ def _mobile_api_login(context, domain, email, password, mfa_code, sso)
72
+ req = context.request
52
73
  headers = {
53
74
  'User-Agent' => MOBILE_UA,
54
75
  'Accept' => 'application/json, text/plain, */*',
@@ -59,7 +80,7 @@ module GitFit
59
80
  params = { 'clientId' => MOBILE_CLIENT_ID, 'locale' => 'en-US', 'service' => MOBILE_SERVICE }
60
81
  referer = "#{sso}/mobile/sso/en_US/sign-in?clientId=#{MOBILE_CLIENT_ID}&service=#{MOBILE_SERVICE}"
61
82
 
62
- signin_resp = context.get(
83
+ signin_resp = req.get(
63
84
  "#{sso}/mobile/sso/en_US/sign-in",
64
85
  params: { 'clientId' => MOBILE_CLIENT_ID, 'service' => MOBILE_SERVICE },
65
86
  headers: { 'User-Agent' => MOBILE_UA, 'Accept' => 'text/html,...', 'Accept-Language' => 'en-US,en;q=0.9' },
@@ -78,7 +99,7 @@ module GitFit
78
99
 
79
100
  _random_delay('mobile')
80
101
  warn 'posting login credentials...'
81
- resp = context.post(
102
+ resp = req.post(
82
103
  "#{sso}/mobile/api/login",
83
104
  params: params,
84
105
  headers: headers.merge('Referer' => referer),
@@ -103,8 +124,30 @@ module GitFit
103
124
 
104
125
  mfa_method = data.dig('customerMfaInfo', 'mfaLastMethodUsed') || 'email'
105
126
  code = resolve_mfa_code(email, mfa_code)
127
+ if code.nil?
128
+ LoginSession.save(domain, {
129
+ 'strategy' => 'B',
130
+ 'sso' => sso,
131
+ 'method' => mfa_method,
132
+ 'storage_state' => context.storage_state,
133
+ })
134
+ raise MfaRequired, mfa_required_message(email)
135
+ end
106
136
 
137
+ verify_mfa(context, sso, mfa_method, code)
138
+ end
139
+
140
+ def verify_mfa(context, sso, mfa_method, code)
107
141
  warn 'verifying MFA code...'
142
+ headers = {
143
+ 'User-Agent' => MOBILE_UA,
144
+ 'Accept' => 'application/json, text/plain, */*',
145
+ 'Content-Type' => 'application/json',
146
+ 'Origin' => sso,
147
+ 'Accept-Language' => 'en-US,en;q=0.9',
148
+ }
149
+ params = { 'clientId' => MOBILE_CLIENT_ID, 'locale' => 'en-US', 'service' => MOBILE_SERVICE }
150
+ referer = "#{sso}/mobile/sso/en_US/sign-in?clientId=#{MOBILE_CLIENT_ID}&service=#{MOBILE_SERVICE}"
108
151
  mfa_data = {
109
152
  'mfaMethod' => mfa_method,
110
153
  'mfaVerificationCode' => code,
@@ -113,7 +156,7 @@ module GitFit
113
156
  'mfaSetup' => false,
114
157
  }
115
158
 
116
- resp = context.post(
159
+ resp = context.request.post(
117
160
  "#{sso}/mobile/api/mfa/verifyCode",
118
161
  params: params,
119
162
  headers: headers.merge('Referer' => referer),
@@ -143,15 +186,16 @@ module GitFit
143
186
  $stderr.write('Enter the 6-digit code: ') if $stdin.tty?
144
187
  code = $stdin.gets.to_s.strip
145
188
  end
146
- return code unless code.empty?
189
+ code.empty? ? nil : code
190
+ end
147
191
 
148
- raise GitFit::Sync::AuthError,
149
- "MFA required — check your email (#{email}) for the code.\n" \
150
- " This code is valid for a few minutes.\n\n" \
151
- " To continue, choose one:\n" \
152
- " echo \"<CODE>\" | git fit auth garmin #{AUTH_FLAG}\n" \
153
- " git fit auth garmin #{AUTH_FLAG} --mfa-code <CODE>\n\n" \
154
- " If the code has expired, request a new one: git fit auth garmin #{AUTH_FLAG}"
192
+ def mfa_required_message(email)
193
+ "MFA required — check your email (#{email}) for the code.\n" \
194
+ " This code is valid for a few minutes.\n\n" \
195
+ " To continue, choose one:\n" \
196
+ " echo \"<CODE>\" | git fit auth garmin #{AUTH_FLAG}\n" \
197
+ " git fit auth garmin #{AUTH_FLAG} --mfa-code <CODE>\n\n" \
198
+ " If the code has expired, request a new one: git fit auth garmin #{AUTH_FLAG}"
155
199
  end
156
200
 
157
201
  def _random_delay(label)
@@ -3,6 +3,7 @@
3
3
  require_relative 'garmin/strategy_a'
4
4
  require_relative 'garmin/strategy_b'
5
5
  require_relative 'garmin/di_exchange'
6
+ require_relative 'garmin/login_session'
6
7
  require_relative '../config'
7
8
  require_relative '../cli/gh_cli'
8
9
  require 'json'
@@ -48,10 +49,10 @@ module GitFit
48
49
  if chosen.size > 1
49
50
  raise GitFit::Sync::AuthError,
50
51
  'git fit auth garmin: -A/-B/--auto/--sync are mutually exclusive — ' \
51
- 'use exactly one (default: -B)'
52
+ 'use exactly one (default: --auto)'
52
53
  end
53
54
 
54
- chosen.first || :B
55
+ chosen.first || :auto
55
56
  end
56
57
 
57
58
  def flag_enabled?(flag)
@@ -88,7 +89,7 @@ module GitFit
88
89
 
89
90
  def single_strategy(label, creds)
90
91
  token, error = run_strategy(label, creds)
91
- exit_with_error("Garmin auth failed: #{error}") unless token
92
+ exit_with_error("Garmin auth failed: #{error.message}") unless token
92
93
 
93
94
  token
94
95
  end
@@ -97,22 +98,35 @@ module GitFit
97
98
  token, b_error = run_strategy(:B, creds)
98
99
  return token if token
99
100
 
101
+ if b_error.is_a?(MfaRequired)
102
+ # B triggered MFA and saved the login session. Falling back to A
103
+ # would re-trigger a second MFA — stop and ask the user for the code.
104
+ exit_with_error(b_error.message)
105
+ end
106
+
100
107
  warn '[auto] B failed, falling back to A'
101
108
  token, a_error = run_strategy(:A, creds)
102
109
  return token if token
103
110
 
104
- exit_with_error("Garmin auth failed: B: #{b_error}; A: #{a_error}")
111
+ exit_with_error(a_error.message) if a_error.is_a?(MfaRequired)
112
+
113
+ exit_with_error("Garmin auth failed: B: #{b_error.message}; A: #{a_error.message}")
105
114
  end
106
115
 
107
116
  def run_strategy(label, creds)
108
117
  token = STRATEGIES.fetch(label).call(**creds)
109
- return [token, nil] if json_round_trips?(token)
110
-
111
- [nil, "Strategy #{label} returned a token that fails JSON round-trip"]
118
+ return [token, nil] if token && json_round_trips?(token)
119
+
120
+ message = if token.nil?
121
+ "Strategy #{label} failed (no token returned)"
122
+ else
123
+ "Strategy #{label} returned a token that fails JSON round-trip"
124
+ end
125
+ [nil, RuntimeError.new(message)]
112
126
  rescue GitFit::Sync::AuthError => e
113
- [nil, e.message]
127
+ [nil, e]
114
128
  rescue StandardError => e
115
- [nil, "unexpected error: #{e.message}"]
129
+ [nil, e]
116
130
  end
117
131
 
118
132
  def json_round_trips?(token)
data/lib/git_fit/cli.rb CHANGED
@@ -74,8 +74,8 @@ module GitFit
74
74
 
75
75
  desc 'auth SOURCE', 'Authenticate with a sync source (strava, garmin, garmin_cn)'
76
76
  option :A, type: :boolean, desc: 'Garmin Strategy A: curl-impersonate shellout'
77
- option :B, type: :boolean, desc: 'Garmin Strategy B: Playwright (default)'
78
- option :auto, type: :boolean, desc: 'Garmin auto: B first, fallback to A'
77
+ option :B, type: :boolean, desc: 'Garmin Strategy B: Playwright'
78
+ option :auto, type: :boolean, desc: 'Garmin auto: B first, fallback to A (default)'
79
79
  option :sync, type: :boolean, desc: 'Garmin: promote cached token to auth_seed'
80
80
  option :mfa_code, type: :string, desc: 'Garmin MFA code (from email), re-run to continue'
81
81
  def auth(source)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GitFit
4
- VERSION = '0.10.10'
4
+ VERSION = '0.10.11'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: git-fit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.10
4
+ version: 0.10.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lax
@@ -248,6 +248,7 @@ files:
248
248
  - lib/git-fit.rb
249
249
  - lib/git_fit/auth/garmin.rb
250
250
  - lib/git_fit/auth/garmin/di_exchange.rb
251
+ - lib/git_fit/auth/garmin/login_session.rb
251
252
  - lib/git_fit/auth/garmin/strategy_a.rb
252
253
  - lib/git_fit/auth/garmin/strategy_b.rb
253
254
  - lib/git_fit/auth/garmin_token.rb