@gooin/garmin-connect
Version:
Makes it simple to interface with Garmin Connect to get or set any data point
361 lines • 14.9 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.HttpClient = void 0;
const axios_1 = __importDefault(require("axios"));
const form_data_1 = __importDefault(require("form-data"));
const lodash_1 = __importDefault(require("lodash"));
const luxon_1 = require("luxon");
const oauth_1_0a_1 = __importDefault(require("oauth-1.0a"));
const qs_1 = __importDefault(require("qs"));
const node_crypto_1 = __importDefault(require("node:crypto"));
const CSRF_RE = new RegExp('name="_csrf"\\s+value="(.+?)"');
const TICKET_RE = new RegExp('ticket=([^"]+)"');
const ACCOUNT_LOCKED_RE = new RegExp('var statuss*=s*"([^"]*)"');
const PAGE_TITLE_RE = new RegExp('<title>([^<]*)</title>');
const USER_AGENT_CONNECTMOBILE = 'com.garmin.android.apps.connectmobile';
const USER_AGENT_BROWSER = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36';
const USER_AGENT_BROWSER_MAC = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
const OAUTH_CONSUMER_URL = 'https://thegarth.s3.amazonaws.com/oauth_consumer.json';
const HTTP_STATUS = {
UNAUTHORIZED: 401
};
let tokenRefreshPromise = null;
let refreshSubscribers = [];
class HttpClient {
constructor(url, config) {
var _a, _b;
this.url = url;
this.client = axios_1.default.create({
timeout: (_a = config === null || config === void 0 ? void 0 : config.timeout) !== null && _a !== void 0 ? _a : 5000,
timeoutErrorMessage: `Request Timeout: > ${(_b = config === null || config === void 0 ? void 0 : config.timeout) !== null && _b !== void 0 ? _b : 5000} ms`
/**
* Charles debugger: uncomment `proxy` and `httpsAgent`, then run bellow command.
* NODE_TLS_REJECT_UNAUTHORIZED=0 node test/sync.js
*/
// proxy: {
// host: '127.0.0.1',
// port: 8888,
// protocol: 'http'
// },
// httpsAgent: new (require('https').Agent)({
// rejectUnauthorized: false
// })
});
this.config = config;
this.client.interceptors.response.use((response) => response, async (error) => {
var _a;
if (axios_1.default.isAxiosError(error) &&
error.code === 'ECONNABORTED') {
throw new Error(error.message || 'Request Timeout');
}
const originalRequest = error.config;
if (((_a = error === null || error === void 0 ? void 0 : error.response) === null || _a === void 0 ? void 0 : _a.status) === HTTP_STATUS.UNAUTHORIZED &&
!(originalRequest === null || originalRequest === void 0 ? void 0 : originalRequest._retry)) {
if (!this.oauth2Token) {
throw new Error('No OAuth2 token available');
}
originalRequest._retry = true;
try {
if (!tokenRefreshPromise) {
tokenRefreshPromise =
this.refreshOauth2Token().finally(() => {
tokenRefreshPromise = null;
});
}
await tokenRefreshPromise;
originalRequest.headers.Authorization = `Bearer ${this.oauth2Token.access_token}`;
return this.client(originalRequest);
}
catch (err) {
console.error('Token refresh failed:', err);
throw err;
}
}
if (axios_1.default.isAxiosError(error) && error.response) {
this.handleError(error.response);
}
else {
// 处理没有response的情况
throw new Error('Network error or unknown error occurred');
}
throw error;
});
this.client.interceptors.request.use(async (config) => {
if (this.oauth2Token) {
config.headers.Authorization =
'Bearer ' + this.oauth2Token.access_token;
}
return config;
});
}
async fetchOauthConsumer() {
const response = await axios_1.default.get(OAUTH_CONSUMER_URL);
this.OAUTH_CONSUMER = {
key: response.data.consumer_key,
secret: response.data.consumer_secret
};
}
async checkTokenVaild() {
if (this.oauth2Token) {
if (this.oauth2Token.expires_at < luxon_1.DateTime.now().toSeconds()) {
console.error('Token expired!');
await this.refreshOauth2Token();
}
}
}
async get(url, config) {
const response = await this.client.get(url, config);
return response === null || response === void 0 ? void 0 : response.data;
}
async post(url, data, config) {
const response = await this.client.post(url, data, config);
return response === null || response === void 0 ? void 0 : response.data;
}
async put(url, data, config) {
const response = await this.client.put(url, data, config);
return response === null || response === void 0 ? void 0 : response.data;
}
async delete(url, config) {
const response = await this.client.post(url, null, {
...config,
headers: {
...config === null || config === void 0 ? void 0 : config.headers,
'X-Http-Method-Override': 'DELETE'
}
});
return response === null || response === void 0 ? void 0 : response.data;
}
setCommonHeader(headers) {
lodash_1.default.each(headers, (headerValue, key) => {
this.client.defaults.headers.common[key] = headerValue;
});
}
handleError(response) {
this.handleHttpError(response);
}
handleHttpError(response) {
const { status, statusText, data } = response;
const errorMessage = {
status,
statusText,
data: typeof data === 'object' ? JSON.stringify(data) : data
};
console.error('HTTP Error:', errorMessage);
throw new Error(`HTTP Error (${status}): ${statusText}`);
}
/**
* Login to Garmin Connect
* @param username
* @param password
* @returns {Promise<HttpClient>}
*/
async login(username, password) {
await this.fetchOauthConsumer();
// Step1-3: Get ticket from page.
const ticket = await this.getLoginTicket(username, password);
// Step4: Oauth1
const oauth1 = await this.getOauth1Token(ticket);
// TODO: Handle MFA
// Step 5: Oauth2
await this.exchange(oauth1);
return this;
}
async getLoginTicket(username, password) {
// Step1: Set cookie
const step1Params = {
clientId: 'GarminConnect',
locale: 'en',
service: this.url.GC_MODERN
};
const step1Url = `${this.url.GARMIN_SSO_EMBED}?${qs_1.default.stringify(step1Params)}`;
// console.log('login - step1Url:', step1Url);
await this.client.get(step1Url);
// Step2 Get _csrf
const step2Params = {
id: 'gauth-widget',
embedWidget: true,
locale: 'en',
gauthHost: this.url.GARMIN_SSO_EMBED
};
const step2Url = `${this.url.SIGNIN_URL}?${qs_1.default.stringify(step2Params)}`;
// console.log('login - step2Url:', step2Url);
const step2Result = await this.get(step2Url);
// console.log('login - step2Result:', step2Result)
const csrfRegResult = CSRF_RE.exec(step2Result);
if (!csrfRegResult) {
throw new Error('login - csrf not found');
}
const csrf_token = csrfRegResult[1];
// console.log('login - csrf:', csrf_token);
// Step3 Get ticket
const signinParams = {
id: 'gauth-widget',
embedWidget: true,
clientId: 'GarminConnect',
locale: 'en',
gauthHost: this.url.GARMIN_SSO_EMBED,
service: this.url.GARMIN_SSO_EMBED,
source: this.url.GARMIN_SSO_EMBED,
redirectAfterAccountLoginUrl: this.url.GARMIN_SSO_EMBED,
redirectAfterAccountCreationUrl: this.url.GARMIN_SSO_EMBED
};
const step3Url = `${this.url.SIGNIN_URL}?${qs_1.default.stringify(signinParams)}`;
// console.log('login - step3Url:', step3Url);
const step3Form = new form_data_1.default();
step3Form.append('username', username);
step3Form.append('password', password);
step3Form.append('embed', 'true');
step3Form.append('_csrf', csrf_token);
const step3Result = await this.post(step3Url, step3Form, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Dnt: 1,
Origin: this.url.GARMIN_SSO_ORIGIN,
Referer: this.url.SIGNIN_URL,
'User-Agent': USER_AGENT_BROWSER
}
});
// console.log('step3Result:', step3Result)
this.handleAccountLocked(step3Result);
this.handlePageTitle(step3Result);
this.handleMFA(step3Result);
const ticketRegResult = TICKET_RE.exec(step3Result);
if (!ticketRegResult) {
throw new Error('login failed (Ticket not found or MFA), please check username and password');
}
const ticket = ticketRegResult[1];
return ticket;
}
// TODO: Handle MFA
handleMFA(htmlStr) { }
// TODO: Handle Phone number
handlePageTitle(htmlStr) {
const pageTitileRegResult = PAGE_TITLE_RE.exec(htmlStr);
if (pageTitileRegResult) {
const title = pageTitileRegResult[1];
console.log('login page title:', title);
if (lodash_1.default.includes(title, 'Update Phone Number')) {
// current I don't know where to update it
// See: https://github.com/matin/garth/issues/19
throw new Error('login failed (Update Phone number), please update your phone number, See: https://github.com/matin/garth/issues/19');
}
}
}
handleAccountLocked(htmlStr) {
const accountLockedRegResult = ACCOUNT_LOCKED_RE.exec(htmlStr);
if (accountLockedRegResult) {
const msg = accountLockedRegResult[1];
console.error(msg);
throw new Error('login failed (AccountLocked), please open connect web page to unlock your account');
}
}
async refreshOauth2Token() {
try {
if (!this.OAUTH_CONSUMER) {
await this.fetchOauthConsumer();
}
if (!this.oauth2Token || !this.oauth1Token) {
throw new Error('Missing required tokens for refresh');
}
const oauth1 = {
oauth: this.getOauthClient(this.OAUTH_CONSUMER),
token: this.oauth1Token
};
await this.exchange(oauth1);
console.log(`「${this.config.username}」in「${this.url.domain}」 OAuth2 token refreshed successfully`);
}
catch (error) {
console.error('Failed to refresh OAuth2 token:', error);
throw error;
}
}
async getOauth1Token(ticket) {
if (!this.OAUTH_CONSUMER) {
throw new Error('No OAUTH_CONSUMER');
}
const params = {
ticket,
'login-url': this.url.GARMIN_SSO_EMBED,
'accepts-mfa-tokens': true
};
const url = `${this.url.OAUTH_URL}/preauthorized?${qs_1.default.stringify(params)}`;
const oauth = this.getOauthClient(this.OAUTH_CONSUMER);
const step4RequestData = {
url: url,
method: 'GET'
};
const headers = oauth.toHeader(oauth.authorize(step4RequestData));
// console.log('getOauth1Token - headers:', headers);
const response = await this.get(url, {
headers: {
...headers,
'User-Agent': USER_AGENT_CONNECTMOBILE
}
});
// console.log('getOauth1Token - response:', response);
const token = qs_1.default.parse(response);
// console.log('getOauth1Token - token:', token);
this.oauth1Token = token;
return { token, oauth };
}
getOauthClient(consumer) {
const oauth = new oauth_1_0a_1.default({
consumer: consumer,
signature_method: 'HMAC-SHA1',
hash_function(base_string, key) {
return node_crypto_1.default
.createHmac('sha1', key)
.update(base_string)
.digest('base64');
}
});
return oauth;
}
//
async exchange(oauth1) {
const token = {
key: oauth1.token.oauth_token,
secret: oauth1.token.oauth_token_secret
};
// console.log('exchange - token:', token);
const baseUrl = `${this.url.OAUTH_URL}/exchange/user/2.0`;
const requestData = {
url: baseUrl,
method: 'POST',
data: null
};
const step5AuthData = oauth1.oauth.authorize(requestData, token);
// console.log('login - step5AuthData:', step5AuthData);
const url = `${baseUrl}?${qs_1.default.stringify(step5AuthData)}`;
// console.log('exchange - url:', url);
this.oauth2Token = undefined;
const response = await this.post(url, null, {
headers: {
'User-Agent': USER_AGENT_CONNECTMOBILE,
'Content-Type': 'application/x-www-form-urlencoded'
}
});
// console.log('exchange - response:', response);
this.oauth2Token = this.setOauth2TokenExpiresAt(response);
// console.log('exchange - oauth2Token:', this.oauth2Token);
}
setOauth2TokenExpiresAt(token) {
const now = luxon_1.DateTime.now();
const expiresAt = now.plus({ seconds: token.expires_in });
const refreshTokenExpiresAt = now.plus({
seconds: token.refresh_token_expires_in
});
return {
...token,
last_update_date: now.toLocal().toString(),
expires_date: expiresAt.toLocal().toString(),
expires_at: expiresAt.toSeconds(),
refresh_token_expires_at: refreshTokenExpiresAt.toSeconds()
};
}
}
exports.HttpClient = HttpClient;
//# sourceMappingURL=HttpClient.js.map