177 lines
5.6 KiB
TypeScript
177 lines
5.6 KiB
TypeScript
|
|
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<string, string | boolean> = {
|
||
|
|
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, string | boolean>): string {
|
||
|
|
return Object.entries(params)
|
||
|
|
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
|
||
|
|
.join("&");
|
||
|
|
}
|
||
|
|
|
||
|
|
class CookieJar {
|
||
|
|
private cookies = new Map<string, string>();
|
||
|
|
|
||
|
|
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<GarminLoginResult> {
|
||
|
|
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<string> {
|
||
|
|
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];
|
||
|
|
}
|