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.
- package/README.md +13 -11
- package/dist/auth-html-parser.d.ts +18 -0
- package/dist/auth-html-parser.d.ts.map +1 -0
- package/dist/auth-html-parser.js +88 -0
- package/dist/auth-html-parser.js.map +1 -0
- package/dist/authentication-service.d.ts +9 -24
- package/dist/authentication-service.d.ts.map +1 -1
- package/dist/authentication-service.js +87 -299
- package/dist/authentication-service.js.map +1 -1
- package/dist/client.d.ts +3 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +6 -33
- package/dist/client.js.map +1 -1
- package/dist/curl-client.d.ts +16 -0
- package/dist/curl-client.d.ts.map +1 -0
- package/dist/curl-client.js +116 -0
- package/dist/curl-client.js.map +1 -0
- package/dist/errors.d.ts +0 -6
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +1 -12
- package/dist/errors.js.map +1 -1
- package/dist/http-client.d.ts +4 -7
- package/dist/http-client.d.ts.map +1 -1
- package/dist/http-client.js +22 -75
- package/dist/http-client.js.map +1 -1
- package/dist/index.d.ts +13 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -39
- package/dist/index.js.map +1 -1
- package/dist/oauth-utils.d.ts +3 -8
- package/dist/oauth-utils.d.ts.map +1 -1
- package/dist/oauth-utils.js +14 -38
- package/dist/oauth-utils.js.map +1 -1
- package/dist/oauth2-exchanger.d.ts +9 -0
- package/dist/oauth2-exchanger.d.ts.map +1 -0
- package/dist/oauth2-exchanger.js +58 -0
- package/dist/oauth2-exchanger.js.map +1 -0
- package/dist/types.d.ts +25 -13
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +8 -1
- package/dist/types.js.map +1 -1
- package/dist/urls.d.ts +7 -14
- package/dist/urls.d.ts.map +1 -1
- package/dist/urls.js +71 -60
- package/dist/urls.js.map +1 -1
- package/package.json +4 -2
- package/dist/auth-context.d.ts +0 -23
- package/dist/auth-context.d.ts.map +0 -1
- package/dist/auth-context.js +0 -53
- 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 {
|
|
14
|
+
import { login } from 'garmin-connect-client';
|
|
15
15
|
|
|
16
|
-
// Step 1:
|
|
17
|
-
const
|
|
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:
|
|
23
|
-
const
|
|
24
|
-
|
|
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 {
|
|
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
|
|
40
|
-
const
|
|
41
|
-
|
|
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 =
|
|
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
|
|
5
|
-
|
|
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
|
-
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
readonly mfaMethod: MfaMethod;
|
|
6
|
+
readonly ticket: string;
|
|
7
|
+
} | {
|
|
8
|
+
readonly mfaRequired: true;
|
|
19
9
|
readonly cookies: string;
|
|
20
|
-
|
|
21
|
-
}
|
|
10
|
+
};
|
|
22
11
|
export declare class AuthenticationService {
|
|
23
|
-
static startAuthentication(urls: GarminUrls, username: string, password: string): Promise<
|
|
24
|
-
static completeAuthentication(urls: GarminUrls,
|
|
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
|
|
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":"
|
|
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
|
-
|
|
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
|
-
//
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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
|
-
//
|
|
127
|
-
// Returns
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
138
|
-
//
|
|
139
|
-
static async completeAuthentication(urls,
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
//
|
|
157
|
-
//
|
|
158
|
-
static async
|
|
159
|
-
if (
|
|
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
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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;
|