Skip to content

feat(auth): store more than two local accounts (#784) - #1122

Merged
cevheri merged 21 commits into
libredb:mainfrom
nawazish2:feat/784-local-accounts
Sep 28, 2026
Merged

cevheri merged 21 commits into
libredb:mainfrom
nawazish2:feat/784-local-accounts

Conversation

@nawazish2

@nawazish2 nawazish2 commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Summary

With STORAGE_PROVIDER=sqlite or postgres, local email/password accounts live in an accounts table next to user_storage, so a team is no longer capped at one admin and one user. ADMIN_* (and USER_* when a password is set) seed the table once; further accounts are managed under Admin → Accounts. STORAGE_PROVIDER=local (the zero-config default) and OIDC mode are unchanged and never touch the table.

Original implementation by @nawazish2; review, security fixes and the Accounts screen redesign by the maintainers.

What it does

  • Accounts: scrypt passwords (N=16384, r=8, p=1, rehash on login), create, role change, disable, delete, set password. The last enabled admin cannot be removed, and the store decides that inside the write's transaction (Postgres FOR UPDATE, SQLite BEGIN IMMEDIATE), so concurrent requests cannot remove the last two.
  • Sessions follow the account: each session and MCP token carries the account's session_version, checked on every request. Disabling, deleting, a role change or a password reset ends them at the next request, and the revoked tab is sent to the login screen. Upgrading a sqlite/postgres deployment signs local users out once.
  • Second factor per account: every account sets up its own authenticator under the user menu → Authenticator. Setup needs the current password; turning it off needs the password and a current code; wrong answers spend the login budgets. Stored TOTP secrets are sealed like connection passwords; an unreadable one locks the factor rather than removing it.
  • Recovery: each start warns when ADMIN_PASSWORD no longer matches the stored admin; ADMIN_PASSWORD_RESET=true restores ADMIN_EMAIL as an enabled admin with the environment's password and factor.
  • Hardening found in review: account deletes are transactional, API paths with a dot (an email in /api/admin/accounts/<email>) now get the Origin check and security headers, and refused admin changes are audited with the acting admin.
  • Accounts screen: data table with search and filter; every change that ends someone's sessions or weakens sign-in asks the admin to type the account's email.

Docs: docs/STORAGE.md, docs/MFA.md, docs/API_DOCS.md, docs/SECURITY.md (control 1.7), .env.example. H14 filed in docs/BACKLOG.md (an unclassified 500 returns the raw error message).

Closes #784

Testing

  • All required-check gates locally: format, lint, typecheck, knip, chart, showcase, readme and security checks, bun run test, build, test:coverage + coverage:check (100%), build:lib, attw.
  • Red-team probes re-run against a production build: offboarding (demote, disable, delete), stolen-cookie TOTP changes, CSRF on dotted paths, the last-admin race over HTTP (36 rounds) and against real Postgres 17 (72 rounds, 0 lockouts).
  • Browser, production build: zero-config mode, account creation, a non-admin setting up and signing in with a TOTP code, disabling and demoting a live session, typed confirmations, delete, and the last-admin refusal.

@gitguardian

gitguardian Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 1 secret following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

Since your pull request originates from a forked repository, GitGuardian is not able to associate the secrets uncovered with secret incidents on your GitGuardian dashboard.
Skipping this check run and merging your pull request will create secret incidents on your GitGuardian dashboard.

🔎 Detected hardcoded secret in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
- - Generic Password 47ab8ba tests/api/auth/totp-reauth.test.ts View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secret safely. Learn here the best practices.
  3. Revoke and rotate this secret.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

@codecov

codecov Bot commented Sep 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

When storage is sqlite or postgres, local email/password accounts live in
an accounts table. Env credentials seed that table once. Passwords are
scrypt, and Admin → Accounts can create, disable, delete, and enrol TOTP.
Local mode and OIDC stay on their existing paths.
@nawazish2
nawazish2 force-pushed the feat/784-local-accounts branch from d99282b to d195507 Compare September 25, 2026 17:23
@cevheri cevheri added Needs Triage security Supply-chain, auth, or hardening work core-capabilities labels Sep 25, 2026
@cevheri

cevheri commented Sep 27, 2026

Copy link
Copy Markdown
Member

@nktnet1 @nawazish2 I'm taking this PR over and starting the review now. Please don't push new commits to this branch from here on. I'll review it, update it where needed, and let you both know here once that's done. Thanks for the work so far.

@cevheri cevheri self-assigned this Sep 27, 2026
@cevheri
cevheri self-requested a review September 27, 2026 22:51
…ed, deleted or reset

A session is a 24-hour JWT, so the registry decided only who could log in next. A disabled
account kept reading and writing its storage, a demoted admin kept the account registry and
could promote itself back, and a deleted account's live session wrote rows that a later
account with the same email inherited.

Accounts now carry a session version that is signed into the token at login and compared on
every getSession(). Disabling, a role change and a password change increment it; a deleted
account has no row. A registry that cannot be read refuses the session. OIDC and
STORAGE_PROVIDER=local sessions are not looked up.
Two separate statements could remove the account and leave its user_storage rows, which the
next account created with the same email then inherited.
Delete removes the account's saved connections, history and saved queries with it, and a
single click did it with no way back.
Once the registry is seeded the environment no longer sets the admin's password, so an
operator who rotated ADMIN_PASSWORD saw no effect and no explanation, and an admin locked out
of every enabled account had no way back short of editing the database.

Each start now compares the env admin with the store once and logs a warning when the
password differs or ADMIN_EMAIL has no row. ADMIN_PASSWORD_RESET=true makes ADMIN_EMAIL an
enabled admin with ADMIN_PASSWORD and ADMIN_TOTP_SECRET (or no second factor), ends its old
sessions, audits the change with actor "environment", and warns until the variable is removed.

New rows now start at a random session version, so a session of a deleted account never
matches a later account with the same email, and a token without a version matches nothing.
A session cookie alone could replace an active TOTP secret (begin, then confirm with the
thief's authenticator) or remove it, so a stolen cookie defeated the factor that exists to
limit what a stolen credential can do.

begin now needs the current password and refuses while a factor is active; disable needs the
password and, when a factor is active, a current code that has not been used. A wrong answer is
audited and charged to the same login_client and login_account budgets as a failed login.
Enrolment was reachable only from the admin Accounts tab, so an account without admin rights
had no way to turn a second factor on. The authenticator section is now one component, shown
in the Accounts tab and on /settings/authenticator, linked from the user menu. It asks for the
current password, and for a current code when turning the factor off. GET /api/auth/totp says
whether the account has a factor, or why setup is not offered (OIDC, or no server store).
…deleted or reset

An MCP token carries the role it was minted with and lives 30 days by default, so with the
account registry it outlived every change to its account: a disabled or deleted account kept
run_read_query, and a demoted admin kept the admin role on the MCP channel.

A token minted for a stored account now carries its session version, and the route checks it
against the registry on every call, answering the same 401 an invalid token gets. The proxy
stays a signature check, because it must not load the storage drivers.
…action

The last-admin check read the accounts, awaited, then wrote, so two concurrent requests could
each pass it and remove one of the last two admins. The red-team run left zero admins in 6 of
12 rounds on Postgres, and the same race reproduces in one process on SQLite.

A write that removes an enabled admin now runs with keepEnabledAdmin: Postgres locks the
enabled admin rows FOR UPDATE, SQLite takes the write lock with BEGIN IMMEDIATE, and the write
rolls back with LastAdminError when no enabled admin would remain. The application-level
pre-check is gone; its 409 now comes from the store.
The accounts table held each second factor's shared secret in the clear while connection
passwords in the same store are sealed, so a copy of the database was enough to mint codes.

totp_secret and totp_pending now go through the same AES-GCM envelope and key as connection
secrets. A sealed factor the current key cannot open reads as an unusable secret rather than as
no factor, so rotating the key locks the account's second factor instead of switching it off,
and the log names the account.
…th a dot

The matcher skips every path containing a dot, which is meant for static assets, so PATCH and
DELETE on /api/admin/accounts/<email> bypassed the Origin check and returned no CSP or HSTS
whenever the email had a dot, which is nearly always. The route's session check still held.

A second matcher entry covers API paths with a dot; static assets and /api/storage/config stay
excluded.
… name

Only successful account changes reached the audit log, so an attempt to remove the last enabled
admin, or to act on an account that does not exist, left no trace. Refused creates, changes and
deletes now emit an account event with outcome failure and reason account_refused.

Also files H14: an unclassified 500 returns the raw error message, which with the account
registry means an unreachable Postgres store's address reaches an unauthenticated caller.
STORAGE.md gains the session_version column, the DELETE grant on user_storage that removing an
account needs, and how sessions, the last-admin guard, the drift warning and
ADMIN_PASSWORD_RESET behave. MFA.md describes self-service setup with re-authentication and the
sealed secrets. API_DOCS.md documents the account and TOTP routes. SECURITY.md adds control 1.7
and corrects the MCP revocation note, which no longer holds for a stored account.
The Accounts tab becomes a data table with role and status badges, a two-factor mark and one
row menu per account, over a toolbar with search by email, a role, status and two-factor filter
and a count. Adding an account and setting a password open their own dialogs, and a refusal is
shown inside the dialog that caused it.

Every change that ends someone's sessions or weakens sign-in (make admin, make user, disable,
clear two-factor, delete) asks the admin to type the account's email before the button enables.
The authenticator section and /settings/authenticator are restyled to match.
…oken editor

Driving it in a browser: after an admin disabled an account, its open editor tab stayed on a
screen of "Authentication required" errors, and because the proxy only verifies the cookie's
signature, /login redirected straight back to the editor, so the person could not sign in again
without clearing cookies by hand.

getSession() now clears the cookie when the registry refuses the session, and useAuth() sends
the tab to /login when /api/auth/me answers 401.
@cevheri

cevheri commented Sep 28, 2026

Copy link
Copy Markdown
Member

@nktnet1 @nawazish2 The review and update are done, and the result is pushed to this branch. The shape matches what #784 asked for, and the zero-config path is unchanged: with STORAGE_PROVIDER=local nothing new runs.

The security pass found gaps that had to close before this could merge, now fixed with tests:

  1. A disabled, deleted or demoted account kept its session and MCP token until expiry, so a demoted admin could promote itself back. Both now carry a per-account version and end at the next request.
  2. Two concurrent requests could remove the last two admins (6 of 12 rounds on Postgres). The check now runs inside the write's transaction.
  3. A session cookie alone could remove or replace a second factor. Setup and turning off now ask for the current password, and turning off also needs a current code.
  4. TOTP secrets were stored in the clear. They are sealed like connection passwords now.
  5. After seeding, a rotated ADMIN_PASSWORD had no effect and there was no recovery. A drift warning and ADMIN_PASSWORD_RESET cover that.

Every account can also set up its own authenticator from the user menu, and the Accounts screen is redesigned, with typed confirmation for destructive changes. Thanks for the solid base, @nawazish2.

@cevheri cevheri added enhancement New feature or request help wanted Extra attention is needed and removed Needs Triage labels Sep 28, 2026
A realistic literal in a fixture is what secret scanners flag; these tests now read their
passwords from named placeholder constants.
Comment thread src/app/api/auth/totp/route.ts Fixed
Comment thread src/app/api/auth/totp/route.ts Fixed
Comment thread src/app/api/auth/totp/route.ts Fixed
CodeQL flagged js/user-controlled-bypass on the begin, confirm and disable branches: a value
from the request body chose which credential-checking call ran. Every entry already sits behind
the session guard and makes its own password and code checks, so the table changes nothing a
caller can reach; inherited keys such as toString are refused.
@cevheri
cevheri merged commit 2c7df93 into libredb:main Sep 28, 2026
32 of 33 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core-capabilities enhancement New feature or request help wanted Extra attention is needed security Supply-chain, auth, or hardening work

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] auth - support multi-user (not just 1 admin + 1 user)

3 participants