Files
warmbly/qa/lib/auth.ts
T

135 lines
5.5 KiB
TypeScript

import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { authFile, env } from "./env.ts";
type Tokens = {
access_token: string;
access_token_expires_at: string;
refresh_token: string;
refresh_token_expires_at: string;
};
type StorageState = {
cookies: [];
origins: { origin: string; localStorage: { name: string; value: string }[] }[];
};
const TOKEN_KEYS = ["access_token", "access_token_expires_at", "refresh_token", "refresh_token_expires_at"] as const;
// Reuses the saved session while it still works: every fresh login sends a code email, and those are budgeted.
export async function ensureSession(): Promise<string> {
const file = authFile();
const saved = readTokens(file);
if (saved && (await sessionWorks(saved))) {
await finishOnboarding(saved.access_token);
return file;
}
const tokens = await login();
await finishOnboarding(tokens.access_token);
mkdirSync(dirname(file), { recursive: true });
const state: StorageState = {
cookies: [],
origins: [
{
origin: env.webURL,
// The dashboard reads `auth_token` (one JSON object) and keeps the four flat keys alongside it.
localStorage: [
{ name: "auth_token", value: JSON.stringify(Object.fromEntries(TOKEN_KEYS.map((k) => [k, tokens[k]]))) },
...TOKEN_KEYS.map((k) => ({ name: k, value: tokens[k] })),
],
},
],
};
writeFileSync(file, JSON.stringify(state, null, 2));
return file;
}
function readTokens(file: string): Tokens | undefined {
if (!existsSync(file)) return undefined;
const state = JSON.parse(readFileSync(file, "utf8")) as StorageState;
const items = state.origins.find((o) => o.origin === env.webURL)?.localStorage ?? [];
const get = (k: string) => items.find((i) => i.name === k)?.value ?? "";
const tokens = Object.fromEntries(TOKEN_KEYS.map((k) => [k, get(k)])) as Tokens;
if (!tokens.refresh_token || Date.parse(tokens.refresh_token_expires_at) < Date.now() + 3600_000) return undefined;
return tokens;
}
async function sessionWorks(tokens: Tokens): Promise<boolean> {
if (Date.parse(tokens.access_token_expires_at) > Date.now() + 60_000) {
const res = await fetch(`${env.apiURL}/v1/auth/me`, { headers: { Authorization: `Bearer ${tokens.access_token}` } });
return res.ok;
}
// An expired access token is fine: the dashboard refreshes it on its first request.
return true;
}
async function call<T>(method: string, path: string, body?: object, token?: string): Promise<T> {
const res = await fetch(`${env.apiURL}/v1${path}`, {
method,
headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
const json = (await res.json().catch(() => ({}))) as T & { error?: string; code?: string };
if (!res.ok) throw new Error(`${method} ${path} answered ${res.status}: ${json.code ?? ""} ${json.error ?? ""}`.trim());
return json;
}
function post<T>(path: string, body: object): Promise<T> {
return call<T>("POST", path, body);
}
// Seeded accounts have not been through the first-run wizard, and the dashboard sends them there
// before anything else. Answer it once through the same endpoint the wizard uses.
async function finishOnboarding(token: string): Promise<void> {
if (!token) return;
const me = await call<{ first_name?: string; last_name?: string; onboarding_completed_at?: string | null }>("GET", "/auth/me", undefined, token).catch(() => undefined);
if (!me || me.onboarding_completed_at) return;
await call("PATCH", "/auth/me/onboarding", {
first_name: me.first_name || "Dev",
last_name: me.last_name || "User",
referral_source: "other",
}, token);
}
async function login(): Promise<Tokens> {
const startedAt = Date.now();
const start = await post<{ session?: string; code_required: boolean; token?: Tokens; two_fa_required?: boolean }>(
"/auth/login",
{ email: env.email, password: env.password, turnstile: env.turnstile },
);
if (start.token) return start.token;
if (start.two_fa_required) throw new Error(`${env.email} has 2FA enabled; use an account without it for QA`);
if (!start.session) throw new Error("login answered with neither a token nor a code session");
const code = await loginCode(startedAt);
const confirm = await post<Partial<Tokens> & { two_fa_required?: boolean }>("/auth/login/confirm", {
session: start.session,
code,
turnstile: env.turnstile,
});
if (confirm.two_fa_required) throw new Error(`${env.email} has 2FA enabled; use an account without it for QA`);
if (!confirm.access_token) throw new Error("login confirm returned no token");
return confirm as Tokens;
}
type MailpitSummary = { ID: string; Created: string };
async function loginCode(since: number): Promise<string> {
const query = encodeURIComponent(`to:"${env.email}" subject:"Your Login Code"`);
for (let i = 0; i < 40; i++) {
const res = await fetch(`${env.mailpitURL}/api/v1/search?query=${query}&limit=5`);
if (res.ok) {
const { messages } = (await res.json()) as { messages: MailpitSummary[] };
const fresh = messages.find((m) => Date.parse(m.Created) >= since - 2000);
if (fresh) {
const msg = (await (await fetch(`${env.mailpitURL}/api/v1/message/${fresh.ID}`)).json()) as { Text: string };
const code = msg.Text.match(/\b(\d{6})\b/)?.[1];
if (code) return code;
}
}
await new Promise((r) => setTimeout(r, 500));
}
throw new Error(`no login code for ${env.email} reached Mailpit at ${env.mailpitURL}`);
}