@nativesquare/soma 0.5.0 → 0.7.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/dist/client/index.d.ts +151 -53
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +162 -69
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +130 -17
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/garmin.d.ts +61 -43
- package/dist/component/garmin.d.ts.map +1 -1
- package/dist/component/garmin.js +208 -122
- package/dist/component/garmin.js.map +1 -1
- package/dist/component/public.d.ts +363 -0
- package/dist/component/public.d.ts.map +1 -1
- package/dist/component/public.js +124 -0
- package/dist/component/public.js.map +1 -1
- package/dist/component/schema.d.ts +7 -9
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +9 -10
- package/dist/component/schema.js.map +1 -1
- package/dist/component/strava.d.ts +0 -1
- package/dist/component/strava.d.ts.map +1 -1
- package/dist/component/strava.js +0 -1
- package/dist/component/strava.js.map +1 -1
- package/dist/component/validators/enums.d.ts +1 -1
- package/dist/garmin/auth.d.ts +55 -46
- package/dist/garmin/auth.d.ts.map +1 -1
- package/dist/garmin/auth.js +82 -122
- package/dist/garmin/auth.js.map +1 -1
- package/dist/garmin/client.d.ts +64 -17
- package/dist/garmin/client.d.ts.map +1 -1
- package/dist/garmin/client.js +143 -29
- package/dist/garmin/client.js.map +1 -1
- package/dist/garmin/index.d.ts +3 -3
- package/dist/garmin/index.d.ts.map +1 -1
- package/dist/garmin/index.js +4 -4
- package/dist/garmin/index.js.map +1 -1
- package/dist/garmin/plannedWorkout.d.ts +12 -0
- package/dist/garmin/plannedWorkout.d.ts.map +1 -0
- package/dist/garmin/plannedWorkout.js +267 -0
- package/dist/garmin/plannedWorkout.js.map +1 -0
- package/dist/garmin/types.d.ts +78 -6
- package/dist/garmin/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +236 -85
- package/src/component/_generated/component.ts +155 -17
- package/src/component/garmin.ts +258 -124
- package/src/component/public.ts +135 -0
- package/src/component/schema.ts +9 -10
- package/src/component/strava.ts +0 -1
- package/src/garmin/auth.test.ts +71 -96
- package/src/garmin/auth.ts +129 -193
- package/src/garmin/client.ts +197 -51
- package/src/garmin/index.ts +13 -14
- package/src/garmin/plannedWorkout.ts +333 -0
- package/src/garmin/types.ts +149 -7
package/src/garmin/auth.ts
CHANGED
|
@@ -1,249 +1,185 @@
|
|
|
1
|
-
// ─── Garmin OAuth
|
|
2
|
-
// Pure helper functions for the Garmin OAuth
|
|
3
|
-
// Uses the Web Crypto API for
|
|
1
|
+
// ─── Garmin OAuth 2.0 PKCE Helpers ──────────────────────────────────────────
|
|
2
|
+
// Pure helper functions for the Garmin OAuth 2.0 PKCE flow.
|
|
3
|
+
// Uses the Web Crypto API for SHA-256 challenge generation and global `fetch`.
|
|
4
4
|
|
|
5
|
-
import type {
|
|
6
|
-
GarminOAuthRequestTokenResponse,
|
|
7
|
-
GarminOAuthAccessTokenResponse,
|
|
8
|
-
} from "./types.js";
|
|
5
|
+
import type { GarminOAuth2TokenResponse } from "./types.js";
|
|
9
6
|
|
|
10
|
-
const
|
|
11
|
-
const
|
|
7
|
+
const AUTH_URL = "https://connect.garmin.com/oauth2Confirm";
|
|
8
|
+
const TOKEN_URL =
|
|
9
|
+
"https://diauth.garmin.com/di-oauth2-service/oauth/token";
|
|
12
10
|
|
|
13
|
-
// ───
|
|
11
|
+
// ─── PKCE Helpers ───────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
const PKCE_CHARSET =
|
|
14
|
+
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
|
-
* Generate a random
|
|
17
|
+
* Generate a cryptographically random code verifier for PKCE.
|
|
18
|
+
* Returns a 64-character string from the unreserved character set.
|
|
17
19
|
*/
|
|
18
|
-
export function
|
|
19
|
-
const bytes = new Uint8Array(
|
|
20
|
+
export function generateCodeVerifier(length = 64): string {
|
|
21
|
+
const bytes = new Uint8Array(length);
|
|
20
22
|
crypto.getRandomValues(bytes);
|
|
21
|
-
return Array.from(bytes, (b) => b.
|
|
23
|
+
return Array.from(bytes, (b) => PKCE_CHARSET[b % PKCE_CHARSET.length]).join(
|
|
24
|
+
"",
|
|
25
|
+
);
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
/**
|
|
25
|
-
*
|
|
29
|
+
* Generate a random state parameter for CSRF protection.
|
|
26
30
|
*/
|
|
27
|
-
export function
|
|
28
|
-
|
|
31
|
+
export function generateState(): string {
|
|
32
|
+
const bytes = new Uint8Array(32);
|
|
33
|
+
crypto.getRandomValues(bytes);
|
|
34
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
38
|
+
* Compute the S256 code challenge from a code verifier.
|
|
39
|
+
* Returns `base64url(sha256(verifier))`.
|
|
34
40
|
*/
|
|
35
|
-
export function
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
);
|
|
41
|
+
export async function generateCodeChallenge(verifier: string): Promise<string> {
|
|
42
|
+
const encoder = new TextEncoder();
|
|
43
|
+
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(verifier));
|
|
44
|
+
const base64 = btoa(String.fromCharCode(...new Uint8Array(digest)));
|
|
45
|
+
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
40
46
|
}
|
|
41
47
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
url: string,
|
|
54
|
-
params: Record<string, string>,
|
|
55
|
-
consumerSecret: string,
|
|
56
|
-
tokenSecret: string = "",
|
|
57
|
-
): Promise<string> {
|
|
58
|
-
const sortedKeys = Object.keys(params).sort();
|
|
59
|
-
const paramString = sortedKeys
|
|
60
|
-
.map((key) => `${percentEncode(key)}=${percentEncode(params[key])}`)
|
|
61
|
-
.join("&");
|
|
62
|
-
|
|
63
|
-
const signatureBaseString = [
|
|
64
|
-
method.toUpperCase(),
|
|
65
|
-
percentEncode(url),
|
|
66
|
-
percentEncode(paramString),
|
|
67
|
-
].join("&");
|
|
68
|
-
|
|
69
|
-
const signingKey = `${percentEncode(consumerSecret)}&${percentEncode(tokenSecret)}`;
|
|
70
|
-
const encoder = new TextEncoder();
|
|
71
|
-
const key = await crypto.subtle.importKey(
|
|
72
|
-
"raw",
|
|
73
|
-
encoder.encode(signingKey),
|
|
74
|
-
{ name: "HMAC", hash: "SHA-1" },
|
|
75
|
-
false,
|
|
76
|
-
["sign"],
|
|
77
|
-
);
|
|
78
|
-
const signature = await crypto.subtle.sign(
|
|
79
|
-
"HMAC",
|
|
80
|
-
key,
|
|
81
|
-
encoder.encode(signatureBaseString),
|
|
82
|
-
);
|
|
83
|
-
return btoa(String.fromCharCode(...new Uint8Array(signature)));
|
|
48
|
+
// ─── Build Authorization URL ────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
export interface BuildAuthUrlOptions {
|
|
51
|
+
/** Your Garmin application's Client ID. */
|
|
52
|
+
clientId: string;
|
|
53
|
+
/** The code challenge derived from the PKCE code verifier. */
|
|
54
|
+
codeChallenge: string;
|
|
55
|
+
/** The URL Garmin will redirect to after authorization. */
|
|
56
|
+
redirectUri?: string;
|
|
57
|
+
/** State parameter for CSRF protection (echoed back in the callback). */
|
|
58
|
+
state?: string;
|
|
84
59
|
}
|
|
85
60
|
|
|
86
61
|
/**
|
|
87
|
-
* Build the
|
|
62
|
+
* Build the Garmin OAuth 2.0 authorization URL.
|
|
63
|
+
*
|
|
64
|
+
* Redirect the user to this URL to begin the OAuth flow. After the user
|
|
65
|
+
* grants access, Garmin will redirect back to `redirectUri` with `code`
|
|
66
|
+
* and `state` query parameters.
|
|
88
67
|
*/
|
|
89
|
-
export function
|
|
90
|
-
const
|
|
91
|
-
.
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
68
|
+
export function buildAuthUrl(opts: BuildAuthUrlOptions): string {
|
|
69
|
+
const params = new URLSearchParams({
|
|
70
|
+
client_id: opts.clientId,
|
|
71
|
+
response_type: "code",
|
|
72
|
+
code_challenge: opts.codeChallenge,
|
|
73
|
+
code_challenge_method: "S256",
|
|
74
|
+
});
|
|
95
75
|
|
|
96
|
-
|
|
76
|
+
if (opts.redirectUri) {
|
|
77
|
+
params.set("redirect_uri", opts.redirectUri);
|
|
78
|
+
}
|
|
79
|
+
if (opts.state) {
|
|
80
|
+
params.set("state", opts.state);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return `${AUTH_URL}?${params.toString()}`;
|
|
84
|
+
}
|
|
97
85
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
86
|
+
// ─── Exchange Authorization Code ────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
export interface ExchangeCodeOptions {
|
|
89
|
+
/** Your Garmin application's Client ID. */
|
|
90
|
+
clientId: string;
|
|
91
|
+
/** Your Garmin application's Client Secret. */
|
|
92
|
+
clientSecret: string;
|
|
93
|
+
/** The authorization code from the OAuth callback. */
|
|
94
|
+
code: string;
|
|
95
|
+
/** The original PKCE code verifier (generated in Step 1). */
|
|
96
|
+
codeVerifier: string;
|
|
97
|
+
/** The redirect URI used in the authorization request. */
|
|
98
|
+
redirectUri?: string;
|
|
102
99
|
}
|
|
103
100
|
|
|
104
101
|
/**
|
|
105
|
-
*
|
|
102
|
+
* Exchange an authorization code for access and refresh tokens.
|
|
106
103
|
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* 2. Redirect the user to the returned `authUrl`
|
|
110
|
-
* 3. After the user authorizes, exchange the verifier for an access token
|
|
104
|
+
* Call this from your OAuth callback endpoint after receiving the `code`
|
|
105
|
+
* query parameter from Garmin.
|
|
111
106
|
*
|
|
112
|
-
* @returns The
|
|
107
|
+
* @returns The token response including `access_token`, `refresh_token`,
|
|
108
|
+
* `expires_in`, and `refresh_token_expires_in`.
|
|
113
109
|
*/
|
|
114
|
-
export async function
|
|
115
|
-
opts:
|
|
116
|
-
): Promise<
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
oauth_signature_method: "HMAC-SHA1",
|
|
125
|
-
oauth_timestamp: timestamp,
|
|
126
|
-
oauth_version: "1.0",
|
|
127
|
-
};
|
|
128
|
-
|
|
129
|
-
if (opts.callbackUrl) {
|
|
130
|
-
oauthParams.oauth_callback = opts.callbackUrl;
|
|
131
|
-
}
|
|
110
|
+
export async function exchangeCode(
|
|
111
|
+
opts: ExchangeCodeOptions,
|
|
112
|
+
): Promise<GarminOAuth2TokenResponse> {
|
|
113
|
+
const body = new URLSearchParams({
|
|
114
|
+
grant_type: "authorization_code",
|
|
115
|
+
client_id: opts.clientId,
|
|
116
|
+
client_secret: opts.clientSecret,
|
|
117
|
+
code: opts.code,
|
|
118
|
+
code_verifier: opts.codeVerifier,
|
|
119
|
+
});
|
|
132
120
|
|
|
133
|
-
|
|
134
|
-
"
|
|
135
|
-
|
|
136
|
-
oauthParams,
|
|
137
|
-
opts.consumerSecret,
|
|
138
|
-
);
|
|
139
|
-
oauthParams.oauth_signature = signature;
|
|
121
|
+
if (opts.redirectUri) {
|
|
122
|
+
body.set("redirect_uri", opts.redirectUri);
|
|
123
|
+
}
|
|
140
124
|
|
|
141
|
-
const response = await fetch(
|
|
125
|
+
const response = await fetch(TOKEN_URL, {
|
|
142
126
|
method: "POST",
|
|
143
|
-
headers: {
|
|
144
|
-
|
|
145
|
-
},
|
|
127
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
128
|
+
body: body.toString(),
|
|
146
129
|
});
|
|
147
130
|
|
|
148
131
|
if (!response.ok) {
|
|
149
|
-
const
|
|
150
|
-
throw new Error(
|
|
151
|
-
`Garmin OAuth error (getRequestToken): ${response.status} ${response.statusText} — ${body}`,
|
|
152
|
-
);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const responseText = await response.text();
|
|
156
|
-
const parsed = new URLSearchParams(responseText);
|
|
157
|
-
const oauthToken = parsed.get("oauth_token");
|
|
158
|
-
const oauthTokenSecret = parsed.get("oauth_token_secret");
|
|
159
|
-
|
|
160
|
-
if (!oauthToken || !oauthTokenSecret) {
|
|
132
|
+
const text = await response.text().catch(() => "");
|
|
161
133
|
throw new Error(
|
|
162
|
-
`Garmin OAuth error:
|
|
134
|
+
`Garmin OAuth error (exchangeCode): ${response.status} ${response.statusText} — ${text}`,
|
|
163
135
|
);
|
|
164
136
|
}
|
|
165
137
|
|
|
166
|
-
|
|
167
|
-
? `${AUTH_CONFIRM_URL}?oauth_token=${encodeURIComponent(oauthToken)}&oauth_callback=${encodeURIComponent(opts.callbackUrl)}`
|
|
168
|
-
: `${AUTH_CONFIRM_URL}?oauth_token=${encodeURIComponent(oauthToken)}`;
|
|
169
|
-
|
|
170
|
-
return {
|
|
171
|
-
oauthToken,
|
|
172
|
-
oauthTokenSecret,
|
|
173
|
-
authUrl,
|
|
174
|
-
};
|
|
138
|
+
return (await response.json()) as GarminOAuth2TokenResponse;
|
|
175
139
|
}
|
|
176
140
|
|
|
177
|
-
// ───
|
|
178
|
-
|
|
179
|
-
export interface
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
|
|
184
|
-
/** The
|
|
185
|
-
|
|
186
|
-
/** The verifier from the OAuth callback (Step 2). */
|
|
187
|
-
verifier: string;
|
|
141
|
+
// ─── Refresh Token ──────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
export interface RefreshTokenOptions {
|
|
144
|
+
/** Your Garmin application's Client ID. */
|
|
145
|
+
clientId: string;
|
|
146
|
+
/** Your Garmin application's Client Secret. */
|
|
147
|
+
clientSecret: string;
|
|
148
|
+
/** The refresh token from a previous token exchange or refresh. */
|
|
149
|
+
refreshToken: string;
|
|
188
150
|
}
|
|
189
151
|
|
|
190
152
|
/**
|
|
191
|
-
*
|
|
153
|
+
* Refresh an expired access token using a refresh token.
|
|
154
|
+
*
|
|
155
|
+
* Garmin access tokens expire after ~24 hours. Call this when the token
|
|
156
|
+
* is near expiry to obtain a fresh access token. A new refresh token
|
|
157
|
+
* is returned each time.
|
|
192
158
|
*
|
|
193
|
-
*
|
|
194
|
-
* The returned access token and secret are permanent — Garmin tokens
|
|
195
|
-
* do not expire and there is no refresh flow.
|
|
159
|
+
* @returns A new token response with fresh `access_token` and `refresh_token`.
|
|
196
160
|
*/
|
|
197
|
-
export async function
|
|
198
|
-
opts:
|
|
199
|
-
): Promise<
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
oauth_nonce: nonce,
|
|
207
|
-
oauth_signature_method: "HMAC-SHA1",
|
|
208
|
-
oauth_timestamp: timestamp,
|
|
209
|
-
oauth_token: opts.token,
|
|
210
|
-
oauth_verifier: opts.verifier,
|
|
211
|
-
oauth_version: "1.0",
|
|
212
|
-
};
|
|
213
|
-
|
|
214
|
-
const signature = await buildOAuthSignature(
|
|
215
|
-
"POST",
|
|
216
|
-
url,
|
|
217
|
-
oauthParams,
|
|
218
|
-
opts.consumerSecret,
|
|
219
|
-
opts.tokenSecret,
|
|
220
|
-
);
|
|
221
|
-
oauthParams.oauth_signature = signature;
|
|
161
|
+
export async function refreshToken(
|
|
162
|
+
opts: RefreshTokenOptions,
|
|
163
|
+
): Promise<GarminOAuth2TokenResponse> {
|
|
164
|
+
const body = new URLSearchParams({
|
|
165
|
+
grant_type: "refresh_token",
|
|
166
|
+
client_id: opts.clientId,
|
|
167
|
+
client_secret: opts.clientSecret,
|
|
168
|
+
refresh_token: opts.refreshToken,
|
|
169
|
+
});
|
|
222
170
|
|
|
223
|
-
const response = await fetch(
|
|
171
|
+
const response = await fetch(TOKEN_URL, {
|
|
224
172
|
method: "POST",
|
|
225
|
-
headers: {
|
|
226
|
-
|
|
227
|
-
},
|
|
173
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
174
|
+
body: body.toString(),
|
|
228
175
|
});
|
|
229
176
|
|
|
230
177
|
if (!response.ok) {
|
|
231
|
-
const
|
|
232
|
-
throw new Error(
|
|
233
|
-
`Garmin OAuth error (getAccessToken): ${response.status} ${response.statusText} — ${body}`,
|
|
234
|
-
);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
const responseText = await response.text();
|
|
238
|
-
const parsed = new URLSearchParams(responseText);
|
|
239
|
-
const oauthToken = parsed.get("oauth_token");
|
|
240
|
-
const oauthTokenSecret = parsed.get("oauth_token_secret");
|
|
241
|
-
|
|
242
|
-
if (!oauthToken || !oauthTokenSecret) {
|
|
178
|
+
const text = await response.text().catch(() => "");
|
|
243
179
|
throw new Error(
|
|
244
|
-
`Garmin OAuth error:
|
|
180
|
+
`Garmin OAuth error (refreshToken): ${response.status} ${response.statusText} — ${text}`,
|
|
245
181
|
);
|
|
246
182
|
}
|
|
247
183
|
|
|
248
|
-
return
|
|
184
|
+
return (await response.json()) as GarminOAuth2TokenResponse;
|
|
249
185
|
}
|