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 };