-
Notifications
You must be signed in to change notification settings - Fork 1
Accounts and Login
How the app stores National Grid logins, discovers billing accounts, and handles MFA — without ever putting a secret in the DB or git, and without becoming a public auth layer.
A saved login is an NgLogin row: username plus the password sealed with AES-256-GCM
into ciphertext / iv (12-byte nonce) / authTag. The seal/open math is a pure function in
lib/crypto.ts; the AES key is scrypt-derived from raw key material that lives only outside
the DB.
A decrypted password is held in memory just long enough to log in — it is never logged and never
returned to the client. The API only ever reports a login's username/label/status.
Resolution order — env always wins so existing installs are unchanged:
-
NGRID_SECRET_KEYenv var, if set and non-blank. - A persisted file at
DATA_DIR/session/secret.key, if present. - Otherwise generate a strong 32-byte base64 key, write it
0600(mkdir the dir0700), and use it. This makes the prod cutover automatic — the encrypted store works out of the box.
Once generated, the key is stable across restarts (steps 2→3 happen once). Changing the key
material would orphan every credential sealed under the old key, so the file lives in the
root-only session volume (same place as the Playwright session). Keep that volume to
keep stored logins decryptable. The decision logic (decideSecretKeyMaterial) is pure and tested;
the I/O is best-effort (a transient FS error degrades to an in-memory key rather than crashing
startup).
NGRID_USER / NGRID_PASS still work and remain the fallback when no NgLogin row exists.
Accounts discovered through env creds get an Account row with a null loginId. On first run
the app performs an automatic env → encrypted-store cutover (lib/ngrid/{bootstrap,firstRun}.ts)
and a first-run setup flow so a fresh install lands in the encrypted store without manual steps.
One login can expose several billing accounts (multi-premise / linked). They're discovered during
the scrape (lib/ngrid/accounts.ts), persisted as Account rows under the NgLogin, and the UI
account switcher (components/AccountSwitcher.tsx, lib/accountSwitcher.ts) scopes every
chart, export, and PDF view to the selected account.
The scraper reuses the session so it rarely logs in. When it must — a new login, an expired session, or a re-auth — and the account demands MFA, the in-app flow handles it interactively:
-
POST /api/ng-logins/preflight/[id]starts a login attempt; if an OTP is required it pauses. -
POST /api/ng-logins/preflight/[id]/otpsubmits the code and finishes, seeding the session. -
POST /api/ng-logins/[id]/reauthre-runs this for a login whose session went stale.
State lives in lib/ngrid/{preflight,preflightState,loginStatus}.ts. The unattended scheduler
cannot answer an OTP prompt, so a login that always demands MFA surfaces a needs_reauth
connection status instead of silently failing — you finish it from the UI.
Removing a login (DELETE /api/ng-logins/[id]) requires a password confirmation and lets you
choose to keep or delete that account's data (bills/usage/PDFs). Account.loginId is
onDelete: SetNull, so keeping the data simply detaches it from the removed login.
The in-app login-management UI is not a public application-login layer. It inherits the existing access gate (LAN-only / reverse proxy / SSO) and must never expose financial data un-gated. Don't turn it into, or document it as, a public auth front door. See Contributing.