const GARMIN_SSO_ORIGIN = "https://sso.garmin.com"; const GARMIN_SSO = `${GARMIN_SSO_ORIGIN}/sso`; const GARMIN_SSO_EMBED = `${GARMIN_SSO}/embed`; const GC_MODERN = "https://connect.garmin.com/modern"; const SIGNIN_URL = `${GARMIN_SSO}/signin`; 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 TICKET_RE = /ticket=([^"]+)"/; const CSRF_RE = /name="_csrf"\s+value="(.+?)"/; const SIGNIN_PARAMS: Record = { id: "gauth-widget", embedWidget: true, clientId: "GarminConnect", locale: "en", gauthHost: GARMIN_SSO_EMBED, service: GARMIN_SSO_EMBED, source: GARMIN_SSO_EMBED, redirectAfterAccountLoginUrl: GARMIN_SSO_EMBED, redirectAfterAccountCreationUrl: GARMIN_SSO_EMBED, }; export type GarminPendingMfa = { cookies: [string, string][]; mfaUrl: string; csrf: string; }; export type GarminLoginResult = { ticket: string } | { mfaRequired: true; pendingState: GarminPendingMfa }; function toQueryString(params: Record): string { return Object.entries(params) .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`) .join("&"); } class CookieJar { private cookies = new Map(); constructor(initial?: [string, string][]) { if (initial) { for (const [key, value] of initial) this.cookies.set(key, value); } } apply(response: Response): void { const setCookies = response.headers.getSetCookie?.() ?? []; for (const cookie of setCookies) { const [pair] = cookie.split(";"); const idx = pair.indexOf("="); this.cookies.set(pair.slice(0, idx), pair.slice(idx + 1)); } } header(): string { return Array.from(this.cookies.entries()) .map(([key, value]) => `${key}=${value}`) .join("; "); } entries(): [string, string][] { return Array.from(this.cookies.entries()); } } async function request(jar: CookieJar, url: string, init: RequestInit = {}): Promise<{ response: Response; body: string }> { const response = await fetch(url, { ...init, redirect: "manual", headers: { "User-Agent": USER_AGENT_BROWSER, Cookie: jar.header(), ...(init.headers ?? {}), }, }); jar.apply(response); const body = await response.text(); return { response, body }; } /** * Replays the Garmin SSO web login flow (garmin-connect's HttpClient has no * cookie jar and a no-op handleMFA, so it cannot complete login when the * account has email-based MFA enabled). */ export async function loginAndGetTicket(username: string, password: string): Promise { const jar = new CookieJar(); const embedUrl = `${GARMIN_SSO_EMBED}?${toQueryString({ clientId: "GarminConnect", locale: "en", service: GC_MODERN })}`; await request(jar, embedUrl); const signinPageUrl = `${SIGNIN_URL}?${toQueryString({ id: "gauth-widget", embedWidget: true, locale: "en", gauthHost: GARMIN_SSO_EMBED, })}`; const signinPage = await request(jar, signinPageUrl); const csrfMatch = signinPage.body.match(CSRF_RE); if (!csrfMatch) { throw new Error("Logowanie do Garmin nie powiodło się (brak tokenu CSRF na stronie logowania)."); } const signinUrl = `${SIGNIN_URL}?${toQueryString(SIGNIN_PARAMS)}`; const credentialsForm = new URLSearchParams({ username, password, embed: "true", _csrf: csrfMatch[1] }); const credentialsResult = await request(jar, signinUrl, { method: "POST", body: credentialsForm.toString(), headers: { "Content-Type": "application/x-www-form-urlencoded", Dnt: "1", Origin: GARMIN_SSO_ORIGIN, Referer: SIGNIN_URL, }, }); const redirectLocation = credentialsResult.response.headers.get("location"); if (redirectLocation?.includes("verifyMFA")) { const mfaPage = await request(jar, redirectLocation, { headers: { Referer: signinUrl } }); const mfaCsrfMatch = mfaPage.body.match(CSRF_RE); if (!mfaCsrfMatch) { throw new Error("Logowanie do Garmin nie powiodło się (nie znaleziono formularza kodu MFA)."); } return { mfaRequired: true, pendingState: { cookies: jar.entries(), mfaUrl: redirectLocation, csrf: mfaCsrfMatch[1] }, }; } const ticketMatch = credentialsResult.body.match(TICKET_RE); if (!ticketMatch) { throw new Error("Logowanie do Garmin nie powiodło się (Ticket not found or MFA), sprawdź login i hasło."); } return { ticket: ticketMatch[1] }; } export async function completeMfaAndGetTicket(pendingState: GarminPendingMfa, code: string): Promise { const jar = new CookieJar(pendingState.cookies); const mfaForm = new URLSearchParams({ "mfa-code": code.trim(), embed: "true", _csrf: pendingState.csrf, fromPage: "setupEnterMfaCode", }); const verifyResult = await request(jar, pendingState.mfaUrl, { method: "POST", body: mfaForm.toString(), headers: { "Content-Type": "application/x-www-form-urlencoded", Dnt: "1", Origin: GARMIN_SSO_ORIGIN, Referer: pendingState.mfaUrl, }, }); let body = verifyResult.body; let location = verifyResult.response.headers.get("location"); let previousUrl = pendingState.mfaUrl; let hops = 0; while (location && hops < 5) { const next = await request(jar, location, { headers: { Referer: previousUrl } }); body = next.body; previousUrl = location; location = next.response.headers.get("location"); hops += 1; } const ticketMatch = body.match(TICKET_RE); if (!ticketMatch) { throw new Error("Weryfikacja kodu MFA nie powiodła się - sprawdź kod i spróbuj ponownie."); } return ticketMatch[1]; }