git-fit 0.10.9 → 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 +4 -4
- data/lib/git_fit/auth/garmin/login_session.rb +53 -0
- data/lib/git_fit/auth/garmin/strategy_a.rb +70 -23
- data/lib/git_fit/auth/garmin/strategy_b.rb +68 -15
- data/lib/git_fit/auth/garmin.rb +29 -9
- data/lib/git_fit/cli.rb +3 -2
- data/lib/git_fit/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 49ad8e07b35c5dc5be5cd8e9067fdaa21dc1684a6d5d5bb14ed873243c926de5
|
|
4
|
+
data.tar.gz: 1b4057796536c438ce997cccf15268fa121f5ad28f19f7ba0af30922c374d2c5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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'
|
|
@@ -36,15 +37,23 @@ module GitFit
|
|
|
36
37
|
MOBILE_FINGERPRINT = 'safari18_0'
|
|
37
38
|
TLS_FINGERPRINTS = %w[safari18_0 safari17_0 chrome120 edge101 chrome124].freeze
|
|
38
39
|
CURL_BINARY = 'curl-impersonate'
|
|
40
|
+
AUTH_FLAG = '-A'
|
|
39
41
|
|
|
40
42
|
module_function
|
|
41
43
|
|
|
42
44
|
def call(email:, password:, domain: 'garmin.com', mfa_code: nil)
|
|
43
45
|
check_curl_available!
|
|
44
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
|
+
|
|
45
52
|
errors = []
|
|
46
53
|
begin
|
|
47
54
|
return finish(mobile_login(domain, email, password, mfa_code), domain)
|
|
55
|
+
rescue MfaRequired
|
|
56
|
+
raise
|
|
48
57
|
rescue GitFit::Sync::AuthError => e
|
|
49
58
|
errors << "mobile+cffi: #{e.message}"
|
|
50
59
|
warn "[garmin_auth] #{errors.last}"
|
|
@@ -55,6 +64,8 @@ module GitFit
|
|
|
55
64
|
|
|
56
65
|
begin
|
|
57
66
|
return finish(portal_login(domain, email, password, mfa_code), domain)
|
|
67
|
+
rescue MfaRequired
|
|
68
|
+
raise
|
|
58
69
|
rescue GitFit::Sync::AuthError => e
|
|
59
70
|
errors << "portal+cffi: #{e.message}"
|
|
60
71
|
warn "[garmin_auth] #{errors.last}"
|
|
@@ -75,11 +86,11 @@ module GitFit
|
|
|
75
86
|
def mobile_login(domain, email, password, mfa_code)
|
|
76
87
|
sso = "https://sso.#{domain}"
|
|
77
88
|
with_session_files do |jar, body|
|
|
78
|
-
mobile_login_session(sso, email, password, mfa_code, jar, body)
|
|
89
|
+
mobile_login_session(sso, domain, email, password, mfa_code, jar, body)
|
|
79
90
|
end
|
|
80
91
|
end
|
|
81
92
|
|
|
82
|
-
def mobile_login_session(sso, email, password, mfa_code, jar, body)
|
|
93
|
+
def mobile_login_session(sso, domain, email, password, mfa_code, jar, body)
|
|
83
94
|
random_delay('mobile+cffi')
|
|
84
95
|
signin_url = "#{sso}/mobile/sso/en_US/sign-in"
|
|
85
96
|
get_params = { clientId: MOBILE_CLIENT_ID, service: MOBILE_SERVICE }
|
|
@@ -129,7 +140,8 @@ module GitFit
|
|
|
129
140
|
if resp_type == 'MFA_REQUIRED'
|
|
130
141
|
method = res.dig('customerMfaInfo', 'mfaLastMethodUsed') || 'email'
|
|
131
142
|
res = handle_mfa(sso, method, login_params, post_headers, mfa_code,
|
|
132
|
-
impersonate: MOBILE_FINGERPRINT, jar: jar, output: body
|
|
143
|
+
impersonate: MOBILE_FINGERPRINT, jar: jar, output: body,
|
|
144
|
+
email: email, domain: domain, service_url: MOBILE_SERVICE)
|
|
133
145
|
resp_type = res.dig('responseStatus', 'type')
|
|
134
146
|
end
|
|
135
147
|
|
|
@@ -145,19 +157,19 @@ module GitFit
|
|
|
145
157
|
def portal_login(domain, email, password, mfa_code)
|
|
146
158
|
sso = "https://sso.#{domain}"
|
|
147
159
|
TLS_FINGERPRINTS.each do |imp|
|
|
148
|
-
result = attempt_portal_fingerprint(sso, imp, email, password, mfa_code)
|
|
160
|
+
result = attempt_portal_fingerprint(sso, domain, imp, email, password, mfa_code)
|
|
149
161
|
return result if result
|
|
150
162
|
end
|
|
151
163
|
raise GitFit::Sync::AuthError, 'portal+cffi: all TLS fingerprints exhausted'
|
|
152
164
|
end
|
|
153
165
|
|
|
154
|
-
def attempt_portal_fingerprint(sso, imp, email, password, mfa_code)
|
|
166
|
+
def attempt_portal_fingerprint(sso, domain, imp, email, password, mfa_code)
|
|
155
167
|
with_session_files do |jar, body|
|
|
156
|
-
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)
|
|
157
169
|
end
|
|
158
170
|
end
|
|
159
171
|
|
|
160
|
-
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)
|
|
161
173
|
random_delay("portal+cffi/#{imp}")
|
|
162
174
|
signin_url = "#{sso}/portal/sso/en-US/sign-in"
|
|
163
175
|
get_params = { clientId: PORTAL_CLIENT_ID, service: PORTAL_SERVICE }
|
|
@@ -215,7 +227,8 @@ module GitFit
|
|
|
215
227
|
if resp_type == 'MFA_REQUIRED'
|
|
216
228
|
method = res.dig('customerMfaInfo', 'mfaLastMethodUsed') || 'email'
|
|
217
229
|
res = handle_mfa(sso, method, login_params, post_headers, mfa_code,
|
|
218
|
-
impersonate: imp, jar: jar, output: body
|
|
230
|
+
impersonate: imp, jar: jar, output: body,
|
|
231
|
+
email: email, domain: domain, service_url: PORTAL_SERVICE)
|
|
219
232
|
resp_type = res.dig('responseStatus', 'type')
|
|
220
233
|
end
|
|
221
234
|
|
|
@@ -230,8 +243,17 @@ module GitFit
|
|
|
230
243
|
nil
|
|
231
244
|
end
|
|
232
245
|
|
|
233
|
-
def handle_mfa(sso, method, login_params, post_headers, mfa_code,
|
|
234
|
-
|
|
246
|
+
def handle_mfa(sso, method, login_params, post_headers, mfa_code,
|
|
247
|
+
impersonate:, jar:, output:, email:, domain:, service_url:)
|
|
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:)
|
|
235
257
|
mfa_data = {
|
|
236
258
|
'mfaMethod' => method,
|
|
237
259
|
'mfaVerificationCode' => code,
|
|
@@ -266,22 +288,47 @@ module GitFit
|
|
|
266
288
|
raise GitFit::Sync::AuthError, 'MFA verification failed on all endpoints'
|
|
267
289
|
end
|
|
268
290
|
|
|
269
|
-
def
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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)
|
|
276
312
|
end
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def resolve_mfa_code(mfa_code, email)
|
|
316
|
+
code = mfa_code.to_s.strip
|
|
317
|
+
if code.empty?
|
|
318
|
+
warn "[garmin_auth] Garmin sent a verification code to #{email}"
|
|
319
|
+
$stderr.write('Enter the 6-digit code: ') if $stdin.tty?
|
|
320
|
+
code = $stdin.gets.to_s.strip
|
|
280
321
|
end
|
|
281
|
-
code
|
|
282
|
-
|
|
322
|
+
code.empty? ? nil : code
|
|
323
|
+
end
|
|
283
324
|
|
|
284
|
-
|
|
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}"
|
|
285
332
|
end
|
|
286
333
|
|
|
287
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'
|
|
@@ -19,16 +20,20 @@ module GitFit
|
|
|
19
20
|
LOGIN_DELAY_MAX = 45.0
|
|
20
21
|
SESSION_CACHE_DIR = 'data/cache'
|
|
21
22
|
REDIRECT_STATUSES = [301, 302, 303, 307, 308].freeze
|
|
23
|
+
AUTH_FLAG = '-B'
|
|
22
24
|
|
|
23
25
|
module_function
|
|
24
26
|
|
|
25
27
|
def call(email:, password:, domain: 'garmin.com', mfa_code: nil)
|
|
26
28
|
mfa_code = mfa_code.to_s.strip
|
|
27
|
-
mfa_code = ENV['SYNC__GARMIN__MFA_CODE'].to_s.strip if mfa_code.empty?
|
|
28
29
|
sso = "https://sso.#{domain}"
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
102
|
+
resp = req.post(
|
|
82
103
|
"#{sso}/mobile/api/login",
|
|
83
104
|
params: params,
|
|
84
105
|
headers: headers.merge('Referer' => referer),
|
|
@@ -102,18 +123,31 @@ module GitFit
|
|
|
102
123
|
raise GitFit::Sync::AuthError, "Unexpected login response: #{resp_type}" unless resp_type == 'MFA_REQUIRED'
|
|
103
124
|
|
|
104
125
|
mfa_method = data.dig('customerMfaInfo', 'mfaLastMethodUsed') || 'email'
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
raise
|
|
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)
|
|
114
135
|
end
|
|
115
136
|
|
|
137
|
+
verify_mfa(context, sso, mfa_method, code)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def verify_mfa(context, sso, mfa_method, code)
|
|
116
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}"
|
|
117
151
|
mfa_data = {
|
|
118
152
|
'mfaMethod' => mfa_method,
|
|
119
153
|
'mfaVerificationCode' => code,
|
|
@@ -122,7 +156,7 @@ module GitFit
|
|
|
122
156
|
'mfaSetup' => false,
|
|
123
157
|
}
|
|
124
158
|
|
|
125
|
-
resp = context.post(
|
|
159
|
+
resp = context.request.post(
|
|
126
160
|
"#{sso}/mobile/api/mfa/verifyCode",
|
|
127
161
|
params: params,
|
|
128
162
|
headers: headers.merge('Referer' => referer),
|
|
@@ -145,6 +179,25 @@ module GitFit
|
|
|
145
179
|
url[/[?&]ticket=(ST-[^&\s]+)/, 1]
|
|
146
180
|
end
|
|
147
181
|
|
|
182
|
+
def resolve_mfa_code(email, mfa_code)
|
|
183
|
+
code = mfa_code.to_s.strip
|
|
184
|
+
if code.empty?
|
|
185
|
+
warn "[garmin_auth_pw] Garmin sent a verification code to #{email}"
|
|
186
|
+
$stderr.write('Enter the 6-digit code: ') if $stdin.tty?
|
|
187
|
+
code = $stdin.gets.to_s.strip
|
|
188
|
+
end
|
|
189
|
+
code.empty? ? nil : code
|
|
190
|
+
end
|
|
191
|
+
|
|
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}"
|
|
199
|
+
end
|
|
200
|
+
|
|
148
201
|
def _random_delay(label)
|
|
149
202
|
delay = rand(LOGIN_DELAY_MIN..LOGIN_DELAY_MAX)
|
|
150
203
|
warn "[garmin_auth_pw] #{label}: waiting #{delay.round}s for Cloudflare..."
|
data/lib/git_fit/auth/garmin.rb
CHANGED
|
@@ -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:
|
|
52
|
+
'use exactly one (default: --auto)'
|
|
52
53
|
end
|
|
53
54
|
|
|
54
|
-
chosen.first || :
|
|
55
|
+
chosen.first || :auto
|
|
55
56
|
end
|
|
56
57
|
|
|
57
58
|
def flag_enabled?(flag)
|
|
@@ -61,6 +62,7 @@ module GitFit
|
|
|
61
62
|
def sso_token(mode)
|
|
62
63
|
email, password = sso_credentials
|
|
63
64
|
creds = { email: email, password: password, domain: domain }
|
|
65
|
+
creds[:mfa_code] = @options[:mfa_code] if mfa_code_provided?
|
|
64
66
|
case mode
|
|
65
67
|
when :A then single_strategy(:A, creds)
|
|
66
68
|
when :B then single_strategy(:B, creds)
|
|
@@ -68,6 +70,11 @@ module GitFit
|
|
|
68
70
|
end
|
|
69
71
|
end
|
|
70
72
|
|
|
73
|
+
def mfa_code_provided?
|
|
74
|
+
code = @options[:mfa_code]
|
|
75
|
+
!code.nil? && !code.to_s.strip.empty?
|
|
76
|
+
end
|
|
77
|
+
|
|
71
78
|
def sso_credentials
|
|
72
79
|
cfg = @config.sync_config('garmin')
|
|
73
80
|
email = cfg['email'].to_s
|
|
@@ -82,7 +89,7 @@ module GitFit
|
|
|
82
89
|
|
|
83
90
|
def single_strategy(label, creds)
|
|
84
91
|
token, error = run_strategy(label, creds)
|
|
85
|
-
exit_with_error("Garmin auth failed: #{error}") unless token
|
|
92
|
+
exit_with_error("Garmin auth failed: #{error.message}") unless token
|
|
86
93
|
|
|
87
94
|
token
|
|
88
95
|
end
|
|
@@ -91,22 +98,35 @@ module GitFit
|
|
|
91
98
|
token, b_error = run_strategy(:B, creds)
|
|
92
99
|
return token if token
|
|
93
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
|
+
|
|
94
107
|
warn '[auto] B failed, falling back to A'
|
|
95
108
|
token, a_error = run_strategy(:A, creds)
|
|
96
109
|
return token if token
|
|
97
110
|
|
|
98
|
-
exit_with_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}")
|
|
99
114
|
end
|
|
100
115
|
|
|
101
116
|
def run_strategy(label, creds)
|
|
102
117
|
token = STRATEGIES.fetch(label).call(**creds)
|
|
103
|
-
return [token, nil] if json_round_trips?(token)
|
|
104
|
-
|
|
105
|
-
|
|
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)]
|
|
106
126
|
rescue GitFit::Sync::AuthError => e
|
|
107
|
-
[nil, e
|
|
127
|
+
[nil, e]
|
|
108
128
|
rescue StandardError => e
|
|
109
|
-
[nil,
|
|
129
|
+
[nil, e]
|
|
110
130
|
end
|
|
111
131
|
|
|
112
132
|
def json_round_trips?(token)
|
data/lib/git_fit/cli.rb
CHANGED
|
@@ -74,9 +74,10 @@ 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
|
|
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
|
+
option :mfa_code, type: :string, desc: 'Garmin MFA code (from email), re-run to continue'
|
|
80
81
|
def auth(source)
|
|
81
82
|
if source == 'garmin'
|
|
82
83
|
GitFit::Auth::Garmin.new(options, git_fit_config).call
|
data/lib/git_fit/version.rb
CHANGED
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.
|
|
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
|