auth.jsjavascript
/**
 * @file Auth + entitlement client (M3). Lives entirely in the MAIN process:
 * tokens never reach the DOM; the renderer only ever sees the verified
 * entitlement payload over IPC. Contract (docs/Backend_Architecture.md): the
 * app's licensing knowledge is one signed payload from GET /entitlement,
 * verified against the Ed25519 public key embedded below, cached via
 * safeStorage. The cache IS the offline story — no network needed until
 * payload.valid_until passes.
 * @module auth
 */

const { app, ipcMain, safeStorage } = require('electron');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { getDeviceId } = require('./device-id');
const {
  publicKeyFromB64,
  verifyEntitlement,
  checkCachedEntitlement,
  nextRefreshDelayMs,
} = require('./entitlement-verify');

/**
 * Packaged builds are PINNED to prod: URL and signing key are constants, and
 * the EZL_* env overrides are ignored entirely. A shipped app that honored
 * env redirection would (a) trust the committed dev key below, whose private
 * half is public in this repo, and (b) happily send sign-in credentials to
 * whatever EZL_API_URL says. Dev runs keep the overrides — that's how the
 * local and prod lanes work from source.
 */
const PROD_API_URL = 'https://app.ezlaybacks.com';
/**
 * Production public key — its private half is the box's
 * ENTITLEMENT_SIGNING_KEY (kept in 1Password, never committed). Packaged
 * builds verify prod entitlements against this and nothing else. NEVER put
 * the dev key here.
 */
const PROD_ENTITLEMENT_PUBLIC_KEY_B64 = '6CemjGn/+gsjTr8g5P/AeMihFsCEf5gXAoUzh7ir75A=';

/**
 * Dev backend selection uses one switch: EZL_ENV = local | prod. Each lane
 * pairs its URL with the public half of THAT backend's entitlement signing
 * key — the two must always travel together (a URL pointed at one backend
 * with another backend's key "logs in" fine, then rejects every entitlement
 * as a bad signature). This is the committed dev key for the local lane; its
 * private half is in customer-app/.env.example, so every local backend
 * matches. The prod lane's public key comes from .env
 * (EZL_PROD_ENTITLEMENT_PUBLIC_KEY, value in 1Password) and is never
 * committed — a placeholder lives in .env.example.
 */
const DEV_ENTITLEMENT_PUBLIC_KEY_B64 = '0o2XTDWN7II8kbDD01Gqu/F9kRiYwblvtSJ2r8n7KXw=';

/**
 * Resolve the dev-run backend lane from EZL_ENV (local | prod), pairing the
 * API URL with that backend's entitlement public key. The local lane uses
 * 127.0.0.1 (not "localhost"): Node's fetch resolves localhost to ::1 first,
 * and uvicorn binds the IPv4 loopback only. Explicit EZL_API_URL /
 * EZL_ENTITLEMENT_PUBLIC_KEY override the lane as an escape hatch for
 * one-off backends — if one is set, the other must match that backend's
 * signing key; a mismatched pair is exactly the bug EZL_ENV exists to
 * prevent. Throws on an unknown lane or a missing required env var.
 * @returns {{name: string, url: string, key: string}} Lane name, API base
 * URL, and base64 entitlement public key.
 */
function resolveDevEnv() {
  const name = process.env.EZL_ENV || 'local';
  const lanes = {
    local: {
      url: 'http://127.0.0.1:8000',
      key: DEV_ENTITLEMENT_PUBLIC_KEY_B64,
    },
    prod: {
      url: PROD_API_URL,
      key: process.env.EZL_PROD_ENTITLEMENT_PUBLIC_KEY,
      need: ['EZL_PROD_ENTITLEMENT_PUBLIC_KEY'],
    },
  };
  const lane = lanes[name];
  if (!lane) throw new Error(`EZL_ENV must be local | prod (got "${name}")`);
  const missing = (lane.need || []).filter((k) => !process.env[k]);
  if (missing.length) {
    throw new Error(`EZL_ENV=${name} needs ${missing.join(', ')} set (see .env.example)`);
  }
  return {
    name,
    url: process.env.EZL_API_URL || lane.url,
    key: process.env.EZL_ENTITLEMENT_PUBLIC_KEY || lane.key,
  };
}

const devEnv = app.isPackaged ? null : resolveDevEnv();
const API_BASE = app.isPackaged ? PROD_API_URL : devEnv.url;

/**
 * Public half of the server's entitlement signing key. A tampered client
 * can't flip its own subscription status without forging a signature against
 * this.
 */
const ENTITLEMENT_PUBLIC_KEY_B64 = app.isPackaged
  ? PROD_ENTITLEMENT_PUBLIC_KEY_B64
  : devEnv.key;

/**
 * Fail closed: a packaged build without the production key must not ship —
 * it would either crash later or, worse, be re-pointed at a dev-key backend.
 */
if (app.isPackaged && !ENTITLEMENT_PUBLIC_KEY_B64) {
  throw new Error(
    'Packaged build has no production entitlement key baked in — ' +
    'set PROD_ENTITLEMENT_PUBLIC_KEY_B64 in src/auth.js before shipping.'
  );
}

/**
 * Every launch says which backend it's talking to — kills the "stale .env
 * silently redirected me" class of confusion.
 */
console.log(`[auth] targeting ${API_BASE}${app.isPackaged ? ' (pinned, packaged build)' : ` (dev — EZL_ENV=${devEnv.name}, key ${ENTITLEMENT_PUBLIC_KEY_B64.slice(0, 6)}…)`}`);

// ── encrypted little files in userData ───────────────────────────────────────

/**
 * Absolute path of a named file in the app's userData directory.
 * @param {string} name File name.
 * @returns {string} Full path.
 */
const file = (name) => path.join(app.getPath('userData'), name);

/**
 * Encrypt an object with safeStorage and write it atomically, same pattern
 * as settings-store: serialize to a temp file in the same directory, then
 * rename over the real file. rename() is atomic on a single filesystem, so a
 * crash mid-write can't leave a truncated blob — and a truncated tokens file
 * is a lost session. userData may not exist on first run, so it's created.
 * @param {string} name File name in userData.
 * @param {object} obj Value to serialize and encrypt.
 */
function saveEncrypted(name, obj) {
  const target = file(name);
  const tmp = `${target}.${process.pid}.tmp`;
  fs.mkdirSync(path.dirname(target), { recursive: true });
  fs.writeFileSync(tmp, safeStorage.encryptString(JSON.stringify(obj)));
  fs.renameSync(tmp, target);
}

/**
 * Read and decrypt a safeStorage-encrypted JSON file.
 * @param {string} name File name in userData.
 * @returns {object|null} The parsed object, or null when the file is missing,
 * corrupt, or the keychain changed — all treated as absent.
 */
function loadEncrypted(name) {
  try {
    return JSON.parse(safeStorage.decryptString(fs.readFileSync(file(name))));
  } catch {
    return null;
  }
}

/**
 * Delete a userData file, ignoring a file that's already gone.
 * @param {string} name File name in userData.
 */
function deleteFile(name) {
  try { fs.unlinkSync(file(name)); } catch { /* already gone */ }
}

/** Persisted tokens: { refresh } — access tokens are not persisted. */
const TOKENS_FILE = 'ezl-auth.bin';
/** Cached entitlement: { response, lastSeenClock }. */
const ENTITLEMENT_FILE = 'ezl-ent.bin';

// ── Ed25519 verification ─────────────────────────────────────────────────────

/**
 * KeyObject for signature checks. Verification and cache-validity rules live
 * in ./entitlement-verify (pure Node); this file just binds them to the
 * configured public key.
 */
const verifyKey = publicKeyFromB64(ENTITLEMENT_PUBLIC_KEY_B64);

// ── API calls ────────────────────────────────────────────────────────────────

/**
 * Cap on every auth call: these endpoints answer in milliseconds, and a
 * black-holed connection must not stall session restore for minutes. A
 * timeout aborts the fetch, which throws — the same path as any network
 * failure, so callers fall back to the cached entitlement rather than
 * treating it as a rejected session.
 */
const API_TIMEOUT_MS = 10_000;

/**
 * Perform a JSON request against the licensing API.
 * @param {string} route Path starting with '/', appended to API_BASE.
 * @param {object} [opts]
 * @param {string} [opts.method] HTTP method, default 'GET'.
 * @param {object} [opts.body] JSON-serialized request body.
 * @param {string} [opts.accessToken] Bearer token for the Authorization header.
 * @returns {Promise<{ok: boolean, status: number, data: object|null}>} data is
 * null for 204 responses or unparseable bodies. Throws on network failure or
 * timeout.
 */
async function api(route, { method = 'GET', body, accessToken } = {}) {
  const res = await fetch(API_BASE + route, {
    method,
    headers: {
      'Content-Type': 'application/json',
      ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
    signal: AbortSignal.timeout(API_TIMEOUT_MS),
  });
  const data = res.status === 204 ? null : await res.json().catch(() => null);
  return { ok: res.ok, status: res.status, data };
}

/** Memoized device id — resolved lazily so nothing touches the Keychain before first use. */
let deviceIdCache = null;

/**
 * Query-string identifying this machine for the per-seat device cap — the id
 * is created once, then stable.
 * @returns {string} URL-encoded device_id, platform, and device_name params.
 */
function deviceParams() {
  if (!deviceIdCache) deviceIdCache = getDeviceId(app.getPath('userData'));
  return new URLSearchParams({
    device_id: deviceIdCache,
    platform: process.platform,
    device_name: os.hostname().replace(/\.local$/, '').slice(0, 80),
  }).toString();
}

/**
 * Fetch + verify + cache a fresh entitlement using a live access token.
 * Carries the device identity; the signed payload comes back with a `device`
 * block, and a refused machine gets can_save=false from the server — the app
 * needs no second rule.
 * @param {string} accessToken Live access token.
 * @returns {Promise<object|null>} The verified payload, or null when the
 * request failed or the signature didn't verify.
 */
async function fetchEntitlement(accessToken) {
  const { ok, data } = await api('/entitlement?' + deviceParams(), { accessToken });
  if (!ok || !verifyEntitlement(data, verifyKey)) return null;
  saveEncrypted(ENTITLEMENT_FILE, { response: data, lastSeenClock: Date.now() });
  return data.payload;
}

/**
 * The offline path: re-verify the cached blob, then check it hasn't aged past
 * valid_until and that the clock hasn't been rolled back to fake freshness.
 * A valid read also advances the persisted lastSeenClock.
 * @returns {object|null} The verified cached payload, or null when absent,
 * invalid, expired, or clock-rolled-back.
 */
function cachedEntitlement() {
  const cached = loadEncrypted(ENTITLEMENT_FILE);
  const now = Date.now();
  const payload = checkCachedEntitlement(cached, verifyKey, now);
  if (!payload) return null;
  saveEncrypted(ENTITLEMENT_FILE, { ...cached, lastSeenClock: now });
  return payload;
}

// ── session flows ────────────────────────────────────────────────────────────

/**
 * Shared in-flight rotation promise. The backend rotates the refresh token on
 * every use: the old token dies as the new one is issued. Two overlapping
 * rotations (launch restore + the 24h timer, or a portal handoff mid-restore)
 * would race — the loser sends an already-spent token and gets rejected, or
 * overwrites the fresh token on disk with a dead one. So rotation is
 * single-flight: one shared promise, every caller awaits the same rotation,
 * and the slot clears once it settles.
 */
let refreshInFlight = null;

/**
 * Single-flight wrapper around rotateRefreshToken — concurrent callers share
 * one rotation.
 * @returns {Promise<object|null>} See rotateRefreshToken.
 */
function refreshSession() {
  if (!refreshInFlight) {
    refreshInFlight = rotateRefreshToken().finally(() => { refreshInFlight = null; });
  }
  return refreshInFlight;
}

/**
 * Rotate the persisted refresh token for a live access token. The token file
 * is re-read at rotation time, not caller time: a rotation that just finished
 * may have replaced the token on disk since the caller last looked.
 * @returns {Promise<object|null>} The token pair on success (the rotated
 * refresh token is already persisted), or null when the server says the
 * session itself is invalid (401/403 — revoked or expired). Anything
 * transient — network failure, timeout, 5xx — throws, so callers keep the
 * tokens and fall back to the cached entitlement.
 */
async function rotateRefreshToken() {
  const tokens = loadEncrypted(TOKENS_FILE);
  if (!tokens?.refresh) return null;
  const { ok, status, data } = await api('/auth/refresh', {
    method: 'POST', body: { refresh_token: tokens.refresh },
  });
  if (status === 401 || status === 403) return null;
  if (!ok || !data?.refresh_token) {
    throw new Error(`auth/refresh failed transiently (HTTP ${status})`);
  }
  saveEncrypted(TOKENS_FILE, { refresh: data.refresh_token });
  return data;
}

/**
 * Silent restore on launch: rotate the refresh token for a fresh access
 * token, then pull a fresh entitlement. A null rotation means the session was
 * revoked or expired server-side — tokens are deleted and a real login is
 * demanded. Falls back to the verified cache when the API is unreachable,
 * times out, or errors transiently (the 7-day offline grace window). Only an
 * explicit 401/403 ends the session.
 * @returns {Promise<{loggedIn: boolean, payload: object, offline: boolean}>}
 */
async function getSession() {
  const tokens = loadEncrypted(TOKENS_FILE);
  if (!tokens?.refresh) return { loggedIn: false };
  try {
    const refreshed = await refreshSession();
    if (!refreshed) {
      deleteFile(TOKENS_FILE);
      return { loggedIn: false };
    }
    const payload = await fetchEntitlement(refreshed.access_token);
    return payload ? { loggedIn: true, payload } : { loggedIn: false };
  } catch {
    const payload = cachedEntitlement();
    return payload ? { loggedIn: true, payload, offline: true }
                   : { loggedIn: false, offline: true };
  }
}

/**
 * Interactive sign-in. A different account signing in must not inherit the
 * previous user's cached entitlement: if the fresh fetch fails, the offline
 * path would surface the old identity and tier — so a cross-account sign-in
 * deletes the cache first. Same-account sign-ins keep the cache — it's their
 * offline fallback.
 * @param {{email: string, password: string}} credentials
 * @returns {Promise<{ok: boolean, payload: object, error: string}>} On
 * failure, error is a user-facing sentence (the server's detail string when
 * it provides one).
 */
async function login({ email, password }) {
  let result;
  try {
    result = await api('/auth/login', {
      method: 'POST', body: { email, password },
    });
  } catch {
    return { ok: false, error: 'Can’t reach the server. Check your connection and try again.' };
  }
  if (!result.ok) {
    const detail = result.data?.detail;
    return { ok: false, error: typeof detail === 'string' ? detail : 'Sign-in failed. Please try again.' };
  }
  const cachedEmail = loadEncrypted(ENTITLEMENT_FILE)?.response?.payload?.email;
  if (cachedEmail && cachedEmail.toLowerCase() !== email.toLowerCase()) {
    deleteFile(ENTITLEMENT_FILE);
  }
  saveEncrypted(TOKENS_FILE, { refresh: result.data.refresh_token });
  const payload = await fetchEntitlement(result.data.access_token).catch(() => null);
  if (!payload) return { ok: false, error: 'Signed in, but couldn’t load your subscription. Try again.' };
  return { ok: true, payload };
}

/**
 * Build the URL to open the web portal AS THE SIGNED-IN APP USER. The app's
 * identity lives here in main; the browser has its own (possibly different,
 * or logged-out) session. So the refresh token is rotated for a live access
 * token (sharing the single-flight rotation with getSession, which also
 * persists the rotated refresh token — the handoff can't invalidate the
 * session the next silent restore relies on), the backend is asked for a
 * one-time SSO token, and the result is a /account/sso link the browser can
 * redeem to land as the same user. Any failure — including offline / server
 * down, when there's nothing to hand off with — falls back to a plain portal
 * URL: the browser then just uses whatever session it has / shows login,
 * which is safe, never wrong-user-silently once redemption is required.
 * @returns {Promise<string>} SSO handoff URL, or the plain /account URL.
 */
async function accountPortalUrl() {
  const fallback = `${API_BASE}/account`;
  const tokens = loadEncrypted(TOKENS_FILE);
  if (!tokens?.refresh) return fallback;
  try {
    const refreshed = await refreshSession();
    if (!refreshed) return fallback;
    const link = await api('/auth/portal-link', {
      method: 'POST', accessToken: refreshed.access_token,
    });
    if (!link.ok || !link.data?.token) return fallback;
    return `${API_BASE}/account/sso?token=${encodeURIComponent(link.data.token)}`;
  } catch {
    return fallback;
  }
}

/**
 * Submit the Help-modal support form to POST /support. Lives here (not the
 * renderer) because the request needs the bearer token, and tokens never
 * leave the main process. The server derives Reply-To and subject from the
 * authenticated user — the app sends only the message body, an optional
 * error dump, and a small diagnostics bag (see SupportRequestIn in
 * ezl-core).
 * @param {{message: string, error: string, diagnostics: object}} [req]
 * @returns {Promise<{ok: boolean, status: number, reason: string}>} reason
 * is 'signed-out' when there's no session, 'network' when offline / server
 * down / timeout.
 */
async function submitSupportRequest({ message, error, diagnostics } = {}) {
  try {
    const refreshed = await refreshSession();
    if (!refreshed) return { ok: false, reason: 'signed-out' };
    const { ok, status } = await api('/support', {
      method: 'POST', accessToken: refreshed.access_token,
      body: { message, error: error || null, diagnostics: diagnostics || {} },
    });
    return { ok, status };
  } catch {
    return { ok: false, reason: 'network' };
  }
}

/**
 * The raw cached entitlement ({payload, signature}) base64-encoded, for the
 * updates Worker to verify server-side (updates-worker/). Deliberately served
 * from the cache, not a fresh fetch — update checks must work offline-grace
 * and must never block on the API.
 * @returns {string|null} Base64 header value, or null when signed out.
 */
function entitlementAuthHeader() {
  const cached = loadEncrypted(ENTITLEMENT_FILE);
  if (!cached?.response) return null;
  return Buffer.from(JSON.stringify(cached.response)).toString('base64');
}

/**
 * Sign out: best-effort server-side revocation of the refresh token, then
 * local token + entitlement state is cleared regardless.
 * @returns {Promise<{ok: true}>}
 */
async function logout() {
  const tokens = loadEncrypted(TOKENS_FILE);
  if (tokens?.refresh) {
    await api('/auth/logout', { method: 'POST', body: { refresh_token: tokens.refresh } })
      .catch(() => {});
  }
  deleteFile(TOKENS_FILE);
  deleteFile(ENTITLEMENT_FILE);
  return { ok: true };
}

// ── background refresh ───────────────────────────────────────────────────────

/**
 * Background refresh is the 24h cadence (design §6), upgraded from
 * fire-and-forget to a push: each refresh is delivered to the renderer over
 * `auth:session` so a subscription that lapses MID-SESSION flips the app to
 * review-only without a relaunch. The schedule is boundary-aware — if the
 * current payload says the trial (or paid period) ends sooner than 24h, the
 * refresh fires just after that moment instead. No polling: still ~1
 * request/user/day unless a boundary is near. This is the ceiling between
 * refreshes.
 */
const REFRESH_CAP_MS   = 24 * 60 * 60 * 1000;
/** Retry cadence after an offline/transient refresh failure. */
const REFRESH_RETRY_MS = 60 * 60 * 1000;
/**
 * Post-boundary grace before refreshing. Test hook (dev runs only, like the
 * other EZL_* overrides): EZL_ENT_REFRESH_GRACE_MS shrinks the boundary grace
 * so e2e can watch an expiry land in seconds, not minutes.
 */
const graceMs = (!app.isPackaged && Number(process.env.EZL_ENT_REFRESH_GRACE_MS)) || 60 * 1000;
/** Minimum delay between refreshes. Shrinks with graceMs — its job is scale, not a fixed number. */
const floorMs = Math.min(30 * 1000, graceMs);

/** Handle for the pending refresh timeout. */
let refreshTimer = null;
/** Returns the BrowserWindow (or null) for auth:session pushes; set by register(). */
let getWin = () => null;

/**
 * (Re)schedule the next background refresh from the payload we currently
 * hold. Chained setTimeout rather than setInterval so every refresh
 * re-derives its own delay. When the timer fires, the fresh session is
 * pushed to the renderer over `auth:session` (a gone window is ignored); a
 * real sign-out stops the chain (login() re-arms), otherwise the next
 * refresh is armed — in retry cadence when offline. The timer is unref'd so
 * it never keeps the app alive just to refresh.
 * @param {object} payload Current entitlement payload driving the schedule.
 * @param {{retry: boolean}} [opts] retry: use the shorter offline cadence.
 */
function armSessionRefresh(payload, { retry = false } = {}) {
  clearTimeout(refreshTimer);
  const capMs = retry ? REFRESH_RETRY_MS : REFRESH_CAP_MS;
  const delay = nextRefreshDelayMs(payload, Date.now(), { capMs, floorMs, graceMs });
  refreshTimer = setTimeout(async () => {
    const session = await getSession().catch(() => ({ loggedIn: false, offline: true }));
    try { getWin()?.webContents.send('auth:session', session); } catch { /* window gone */ }
    if (!session.loggedIn && !session.offline) return;
    armSessionRefresh(session.payload, { retry: !!session.offline });
  }, delay);
  refreshTimer.unref?.();
}

// ── wiring ───────────────────────────────────────────────────────────────────

/**
 * Register the auth IPC surface. Every session read/creation re-arms the
 * refresh scheduler with the freshest payload (launch restore, account-modal
 * reopen, interactive sign-in) — the schedule always tracks what the
 * renderer was last told.
 *
 * Channels:
 * - 'auth-session' — no payload; resolves with { loggedIn, payload?, offline? }.
 * - 'auth-login' — payload { email, password }; resolves with { ok, payload?, error? }.
 * - 'auth-logout' — no payload; cancels the refresh timer and clears local state.
 * - 'support-request' — payload { message, error?, diagnostics? }; resolves
 *   with { ok, status?, reason? }.
 * @param {function(): BrowserWindow|null} getWindow Accessor for the window that
 * receives `auth:session` pushes.
 */
function register(getWindow) {
  if (getWindow) getWin = getWindow;
  ipcMain.handle('auth-session', async () => {
    const session = await getSession();
    if (session.loggedIn) armSessionRefresh(session.payload, { retry: !!session.offline });
    return session;
  });
  ipcMain.handle('auth-login', async (_e, args) => {
    const result = await login(args);
    if (result.ok) armSessionRefresh(result.payload);
    return result;
  });
  ipcMain.handle('auth-logout', () => { clearTimeout(refreshTimer); return logout(); });
  ipcMain.handle('support-request', (_e, args) => submitSupportRequest(args));
}

module.exports = { register, API_BASE, accountPortalUrl, entitlementAuthHeader };