garmin-connect-client 1.2.0 → 2.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.
Files changed (50) hide show
  1. package/README.md +13 -11
  2. package/dist/auth-html-parser.d.ts +18 -0
  3. package/dist/auth-html-parser.d.ts.map +1 -0
  4. package/dist/auth-html-parser.js +88 -0
  5. package/dist/auth-html-parser.js.map +1 -0
  6. package/dist/authentication-service.d.ts +9 -24
  7. package/dist/authentication-service.d.ts.map +1 -1
  8. package/dist/authentication-service.js +87 -299
  9. package/dist/authentication-service.js.map +1 -1
  10. package/dist/client.d.ts +3 -2
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/client.js +6 -33
  13. package/dist/client.js.map +1 -1
  14. package/dist/curl-client.d.ts +16 -0
  15. package/dist/curl-client.d.ts.map +1 -0
  16. package/dist/curl-client.js +116 -0
  17. package/dist/curl-client.js.map +1 -0
  18. package/dist/errors.d.ts +0 -6
  19. package/dist/errors.d.ts.map +1 -1
  20. package/dist/errors.js +1 -12
  21. package/dist/errors.js.map +1 -1
  22. package/dist/http-client.d.ts +4 -7
  23. package/dist/http-client.d.ts.map +1 -1
  24. package/dist/http-client.js +22 -75
  25. package/dist/http-client.js.map +1 -1
  26. package/dist/index.d.ts +13 -7
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +19 -39
  29. package/dist/index.js.map +1 -1
  30. package/dist/oauth-utils.d.ts +3 -8
  31. package/dist/oauth-utils.d.ts.map +1 -1
  32. package/dist/oauth-utils.js +14 -38
  33. package/dist/oauth-utils.js.map +1 -1
  34. package/dist/oauth2-exchanger.d.ts +9 -0
  35. package/dist/oauth2-exchanger.d.ts.map +1 -0
  36. package/dist/oauth2-exchanger.js +58 -0
  37. package/dist/oauth2-exchanger.js.map +1 -0
  38. package/dist/types.d.ts +25 -13
  39. package/dist/types.d.ts.map +1 -1
  40. package/dist/types.js +8 -1
  41. package/dist/types.js.map +1 -1
  42. package/dist/urls.d.ts +7 -14
  43. package/dist/urls.d.ts.map +1 -1
  44. package/dist/urls.js +71 -60
  45. package/dist/urls.js.map +1 -1
  46. package/package.json +4 -2
  47. package/dist/auth-context.d.ts +0 -23
  48. package/dist/auth-context.d.ts.map +0 -1
  49. package/dist/auth-context.js +0 -53
  50. package/dist/auth-context.js.map +0 -1
package/README.md CHANGED
@@ -11,17 +11,18 @@ npm install garmin-connect-client
11
11
  ## Usage
12
12
 
13
13
  ```typescript
14
- import { createAuthContext, create } from 'garmin-connect-client';
14
+ import { login } from 'garmin-connect-client';
15
15
 
16
- // Step 1: Create an authentication context
17
- const authContext = await createAuthContext({
16
+ // Step 1: Log in with credentials
17
+ const result = await login({
18
18
  username: 'your-username',
19
19
  password: 'your-password',
20
20
  });
21
21
 
22
- // Step 2: Create authenticated client (provide MFA code if required)
23
- const mfaCode = authContext.mfaRequired ? await getUserMfaCode() : undefined;
24
- const client = await create(authContext, mfaCode);
22
+ // Step 2: If MFA is required, resume the login with the user's code
23
+ const client = result.mfaRequired
24
+ ? await login(result, await getUserMfaCode())
25
+ : result.client;
25
26
 
26
27
  // Use the client
27
28
  const activities = await client.getActivities();
@@ -32,20 +33,21 @@ const activities = await client.getActivities();
32
33
  Authenticate once, then persist and restore the session to avoid repeated logins (and MFA prompts):
33
34
 
34
35
  ```typescript
35
- import { create, createAuthContext, createFromSession } from 'garmin-connect-client';
36
+ import { login, fromSession } from 'garmin-connect-client';
36
37
  import fs from 'fs/promises';
37
38
 
38
39
  // 1. Authenticate and save session
39
- const authContext = await createAuthContext({ username, password });
40
- const mfaCode = authContext.mfaRequired ? await getUserMfaCode() : undefined;
41
- const client = await create(authContext, mfaCode);
40
+ const result = await login({ username, password });
41
+ const client = result.mfaRequired
42
+ ? await login(result, await getUserMfaCode())
43
+ : result.client;
42
44
 
43
45
  const session = client.getSession();
44
46
  await fs.writeFile('session.json', JSON.stringify(session));
45
47
 
46
48
  // 2. Restore client from saved session (no network call)
47
49
  const sessionData = JSON.parse(await fs.readFile('session.json', 'utf-8'));
48
- const restoredClient = createFromSession(sessionData);
50
+ const restoredClient = fromSession(sessionData);
49
51
  const activities = await restoredClient.getActivities();
50
52
  ```
51
53
 
@@ -0,0 +1,18 @@
1
+ export type SsoPostResult = {
2
+ type: 'success';
3
+ ticket: string;
4
+ } | {
5
+ type: 'mfa_required';
6
+ } | {
7
+ type: 'locked';
8
+ message?: string;
9
+ } | {
10
+ type: 'invalid';
11
+ message?: string;
12
+ } | {
13
+ type: 'error';
14
+ message?: string;
15
+ };
16
+ export declare function parseCsrfToken(html: string): string;
17
+ export declare function parseSsoPostResponse(html: string): SsoPostResult;
18
+ //# sourceMappingURL=auth-html-parser.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth-html-parser.d.ts","sourceRoot":"","sources":["../src/auth-html-parser.ts"],"names":[],"mappings":"AAQA,MAAM,MAAM,aAAa,GACrB;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACnC;IAAE,IAAI,EAAE,cAAc,CAAA;CAAE,GACxB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,GACpC;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,GACrC;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAIxC,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAMnD;AAQD,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,aAAa,CAuBhE"}
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+ // Pure-function parsers for Garmin's auth HTML responses.
3
+ //
4
+ // The SSO embed login flow returns HTML pages rather than JSON. These helpers
5
+ // exist so the parsing logic is trivially unit-testable without any network or
6
+ // libcurl machinery.
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.parseCsrfToken = parseCsrfToken;
9
+ exports.parseSsoPostResponse = parseSsoPostResponse;
10
+ const errors_1 = require("./errors");
11
+ // Extracts the `_csrf` token from the signin / MFA form HTML.
12
+ // Throws CsrfTokenError when the token is missing.
13
+ function parseCsrfToken(html) {
14
+ const match = /name="_csrf"\s+value="([^"]+)"/i.exec(html);
15
+ if (!match) {
16
+ throw new errors_1.CsrfTokenError();
17
+ }
18
+ return match[1];
19
+ }
20
+ // Classifies the response to a credentials or MFA POST.
21
+ //
22
+ // The SSO embed login form encodes outcome in the <title> element:
23
+ // - "Success" → extract service ticket from response URL
24
+ // - title contains "MFA" → MFA challenge required
25
+ // - anything else → error (locked / invalid / generic)
26
+ function parseSsoPostResponse(html) {
27
+ const title = extractTitle(html);
28
+ if (title === 'Success') {
29
+ const ticket = extractTicket(html);
30
+ if (!ticket) {
31
+ return { type: 'error', message: 'Login reported success but ticket not found in response' };
32
+ }
33
+ return { type: 'success', ticket };
34
+ }
35
+ if (title && /mfa/i.test(title)) {
36
+ return { type: 'mfa_required' };
37
+ }
38
+ const message = extractErrorMessage(html) ?? title ?? undefined;
39
+ if (message && /lock/i.test(message)) {
40
+ return { type: 'locked', message };
41
+ }
42
+ if (message && /(invalid|incorrect|wrong|password|credentials)/i.test(message)) {
43
+ return { type: 'invalid', message };
44
+ }
45
+ return { type: 'error', message };
46
+ }
47
+ // --- helpers -----------------------------------------------------------------
48
+ function extractTitle(html) {
49
+ const match = /<title>([^<]*)<\/title>/i.exec(html);
50
+ return match ? match[1].trim() : undefined;
51
+ }
52
+ // Garmin's SSO embed login form embeds the post-login redirect URL in one of two forms:
53
+ // var response_url = "https://...?ticket=...";
54
+ // window.location.replace("https://...?ticket=...");
55
+ // We extract the ticket value from whichever is present.
56
+ function extractTicket(html) {
57
+ const patterns = [/var response_url\s*=\s*"([^"]+)"/i, /window\.location\.replace\("([^"]+)"\)/i];
58
+ for (const pattern of patterns) {
59
+ const match = pattern.exec(html);
60
+ if (match) {
61
+ const url = match[1].replaceAll('\\/', '/'); // unescape \/ → / in Garmin's JS string literals
62
+ const ticketMatch = /[&?]ticket=([\da-z-]+)/i.exec(url);
63
+ if (ticketMatch)
64
+ return ticketMatch[1];
65
+ }
66
+ }
67
+ return undefined;
68
+ }
69
+ // Error copy is usually inside an element with class "login-error" or similar.
70
+ // We extract the first non-empty textual error node, falling back to a generic
71
+ // status class if the specific one is absent.
72
+ function extractErrorMessage(html) {
73
+ const patterns = [
74
+ /<[^>]*class="[^"]*(?:login-error|error-message|alert)[^"]*"[^>]*>([^<]+)<\/[^>]+>/i,
75
+ /<div[^>]*id="[^"]*error[^"]*"[^>]*>([^<]+)<\/div>/i,
76
+ ];
77
+ for (const pattern of patterns) {
78
+ const match = pattern.exec(html);
79
+ if (match) {
80
+ const text = match[1].trim();
81
+ if (text.length > 0) {
82
+ return text;
83
+ }
84
+ }
85
+ }
86
+ return undefined;
87
+ }
88
+ //# sourceMappingURL=auth-html-parser.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth-html-parser.js","sourceRoot":"","sources":["../src/auth-html-parser.ts"],"names":[],"mappings":"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"}
@@ -1,32 +1,17 @@
1
- import { MfaMethod } from './auth-context';
2
1
  import { HttpClient } from './http-client';
3
2
  import { GarminUrls } from './urls';
4
- export type AuthenticationParameters = {
5
- type: 'ticket';
6
- ticket: string;
7
- } | {
8
- type: 'mfa';
9
- mfaCode: string;
10
- mfaMethod: MfaMethod;
11
- };
12
- export declare class AuthenticationSuccess {
13
- readonly ticket: string;
3
+ export type AuthContext = {
4
+ readonly mfaRequired: false;
14
5
  readonly cookies: string;
15
- constructor(ticket: string, cookies: string);
16
- }
17
- export declare class MfaRequiredResult {
18
- readonly mfaMethod: MfaMethod;
6
+ readonly ticket: string;
7
+ } | {
8
+ readonly mfaRequired: true;
19
9
  readonly cookies: string;
20
- constructor(mfaMethod: MfaMethod, cookies: string);
21
- }
10
+ };
22
11
  export declare class AuthenticationService {
23
- static startAuthentication(urls: GarminUrls, username: string, password: string): Promise<AuthenticationSuccess | MfaRequiredResult>;
24
- static completeAuthentication(urls: GarminUrls, cookies: string, parameters: AuthenticationParameters): Promise<HttpClient>;
25
- private static extractServiceTicketId;
26
- private static getLoginTicket;
12
+ static startAuthentication(urls: GarminUrls, username: string, password: string): Promise<AuthContext>;
13
+ static completeAuthentication(urls: GarminUrls, context: AuthContext, mfaCode?: string): Promise<HttpClient>;
27
14
  private static verifyMfaCode;
28
- private static completeOAuthFlow;
29
- private static getOauth1Token;
30
- private static exchange;
15
+ private static loginError;
31
16
  }
32
17
  //# sourceMappingURL=authentication-service.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"authentication-service.d.ts","sourceRoot":"","sources":["../src/authentication-service.ts"],"names":[],"mappings":"AAUA,OAAO,EAAe,SAAS,EAAkB,MAAM,gBAAgB,CAAC;AAExE,OAAO,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAS3C,OAAO,EAAE,UAAU,EAAE,MAAM,QAAQ,CAAC;AAGpC,MAAM,MAAM,wBAAwB,GAChC;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAClC;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,SAAS,CAAA;CAAE,CAAC;AAG3D,qBAAa,qBAAqB;IAChC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;gBAEb,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM;CAI5C;AAED,qBAAa,iBAAiB;IAC5B,QAAQ,CAAC,SAAS,EAAE,SAAS,CAAC;IAC9B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;gBAEb,SAAS,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM;CAIlD;AAyGD,qBAAa,qBAAqB;WAGnB,mBAAmB,CAC9B,IAAI,EAAE,UAAU,EAChB,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,qBAAqB,GAAG,iBAAiB,CAAC;WAYxC,sBAAsB,CACjC,IAAI,EAAE,UAAU,EAChB,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,wBAAwB,GACnC,OAAO,CAAC,UAAU,CAAC;mBA8BD,sBAAsB;mBA6BtB,cAAc;mBA2Dd,aAAa;mBAyCb,iBAAiB;mBAajB,cAAc;mBAwCd,QAAQ;CA8B9B"}
1
+ {"version":3,"file":"authentication-service.d.ts","sourceRoot":"","sources":["../src/authentication-service.ts"],"names":[],"mappings":"AAWA,OAAO,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAE3C,OAAO,EAAE,UAAU,EAAE,MAAM,QAAQ,CAAC;AAOpC,MAAM,MAAM,WAAW,GACnB;IAAE,QAAQ,CAAC,WAAW,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAClF;IAAE,QAAQ,CAAC,WAAW,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAE7D,qBAAa,qBAAqB;WAGnB,mBAAmB,CAAC,IAAI,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC;WAsC/F,sBAAsB,CAAC,IAAI,EAAE,UAAU,EAAE,OAAO,EAAE,WAAW,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC;mBAW7F,aAAa;IAqClC,OAAO,CAAC,MAAM,CAAC,UAAU;CAY1B"}
@@ -1,321 +1,109 @@
1
1
  "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.AuthenticationService = exports.MfaRequiredResult = exports.AuthenticationSuccess = void 0;
4
- // Authentication service for Garmin Connect API.
5
- //
6
- // Handles the complete authentication flow:
7
- // 1. Submit credentials and handle MFA if required
8
- // 2. Exchange login ticket for OAuth 1.0 token
9
- // 3. Exchange OAuth 1.0 token for OAuth 2.0 bearer token
2
+ // Authentication service for Garmin Connect.
10
3
  //
11
- // This service uses HttpClient internally to perform HTTP requests during authentication.
12
- const zod_1 = require("zod");
13
- const auth_context_1 = require("./auth-context");
4
+ // Orchestrates the SSO embed login flow:
5
+ // 1. Drives steps 1–4 (embed → signin → credentials → MFA) over CurlClient
6
+ // so Cloudflare accepts the request.
7
+ // 2. The resulting service ticket is exchanged for an OAuth2 bearer token
8
+ // via the device-identity endpoint (diauth.garmin.com).
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.AuthenticationService = void 0;
11
+ const auth_html_parser_1 = require("./auth-html-parser");
12
+ const curl_client_1 = require("./curl-client");
14
13
  const errors_1 = require("./errors");
15
14
  const http_client_1 = require("./http-client");
16
- const oauth_utils_1 = require("./oauth-utils");
17
- // Result classes for authentication flow
18
- class AuthenticationSuccess {
19
- constructor(ticket, cookies) {
20
- this.ticket = ticket;
21
- this.cookies = cookies;
22
- }
23
- }
24
- exports.AuthenticationSuccess = AuthenticationSuccess;
25
- class MfaRequiredResult {
26
- constructor(mfaMethod, cookies) {
27
- this.mfaMethod = mfaMethod;
28
- this.cookies = cookies;
29
- }
30
- }
31
- exports.MfaRequiredResult = MfaRequiredResult;
32
- // Zod schemas for API responses
33
- const LoginResponseSchema = zod_1.z
34
- .object({
35
- serviceURL: zod_1.z.string().nullable().optional(),
36
- serviceTicketId: zod_1.z.string().nullable().optional(),
37
- responseStatus: zod_1.z
38
- .object({
39
- type: zod_1.z.string(),
40
- message: zod_1.z.string().optional(),
41
- httpStatus: zod_1.z.string().optional(),
42
- })
43
- .optional(),
44
- responseReason: zod_1.z.string().nullable().optional(),
45
- customerMfaInfo: zod_1.z
46
- .object({
47
- mfaLastMethodUsed: zod_1.z.string().optional(),
48
- email: zod_1.z.string().optional(),
49
- phoneNumber: zod_1.z.string().nullable().optional(),
50
- defaultMfaMethod: zod_1.z.string().nullable().optional(),
51
- mfaUISetting: zod_1.z
52
- .object({
53
- allowPhoneOption: zod_1.z.boolean().optional(),
54
- allowAddEmailAddress: zod_1.z.boolean().optional(),
55
- chooseDifferentWayShown: zod_1.z.boolean().optional(),
56
- })
57
- .optional(),
58
- })
59
- .nullable()
60
- .optional(),
61
- consentTypeList: zod_1.z.array(zod_1.z.unknown()).nullable().optional(),
62
- captchaAlreadyPassed: zod_1.z.boolean().optional(),
63
- samlResponse: zod_1.z.string().nullable().optional(),
64
- authType: zod_1.z.string().nullable().optional(),
65
- })
66
- .passthrough();
67
- // Helper function to parse login response and extract error information
68
- function parseLoginResponseError(error) {
69
- if (error instanceof errors_1.HttpError && error.responseData) {
70
- const errorResponse = LoginResponseSchema.safeParse(error.responseData);
71
- if (errorResponse.success) {
72
- return errorResponse.data;
73
- }
74
- }
75
- return undefined;
76
- }
77
- // Helper function to extract InvalidCredentialsError from login response errors
78
- function extractInvalidCredentialsError(error) {
79
- const loginResponse = parseLoginResponseError(error);
80
- if (loginResponse) {
81
- const responseStatus = loginResponse.responseStatus;
82
- if (responseStatus?.type === 'INVALID_USERNAME_PASSWORD') {
83
- return new errors_1.InvalidCredentialsError('Invalid username or password');
84
- }
85
- }
86
- // Fallback to status code check if responseStatus not available
87
- if (error instanceof errors_1.HttpError && (error.statusCode === 401 || error.statusCode === 403)) {
88
- return new errors_1.InvalidCredentialsError('Invalid username or password');
89
- }
90
- return undefined;
91
- }
92
- // Helper function to extract MfaCodeInvalidError from MFA verification errors
93
- function extractMfaCodeInvalidError(error) {
94
- const loginResponse = parseLoginResponseError(error);
95
- if (loginResponse?.responseStatus) {
96
- const responseStatus = loginResponse.responseStatus;
97
- if (responseStatus.type === 'SESSION_EXPIRED') {
98
- return new errors_1.MfaCodeInvalidError('Session expired. Please try logging in again.');
99
- }
100
- }
101
- // Fallback to status code check
102
- if (error instanceof errors_1.HttpError) {
103
- if (error.statusCode === 401 || error.statusCode === 403) {
104
- return new errors_1.MfaCodeInvalidError('Invalid MFA code');
105
- }
106
- // Handle 409 Conflict (session expired)
107
- if (error.statusCode === 409) {
108
- return new errors_1.MfaCodeInvalidError('Session expired. Please try logging in again.');
109
- }
110
- }
111
- return undefined;
112
- }
113
- // Helper function to create common JSON API headers for authentication requests
114
- function getJsonApiHeaders(urls, referer) {
115
- return {
116
- 'Content-Type': 'application/json',
117
- Accept: 'application/json, text/plain, */*',
118
- Origin: urls.GARMIN_SSO_ORIGIN,
119
- Referer: referer,
120
- 'User-Agent': oauth_utils_1.USER_AGENT_MOBILE_IOS,
121
- 'Accept-Language': 'en-US,en;q=0.9',
122
- 'Accept-Encoding': 'gzip, deflate, br',
123
- };
124
- }
15
+ const oauth2_exchanger_1 = require("./oauth2-exchanger");
125
16
  class AuthenticationService {
126
- // Starts authentication and returns MFA context if MFA is required
127
- // Returns AuthenticationSuccess with ticket and cookies if authentication succeeds, or MfaRequiredResult if MFA is needed
17
+ // Drives the SSO flow up to (and not including) the OAuth exchange.
18
+ // Returns either a service ticket ready for exchange, or an MFA challenge.
128
19
  static async startAuthentication(urls, username, password) {
129
- const httpClient = new http_client_1.HttpClient(urls);
130
- const ticketResult = await AuthenticationService.getLoginTicket(httpClient, urls, username, password);
131
- const cookies = httpClient.getCookies();
132
- if (ticketResult.type === 'success') {
133
- return new AuthenticationSuccess(ticketResult.ticket, cookies);
20
+ const curl = new curl_client_1.CurlClient();
21
+ try {
22
+ // Seed session cookies via the embed endpoint before fetching the signin form.
23
+ await curl.get(urls.SSO_EMBED());
24
+ const signinHtml = await curl.get(urls.SSO_SIGNIN(), {
25
+ headers: ['Referer: ' + urls.SSO_BASE + '/embed'],
26
+ });
27
+ const csrf = (0, auth_html_parser_1.parseCsrfToken)(signinHtml);
28
+ const credBody = new URLSearchParams({
29
+ username,
30
+ password,
31
+ embed: 'true',
32
+ _csrf: csrf,
33
+ }).toString();
34
+ const postHtml = await curl.post(urls.SSO_SIGNIN(), credBody, {
35
+ headers: ['Referer: ' + urls.SSO_SIGNIN(), 'Content-Type: application/x-www-form-urlencoded'],
36
+ allowClientError: true,
37
+ });
38
+ const result = (0, auth_html_parser_1.parseSsoPostResponse)(postHtml);
39
+ const cookies = curl.getCookies();
40
+ if (result.type === 'success') {
41
+ return { mfaRequired: false, cookies, ticket: result.ticket };
42
+ }
43
+ if (result.type === 'mfa_required') {
44
+ return { mfaRequired: true, cookies };
45
+ }
46
+ throw AuthenticationService.loginError(result);
47
+ }
48
+ finally {
49
+ curl.close();
134
50
  }
135
- return new MfaRequiredResult(ticketResult.mfaMethod, cookies);
136
51
  }
137
- // Completes authentication using either a ticket (non-MFA) or MFA code
138
- // Returns an authenticated HttpClient instance
139
- static async completeAuthentication(urls, cookies, parameters) {
140
- // Create an unauthenticated HttpClient with cookies for making auth requests
141
- // We'll create an authenticated one after completing OAuth flow
142
- const httpClient = new http_client_1.HttpClient(urls, undefined, cookies);
143
- // Extract ticket from parameters (either directly or via MFA verification)
144
- const serviceTicketId = await AuthenticationService.extractServiceTicketId(httpClient, urls, parameters);
145
- // Complete OAuth flow
146
- const { oauth1Token, oauth2Token } = await AuthenticationService.completeOAuthFlow(httpClient, urls, serviceTicketId);
147
- const updatedCookies = httpClient.getCookies();
148
- // Create fully authenticated AuthContext with OAuth tokens
149
- const authenticatedContext = new auth_context_1.AuthContext(false, // mfaRequired is false after OAuth completes
150
- updatedCookies, undefined, // mfaMethod not needed after auth
151
- undefined, // ticket not needed after OAuth
152
- oauth1Token, oauth2Token);
153
- // Create a new authenticated HttpClient with the authenticated context
154
- return new http_client_1.HttpClient(urls, authenticatedContext);
52
+ // Completes authentication by turning an AuthContext (plus an optional MFA
53
+ // code when one is required) into a fully authenticated HttpClient.
54
+ static async completeAuthentication(urls, context, mfaCode) {
55
+ const { ticket } = context.mfaRequired
56
+ ? await AuthenticationService.verifyMfaCode(urls, context.cookies, mfaCode)
57
+ : context;
58
+ const { oauth2Token, diClientId } = await (0, oauth2_exchanger_1.exchangeDiToken)(urls, ticket);
59
+ return new http_client_1.HttpClient(urls, { oauth2Token, diClientId });
155
60
  }
156
- // Extracts service ticket ID from authentication parameters
157
- // Handles both ticket-based (non-MFA) and MFA-based authentication
158
- static async extractServiceTicketId(httpClient, urls, parameters) {
159
- if (parameters.type === 'ticket') {
160
- // Non-MFA case - use provided ticket
161
- return parameters.ticket;
162
- }
163
- // MFA case - verify code and get ticket
164
- if (!parameters.mfaCode.trim()) {
61
+ // Resumes the SSO session with the user-supplied MFA code. Requires the
62
+ // cookies captured by `startAuthentication` so Garmin accepts the POST.
63
+ static async verifyMfaCode(urls, cookies, mfaCode) {
64
+ if (!mfaCode?.trim()) {
165
65
  throw new errors_1.MfaCodeError();
166
66
  }
167
- const mfaResult = await AuthenticationService.verifyMfaCode(httpClient, urls, parameters.mfaCode.trim(), parameters.mfaMethod);
168
- if (!mfaResult.serviceTicketId) {
169
- throw new errors_1.MfaCodeInvalidError('MFA code submitted but ticket not found - please check your MFA code');
170
- }
171
- return mfaResult.serviceTicketId;
172
- }
173
- // Obtains a login ticket
174
- // Returns discriminated union with ticket if successful, or MFA requirement if MFA needed
175
- static async getLoginTicket(httpClient, urls, username, password) {
176
- // First, visit the sign-in page to establish a session and get cookies
177
- const signInUrl = urls.SIGN_IN_PAGE();
178
- await httpClient.get(signInUrl, {
179
- headers: {
180
- Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
181
- 'User-Agent': oauth_utils_1.USER_AGENT_MOBILE_IOS,
182
- 'Accept-Language': 'en-US,en;q=0.9',
183
- 'Accept-Encoding': 'gzip, deflate, br',
184
- },
185
- });
186
- const loginUrl = urls.LOGIN_API();
187
- const loginBody = {
188
- username,
189
- password,
190
- rememberMe: false,
191
- captchaToken: '',
192
- };
193
- let loginResponse;
67
+ const curl = new curl_client_1.CurlClient(cookies);
194
68
  try {
195
- const response = await httpClient.post(loginUrl, loginBody, {
196
- headers: getJsonApiHeaders(urls, urls.SIGN_IN_REFERER()),
69
+ // Re-scrape the CSRF token from the MFA page — it rotates across requests.
70
+ await curl.get(urls.SSO_EMBED());
71
+ const mfaPageHtml = await curl.get(urls.SSO_SIGNIN(), {
72
+ headers: ['Referer: ' + urls.SSO_BASE + '/embed'],
197
73
  });
198
- loginResponse = LoginResponseSchema.parse(response);
199
- }
200
- catch (error) {
201
- const credentialsError = extractInvalidCredentialsError(error);
202
- if (credentialsError) {
203
- throw credentialsError;
74
+ const csrf = (0, auth_html_parser_1.parseCsrfToken)(mfaPageHtml);
75
+ const mfaBody = new URLSearchParams({
76
+ 'mfa-code': mfaCode.trim(),
77
+ embed: 'true',
78
+ _csrf: csrf,
79
+ fromPage: 'setupEnterMfaCode',
80
+ }).toString();
81
+ const postHtml = await curl.post(urls.SSO_MFA_VERIFY(), mfaBody, {
82
+ headers: ['Referer: ' + urls.SSO_SIGNIN(), 'Content-Type: application/x-www-form-urlencoded'],
83
+ allowClientError: true,
84
+ });
85
+ const result = (0, auth_html_parser_1.parseSsoPostResponse)(postHtml);
86
+ if (result.type === 'success') {
87
+ return { ticket: result.ticket };
204
88
  }
205
- throw error;
206
- }
207
- // Try to extract ticket first - if present, login was successful
208
- if (loginResponse.serviceTicketId) {
209
- return { type: 'success', ticket: loginResponse.serviceTicketId };
89
+ if (result.type === 'mfa_required') {
90
+ throw new errors_1.MfaCodeInvalidError('MFA code rejected; server still requires MFA');
91
+ }
92
+ throw new errors_1.MfaCodeInvalidError(result.message ?? 'Invalid MFA code');
210
93
  }
211
- // No ticket found - check if MFA is required
212
- if (loginResponse.responseStatus?.type === 'MFA_REQUIRED') {
213
- const methodString = loginResponse.customerMfaInfo?.mfaLastMethodUsed || 'email';
214
- const mfaMethod = (0, auth_context_1.parseMfaMethod)(methodString);
215
- return { type: 'mfa_required', mfaMethod };
94
+ finally {
95
+ curl.close();
216
96
  }
217
- throw new errors_1.InvalidCredentialsError('login failed (Ticket not found or MFA), please check username and password');
218
97
  }
219
- // Verifies a multi-factor authentication code
220
- // Returns the authentication response containing the login ticket
221
- // Throws MfaCodeInvalidError if the MFA code is invalid or expired
222
- static async verifyMfaCode(httpClient, urls, mfaCode, mfaMethod = auth_context_1.MfaMethod.EMAIL) {
223
- const mfaUrl = urls.MFA_VERIFY_API();
224
- const mfaBody = {
225
- mfaMethod: mfaMethod,
226
- mfaVerificationCode: mfaCode,
227
- rememberMyBrowser: false,
228
- reconsentList: [],
229
- mfaSetup: false,
230
- };
231
- try {
232
- const response = await httpClient.post(mfaUrl, mfaBody, {
233
- headers: getJsonApiHeaders(urls, urls.MFA_REFERER()),
234
- });
235
- const result = LoginResponseSchema.parse(response);
236
- // Check for MFA_CODE_INVALID in response status (can occur even with 200 OK)
237
- if (result.responseStatus?.type === 'MFA_CODE_INVALID') {
238
- throw new errors_1.MfaCodeInvalidError(result.responseStatus.message || 'Invalid MFA code. Please check your code and try again.');
239
- }
240
- return result;
98
+ static loginError(result) {
99
+ if (result.type === 'locked') {
100
+ return new errors_1.InvalidCredentialsError(result.message ?? 'Account is locked');
241
101
  }
242
- catch (error) {
243
- const mfaError = extractMfaCodeInvalidError(error);
244
- if (mfaError) {
245
- throw mfaError;
246
- }
247
- throw error;
102
+ if (result.type === 'invalid' || result.type === 'error') {
103
+ return new errors_1.InvalidCredentialsError(result.message ?? 'Login failed');
248
104
  }
249
- }
250
- // Completes the OAuth flow: exchanges ticket for OAuth 1.0 token, then OAuth 2.0 token
251
- // Returns the tokens for creating an authenticated HttpClient
252
- static async completeOAuthFlow(httpClient, urls, ticket) {
253
- const oauth1Token = await AuthenticationService.getOauth1Token(httpClient, urls, ticket);
254
- const oauth2Token = await AuthenticationService.exchange(httpClient, urls, oauth1Token);
255
- return { oauth1Token, oauth2Token };
256
- }
257
- // Exchanges a login ticket for an OAuth 1.0 token
258
- // Uses OAuth 1.0a signing to authenticate the request
259
- // Returns OAuth 1.0 token containing oauth_token and oauth_token_secret
260
- static async getOauth1Token(httpClient, urls, ticket) {
261
- const oauthParameters = {
262
- ticket,
263
- 'login-url': urls.MOBILE_SERVICE,
264
- 'accepts-mfa-tokens': true,
265
- };
266
- const appOauthIdentity = (0, oauth_utils_1.getAppOauthIdentity)();
267
- const oauth = (0, oauth_utils_1.createOauthClient)(appOauthIdentity);
268
- const baseUrl = urls.OAUTH_PREAUTHORIZED_BASE();
269
- const requestData = {
270
- url: baseUrl,
271
- method: 'GET',
272
- data: oauthParameters,
273
- };
274
- const authData = oauth.authorize(requestData);
275
- // Convert Authorization object to plain object for URL building
276
- const authParameters = { ...authData };
277
- const url = urls.OAUTH_PREAUTHORIZED(oauthParameters, authParameters);
278
- const response = await httpClient.get(url, {
279
- headers: {
280
- 'User-Agent': oauth_utils_1.USER_AGENT_CONNECTMOBILE,
281
- },
282
- });
283
- // Parse the query string response (format: "oauth_token=xxx&oauth_token_secret=yyy")
284
- const responseParameters = new URLSearchParams(response);
285
- const token = {
286
- oauth_token: responseParameters.get('oauth_token') || '',
287
- oauth_token_secret: responseParameters.get('oauth_token_secret') || '',
288
- };
289
- return token;
290
- }
291
- // Exchanges an OAuth 1.0 token for an OAuth 2.0 bearer token
292
- //
293
- // This is the final step in the authentication flow. The OAuth 2.0 token
294
- // is used for all subsequent API requests via Bearer authentication.
295
- // Returns OAuth 2.0 bearer token with expiration metadata
296
- static async exchange(httpClient, urls, oauth1Token) {
297
- const appOauthIdentity = (0, oauth_utils_1.getAppOauthIdentity)();
298
- const oauth = (0, oauth_utils_1.createOauthClient)(appOauthIdentity);
299
- const token = {
300
- key: oauth1Token.oauth_token,
301
- secret: oauth1Token.oauth_token_secret,
302
- };
303
- const baseUrl = urls.OAUTH_EXCHANGE_BASE();
304
- const requestData = {
305
- url: baseUrl,
306
- method: 'POST',
307
- };
308
- const step5AuthData = oauth.authorize(requestData, token);
309
- // Convert Authorization object to plain object for URL building
310
- const oauthParameters = { ...step5AuthData };
311
- const url = urls.OAUTH_EXCHANGE(oauthParameters);
312
- const response = await httpClient.post(url, undefined, {
313
- headers: {
314
- 'User-Agent': oauth_utils_1.USER_AGENT_CONNECTMOBILE,
315
- 'Content-Type': 'application/x-www-form-urlencoded',
316
- },
317
- });
318
- return (0, oauth_utils_1.setOauth2TokenExpiresAt)(response);
105
+ const _exhaustive = result;
106
+ return _exhaustive;
319
107
  }
320
108
  }
321
109
  exports.AuthenticationService = AuthenticationService;