ruby_garmin_connect 0.1.0

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,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GarminConnect
4
+ module API
5
+ # Workouts and training plan endpoints.
6
+ module Workouts
7
+ # List workouts.
8
+ # @param start [Integer] pagination offset
9
+ # @param limit [Integer]
10
+ def workouts(start: 0, limit: 20)
11
+ connection.get(
12
+ "/workout-service/workouts",
13
+ params: { "start" => start, "limit" => limit }
14
+ )
15
+ end
16
+
17
+ # Get a specific workout by ID.
18
+ # @param workout_id [String, Integer]
19
+ def workout(workout_id)
20
+ connection.get("/workout-service/workout/#{workout_id}")
21
+ end
22
+
23
+ # Download a workout as a FIT file.
24
+ # @param workout_id [String, Integer]
25
+ # @return [String] raw bytes
26
+ def download_workout(workout_id)
27
+ connection.download("/workout-service/workout/FIT/#{workout_id}")
28
+ end
29
+
30
+ # Create/upload a workout from a JSON payload.
31
+ # @param payload [Hash] the workout definition
32
+ def create_workout(payload)
33
+ connection.post("/workout-service/workout", body: payload)
34
+ end
35
+
36
+ # Get a scheduled workout by ID.
37
+ # @param scheduled_workout_id [String, Integer]
38
+ def scheduled_workout(scheduled_workout_id)
39
+ connection.get("/workout-service/schedule/#{scheduled_workout_id}")
40
+ end
41
+
42
+ # --- Training Plans ---
43
+
44
+ # Get all available training plans.
45
+ def training_plans
46
+ connection.get("/trainingplan-service/trainingplan/plans")
47
+ end
48
+
49
+ # Get a phased training plan by ID.
50
+ # @param plan_id [String, Integer]
51
+ def training_plan(plan_id)
52
+ connection.get("/trainingplan-service/trainingplan/phased/#{plan_id}")
53
+ end
54
+
55
+ # Get an adaptive training plan by ID.
56
+ # @param plan_id [String, Integer]
57
+ def adaptive_training_plan(plan_id)
58
+ connection.get("/trainingplan-service/trainingplan/fbt-adaptive/#{plan_id}")
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GarminConnect
4
+ module Auth
5
+ # Represents an OAuth1 token pair obtained from the SSO ticket exchange.
6
+ # These tokens are long-lived (~1 year) and used to mint OAuth2 tokens.
7
+ class OAuth1Token
8
+ attr_reader :token, :secret, :mfa_token, :mfa_expiration, :domain
9
+
10
+ def initialize(token:, secret:, mfa_token: nil, mfa_expiration: nil, domain: "garmin.com")
11
+ @token = token
12
+ @secret = secret
13
+ @mfa_token = mfa_token
14
+ @mfa_expiration = mfa_expiration
15
+ @domain = domain
16
+ end
17
+
18
+ def to_h
19
+ {
20
+ "oauth_token" => token,
21
+ "oauth_token_secret" => secret,
22
+ "mfa_token" => mfa_token,
23
+ "mfa_expiration_timestamp" => mfa_expiration,
24
+ "domain" => domain
25
+ }
26
+ end
27
+
28
+ def to_json(...)
29
+ to_h.to_json(...)
30
+ end
31
+
32
+ def self.from_hash(data)
33
+ new(
34
+ token: data["oauth_token"],
35
+ secret: data["oauth_token_secret"],
36
+ mfa_token: data["mfa_token"],
37
+ mfa_expiration: data["mfa_expiration_timestamp"],
38
+ domain: data["domain"] || "garmin.com"
39
+ )
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GarminConnect
4
+ module Auth
5
+ # Represents an OAuth2 Bearer token used for API requests.
6
+ # Short-lived, but can be refreshed by re-exchanging the OAuth1 token.
7
+ class OAuth2Token
8
+ attr_reader :access_token, :refresh_token, :token_type, :scope,
9
+ :jti, :expires_in, :expires_at,
10
+ :refresh_token_expires_in, :refresh_token_expires_at
11
+
12
+ def initialize(access_token:, refresh_token: nil, token_type: "Bearer",
13
+ scope: nil, jti: nil, expires_in: 3600, expires_at: nil,
14
+ refresh_token_expires_in: nil, refresh_token_expires_at: nil)
15
+ @access_token = access_token
16
+ @refresh_token = refresh_token
17
+ @token_type = token_type
18
+ @scope = scope
19
+ @jti = jti
20
+ @expires_in = expires_in
21
+ @expires_at = expires_at || (Time.now.to_i + expires_in)
22
+ @refresh_token_expires_in = refresh_token_expires_in
23
+ @refresh_token_expires_at = refresh_token_expires_at ||
24
+ (refresh_token_expires_in ? Time.now.to_i + refresh_token_expires_in : nil)
25
+ end
26
+
27
+ def expired?
28
+ Time.now.to_i >= expires_at
29
+ end
30
+
31
+ def authorization_header
32
+ "#{token_type.capitalize} #{access_token}"
33
+ end
34
+
35
+ alias_method :to_s, :authorization_header
36
+
37
+ def to_h
38
+ {
39
+ "access_token" => access_token,
40
+ "refresh_token" => refresh_token,
41
+ "token_type" => token_type,
42
+ "scope" => scope,
43
+ "jti" => jti,
44
+ "expires_in" => expires_in,
45
+ "expires_at" => expires_at,
46
+ "refresh_token_expires_in" => refresh_token_expires_in,
47
+ "refresh_token_expires_at" => refresh_token_expires_at
48
+ }
49
+ end
50
+
51
+ def to_json(...)
52
+ to_h.to_json(...)
53
+ end
54
+
55
+ def self.from_hash(data)
56
+ new(
57
+ access_token: data["access_token"],
58
+ refresh_token: data["refresh_token"],
59
+ token_type: data["token_type"] || "Bearer",
60
+ scope: data["scope"],
61
+ jti: data["jti"],
62
+ expires_in: data["expires_in"],
63
+ expires_at: data["expires_at"],
64
+ refresh_token_expires_in: data["refresh_token_expires_in"],
65
+ refresh_token_expires_at: data["refresh_token_expires_at"]
66
+ )
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,303 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "cgi"
5
+ require "json"
6
+ require "net/http"
7
+ require "faraday"
8
+ require "faraday/cookie_jar"
9
+ require "faraday/follow_redirects"
10
+ require "oauth"
11
+
12
+ module GarminConnect
13
+ module Auth
14
+ # Handles the Garmin SSO login flow and OAuth token exchanges.
15
+ #
16
+ # The flow mirrors the mobile app authentication:
17
+ # 1. Load SSO embed page (establish cookies)
18
+ # 2. Load signin page (extract CSRF token)
19
+ # 3. POST credentials (email + password)
20
+ # 4. Handle MFA if required
21
+ # 5. Extract SSO ticket from success page
22
+ # 6. Exchange ticket for OAuth1 token (via consumer key/secret)
23
+ # 7. Exchange OAuth1 for OAuth2 Bearer token
24
+ module SSO
25
+ CONSUMER_URL = "https://thegarth.s3.amazonaws.com/oauth_consumer.json"
26
+
27
+ SSO_USER_AGENT = "GCM-iOS-5.19.1.2"
28
+ OAUTH_USER_AGENT = "com.garmin.android.apps.connectmobile"
29
+
30
+ CSRF_RE = /name="_csrf"\s+value="(.+?)"/
31
+ TITLE_RE = /<title>(.+?)<\/title>/m
32
+ TICKET_RE = /embed\?ticket=([^"]+)"/
33
+
34
+ module_function
35
+
36
+ # Perform a full login and return [OAuth1Token, OAuth2Token].
37
+ #
38
+ # @param email [String] Garmin account email
39
+ # @param password [String] Garmin account password
40
+ # @param domain [String] Garmin domain ("garmin.com" or "garmin.cn")
41
+ # @param mfa_handler [Proc, nil] Called with no args when MFA is needed; must return the code.
42
+ # Defaults to reading from $stdin.
43
+ # @return [Array(OAuth1Token, OAuth2Token)]
44
+ def login(email:, password:, domain: "garmin.com", mfa_handler: nil)
45
+ sso_base = "https://sso.#{domain}/sso"
46
+ conn = build_sso_connection
47
+
48
+ # Step 1: Establish SSO cookies
49
+ conn.get(embed_url(sso_base))
50
+
51
+ # Step 2: Load signin page and extract CSRF
52
+ signin_get_url = signin_url(sso_base)
53
+ resp = conn.get(signin_get_url) do |req|
54
+ req.headers["Referer"] = "#{sso_base}/embed"
55
+ end
56
+ csrf = extract_csrf(resp.body)
57
+
58
+ # Step 3: Submit credentials
59
+ resp = conn.post(signin_get_url) do |req|
60
+ req.headers["Referer"] = signin_get_url
61
+ req.headers["Content-Type"] = "application/x-www-form-urlencoded"
62
+ req.body = URI.encode_www_form(
63
+ "username" => email,
64
+ "password" => password,
65
+ "embed" => "true",
66
+ "_csrf" => csrf
67
+ )
68
+ end
69
+
70
+ title = extract_title(resp.body)
71
+
72
+ # Step 4: Handle MFA
73
+ if title.downcase.include?("mfa")
74
+ resp = handle_mfa(conn, resp.body, sso_base, mfa_handler)
75
+ title = extract_title(resp.body)
76
+ end
77
+
78
+ raise LoginError, "Login failed. Response title: '#{title}'" unless title == "Success"
79
+
80
+ # Step 5: Extract SSO ticket
81
+ ticket = extract_ticket(resp.body)
82
+
83
+ # Steps 6-7: Exchange ticket -> OAuth1 -> OAuth2
84
+ consumer_key, consumer_secret = fetch_consumer_credentials
85
+ oauth1 = exchange_ticket(ticket, consumer_key, consumer_secret, domain)
86
+ oauth2 = exchange_oauth1(oauth1, consumer_key, consumer_secret, domain)
87
+
88
+ [oauth1, oauth2]
89
+ end
90
+
91
+ # Exchange an existing OAuth1 token for a fresh OAuth2 token.
92
+ # Used for token refresh without re-logging in.
93
+ #
94
+ # @param oauth1_token [OAuth1Token]
95
+ # @param domain [String]
96
+ # @return [OAuth2Token]
97
+ def refresh(oauth1_token, domain: nil)
98
+ domain ||= oauth1_token.domain || "garmin.com"
99
+ consumer_key, consumer_secret = fetch_consumer_credentials
100
+ exchange_oauth1(oauth1_token, consumer_key, consumer_secret, domain)
101
+ end
102
+
103
+ # --- Private helpers ---
104
+
105
+ def build_sso_connection
106
+ Faraday.new do |f|
107
+ f.use :cookie_jar
108
+ f.response :follow_redirects
109
+ f.adapter Faraday.default_adapter
110
+ end.tap do |conn|
111
+ conn.headers["User-Agent"] = SSO_USER_AGENT
112
+ end
113
+ end
114
+
115
+ def embed_url(sso_base)
116
+ params = {
117
+ "id" => "gauth-widget",
118
+ "embedWidget" => "true",
119
+ "gauthHost" => sso_base
120
+ }
121
+ "#{sso_base}/embed?#{URI.encode_www_form(params)}"
122
+ end
123
+
124
+ def signin_url(sso_base)
125
+ embed = "#{sso_base}/embed"
126
+ params = {
127
+ "id" => "gauth-widget",
128
+ "embedWidget" => "true",
129
+ "gauthHost" => embed,
130
+ "service" => embed,
131
+ "source" => embed,
132
+ "redirectAfterAccountLoginUrl" => embed,
133
+ "redirectAfterAccountCreationUrl" => embed
134
+ }
135
+ "#{sso_base}/signin?#{URI.encode_www_form(params)}"
136
+ end
137
+
138
+ def handle_mfa(conn, html, sso_base, mfa_handler)
139
+ csrf = extract_csrf(html)
140
+
141
+ code = if mfa_handler
142
+ mfa_handler.call
143
+ else
144
+ $stderr.print "Enter Garmin MFA code: "
145
+ $stderr.flush
146
+ $stdin.gets&.chomp
147
+ end
148
+
149
+ raise AuthenticationError, "No MFA code provided" if code.nil? || code.empty?
150
+
151
+ mfa_url = "#{sso_base}/verifyMFA/loginEnterMfaCode?#{URI.encode_www_form(signin_params(sso_base))}"
152
+ conn.post(mfa_url) do |req|
153
+ req.headers["Referer"] = "#{sso_base}/verifyMFA/loginEnterMfaCode"
154
+ req.headers["Content-Type"] = "application/x-www-form-urlencoded"
155
+ req.body = URI.encode_www_form(
156
+ "mfa-code" => code,
157
+ "embed" => "true",
158
+ "_csrf" => csrf,
159
+ "fromPage" => "setupEnterMfaCode"
160
+ )
161
+ end
162
+ end
163
+
164
+ def signin_params(sso_base)
165
+ embed = "#{sso_base}/embed"
166
+ {
167
+ "id" => "gauth-widget",
168
+ "embedWidget" => "true",
169
+ "gauthHost" => embed,
170
+ "service" => embed,
171
+ "source" => embed,
172
+ "redirectAfterAccountLoginUrl" => embed,
173
+ "redirectAfterAccountCreationUrl" => embed
174
+ }
175
+ end
176
+
177
+ def fetch_consumer_credentials
178
+ resp = Faraday.get(CONSUMER_URL)
179
+ raise AuthenticationError, "Failed to fetch OAuth consumer credentials" unless resp.success?
180
+
181
+ data = JSON.parse(resp.body)
182
+ [data["consumer_key"], data["consumer_secret"]]
183
+ end
184
+
185
+ def exchange_ticket(ticket, consumer_key, consumer_secret, domain)
186
+ sso_base = "https://sso.#{domain}/sso"
187
+ api_base = "https://connectapi.#{domain}"
188
+
189
+ uri = URI.parse(
190
+ "#{api_base}/oauth-service/oauth/preauthorized?" \
191
+ "#{URI.encode_www_form("ticket" => ticket, "login-url" => "#{sso_base}/embed", "accepts-mfa-tokens" => "true")}"
192
+ )
193
+
194
+ http = Net::HTTP.new(uri.host, uri.port)
195
+ http.use_ssl = true
196
+ http.open_timeout = 10
197
+ http.read_timeout = 10
198
+
199
+ request = Net::HTTP::Get.new(uri.request_uri)
200
+ request["User-Agent"] = OAUTH_USER_AGENT
201
+
202
+ consumer = OAuth::Consumer.new(consumer_key, consumer_secret, site: api_base, scheme: :header)
203
+ helper = OAuth::Client::Helper.new(
204
+ request,
205
+ { consumer: consumer, request_uri: uri.to_s, signature_method: "HMAC-SHA1" }
206
+ )
207
+ request["Authorization"] = helper.header
208
+
209
+ response = http.request(request)
210
+ raise AuthenticationError, "OAuth1 exchange failed: HTTP #{response.code}" unless response.code == "200"
211
+
212
+ data = CGI.parse(response.body).transform_values(&:first)
213
+
214
+ OAuth1Token.new(
215
+ token: data["oauth_token"],
216
+ secret: data["oauth_token_secret"],
217
+ mfa_token: data["mfa_token"],
218
+ mfa_expiration: data["mfa_expiration_timestamp"],
219
+ domain: domain
220
+ )
221
+ end
222
+
223
+ def exchange_oauth1(oauth1_token, consumer_key, consumer_secret, domain)
224
+ api_base = "https://connectapi.#{domain}"
225
+ url = "#{api_base}/oauth-service/oauth/exchange/user/2.0"
226
+ uri = URI.parse(url)
227
+
228
+ http = Net::HTTP.new(uri.host, uri.port)
229
+ http.use_ssl = true
230
+ http.open_timeout = 10
231
+ http.read_timeout = 10
232
+
233
+ request = Net::HTTP::Post.new(uri.request_uri)
234
+ request["User-Agent"] = OAUTH_USER_AGENT
235
+ request["Content-Type"] = "application/x-www-form-urlencoded"
236
+
237
+ request.body = if oauth1_token.mfa_token && !oauth1_token.mfa_token.empty?
238
+ URI.encode_www_form("mfa_token" => oauth1_token.mfa_token)
239
+ else
240
+ ""
241
+ end
242
+
243
+ consumer = OAuth::Consumer.new(consumer_key, consumer_secret, site: api_base, scheme: :header)
244
+ access_token = OAuth::AccessToken.new(consumer, oauth1_token.token, oauth1_token.secret)
245
+
246
+ helper = OAuth::Client::Helper.new(
247
+ request,
248
+ {
249
+ consumer: consumer,
250
+ token: access_token,
251
+ request_uri: url,
252
+ signature_method: "HMAC-SHA1"
253
+ }
254
+ )
255
+ request["Authorization"] = helper.header
256
+
257
+ response = http.request(request)
258
+ raise AuthenticationError, "OAuth2 exchange failed: HTTP #{response.code}" unless response.code == "200"
259
+
260
+ data = JSON.parse(response.body)
261
+
262
+ OAuth2Token.new(
263
+ access_token: data["access_token"],
264
+ refresh_token: data["refresh_token"],
265
+ token_type: data["token_type"] || "Bearer",
266
+ scope: data["scope"],
267
+ jti: data["jti"],
268
+ expires_in: data["expires_in"],
269
+ expires_at: (Time.now.to_i + data["expires_in"]),
270
+ refresh_token_expires_in: data["refresh_token_expires_in"],
271
+ refresh_token_expires_at: data["refresh_token_expires_in"] ? (Time.now.to_i + data["refresh_token_expires_in"]) : nil
272
+ )
273
+ end
274
+
275
+ private_class_method :build_sso_connection, :embed_url, :signin_url,
276
+ :handle_mfa, :signin_params, :fetch_consumer_credentials,
277
+ :exchange_ticket, :exchange_oauth1
278
+
279
+ # --- Regex extraction ---
280
+
281
+ def extract_csrf(html)
282
+ match = html.match(CSRF_RE)
283
+ raise LoginError, "Could not extract CSRF token from SSO page" unless match
284
+
285
+ match[1]
286
+ end
287
+
288
+ def extract_title(html)
289
+ match = html.match(TITLE_RE)
290
+ match ? match[1].strip : ""
291
+ end
292
+
293
+ def extract_ticket(html)
294
+ match = html.match(TICKET_RE)
295
+ raise LoginError, "Could not extract SSO ticket from success page" unless match
296
+
297
+ match[1]
298
+ end
299
+
300
+ private_class_method :extract_csrf, :extract_title, :extract_ticket
301
+ end
302
+ end
303
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "base64"
5
+ require "fileutils"
6
+
7
+ module GarminConnect
8
+ module Auth
9
+ # Handles token persistence to disk or serialization to strings.
10
+ # Compatible with garth's token storage format for interoperability.
11
+ module TokenStore
12
+ OAUTH1_FILENAME = "oauth1_token.json"
13
+ OAUTH2_FILENAME = "oauth2_token.json"
14
+
15
+ module_function
16
+
17
+ # Save tokens to a directory as JSON files.
18
+ def save(directory, oauth1_token:, oauth2_token:)
19
+ FileUtils.mkdir_p(directory)
20
+
21
+ File.write(
22
+ File.join(directory, OAUTH1_FILENAME),
23
+ JSON.pretty_generate(oauth1_token.to_h)
24
+ )
25
+ File.write(
26
+ File.join(directory, OAUTH2_FILENAME),
27
+ JSON.pretty_generate(oauth2_token.to_h)
28
+ )
29
+
30
+ directory
31
+ end
32
+
33
+ # Save only the OAuth2 token (used after refresh).
34
+ def save_oauth2(directory, oauth2_token:)
35
+ FileUtils.mkdir_p(directory)
36
+
37
+ File.write(
38
+ File.join(directory, OAUTH2_FILENAME),
39
+ JSON.pretty_generate(oauth2_token.to_h)
40
+ )
41
+
42
+ directory
43
+ end
44
+
45
+ # Load tokens from a directory.
46
+ # Returns [OAuth1Token, OAuth2Token] or raises if files are missing.
47
+ def load(directory)
48
+ oauth1_path = File.join(directory, OAUTH1_FILENAME)
49
+ oauth2_path = File.join(directory, OAUTH2_FILENAME)
50
+
51
+ raise Error, "Token directory not found: #{directory}" unless Dir.exist?(directory)
52
+ raise Error, "OAuth1 token file not found: #{oauth1_path}" unless File.exist?(oauth1_path)
53
+ raise Error, "OAuth2 token file not found: #{oauth2_path}" unless File.exist?(oauth2_path)
54
+
55
+ oauth1 = OAuth1Token.from_hash(JSON.parse(File.read(oauth1_path)))
56
+ oauth2 = OAuth2Token.from_hash(JSON.parse(File.read(oauth2_path)))
57
+
58
+ [oauth1, oauth2]
59
+ end
60
+
61
+ # Serialize both tokens to a single base64-encoded string.
62
+ # Compatible with garth's dumps/loads format.
63
+ def dumps(oauth1_token:, oauth2_token:)
64
+ payload = [oauth1_token.to_h, oauth2_token.to_h]
65
+ Base64.strict_encode64(JSON.generate(payload))
66
+ end
67
+
68
+ # Deserialize tokens from a base64-encoded string.
69
+ # Returns [OAuth1Token, OAuth2Token].
70
+ def loads(encoded_string)
71
+ decoded = JSON.parse(Base64.strict_decode64(encoded_string))
72
+ oauth1 = OAuth1Token.from_hash(decoded[0])
73
+ oauth2 = OAuth2Token.from_hash(decoded[1])
74
+
75
+ [oauth1, oauth2]
76
+ end
77
+ end
78
+ end
79
+ end