UNPKG

@gooin/garmin-connect

Version:

Makes it simple to interface with Garmin Connect to get or set any data point

361 lines 15 kB
"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, mfaCallback) { 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%20copy.js.map