auth
Source: auth.js:12
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.
Instance Methods
resolveDevEnv(): Object
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
Object— Lane name, API base URL, and base64 entitlement public key.
file(name: string): string
Absolute path of a named file in the app's userData directory.
Parameters
name(string) — File name.
Returns
string— Full path.
saveEncrypted(name: string, obj: object)
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.
Parameters
name(string) — File name in userData.obj(object) — Value to serialize and encrypt.
loadEncrypted(name: string): object | null
Read and decrypt a safeStorage-encrypted JSON file.
Parameters
name(string) — 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.
deleteFile(name: string)
Delete a userData file, ignoring a file that's already gone.
Parameters
name(string) — File name in userData.
api(
route: string,
opts?: object,
): Promise.<{ok: boolean, status: number, data: (object|null)}>
Perform a JSON request against the licensing API.
Parameters
route(string) — Path starting with '/', appended to API_BASE.opts(object, optional)opts.method(string, optional) — HTTP method, default 'GET'.opts.body(object, optional) — JSON-serialized request body.opts.accessToken(string, optional) — 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.
deviceParams(): string
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.
fetchEntitlement(accessToken: string): Promise.<(object|null)>
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.
Parameters
accessToken(string) — Live access token.
Returns
Promise.<(object|null)>— The verified payload, or null when the request failed or the signature didn't verify.
cachedEntitlement(): object | null
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.
refreshSession(): Promise.<(object|null)>
Single-flight wrapper around rotateRefreshToken — concurrent callers share one rotation.
Returns
Promise.<(object|null)>— See rotateRefreshToken.
rotateRefreshToken(): Promise.<(object|null)>
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.
getSession(
): Promise.<{loggedIn: boolean, payload: object, offline: boolean}>
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}>
login(
credentials: Object,
): Promise.<{ok: boolean, payload: object, error: string}>
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.
Parameters
credentials(Object)
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).
accountPortalUrl(): Promise.<string>
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.
submitSupportRequest(
req?: Object,
): Promise.<{ok: boolean, status: number, reason: string}>
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).
Parameters
req(Object, optional)
Returns
Promise.<{ok: boolean, status: number, reason: string}>— reason is 'signed-out' when there's no session, 'network' when offline / server down / timeout.
entitlementAuthHeader(): string | null
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.
logout(): Promise.<{ok: true}>
Sign out: best-effort server-side revocation of the refresh token, then local token + entitlement state is cleared regardless.
Returns
Promise.<{ok: true}>
getWin()
Returns the BrowserWindow (or null) for auth:session pushes; set by register().
armSessionRefresh(payload: object, opts?: Object)
(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.
Parameters
payload(object) — Current entitlement payload driving the schedule.opts(Object, optional) — retry: use the shorter offline cadence.
register(getWindow: function)
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? }.
Parameters
getWindow(function) — Accessor for the window that receivesauth:sessionpushes.
Instance Fields
deviceIdCache
Memoized device id — resolved lazily so nothing touches the Keychain before first use.
refreshInFlight
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.
refreshTimer
Handle for the pending refresh timeout.
Other
PROD_API_URL
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.
PROD_ENTITLEMENT_PUBLIC_KEY_B64
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.
DEV_ENTITLEMENT_PUBLIC_KEY_B64
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.
ENTITLEMENT_PUBLIC_KEY_B64
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.
TOKENS_FILE
Persisted tokens: { refresh } — access tokens are not persisted.
ENTITLEMENT_FILE
Cached entitlement: { response, lastSeenClock }.
verifyKey
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.
API_TIMEOUT_MS
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.
REFRESH_CAP_MS
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.
REFRESH_RETRY_MS
Retry cadence after an offline/transient refresh failure.
graceMs
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.
floorMs
Minimum delay between refreshes. Shrinks with graceMs — its job is scale, not a fixed number.