@kaiord/garmin-connect 5.0.0 → 7.0.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.
- package/README.md +67 -29
- package/dist/index.d.ts +64 -21
- package/dist/index.js +387 -226
- package/dist/index.js.map +1 -1
- package/package.json +9 -6
package/dist/index.js
CHANGED
|
@@ -1,80 +1,104 @@
|
|
|
1
|
-
import { createConsoleLogger, createServiceAuthError, createServiceApiError, toText } from '@kaiord/core';
|
|
1
|
+
import { createConsoleLogger, createServiceAuthError, createServiceApiError, fromText, toText } from '@kaiord/core';
|
|
2
2
|
import fetchCookie from 'fetch-cookie';
|
|
3
3
|
import { createHmac } from 'crypto';
|
|
4
4
|
import OAuth from 'oauth-1.0a';
|
|
5
5
|
import { z } from 'zod';
|
|
6
|
-
import { createGarminWriter } from '@kaiord/garmin';
|
|
6
|
+
import { createGarminWriter, createGarminReader } from '@kaiord/garmin';
|
|
7
7
|
import { unlink, readFile, mkdir, writeFile } from 'fs/promises';
|
|
8
8
|
import { homedir } from 'os';
|
|
9
9
|
import { join, dirname } from 'path';
|
|
10
10
|
|
|
11
11
|
// src/adapters/auth/garmin-auth-provider.ts
|
|
12
12
|
var createCookieFetch = () => fetchCookie(globalThis.fetch);
|
|
13
|
-
var makeRequest = (url, init, refresh, fetchFn) => {
|
|
14
|
-
if (!refresh.state.oauth2Token) {
|
|
15
|
-
throw createServiceApiError("Token unavailable after refresh", 401);
|
|
16
|
-
}
|
|
17
|
-
return fetchFn(url, {
|
|
18
|
-
...init,
|
|
19
|
-
headers: {
|
|
20
|
-
...init?.headers,
|
|
21
|
-
Authorization: `Bearer ${refresh.state.oauth2Token.access_token}`
|
|
22
|
-
}
|
|
23
|
-
});
|
|
24
|
-
};
|
|
25
|
-
var authFetch = async (url, init, refresh, fetchFn) => {
|
|
26
|
-
if (!refresh.state.oauth2Token) {
|
|
27
|
-
throw createServiceApiError("Not authenticated", 401);
|
|
28
|
-
}
|
|
29
|
-
if (refresh.state.oauth2Token.expires_at < Math.floor(Date.now() / 1e3)) {
|
|
30
|
-
await refresh.ensureFreshToken();
|
|
31
|
-
}
|
|
32
|
-
const res = await makeRequest(url, init, refresh, fetchFn);
|
|
33
|
-
if (res.status === 401) {
|
|
34
|
-
await refresh.ensureFreshToken();
|
|
35
|
-
const retry = await makeRequest(url, init, refresh, fetchFn);
|
|
36
|
-
if (!retry.ok) {
|
|
37
|
-
throw createServiceApiError(
|
|
38
|
-
`API request failed after token refresh: ${retry.statusText}`,
|
|
39
|
-
retry.status
|
|
40
|
-
);
|
|
41
|
-
}
|
|
42
|
-
return retry;
|
|
43
|
-
}
|
|
44
|
-
if (!res.ok) {
|
|
45
|
-
throw createServiceApiError(
|
|
46
|
-
`API request failed: ${res.statusText}`,
|
|
47
|
-
res.status
|
|
48
|
-
);
|
|
49
|
-
}
|
|
50
|
-
return res;
|
|
51
|
-
};
|
|
52
13
|
|
|
53
14
|
// src/adapters/http/urls.ts
|
|
54
15
|
var GARMIN_SSO_ORIGIN = "https://sso.garmin.com";
|
|
55
16
|
var GARMIN_SSO_EMBED = "https://sso.garmin.com/sso/embed";
|
|
56
17
|
var SIGNIN_URL = "https://sso.garmin.com/sso/signin";
|
|
57
|
-
var GC_MODERN = "https://connect.garmin.com/modern";
|
|
58
18
|
var API_BASE = "https://connectapi.garmin.com";
|
|
59
19
|
var OAUTH_URL = `${API_BASE}/oauth-service/oauth`;
|
|
60
20
|
var WORKOUT_URL = `${API_BASE}/workout-service`;
|
|
61
21
|
var OAUTH_CONSUMER_URL = "https://thegarth.s3.amazonaws.com/oauth_consumer.json";
|
|
62
22
|
var USER_AGENT_MOBILE = "com.garmin.android.apps.connectmobile";
|
|
63
|
-
var
|
|
23
|
+
var USER_AGENT_SSO = "GCM-iOS-5.7.2.1";
|
|
64
24
|
|
|
65
25
|
// src/adapters/http/oauth-consumer.ts
|
|
66
|
-
var fetchOAuthConsumer = async (fetchFn) => {
|
|
26
|
+
var fetchOAuthConsumer = async (fetchFn, logger) => {
|
|
27
|
+
logger.debug("[SSO] Fetching OAuth consumer credentials");
|
|
67
28
|
const res = await fetchFn(OAUTH_CONSUMER_URL);
|
|
68
29
|
if (!res.ok) {
|
|
30
|
+
logger.error("[SSO] OAuth consumer fetch failed", { status: res.status });
|
|
69
31
|
throw createServiceAuthError(
|
|
70
32
|
`Failed to fetch OAuth consumer: ${res.status} ${res.statusText}`
|
|
71
33
|
);
|
|
72
34
|
}
|
|
35
|
+
logger.debug("[SSO] OAuth consumer fetch", { status: res.status });
|
|
73
36
|
const data = await res.json();
|
|
74
37
|
return { key: data.consumer_key, secret: data.consumer_secret };
|
|
75
38
|
};
|
|
76
|
-
|
|
39
|
+
|
|
40
|
+
// src/adapters/http/sso-html-diagnostics.ts
|
|
77
41
|
var PAGE_TITLE_RE = /<title>([^<]*)<\/title>/;
|
|
42
|
+
var extractTitle = (html) => {
|
|
43
|
+
const match = PAGE_TITLE_RE.exec(html);
|
|
44
|
+
return match?.[1] ?? "unknown";
|
|
45
|
+
};
|
|
46
|
+
var logCsrfResult = (logger, status, size, found) => {
|
|
47
|
+
logger.debug("[SSO] CSRF fetch", { status, size });
|
|
48
|
+
if (!found) logger.warn("[SSO] CSRF token not found in response");
|
|
49
|
+
};
|
|
50
|
+
var logLoginResponse = (logger, status, size) => {
|
|
51
|
+
logger.debug("[SSO] Login response", { status, size });
|
|
52
|
+
};
|
|
53
|
+
var logLoginHtmlDiagnostics = (html, ticketFound, logger) => {
|
|
54
|
+
const title = extractTitle(html);
|
|
55
|
+
const size = new TextEncoder().encode(html).byteLength;
|
|
56
|
+
logger.debug("[SSO] Page title", { title });
|
|
57
|
+
if (/\bmfa\b/i.test(html)) logger.warn("[SSO] MFA detected");
|
|
58
|
+
if (/\berror\b/i.test(html)) logger.warn("[SSO] Error indicators found");
|
|
59
|
+
if (ticketFound) {
|
|
60
|
+
logger.debug("[SSO] Ticket found in HTML");
|
|
61
|
+
} else {
|
|
62
|
+
logger.warn("[SSO] Ticket not found in HTML", { size, title });
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// src/adapters/http/sso-submit.ts
|
|
67
|
+
var buildLoginParams = () => new URLSearchParams({
|
|
68
|
+
id: "gauth-widget",
|
|
69
|
+
embedWidget: "true",
|
|
70
|
+
gauthHost: GARMIN_SSO_EMBED,
|
|
71
|
+
service: GARMIN_SSO_EMBED,
|
|
72
|
+
source: GARMIN_SSO_EMBED,
|
|
73
|
+
redirectAfterAccountLoginUrl: GARMIN_SSO_EMBED,
|
|
74
|
+
redirectAfterAccountCreationUrl: GARMIN_SSO_EMBED
|
|
75
|
+
});
|
|
76
|
+
var submitLogin = async (input) => {
|
|
77
|
+
const { username, password, csrf, fetchFn, logger } = input;
|
|
78
|
+
logger.debug("[SSO] Submitting login");
|
|
79
|
+
const body = new URLSearchParams({
|
|
80
|
+
username,
|
|
81
|
+
password,
|
|
82
|
+
embed: "true",
|
|
83
|
+
_csrf: csrf
|
|
84
|
+
});
|
|
85
|
+
const loginRes = await fetchFn(`${SIGNIN_URL}?${buildLoginParams()}`, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers: {
|
|
88
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
89
|
+
Origin: GARMIN_SSO_ORIGIN,
|
|
90
|
+
Referer: `${SIGNIN_URL}?${buildLoginParams()}`,
|
|
91
|
+
"User-Agent": USER_AGENT_SSO
|
|
92
|
+
},
|
|
93
|
+
body: body.toString()
|
|
94
|
+
});
|
|
95
|
+
const html = await loginRes.text();
|
|
96
|
+
const size = new TextEncoder().encode(html).byteLength;
|
|
97
|
+
logLoginResponse(logger, loginRes.status, size);
|
|
98
|
+
return { html, status: loginRes.status };
|
|
99
|
+
};
|
|
100
|
+
var ACCOUNT_LOCKED_RE = /var status\s*=\s*"([^"]*)"/;
|
|
101
|
+
var PAGE_TITLE_RE2 = /<title>([^<]*)<\/title>/;
|
|
78
102
|
var checkAccountLocked = (html) => {
|
|
79
103
|
const match = ACCOUNT_LOCKED_RE.exec(html);
|
|
80
104
|
if (match && match[1] === "ACCOUNT_LOCKED") {
|
|
@@ -84,7 +108,7 @@ var checkAccountLocked = (html) => {
|
|
|
84
108
|
}
|
|
85
109
|
};
|
|
86
110
|
var checkPageTitle = (html, logger) => {
|
|
87
|
-
const match =
|
|
111
|
+
const match = PAGE_TITLE_RE2.exec(html);
|
|
88
112
|
if (match?.[1]?.includes("Update Phone Number")) {
|
|
89
113
|
throw createServiceAuthError("Login failed: phone number update required.");
|
|
90
114
|
}
|
|
@@ -96,69 +120,66 @@ var checkPageTitle = (html, logger) => {
|
|
|
96
120
|
// src/adapters/http/sso-login.ts
|
|
97
121
|
var CSRF_RE = /name="_csrf"\s+value="(.+?)"/;
|
|
98
122
|
var TICKET_RE = /ticket=([^"]+)"/;
|
|
99
|
-
var
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
123
|
+
var buildSigninParams = () => new URLSearchParams({
|
|
124
|
+
id: "gauth-widget",
|
|
125
|
+
embedWidget: "true",
|
|
126
|
+
gauthHost: GARMIN_SSO_EMBED,
|
|
127
|
+
service: GARMIN_SSO_EMBED,
|
|
128
|
+
source: GARMIN_SSO_EMBED,
|
|
129
|
+
redirectAfterAccountLoginUrl: GARMIN_SSO_EMBED,
|
|
130
|
+
redirectAfterAccountCreationUrl: GARMIN_SSO_EMBED
|
|
131
|
+
});
|
|
132
|
+
var fetchCsrfToken = async (fetchFn, embedUrl, logger) => {
|
|
133
|
+
logger.debug("[SSO] Fetching CSRF token");
|
|
134
|
+
const signinParams = buildSigninParams();
|
|
135
|
+
const csrfRes = await fetchFn(`${SIGNIN_URL}?${signinParams}`, {
|
|
136
|
+
headers: {
|
|
137
|
+
"User-Agent": USER_AGENT_SSO,
|
|
138
|
+
Referer: embedUrl
|
|
139
|
+
}
|
|
105
140
|
});
|
|
106
|
-
const
|
|
141
|
+
const csrfHtml = await csrfRes.text();
|
|
142
|
+
const csrfMatch = CSRF_RE.exec(csrfHtml);
|
|
143
|
+
const size = new TextEncoder().encode(csrfHtml).byteLength;
|
|
144
|
+
logCsrfResult(logger, csrfRes.status, size, !!csrfMatch);
|
|
107
145
|
if (!csrfRes.ok) {
|
|
108
146
|
throw createServiceAuthError(
|
|
109
147
|
`SSO login page returned ${csrfRes.status}: ${csrfRes.statusText}`
|
|
110
148
|
);
|
|
111
149
|
}
|
|
112
|
-
const csrfHtml = await csrfRes.text();
|
|
113
|
-
const csrfMatch = CSRF_RE.exec(csrfHtml);
|
|
114
150
|
if (!csrfMatch) {
|
|
115
151
|
throw createServiceAuthError("CSRF token not found on login page");
|
|
116
152
|
}
|
|
117
153
|
return csrfMatch[1];
|
|
118
154
|
};
|
|
119
|
-
var
|
|
120
|
-
const
|
|
155
|
+
var getLoginTicket = async (username, password, fetchFn, logger) => {
|
|
156
|
+
const embedParams = new URLSearchParams({
|
|
121
157
|
id: "gauth-widget",
|
|
122
158
|
embedWidget: "true",
|
|
123
|
-
|
|
124
|
-
locale: "en",
|
|
125
|
-
gauthHost: GARMIN_SSO_EMBED,
|
|
126
|
-
service: GARMIN_SSO_EMBED,
|
|
127
|
-
source: GARMIN_SSO_EMBED,
|
|
128
|
-
redirectAfterAccountLoginUrl: GARMIN_SSO_EMBED,
|
|
129
|
-
redirectAfterAccountCreationUrl: GARMIN_SSO_EMBED
|
|
159
|
+
gauthHost: "https://sso.garmin.com/sso"
|
|
130
160
|
});
|
|
131
|
-
const
|
|
161
|
+
const embedUrl = `${GARMIN_SSO_EMBED}?${embedParams}`;
|
|
162
|
+
const embedRes = await fetchFn(embedUrl, {
|
|
163
|
+
headers: { "User-Agent": USER_AGENT_SSO }
|
|
164
|
+
});
|
|
165
|
+
logger.debug("[SSO] Embed bootstrap", { status: embedRes.status });
|
|
166
|
+
if (!embedRes.ok) {
|
|
167
|
+
throw createServiceAuthError(
|
|
168
|
+
`SSO embed bootstrap failed: ${embedRes.status} ${embedRes.statusText}`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
const csrf = await fetchCsrfToken(fetchFn, embedUrl, logger);
|
|
172
|
+
const { html: loginHtml } = await submitLogin({
|
|
132
173
|
username,
|
|
133
174
|
password,
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const loginRes = await fetchFn(`${SIGNIN_URL}?${loginParams}`, {
|
|
138
|
-
method: "POST",
|
|
139
|
-
headers: {
|
|
140
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
141
|
-
Dnt: "1",
|
|
142
|
-
Origin: GARMIN_SSO_ORIGIN,
|
|
143
|
-
Referer: SIGNIN_URL,
|
|
144
|
-
"User-Agent": USER_AGENT_BROWSER
|
|
145
|
-
},
|
|
146
|
-
body: body.toString()
|
|
147
|
-
});
|
|
148
|
-
return loginRes.text();
|
|
149
|
-
};
|
|
150
|
-
var getLoginTicket = async (username, password, fetchFn, logger) => {
|
|
151
|
-
const embedParams = new URLSearchParams({
|
|
152
|
-
clientId: "GarminConnect",
|
|
153
|
-
locale: "en",
|
|
154
|
-
service: GC_MODERN
|
|
175
|
+
csrf,
|
|
176
|
+
fetchFn,
|
|
177
|
+
logger
|
|
155
178
|
});
|
|
156
|
-
await fetchFn(`${GARMIN_SSO_EMBED}?${embedParams}`);
|
|
157
|
-
const csrf = await fetchCsrfToken(fetchFn);
|
|
158
|
-
const loginHtml = await submitLogin(username, password, csrf, fetchFn);
|
|
159
179
|
checkAccountLocked(loginHtml);
|
|
160
180
|
checkPageTitle(loginHtml, logger);
|
|
161
181
|
const ticketMatch = TICKET_RE.exec(loginHtml);
|
|
182
|
+
logLoginHtmlDiagnostics(loginHtml, !!ticketMatch, logger);
|
|
162
183
|
if (!ticketMatch) {
|
|
163
184
|
throw createServiceAuthError(
|
|
164
185
|
"Login failed: ticket not found. Check username and password."
|
|
@@ -184,7 +205,7 @@ var createOAuthSigner = (consumer) => {
|
|
|
184
205
|
};
|
|
185
206
|
|
|
186
207
|
// src/adapters/http/sso-oauth.ts
|
|
187
|
-
var getOAuth1Token = async (ticket, consumer, fetchFn) => {
|
|
208
|
+
var getOAuth1Token = async (ticket, consumer, fetchFn, logger) => {
|
|
188
209
|
const signer = createOAuthSigner(consumer);
|
|
189
210
|
const params = new URLSearchParams({
|
|
190
211
|
ticket,
|
|
@@ -196,6 +217,7 @@ var getOAuth1Token = async (ticket, consumer, fetchFn) => {
|
|
|
196
217
|
const res = await fetchFn(url, {
|
|
197
218
|
headers: { ...headers, "User-Agent": USER_AGENT_MOBILE }
|
|
198
219
|
});
|
|
220
|
+
logger.debug("[SSO] OAuth1 token response", { status: res.status });
|
|
199
221
|
if (!res.ok) {
|
|
200
222
|
throw createServiceAuthError(
|
|
201
223
|
`OAuth1 token request failed: ${res.status} ${res.statusText}`
|
|
@@ -210,7 +232,7 @@ var getOAuth1Token = async (ticket, consumer, fetchFn) => {
|
|
|
210
232
|
}
|
|
211
233
|
return { oauth_token: oauthToken, oauth_token_secret: oauthTokenSecret };
|
|
212
234
|
};
|
|
213
|
-
var exchangeOAuth2 = async (oauth1, consumer, fetchFn) => {
|
|
235
|
+
var exchangeOAuth2 = async (oauth1, consumer, fetchFn, logger) => {
|
|
214
236
|
const signer = createOAuthSigner(consumer);
|
|
215
237
|
const baseUrl = `${OAUTH_URL}/exchange/user/2.0`;
|
|
216
238
|
const token = {
|
|
@@ -226,6 +248,7 @@ var exchangeOAuth2 = async (oauth1, consumer, fetchFn) => {
|
|
|
226
248
|
"Content-Type": "application/x-www-form-urlencoded"
|
|
227
249
|
}
|
|
228
250
|
});
|
|
251
|
+
logger.debug("[SSO] OAuth2 exchange response", { status: res.status });
|
|
229
252
|
if (!res.ok) {
|
|
230
253
|
throw createServiceAuthError(
|
|
231
254
|
`OAuth2 exchange failed: ${res.status} ${res.statusText}`
|
|
@@ -241,74 +264,179 @@ var exchangeOAuth2 = async (oauth1, consumer, fetchFn) => {
|
|
|
241
264
|
// src/adapters/http/garmin-sso.ts
|
|
242
265
|
var garminSso = async (username, password, logger, fetchFn) => {
|
|
243
266
|
logger.info("Starting Garmin Connect SSO login");
|
|
244
|
-
|
|
267
|
+
logger.info("[SSO] Step 1/4: OAuth consumer");
|
|
268
|
+
const consumer = await fetchOAuthConsumer(fetchFn, logger);
|
|
269
|
+
logger.info("[SSO] Step 2/4: Login ticket");
|
|
245
270
|
const ticket = await getLoginTicket(username, password, fetchFn, logger);
|
|
246
|
-
logger.
|
|
247
|
-
const oauth1 = await getOAuth1Token(ticket, consumer, fetchFn);
|
|
248
|
-
logger.
|
|
249
|
-
const oauth2 = await exchangeOAuth2(oauth1, consumer, fetchFn);
|
|
271
|
+
logger.info("[SSO] Step 3/4: OAuth1 token");
|
|
272
|
+
const oauth1 = await getOAuth1Token(ticket, consumer, fetchFn, logger);
|
|
273
|
+
logger.info("[SSO] Step 4/4: OAuth2 exchange");
|
|
274
|
+
const oauth2 = await exchangeOAuth2(oauth1, consumer, fetchFn, logger);
|
|
250
275
|
logger.info("Garmin Connect SSO login successful");
|
|
251
276
|
return { oauth1, oauth2 };
|
|
252
277
|
};
|
|
278
|
+
var oauth1TokenSchema = z.object({
|
|
279
|
+
oauth_token: z.string(),
|
|
280
|
+
oauth_token_secret: z.string()
|
|
281
|
+
});
|
|
282
|
+
var oauth2TokenSchema = z.object({
|
|
283
|
+
access_token: z.string(),
|
|
284
|
+
refresh_token: z.string(),
|
|
285
|
+
token_type: z.string(),
|
|
286
|
+
expires_in: z.number(),
|
|
287
|
+
refresh_token_expires_in: z.number(),
|
|
288
|
+
expires_at: z.number()
|
|
289
|
+
});
|
|
290
|
+
var garminTokensSchema = z.object({
|
|
291
|
+
oauth1: oauth1TokenSchema,
|
|
292
|
+
oauth2: oauth2TokenSchema
|
|
293
|
+
});
|
|
253
294
|
|
|
254
|
-
// src/adapters/
|
|
255
|
-
var
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
295
|
+
// src/adapters/auth/garmin-auth-provider.ts
|
|
296
|
+
var buildAuthProvider = (tm, logger, fetchFn) => ({
|
|
297
|
+
login: async (username, password) => {
|
|
298
|
+
const result = await garminSso(username, password, logger, fetchFn);
|
|
299
|
+
await tm.setTokens(result.oauth1, result.oauth2);
|
|
300
|
+
},
|
|
301
|
+
is_authenticated: () => tm.isAuthenticated(),
|
|
302
|
+
export_tokens: async () => {
|
|
303
|
+
const oauth1 = tm.getOAuth1Token();
|
|
304
|
+
const oauth2 = tm.getOAuth2Token();
|
|
305
|
+
if (!oauth1 || !oauth2) {
|
|
306
|
+
throw createServiceAuthError("No tokens to export");
|
|
307
|
+
}
|
|
308
|
+
return { oauth1: { ...oauth1 }, oauth2: { ...oauth2 } };
|
|
309
|
+
},
|
|
310
|
+
restore_tokens: async (data) => {
|
|
311
|
+
const parsed = garminTokensSchema.parse(data);
|
|
312
|
+
await tm.setTokens(parsed.oauth1, parsed.oauth2);
|
|
313
|
+
logger.info("Tokens restored from stored session");
|
|
314
|
+
},
|
|
315
|
+
logout: async () => {
|
|
316
|
+
await tm.clearTokens();
|
|
317
|
+
logger.info("Logged out from Garmin Connect");
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
var createGarminAuthProvider = (options) => {
|
|
321
|
+
const logger = options.logger ?? createConsoleLogger();
|
|
322
|
+
const fetchFn = options.fetchFn ?? createCookieFetch();
|
|
323
|
+
return buildAuthProvider(options.tokenManager, logger, fetchFn);
|
|
260
324
|
};
|
|
261
|
-
var
|
|
262
|
-
if (
|
|
263
|
-
|
|
264
|
-
|
|
325
|
+
var persistBestEffort = async (store, oauth1, oauth2, logger) => {
|
|
326
|
+
if (!store) return;
|
|
327
|
+
try {
|
|
328
|
+
await store.save({ oauth1, oauth2 });
|
|
329
|
+
} catch (error) {
|
|
330
|
+
logger.warn("Failed to persist tokens", {
|
|
331
|
+
errorName: error instanceof Error ? error.name : typeof error
|
|
332
|
+
});
|
|
333
|
+
}
|
|
265
334
|
};
|
|
266
|
-
var
|
|
267
|
-
|
|
268
|
-
|
|
335
|
+
var isExpired = (oauth2) => !oauth2 || oauth2.expires_at <= Date.now() / 1e3;
|
|
336
|
+
var doRefresh = (s, refreshFn, logger, tokenStore) => {
|
|
337
|
+
if (!s.oauth1) {
|
|
338
|
+
throw createServiceApiError("No OAuth1 token for refresh", 401);
|
|
269
339
|
}
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
340
|
+
const currentOAuth1 = s.oauth1;
|
|
341
|
+
const generationAtStart = s.generation;
|
|
342
|
+
s.refreshPromise = refreshFn(currentOAuth1).then(async (newOAuth2) => {
|
|
343
|
+
if (s.generation !== generationAtStart) return;
|
|
344
|
+
s.oauth2 = newOAuth2;
|
|
345
|
+
s.generation++;
|
|
346
|
+
logger.info("Token refreshed", { generation: s.generation });
|
|
347
|
+
await persistBestEffort(tokenStore, currentOAuth1, newOAuth2, logger);
|
|
348
|
+
}).finally(() => {
|
|
349
|
+
s.refreshPromise = void 0;
|
|
350
|
+
});
|
|
351
|
+
return s.refreshPromise;
|
|
352
|
+
};
|
|
353
|
+
var restoreFromStore = async (s, tokenStore, logger) => {
|
|
354
|
+
const data = await tokenStore.load();
|
|
355
|
+
if (!data) return { restored: false };
|
|
356
|
+
if (s.oauth1 && s.oauth2) return { restored: false };
|
|
357
|
+
const parsed = garminTokensSchema.safeParse(data);
|
|
358
|
+
if (!parsed.success) return { restored: false };
|
|
359
|
+
s.oauth1 = parsed.data.oauth1;
|
|
360
|
+
s.oauth2 = parsed.data.oauth2;
|
|
361
|
+
s.generation++;
|
|
362
|
+
if (isExpired(s.oauth2)) logger.warn("Restored tokens are expired");
|
|
363
|
+
logger.info("Tokens restored from store", { generation: s.generation });
|
|
364
|
+
return { restored: true };
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
// src/adapters/token/token-manager.ts
|
|
368
|
+
var createTokenManager = (options) => {
|
|
369
|
+
const { refreshFn, logger, tokenStore } = options;
|
|
370
|
+
const s = {
|
|
371
|
+
oauth1: void 0,
|
|
372
|
+
oauth2: void 0,
|
|
373
|
+
generation: 0,
|
|
374
|
+
refreshPromise: void 0
|
|
279
375
|
};
|
|
280
|
-
let isRefreshing = false;
|
|
281
|
-
let subscribers = [];
|
|
282
376
|
return {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
377
|
+
getAccessToken: () => s.oauth2?.access_token,
|
|
378
|
+
getOAuth1Token: () => s.oauth1 ? { ...s.oauth1 } : void 0,
|
|
379
|
+
getOAuth2Token: () => s.oauth2 ? { ...s.oauth2 } : void 0,
|
|
380
|
+
getGeneration: () => s.generation,
|
|
381
|
+
isAuthenticated: () => !!s.oauth2 && !isExpired(s.oauth2),
|
|
382
|
+
setTokens: async (o1, o2) => {
|
|
383
|
+
s.oauth1 = o1;
|
|
384
|
+
s.oauth2 = o2;
|
|
385
|
+
s.generation++;
|
|
386
|
+
logger.info("Tokens set", { generation: s.generation });
|
|
387
|
+
await persistBestEffort(tokenStore, o1, o2, logger);
|
|
388
|
+
},
|
|
389
|
+
clearTokens: async () => {
|
|
390
|
+
s.oauth1 = void 0;
|
|
391
|
+
s.oauth2 = void 0;
|
|
392
|
+
s.refreshPromise = void 0;
|
|
393
|
+
s.generation++;
|
|
394
|
+
logger.info("Tokens cleared");
|
|
395
|
+
if (tokenStore) await tokenStore.clear();
|
|
396
|
+
},
|
|
397
|
+
refresh: async () => {
|
|
398
|
+
if (s.refreshPromise) return s.refreshPromise;
|
|
399
|
+
return doRefresh(s, refreshFn, logger, tokenStore);
|
|
400
|
+
},
|
|
401
|
+
init: async () => {
|
|
402
|
+
if (s.oauth1 && s.oauth2) return { restored: false };
|
|
403
|
+
if (!tokenStore) return { restored: false };
|
|
404
|
+
return restoreFromStore(s, tokenStore, logger);
|
|
304
405
|
}
|
|
305
406
|
};
|
|
306
407
|
};
|
|
408
|
+
var sendRequest = (url, init, token, fetchFn) => fetchFn(url, {
|
|
409
|
+
...init,
|
|
410
|
+
headers: { ...init?.headers, Authorization: `Bearer ${token}` }
|
|
411
|
+
});
|
|
412
|
+
var getTokenOrThrow = (reader, msg) => {
|
|
413
|
+
const token = reader.getAccessToken();
|
|
414
|
+
if (!token) throw createServiceApiError(msg, 401);
|
|
415
|
+
return token;
|
|
416
|
+
};
|
|
417
|
+
var handleNonOk = (res, prefix) => {
|
|
418
|
+
throw createServiceApiError(`${prefix}: ${res.statusText}`, res.status);
|
|
419
|
+
};
|
|
420
|
+
var authFetch = async (url, init, reader, fetchFn) => {
|
|
421
|
+
if (!reader.isAuthenticated()) await reader.refresh();
|
|
422
|
+
const gen = reader.getGeneration();
|
|
423
|
+
const token = getTokenOrThrow(reader, "Not authenticated");
|
|
424
|
+
const res = await sendRequest(url, init, token, fetchFn);
|
|
425
|
+
if (res.status !== 401) {
|
|
426
|
+
if (!res.ok) handleNonOk(res, "API request failed");
|
|
427
|
+
return res;
|
|
428
|
+
}
|
|
429
|
+
if (reader.getGeneration() === gen) await reader.refresh();
|
|
430
|
+
const freshToken = getTokenOrThrow(reader, "Token unavailable after refresh");
|
|
431
|
+
const retry = await sendRequest(url, init, freshToken, fetchFn);
|
|
432
|
+
if (!retry.ok) handleNonOk(retry, "API request failed after token refresh");
|
|
433
|
+
return retry;
|
|
434
|
+
};
|
|
307
435
|
|
|
308
436
|
// src/adapters/http/garmin-http-client.ts
|
|
309
|
-
var createGarminHttpClient = (
|
|
310
|
-
const
|
|
311
|
-
|
|
437
|
+
var createGarminHttpClient = (tokenReader, fetchFn, logger) => {
|
|
438
|
+
const fetch = (url, init) => authFetch(url, init, tokenReader, fetchFn);
|
|
439
|
+
logger.debug("HTTP client created");
|
|
312
440
|
return {
|
|
313
441
|
get: async (url) => {
|
|
314
442
|
const res = await fetch(url);
|
|
@@ -329,88 +457,80 @@ var createGarminHttpClient = (logger, fetchFn = globalThis.fetch) => {
|
|
|
329
457
|
});
|
|
330
458
|
const text = await res.text();
|
|
331
459
|
return text ? JSON.parse(text) : void 0;
|
|
332
|
-
}
|
|
333
|
-
setTokens: (o1, o2) => {
|
|
334
|
-
refresh.state.oauth1Token = o1;
|
|
335
|
-
refresh.state.oauth2Token = o2;
|
|
336
|
-
},
|
|
337
|
-
clearTokens: () => {
|
|
338
|
-
refresh.state.oauth1Token = void 0;
|
|
339
|
-
refresh.state.oauth2Token = void 0;
|
|
340
|
-
refresh.state.consumer = void 0;
|
|
341
|
-
},
|
|
342
|
-
getOAuth2Token: () => refresh.state.oauth2Token
|
|
460
|
+
}
|
|
343
461
|
};
|
|
344
462
|
};
|
|
345
|
-
var oauth1TokenSchema = z.object({
|
|
346
|
-
oauth_token: z.string(),
|
|
347
|
-
oauth_token_secret: z.string()
|
|
348
|
-
});
|
|
349
|
-
var oauth2TokenSchema = z.object({
|
|
350
|
-
access_token: z.string(),
|
|
351
|
-
refresh_token: z.string(),
|
|
352
|
-
token_type: z.string(),
|
|
353
|
-
expires_in: z.number(),
|
|
354
|
-
refresh_token_expires_in: z.number(),
|
|
355
|
-
expires_at: z.number()
|
|
356
|
-
});
|
|
357
|
-
var garminTokensSchema = z.object({
|
|
358
|
-
oauth1: oauth1TokenSchema,
|
|
359
|
-
oauth2: oauth2TokenSchema
|
|
360
|
-
});
|
|
361
463
|
|
|
362
|
-
// src/adapters/
|
|
363
|
-
var
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
464
|
+
// src/adapters/http/retry.ts
|
|
465
|
+
var isRetryable = (status) => status === 429 || status >= 500 && status <= 599;
|
|
466
|
+
var computeDelay = (attempt, baseDelay, maxDelay, randomFn) => randomFn() * Math.min(maxDelay, baseDelay * 2 ** attempt);
|
|
467
|
+
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
468
|
+
var waitAndLog = (opts, attempt, message, info) => {
|
|
469
|
+
const delay = computeDelay(
|
|
470
|
+
attempt,
|
|
471
|
+
opts.baseDelay,
|
|
472
|
+
opts.maxDelay,
|
|
473
|
+
opts.randomFn
|
|
474
|
+
);
|
|
475
|
+
opts.logger?.debug(message, { ...info, delay: Math.round(delay) });
|
|
476
|
+
return sleep(delay);
|
|
477
|
+
};
|
|
478
|
+
var handleRetryableResponse = async (attempt, response, opts) => {
|
|
479
|
+
if (attempt < opts.maxRetries && isRetryable(response.status)) {
|
|
480
|
+
await waitAndLog(opts, attempt, "Retrying request", {
|
|
481
|
+
attempt: attempt + 1,
|
|
482
|
+
status: response.status
|
|
483
|
+
});
|
|
484
|
+
return true;
|
|
485
|
+
}
|
|
486
|
+
return false;
|
|
487
|
+
};
|
|
488
|
+
var handleRetryableError = async (attempt, error, opts) => {
|
|
489
|
+
if (attempt < opts.maxRetries && error instanceof TypeError) {
|
|
490
|
+
await waitAndLog(opts, attempt, "Retrying request after network error", {
|
|
491
|
+
attempt: attempt + 1,
|
|
492
|
+
error: error.message
|
|
493
|
+
});
|
|
494
|
+
return true;
|
|
495
|
+
}
|
|
496
|
+
return false;
|
|
497
|
+
};
|
|
498
|
+
var withRetry = (fetchFn, options) => {
|
|
499
|
+
const opts = {
|
|
500
|
+
maxRetries: options?.maxRetries ?? 3,
|
|
501
|
+
baseDelay: options?.baseDelay ?? 1e3,
|
|
502
|
+
maxDelay: options?.maxDelay ?? 1e4,
|
|
503
|
+
randomFn: options?.randomFn ?? Math.random,
|
|
504
|
+
logger: options?.logger
|
|
505
|
+
};
|
|
506
|
+
return async (input, init) => {
|
|
507
|
+
for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
|
|
508
|
+
try {
|
|
509
|
+
const response = await fetchFn(input, init);
|
|
510
|
+
if (await handleRetryableResponse(attempt, response, opts)) continue;
|
|
511
|
+
return response;
|
|
512
|
+
} catch (error) {
|
|
513
|
+
if (await handleRetryableError(attempt, error, opts)) continue;
|
|
514
|
+
throw error;
|
|
515
|
+
}
|
|
373
516
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
517
|
+
throw new Error("Unexpected retry exhaustion");
|
|
518
|
+
};
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
// src/adapters/client/build-refresh-fn.ts
|
|
522
|
+
var buildRefreshFn = (fetchFn, logger) => {
|
|
523
|
+
let consumer;
|
|
524
|
+
return async (oauth1) => {
|
|
525
|
+
consumer ??= await fetchOAuthConsumer(fetchFn, logger);
|
|
526
|
+
try {
|
|
527
|
+
return await exchangeOAuth2(oauth1, consumer, fetchFn, logger);
|
|
528
|
+
} catch {
|
|
529
|
+
consumer = void 0;
|
|
530
|
+
consumer = await fetchOAuthConsumer(fetchFn, logger);
|
|
531
|
+
return exchangeOAuth2(oauth1, consumer, fetchFn, logger);
|
|
384
532
|
}
|
|
385
|
-
|
|
386
|
-
},
|
|
387
|
-
restore_tokens: async (data) => {
|
|
388
|
-
const parsed = garminTokensSchema.parse(data);
|
|
389
|
-
ctx.state.oauth1 = parsed.oauth1;
|
|
390
|
-
ctx.httpClient.setTokens(parsed.oauth1, parsed.oauth2);
|
|
391
|
-
ctx.logger.info("Tokens restored from stored session");
|
|
392
|
-
},
|
|
393
|
-
logout: async () => {
|
|
394
|
-
ctx.state.oauth1 = void 0;
|
|
395
|
-
ctx.httpClient.clearTokens();
|
|
396
|
-
if (ctx.tokenStore) await ctx.tokenStore.clear();
|
|
397
|
-
ctx.logger.info("Logged out from Garmin Connect");
|
|
398
|
-
}
|
|
399
|
-
});
|
|
400
|
-
var createGarminAuthProvider = (options) => {
|
|
401
|
-
const logger = options?.logger ?? createConsoleLogger();
|
|
402
|
-
const tokenStore = options?.tokenStore;
|
|
403
|
-
const fetchFn = options?.fetchFn ?? createCookieFetch();
|
|
404
|
-
const httpClient = createGarminHttpClient(logger, fetchFn);
|
|
405
|
-
const state = { oauth1: void 0 };
|
|
406
|
-
const auth = buildAuthProvider({
|
|
407
|
-
httpClient,
|
|
408
|
-
state,
|
|
409
|
-
tokenStore,
|
|
410
|
-
logger,
|
|
411
|
-
fetchFn
|
|
412
|
-
});
|
|
413
|
-
return { auth, httpClient };
|
|
533
|
+
};
|
|
414
534
|
};
|
|
415
535
|
|
|
416
536
|
// src/adapters/mappers/workout-summary.mapper.ts
|
|
@@ -432,6 +552,26 @@ var garminPushResponseSchema = z.object({
|
|
|
432
552
|
workoutId: z.number().or(z.string()),
|
|
433
553
|
workoutName: z.string().optional()
|
|
434
554
|
});
|
|
555
|
+
var pullWorkout = async (workoutId, httpClient, garminReader, log) => {
|
|
556
|
+
try {
|
|
557
|
+
log.info(`Pulling workout ${workoutId} from Garmin Connect`);
|
|
558
|
+
const raw = await httpClient.get(
|
|
559
|
+
`${WORKOUT_URL}/workout/${workoutId}`
|
|
560
|
+
);
|
|
561
|
+
const gcnJson = JSON.stringify(raw);
|
|
562
|
+
return await fromText(gcnJson, garminReader, log);
|
|
563
|
+
} catch (error) {
|
|
564
|
+
throw createServiceApiError("Failed to pull workout", void 0, error);
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
var removeWorkout = async (workoutId, httpClient, log) => {
|
|
568
|
+
try {
|
|
569
|
+
log.info(`Removing workout ${workoutId} from Garmin Connect`);
|
|
570
|
+
await httpClient.del(`${WORKOUT_URL}/workout/${workoutId}`);
|
|
571
|
+
} catch (error) {
|
|
572
|
+
throw createServiceApiError("Failed to remove workout", void 0, error);
|
|
573
|
+
}
|
|
574
|
+
};
|
|
435
575
|
|
|
436
576
|
// src/adapters/client/garmin-workout-service.ts
|
|
437
577
|
var pushWorkout = async (krd, httpClient, garminWriter, log) => {
|
|
@@ -474,17 +614,38 @@ var listWorkouts = async (httpClient, log, options) => {
|
|
|
474
614
|
var createGarminWorkoutService = (httpClient, logger) => {
|
|
475
615
|
const log = logger ?? createConsoleLogger();
|
|
476
616
|
const garminWriter = createGarminWriter(log);
|
|
617
|
+
const garminReader = createGarminReader(log);
|
|
477
618
|
return {
|
|
478
619
|
push: (krd) => pushWorkout(krd, httpClient, garminWriter, log),
|
|
479
|
-
|
|
620
|
+
pull: (id) => pullWorkout(id, httpClient, garminReader, log),
|
|
621
|
+
list: (opts) => listWorkouts(httpClient, log, opts),
|
|
622
|
+
remove: (id) => removeWorkout(id, httpClient, log)
|
|
480
623
|
};
|
|
481
624
|
};
|
|
482
625
|
|
|
483
626
|
// src/adapters/client/garmin-connect-client.ts
|
|
484
627
|
var createGarminConnectClient = (options) => {
|
|
485
|
-
const
|
|
486
|
-
const
|
|
487
|
-
|
|
628
|
+
const logger = options?.logger ?? createConsoleLogger();
|
|
629
|
+
const rawFetchFn = options?.fetchFn ?? createCookieFetch();
|
|
630
|
+
const retryFetchFn = options?.retry ? withRetry(rawFetchFn, { ...options.retry, logger }) : rawFetchFn;
|
|
631
|
+
const refreshFn = buildRefreshFn(rawFetchFn, logger);
|
|
632
|
+
const tokenManager = createTokenManager({
|
|
633
|
+
refreshFn,
|
|
634
|
+
logger,
|
|
635
|
+
tokenStore: options?.tokenStore
|
|
636
|
+
});
|
|
637
|
+
const auth = createGarminAuthProvider({
|
|
638
|
+
tokenManager,
|
|
639
|
+
logger,
|
|
640
|
+
fetchFn: rawFetchFn
|
|
641
|
+
});
|
|
642
|
+
const httpClient = createGarminHttpClient(tokenManager, retryFetchFn, logger);
|
|
643
|
+
const service = createGarminWorkoutService(httpClient, logger);
|
|
644
|
+
return {
|
|
645
|
+
auth,
|
|
646
|
+
service,
|
|
647
|
+
init: () => tokenManager.init()
|
|
648
|
+
};
|
|
488
649
|
};
|
|
489
650
|
var DEFAULT_PATH = join(homedir(), ".kaiord", "garmin-tokens.json");
|
|
490
651
|
var createFileTokenStore = (filePath = DEFAULT_PATH) => ({
|
|
@@ -525,6 +686,6 @@ var createMemoryTokenStore = () => {
|
|
|
525
686
|
};
|
|
526
687
|
};
|
|
527
688
|
|
|
528
|
-
export { createCookieFetch, createFileTokenStore, createGarminAuthProvider, createGarminConnectClient, createMemoryTokenStore };
|
|
689
|
+
export { createCookieFetch, createFileTokenStore, createGarminAuthProvider, createGarminConnectClient, createMemoryTokenStore, createTokenManager };
|
|
529
690
|
//# sourceMappingURL=index.js.map
|
|
530
691
|
//# sourceMappingURL=index.js.map
|