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 receives auth:session pushes.

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.