Skip to content

fix(auth): optional user account; surface missing ADMIN_PASSWORD & JWT_SECRET as clear login errors#106

Merged
cevheri merged 5 commits into
mainfrom
fix/optional-user-account
Jul 1, 2026
Merged

fix(auth): optional user account; surface missing ADMIN_PASSWORD & JWT_SECRET as clear login errors#106
cevheri merged 5 commits into
mainfrom
fix/optional-user-account

Conversation

@cevheri

@cevheri cevheri commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Problem

Two auth misconfigurations failed confusingly instead of clearly:

  1. Running with only admin credentials did not work — getAuthUsers() required both ADMIN_PASSWORD and USER_PASSWORD, so an admin-only deployment could not log in.
  2. A missing/too-short JWT_SECRET failed at token-signing time after correct credentials, and both misconfigs surfaced on screen as the misleading "Invalid email or password" (the login form reads data.message, but the generic error handler returned the text under data.error). The real cause was only in the server console.

Changes

  • User account is optional — exists only when USER_PASSWORD is set. No default user password is ever assumed.
  • Missing ADMIN_PASSWORD → actionable 503 shown on the login screen ("...set the ADMIN_PASSWORD environment variable and restart..."), lazily (no boot-time guard), so embedded/OIDC modes are unaffected.
  • Missing/too-short JWT_SECRET → actionable 503 on the login screen ("...set JWT_SECRET (at least 32 characters)..."), still thrown lazily.
  • Refactor: shared AuthConfigError lives in a dependency-free leaf module src/lib/auth-errors.ts (like @/lib/db/errors); local-provider credential logic (AuthUser, getAuthUsers) lives in src/lib/local-auth.ts (mirroring src/lib/oidc.ts). The login route is a thin handler that forwards each config error's specific message.
  • Docs updated (.env.example, README, DOCKERHUB, docker-compose.example.yml, docs/API_DOCS.md): ADMIN_PASSWORD required, USER_* optional.

End-to-end verification (real Docker container, NODE_ENV=production)

Matrix over ADMIN_PASSWORD (A), USER_PASSWORD (U), JWT_SECRET (J), unset and empty, positive and negative:

Scenario Result
A+ U+ J+ admin 200, user 200, wrong pw 401
A+ U(unset) J+ admin 200, user 401 (no account)
A(unset) J+ 503, message names ADMIN_PASSWORD
A+ J(unset), valid creds 503, message names JWT_SECRET; wrong creds 401 (pre-signing)
A/U/J all unset boots; 503 with ADMIN_PASSWORD precedence
A(empty) J+ 503 (empty treated as unset), ADMIN_PASSWORD
A+ J(empty), valid creds 503 (empty treated as unset), JWT_SECRET

All scenarios pass. Container boots healthy in every config (lazy), and OIDC / npm-embedded-in-platform modes are unaffected.

Testing & coverage

  • New tests/unit/lib/local-auth.test.ts and tests/unit/lib/auth-jwt-config.test.ts (isolated process to sidestep the JWT-secret memoization cache); login route tests updated for the 503 config errors.
  • auth-errors.ts, local-auth.ts, login/route.ts at 100% line/function coverage. auth.ts's only uncovered line is a pre-existing token-expired debug log, so SonarCloud new-code coverage does not drop. Config-error messages are hoisted to single-line module constants to avoid bun's multi-line-string coverage under-count.
  • All five pre-commit checks pass (format, lint, typecheck, test — 486 pass, build).

cevheri added 2 commits July 1, 2026 21:20
… clearly

The login route previously required BOTH ADMIN_PASSWORD and USER_PASSWORD:
getAuthUsers() threw when either was unset. A deployment providing only admin
credentials could not log in at all, and the raw error surfaced as a misleading
"Invalid email or password" on the login screen (the form reads data.message,
but the generic error handler returned it under data.error).

- Make the lower-privilege user account optional: it exists only when
  USER_PASSWORD is set. No default user password is ever assumed.
- Keep ADMIN_PASSWORD mandatory, but surface its absence as an actionable 503
  ("server not configured") that the login screen shows, instead of a generic
  500 or a misleading 401.
- Move the local-provider credential logic (AuthUser, AuthConfigError,
  getAuthUsers) out of the API route into src/lib/local-auth.ts, mirroring
  src/lib/oidc.ts; the route is now a thin handler.
- Update docs (.env.example, README, DOCKERHUB, docker-compose.example,
  API_DOCS) to state ADMIN_PASSWORD is required and USER_* is optional.

Tests: add tests/unit/lib/local-auth.test.ts and update the login route test to
assert the 503 config error. local-auth.ts and login/route.ts stay at 100%
line/function coverage, so SonarCloud coverage does not drop.
A correct login attempt on a server missing JWT_SECRET (or with one shorter than
32 chars) failed at token-signing time: auth.ts threw a generic Error, which the
login route mapped to a generic 500, and the login form displayed the misleading
"Invalid email or password" (the form reads data.message, but the generic handler
returns the text under data.error). The misconfiguration only surfaced in the
server console.

- Promote AuthConfigError to a shared, dependency-free leaf module
  (src/lib/auth-errors.ts), like @/lib/db/errors, so both the shared auth layer
  (auth.ts) and the local provider (local-auth.ts) can throw it without a
  circular provider <-> shared dependency.
- getJwtSecret() now throws AuthConfigError (with an actionable, display-ready
  message) for both the missing-in-production and too-short cases, still lazily
  so it does not crash module load.
- The login route already translates AuthConfigError into a 503; it now forwards
  the error's own message, so JWT_SECRET and ADMIN_PASSWORD misconfigurations each
  show their specific, actionable text on the login screen.
- Hoist the config-error messages to single-line module constants: bun's line
  coverage under-counts continuation lines of multi-line string concatenation,
  which would otherwise show as uncovered new code in SonarCloud.

Tests: add tests/unit/lib/auth-jwt-config.test.ts (isolated process to avoid the
JWT-secret memoization cache) covering the missing/too-short/dev-fallback cases,
and a login route test asserting a JWT AuthConfigError becomes a 503 with its
message. auth-errors.ts, local-auth.ts and login/route.ts stay at 100% coverage;
auth.ts's only uncovered line is a pre-existing token-expired debug log.
@cevheri cevheri changed the title fix(auth): make user account optional; surface missing ADMIN_PASSWORD clearly fix(auth): optional user account; surface missing ADMIN_PASSWORD & JWT_SECRET as clear login errors Jul 1, 2026
@cevheri cevheri requested a review from Copilot July 1, 2026 19:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Improves local-auth configuration handling so operator misconfiguration (missing ADMIN_PASSWORD or missing/invalid JWT_SECRET) surfaces as clear, actionable login errors, while making the lower-privilege user account optional when USER_PASSWORD is unset.

Changes:

  • Introduces AuthConfigError and uses it to turn missing/invalid auth config into specific 503 login responses.
  • Refactors local-provider credential resolution into src/lib/local-auth.ts (admin required, user optional).
  • Adds/updates unit + API tests and updates docs/examples to reflect the new env-var behavior.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/unit/lib/local-auth.test.ts Adds unit coverage for getAuthUsers() admin-required and optional-user behavior.
tests/unit/lib/auth-jwt-config.test.ts Adds unit coverage for JWT_SECRET guard behavior in signJWT().
tests/api/auth/login.test.ts Updates login route tests to assert 503 + actionable messages for config errors and admin-only mode.
src/lib/local-auth.ts New local auth helper extracting env-backed user list (admin required, user optional).
src/lib/auth.ts Converts JWT secret misconfig errors to AuthConfigError with actionable messages; keeps lazy initialization.
src/lib/auth-errors.ts New shared leaf error type to avoid circular deps and standardize config errors.
src/app/api/auth/login/route.ts Uses getAuthUsers() and maps AuthConfigError to 503 with message for the login UI.
README.md Documents required vs optional local-auth env vars and OIDC exemption.
docs/API_DOCS.md Updates login endpoint notes to reflect optional USER_* behavior and required ADMIN_PASSWORD.
DOCKERHUB.md Updates DockerHub usage notes for local auth env vars.
docker-compose.example.yml Makes USER_PASSWORD optional in compose and documents behavior.
.env.example Documents ADMIN_PASSWORD required and USER_* optional semantics.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/api/auth/login.test.ts Outdated
expect(data.message).toContain("ADMIN_PASSWORD");
expect(data.message).not.toBe("Invalid email or password");

process.env.ADMIN_PASSWORD = origAdminPassword!;
Comment thread tests/api/auth/login.test.ts Outdated
expect(data.success).toBe(true);
expect(data.role).toBe("admin");

process.env.USER_PASSWORD = origUserPassword!;
Comment thread tests/api/auth/login.test.ts Outdated
expect(data.success).toBe(false);
expect(data.message).toBe("Invalid email or password");

process.env.USER_PASSWORD = origUserPassword!;
The login route tests restored ADMIN_PASSWORD/USER_PASSWORD via `origValue!`,
which assigns the string "undefined" when the var was originally unset, leaking
into later tests. Use a delete-on-undefined restore() helper, matching the
pattern already used in local-auth.test.ts and auth-jwt-config.test.ts.
@cevheri

cevheri commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the Copilot review (all 3 comments): login route tests now restore ADMIN_PASSWORD/USER_PASSWORD via a delete-on-undefined restore() helper instead of origValue!, so an originally-unset var is never set to the string "undefined". This matches the pattern already used in local-auth.test.ts and auth-jwt-config.test.ts. Fixed in af1a50b.

@cevheri cevheri requested a review from Copilot July 1, 2026 19:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

tests/api/auth/login.test.ts:161

  • This test mutates process.env but only restores it at the end of the test body. If an assertion throws (or the test is interrupted), the env var stays modified and can cascade failures into later tests. Wrap the mutated section in a try/finally (or move the restore into afterEach) so restoration is guaranteed.
  test("returns 503 with an actionable message when ADMIN_PASSWORD is missing", async () => {
    const origAdminPassword = process.env.ADMIN_PASSWORD;
    delete process.env.ADMIN_PASSWORD;

    const req = createMockRequest("/api/auth/login", {

Comment on lines +205 to +222
test("still authenticates admin when USER_PASSWORD is not set", async () => {
const origUserPassword = process.env.USER_PASSWORD;
delete process.env.USER_PASSWORD;

const req = createMockRequest("/api/auth/login", {
method: "POST",
body: { email: "admin@libredb.org", password: "LibreDB.2026" },
});

const res = await POST(req as never);
const data = await parseResponseJSON<{ success: boolean; role: string }>(res);

expect(res.status).toBe(200);
expect(data.success).toBe(true);
expect(data.role).toBe("admin");

restore("USER_PASSWORD", origUserPassword);
});
Comment on lines +224 to +228
test("rejects user login when USER_PASSWORD is not set (account is optional, no default)", async () => {
const origUserPassword = process.env.USER_PASSWORD;
delete process.env.USER_PASSWORD;

const req = createMockRequest("/api/auth/login", {
…opilot review)

Follow-up to the Copilot review: restoring env vars at the end of each test body
is skipped when an earlier expect() throws, leaking state into later tests.
Snapshot the mutated vars (ADMIN_PASSWORD, USER_PASSWORD) in beforeEach and
restore them in afterEach (delete-on-undefined), so restoration always runs.
Matches the afterEach pattern already used in local-auth.test.ts and
auth-jwt-config.test.ts.
@cevheri

cevheri commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the second Copilot review round: the login tests now snapshot the mutated env vars (ADMIN_PASSWORD, USER_PASSWORD) in beforeEach and restore them in afterEach (delete-on-undefined), so restoration always runs even when an earlier expect() throws. This matches the afterEach pattern already used in local-auth.test.ts and auth-jwt-config.test.ts. Fixed in cd745a8.

@sonarqubecloud

sonarqubecloud Bot commented Jul 1, 2026

Copy link
Copy Markdown

@cevheri cevheri merged commit 83ccc6e into main Jul 1, 2026
11 checks passed
@cevheri cevheri deleted the fix/optional-user-account branch July 1, 2026 20:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants