@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.
Files changed (54) hide show
  1. package/dist/client/index.d.ts +151 -53
  2. package/dist/client/index.d.ts.map +1 -1
  3. package/dist/client/index.js +162 -69
  4. package/dist/client/index.js.map +1 -1
  5. package/dist/component/_generated/component.d.ts +130 -17
  6. package/dist/component/_generated/component.d.ts.map +1 -1
  7. package/dist/component/garmin.d.ts +61 -43
  8. package/dist/component/garmin.d.ts.map +1 -1
  9. package/dist/component/garmin.js +208 -122
  10. package/dist/component/garmin.js.map +1 -1
  11. package/dist/component/public.d.ts +363 -0
  12. package/dist/component/public.d.ts.map +1 -1
  13. package/dist/component/public.js +124 -0
  14. package/dist/component/public.js.map +1 -1
  15. package/dist/component/schema.d.ts +7 -9
  16. package/dist/component/schema.d.ts.map +1 -1
  17. package/dist/component/schema.js +9 -10
  18. package/dist/component/schema.js.map +1 -1
  19. package/dist/component/strava.d.ts +0 -1
  20. package/dist/component/strava.d.ts.map +1 -1
  21. package/dist/component/strava.js +0 -1
  22. package/dist/component/strava.js.map +1 -1
  23. package/dist/component/validators/enums.d.ts +1 -1
  24. package/dist/garmin/auth.d.ts +55 -46
  25. package/dist/garmin/auth.d.ts.map +1 -1
  26. package/dist/garmin/auth.js +82 -122
  27. package/dist/garmin/auth.js.map +1 -1
  28. package/dist/garmin/client.d.ts +64 -17
  29. package/dist/garmin/client.d.ts.map +1 -1
  30. package/dist/garmin/client.js +143 -29
  31. package/dist/garmin/client.js.map +1 -1
  32. package/dist/garmin/index.d.ts +3 -3
  33. package/dist/garmin/index.d.ts.map +1 -1
  34. package/dist/garmin/index.js +4 -4
  35. package/dist/garmin/index.js.map +1 -1
  36. package/dist/garmin/plannedWorkout.d.ts +12 -0
  37. package/dist/garmin/plannedWorkout.d.ts.map +1 -0
  38. package/dist/garmin/plannedWorkout.js +267 -0
  39. package/dist/garmin/plannedWorkout.js.map +1 -0
  40. package/dist/garmin/types.d.ts +78 -6
  41. package/dist/garmin/types.d.ts.map +1 -1
  42. package/package.json +1 -1
  43. package/src/client/index.ts +236 -85
  44. package/src/component/_generated/component.ts +155 -17
  45. package/src/component/garmin.ts +258 -124
  46. package/src/component/public.ts +135 -0
  47. package/src/component/schema.ts +9 -10
  48. package/src/component/strava.ts +0 -1
  49. package/src/garmin/auth.test.ts +71 -96
  50. package/src/garmin/auth.ts +129 -193
  51. package/src/garmin/client.ts +197 -51
  52. package/src/garmin/index.ts +13 -14
  53. package/src/garmin/plannedWorkout.ts +333 -0
  54. package/src/garmin/types.ts +149 -7
@@ -1,249 +1,185 @@
1
- // ─── Garmin OAuth 1.0a Helpers ───────────────────────────────────────────────
2
- // Pure helper functions for the Garmin OAuth 1.0a three-legged flow.
3
- // Uses the Web Crypto API for HMAC-SHA1 signing and global `fetch`.
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 OAUTH_BASE_URL = "https://connectapi.garmin.com";
11
- const AUTH_CONFIRM_URL = "https://connect.garmin.com/oauthConfirm";
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
- // ─── OAuth 1.0a Signature ───────────────────────────────────────────────────
11
+ // ─── PKCE Helpers ───────────────────────────────────────────────────────────
12
+
13
+ const PKCE_CHARSET =
14
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
14
15
 
15
16
  /**
16
- * Generate a random nonce for OAuth 1.0a requests.
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 generateNonce(): string {
19
- const bytes = new Uint8Array(16);
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.toString(16).padStart(2, "0")).join("");
23
+ return Array.from(bytes, (b) => PKCE_CHARSET[b % PKCE_CHARSET.length]).join(
24
+ "",
25
+ );
22
26
  }
23
27
 
24
28
  /**
25
- * Get the current Unix timestamp in seconds.
29
+ * Generate a random state parameter for CSRF protection.
26
30
  */
27
- export function getTimestamp(): string {
28
- return String(Math.floor(Date.now() / 1000));
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
- * Percent-encode a string per RFC 3986 (used by OAuth 1.0a).
33
- * Unlike encodeURIComponent, this also encodes `!`, `*`, `'`, `(`, `)`.
38
+ * Compute the S256 code challenge from a code verifier.
39
+ * Returns `base64url(sha256(verifier))`.
34
40
  */
35
- export function percentEncode(str: string): string {
36
- return encodeURIComponent(str).replace(
37
- /[!'()*]/g,
38
- (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
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
- * Build the OAuth 1.0a signature base string and compute the HMAC-SHA1 signature.
44
- *
45
- * @param method - HTTP method (e.g., "POST", "GET")
46
- * @param url - The base URL (without query string)
47
- * @param params - All OAuth + request parameters sorted alphabetically
48
- * @param consumerSecret - The application's consumer secret
49
- * @param tokenSecret - The token secret (empty string for request token step)
50
- */
51
- export async function buildOAuthSignature(
52
- method: string,
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 `Authorization: OAuth ...` header value from OAuth parameters.
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 buildOAuthHeader(params: Record<string, string>): string {
90
- const entries = Object.entries(params)
91
- .map(([key, value]) => `${percentEncode(key)}="${percentEncode(value)}"`)
92
- .join(", ");
93
- return `OAuth ${entries}`;
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
- // ─── Step 1: Get Request Token ──────────────────────────────────────────────
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
- export interface GetRequestTokenOptions {
99
- consumerKey: string;
100
- consumerSecret: string;
101
- callbackUrl?: string;
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
- * Obtain an unauthorized request token from Garmin.
102
+ * Exchange an authorization code for access and refresh tokens.
106
103
  *
107
- * This is Step 1 of the OAuth 1.0a three-legged flow:
108
- * 1. Your server calls this function to get a temporary request token
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 request token, token secret, and the authorization URL
107
+ * @returns The token response including `access_token`, `refresh_token`,
108
+ * `expires_in`, and `refresh_token_expires_in`.
113
109
  */
114
- export async function getRequestToken(
115
- opts: GetRequestTokenOptions,
116
- ): Promise<GarminOAuthRequestTokenResponse & { authUrl: string }> {
117
- const url = `${OAUTH_BASE_URL}/oauth-service/oauth/request_token`;
118
- const nonce = generateNonce();
119
- const timestamp = getTimestamp();
120
-
121
- const oauthParams: Record<string, string> = {
122
- oauth_consumer_key: opts.consumerKey,
123
- oauth_nonce: nonce,
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
- const signature = await buildOAuthSignature(
134
- "POST",
135
- url,
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(url, {
125
+ const response = await fetch(TOKEN_URL, {
142
126
  method: "POST",
143
- headers: {
144
- Authorization: buildOAuthHeader(oauthParams),
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 body = await response.text().catch(() => "");
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: unexpected response format — ${responseText}`,
134
+ `Garmin OAuth error (exchangeCode): ${response.status} ${response.statusText} — ${text}`,
163
135
  );
164
136
  }
165
137
 
166
- const authUrl = opts.callbackUrl
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
- // ─── Step 3: Exchange for Access Token ──────────────────────────────────────
178
-
179
- export interface GetAccessTokenOptions {
180
- consumerKey: string;
181
- consumerSecret: string;
182
- /** The request token from Step 1. */
183
- token: string;
184
- /** The request token secret from Step 1. */
185
- tokenSecret: string;
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
- * Exchange a request token + verifier for a permanent access token.
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
- * This is Step 3 of the OAuth 1.0a three-legged flow.
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 getAccessToken(
198
- opts: GetAccessTokenOptions,
199
- ): Promise<GarminOAuthAccessTokenResponse> {
200
- const url = `${OAUTH_BASE_URL}/oauth-service/oauth/access_token`;
201
- const nonce = generateNonce();
202
- const timestamp = getTimestamp();
203
-
204
- const oauthParams: Record<string, string> = {
205
- oauth_consumer_key: opts.consumerKey,
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(url, {
171
+ const response = await fetch(TOKEN_URL, {
224
172
  method: "POST",
225
- headers: {
226
- Authorization: buildOAuthHeader(oauthParams),
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 body = await response.text().catch(() => "");
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: unexpected access token response — ${responseText}`,
180
+ `Garmin OAuth error (refreshToken): ${response.status} ${response.statusText} — ${text}`,
245
181
  );
246
182
  }
247
183
 
248
- return { oauthToken, oauthTokenSecret };
184
+ return (await response.json()) as GarminOAuth2TokenResponse;
249
185
  }