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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +21 -0
- data/LICENSE +21 -0
- data/README.md +295 -0
- data/lib/garmin_connect/api/activities.rb +190 -0
- data/lib/garmin_connect/api/badges.rb +94 -0
- data/lib/garmin_connect/api/body_composition.rb +127 -0
- data/lib/garmin_connect/api/devices.rb +118 -0
- data/lib/garmin_connect/api/health.rb +139 -0
- data/lib/garmin_connect/api/metrics.rb +177 -0
- data/lib/garmin_connect/api/user.rb +39 -0
- data/lib/garmin_connect/api/wellness.rb +45 -0
- data/lib/garmin_connect/api/workouts.rb +62 -0
- data/lib/garmin_connect/auth/oauth1_token.rb +43 -0
- data/lib/garmin_connect/auth/oauth2_token.rb +70 -0
- data/lib/garmin_connect/auth/sso.rb +303 -0
- data/lib/garmin_connect/auth/token_store.rb +79 -0
- data/lib/garmin_connect/client.rb +213 -0
- data/lib/garmin_connect/connection.rb +148 -0
- data/lib/garmin_connect/errors.rb +46 -0
- data/lib/garmin_connect/version.rb +5 -0
- data/lib/garmin_connect.rb +35 -0
- metadata +136 -0
|
@@ -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
|