From 5c9a84ff766c7d91cec620dd4b1f7a4879f3882b Mon Sep 17 00:00:00 2001 From: cevheri Date: Wed, 1 Jul 2026 21:20:51 +0300 Subject: [PATCH 1/5] fix(auth): make user account optional; surface missing ADMIN_PASSWORD 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. --- .env.example | 6 ++- DOCKERHUB.md | 2 + README.md | 2 + docker-compose.example.yml | 9 ++-- docs/API_DOCS.md | 2 +- src/app/api/auth/login/route.ts | 49 +++++++--------------- src/lib/local-auth.ts | 53 +++++++++++++++++++++++ tests/api/auth/login.test.ts | 52 ++++++++++++++++++++--- tests/unit/lib/local-auth.test.ts | 70 +++++++++++++++++++++++++++++++ 9 files changed, 201 insertions(+), 44 deletions(-) create mode 100644 src/lib/local-auth.ts create mode 100644 tests/unit/lib/local-auth.test.ts diff --git a/.env.example b/.env.example index ec7c42bd..4f053cfe 100644 --- a/.env.example +++ b/.env.example @@ -14,11 +14,13 @@ # ============================================ # AUTHENTICATION (Required) # ============================================ -# Admin credentials (full access + maintenance tools) +# Admin credentials (full access + maintenance tools) — ADMIN_PASSWORD is required. ADMIN_EMAIL=admin@libredb.org ADMIN_PASSWORD=your_secure_admin_password -# User credentials (query execution only) +# User credentials (query execution only) — OPTIONAL. +# The lower-privilege user account exists only when USER_PASSWORD is set. +# Leave USER_PASSWORD unset to run admin-only (no default user password is ever assumed). USER_EMAIL=user@libredb.org USER_PASSWORD=your_secure_user_password diff --git a/DOCKERHUB.md b/DOCKERHUB.md index 36ee01a6..452a8a7f 100644 --- a/DOCKERHUB.md +++ b/DOCKERHUB.md @@ -40,6 +40,8 @@ docker run -d \ Open and log in with the `ADMIN_EMAIL` / `ADMIN_PASSWORD` you set above. **Use your own strong passwords and a random `JWT_SECRET`** — the values here are placeholders. +> With the local auth provider, `ADMIN_PASSWORD` is required — without it the login screen reports a clear configuration error instead of authenticating. `USER_EMAIL` / `USER_PASSWORD` are optional — omit them to run admin-only. They are not used when `NEXT_PUBLIC_AUTH_PROVIDER=oidc`. + > **Enable AI:** add `-e LLM_PROVIDER=gemini -e LLM_API_KEY=your_key -e LLM_MODEL=gemini-2.5-flash`. ### Docker Compose diff --git a/README.md b/README.md index 43fab89d..193d3385 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,8 @@ docker run -d \ Open [http://localhost:3000](http://localhost:3000) and login with `admin@libredb.org` / `LibreDB.2026`. + > **Auth env vars (local provider):** `ADMIN_PASSWORD` is required — without it the login screen shows a clear "server not configured" message instead of letting you in. `USER_EMAIL` / `USER_PASSWORD` are optional; omit them to run admin-only (no default user password is ever assumed). `ADMIN_EMAIL` defaults to `admin@libredb.org`. Using OIDC (`NEXT_PUBLIC_AUTH_PROVIDER=oidc`)? None of these are needed. + > **Tip**: Add `-e LLM_PROVIDER=gemini -e LLM_API_KEY=your_key -e LLM_MODEL=gemini-2.5-flash` to enable AI features. ### Prerequisites diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 6bbeb463..88a02073 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -30,12 +30,15 @@ services: # condition: service_healthy environment: # ----------------------------------------------------------------------- - # AUTHENTICATION (required) + # AUTHENTICATION — local provider. ADMIN_PASSWORD is required (without it + # the login screen shows a clear config error); USER_* is optional. # ----------------------------------------------------------------------- ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@libredb.org} # admin: full access + maintenance tools ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env} - USER_EMAIL: ${USER_EMAIL:-user@libredb.org} # user: query execution only - USER_PASSWORD: ${USER_PASSWORD:?set USER_PASSWORD in .env} + # Optional lower-privilege account (query execution only). Omit USER_PASSWORD + # to run admin-only — no default user password is ever assumed. + USER_EMAIL: ${USER_EMAIL:-user@libredb.org} + USER_PASSWORD: ${USER_PASSWORD:-} # JWT signing secret — min 32 chars. Generate: openssl rand -base64 32 JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in .env (min 32 chars)} diff --git a/docs/API_DOCS.md b/docs/API_DOCS.md index 6e7a484b..8b799b3e 100644 --- a/docs/API_DOCS.md +++ b/docs/API_DOCS.md @@ -120,7 +120,7 @@ Authenticate user and create session. ``` **Notes:** -- Both `email` and `password` are required; matched against `ADMIN_EMAIL`/`ADMIN_PASSWORD` or `USER_EMAIL`/`USER_PASSWORD` environment variables +- Both `email` and `password` are required in the request body; matched against `ADMIN_EMAIL`/`ADMIN_PASSWORD` or `USER_EMAIL`/`USER_PASSWORD` environment variables. `ADMIN_PASSWORD` is mandatory; the `USER_*` account exists only when `USER_PASSWORD` is set - Sets `auth-token` HTTP-only cookie on success --- diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 4dc2335d..f33ed74a 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,41 +1,9 @@ import { login } from "@/lib/auth"; +import { AuthConfigError, getAuthUsers } from "@/lib/local-auth"; import { NextRequest, NextResponse } from "next/server"; import { createErrorResponse } from "@/lib/api/errors"; import { logger } from "@/lib/logger"; -interface AuthUser { - email: string; - password: string; - role: "admin" | "user"; -} - -function getAuthUsers(): AuthUser[] { - const adminEmail = process.env.ADMIN_EMAIL || "admin@libredb.org"; - const adminPassword = process.env.ADMIN_PASSWORD; - const userEmail = process.env.USER_EMAIL || "user@libredb.org"; - const userPassword = process.env.USER_PASSWORD; - - // Passwords MUST come from the environment in every environment. Never fall - // back to a hardcoded default — a baked-in password would be a publicly known - // credential on any deployment that forgets to set ADMIN_PASSWORD/USER_PASSWORD. - if (!adminPassword || !userPassword) { - throw new Error("ADMIN_PASSWORD and USER_PASSWORD environment variables are required"); - } - - return [ - { - email: adminEmail, - password: adminPassword, - role: "admin", - }, - { - email: userEmail, - password: userPassword, - role: "user", - }, - ]; -} - export async function POST(request: NextRequest) { try { const { email, password } = await request.json(); @@ -51,6 +19,21 @@ export async function POST(request: NextRequest) { logger.warn("Failed login attempt", { route: "POST /api/auth/login", email }); return NextResponse.json({ success: false, message: "Invalid email or password" }, { status: 401 }); } catch (error) { + // Server is not configured for local login — surface a clear, actionable + // message on the login screen (503, not a generic 500) so the operator + // knows exactly what to fix rather than seeing "Invalid email or password". + if (error instanceof AuthConfigError) { + logger.error("Local authentication is not configured", error, { route: "POST /api/auth/login" }); + return NextResponse.json( + { + success: false, + message: + "Login is unavailable: this server has no administrator password configured. " + + "Set the ADMIN_PASSWORD environment variable and restart the server.", + }, + { status: 503 }, + ); + } return createErrorResponse(error, { route: "POST /api/auth/login" }); } } diff --git a/src/lib/local-auth.ts b/src/lib/local-auth.ts new file mode 100644 index 00000000..80e1afea --- /dev/null +++ b/src/lib/local-auth.ts @@ -0,0 +1,53 @@ +/** + * Local authentication provider — resolves the admin (and optional user) + * accounts from environment variables. This is the counterpart to `oidc.ts`; + * shared session/JWT concerns live in `auth.ts`. + */ +import type { Role } from "@/lib/auth"; + +export interface AuthUser { + email: string; + password: string; + role: Role; +} + +/** + * Raised when local authentication cannot be served because the server is + * missing required config (no ADMIN_PASSWORD). It is a deployment/operator + * error, not a bad-credentials error — the login route turns it into a clear, + * actionable 503 so the operator sees WHAT to fix on the login screen instead + * of a misleading "Invalid email or password". + */ +export class AuthConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "AuthConfigError"; + } +} + +/** + * Build the list of accounts that can authenticate against the local provider. + * ADMIN_PASSWORD is required; the lower-privilege user account is optional and + * exists only when USER_PASSWORD is set. We never invent a default password — + * a baked-in default would be a publicly known credential on every deployment. + * + * @throws {AuthConfigError} when ADMIN_PASSWORD is not configured. + */ +export function getAuthUsers(): AuthUser[] { + const adminEmail = process.env.ADMIN_EMAIL || "admin@libredb.org"; + const adminPassword = process.env.ADMIN_PASSWORD; + + if (!adminPassword) { + throw new AuthConfigError("ADMIN_PASSWORD is not set"); + } + + const users: AuthUser[] = [{ email: adminEmail, password: adminPassword, role: "admin" }]; + + const userPassword = process.env.USER_PASSWORD; + if (userPassword) { + const userEmail = process.env.USER_EMAIL || "user@libredb.org"; + users.push({ email: userEmail, password: userPassword, role: "user" }); + } + + return users; +} diff --git a/tests/api/auth/login.test.ts b/tests/api/auth/login.test.ts index 5cc0a3ae..613bbe98 100644 --- a/tests/api/auth/login.test.ts +++ b/tests/api/auth/login.test.ts @@ -146,7 +146,7 @@ describe("POST /api/auth/login", () => { expect(mockLogin).toHaveBeenCalledWith("user", "user@libredb.org"); }); - test("returns 500 when required password env vars are missing", async () => { + test("returns 503 with an actionable message when ADMIN_PASSWORD is missing", async () => { const origAdminPassword = process.env.ADMIN_PASSWORD; delete process.env.ADMIN_PASSWORD; @@ -156,12 +156,54 @@ describe("POST /api/auth/login", () => { }); const res = await POST(req as never); - const data = await parseResponseJSON<{ error: string; code: string; statusCode: number }>(res); + const data = await parseResponseJSON<{ success: boolean; message: string }>(res); - expect(res.status).toBe(500); - expect(data.code).toBe("INTERNAL_ERROR"); - expect(data.statusCode).toBe(500); + // A misconfiguration is an operator error, not bad credentials: it must be + // clearly distinguishable (503) and carry a message the login screen shows + // via `data.message` — never the misleading "Invalid email or password". + expect(res.status).toBe(503); + expect(data.success).toBe(false); + expect(data.message).toContain("ADMIN_PASSWORD"); + expect(data.message).not.toBe("Invalid email or password"); process.env.ADMIN_PASSWORD = origAdminPassword!; }); + + 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"); + + process.env.USER_PASSWORD = origUserPassword!; + }); + + 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", { + method: "POST", + body: { email: "user@libredb.org", password: "LibreDB.2026" }, + }); + + const res = await POST(req as never); + const data = await parseResponseJSON<{ success: boolean; message: string }>(res); + + expect(res.status).toBe(401); + expect(data.success).toBe(false); + expect(data.message).toBe("Invalid email or password"); + + process.env.USER_PASSWORD = origUserPassword!; + }); }); diff --git a/tests/unit/lib/local-auth.test.ts b/tests/unit/lib/local-auth.test.ts new file mode 100644 index 00000000..4c693b2b --- /dev/null +++ b/tests/unit/lib/local-auth.test.ts @@ -0,0 +1,70 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { AuthConfigError, getAuthUsers } from "@/lib/local-auth"; + +describe("local-auth getAuthUsers()", () => { + let origAdminEmail: string | undefined; + let origAdminPassword: string | undefined; + let origUserEmail: string | undefined; + let origUserPassword: string | undefined; + + beforeEach(() => { + origAdminEmail = process.env.ADMIN_EMAIL; + origAdminPassword = process.env.ADMIN_PASSWORD; + origUserEmail = process.env.USER_EMAIL; + origUserPassword = process.env.USER_PASSWORD; + }); + + afterEach(() => { + restore("ADMIN_EMAIL", origAdminEmail); + restore("ADMIN_PASSWORD", origAdminPassword); + restore("USER_EMAIL", origUserEmail); + restore("USER_PASSWORD", origUserPassword); + }); + + function restore(key: string, value: string | undefined): void { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + + test("throws AuthConfigError when ADMIN_PASSWORD is missing", () => { + delete process.env.ADMIN_PASSWORD; + expect(() => getAuthUsers()).toThrow(AuthConfigError); + }); + + test("throws AuthConfigError when ADMIN_PASSWORD is empty", () => { + process.env.ADMIN_PASSWORD = ""; + expect(() => getAuthUsers()).toThrow(AuthConfigError); + }); + + test("returns admin-only when USER_PASSWORD is not set", () => { + process.env.ADMIN_PASSWORD = "admin-secret"; + delete process.env.USER_PASSWORD; + + const users = getAuthUsers(); + + expect(users).toHaveLength(1); + expect(users[0]).toMatchObject({ role: "admin", password: "admin-secret" }); + }); + + test("includes the optional user account only when USER_PASSWORD is set", () => { + process.env.ADMIN_PASSWORD = "admin-secret"; + process.env.USER_PASSWORD = "user-secret"; + + const users = getAuthUsers(); + + expect(users).toHaveLength(2); + expect(users.find((u) => u.role === "user")).toMatchObject({ password: "user-secret" }); + }); + + test("defaults emails when not provided", () => { + process.env.ADMIN_PASSWORD = "admin-secret"; + process.env.USER_PASSWORD = "user-secret"; + delete process.env.ADMIN_EMAIL; + delete process.env.USER_EMAIL; + + const users = getAuthUsers(); + + expect(users.find((u) => u.role === "admin")?.email).toBe("admin@libredb.org"); + expect(users.find((u) => u.role === "user")?.email).toBe("user@libredb.org"); + }); +}); From 904cf71fedea8f669d3fdabdeee8dbc62076b528 Mon Sep 17 00:00:00 2001 From: cevheri Date: Wed, 1 Jul 2026 22:16:48 +0300 Subject: [PATCH 2/5] fix(auth): surface missing/invalid JWT_SECRET as a clear login error 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. --- src/app/api/auth/login/route.ts | 22 ++++------- src/lib/auth-errors.ts | 22 +++++++++++ src/lib/auth.ts | 15 +++++++- src/lib/local-auth.ts | 20 +++------- tests/api/auth/login.test.ts | 26 +++++++++++++ tests/unit/lib/auth-jwt-config.test.ts | 52 ++++++++++++++++++++++++++ tests/unit/lib/local-auth.test.ts | 3 +- 7 files changed, 129 insertions(+), 31 deletions(-) create mode 100644 src/lib/auth-errors.ts create mode 100644 tests/unit/lib/auth-jwt-config.test.ts diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index f33ed74a..dadd5d8b 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,5 +1,6 @@ import { login } from "@/lib/auth"; -import { AuthConfigError, getAuthUsers } from "@/lib/local-auth"; +import { AuthConfigError } from "@/lib/auth-errors"; +import { getAuthUsers } from "@/lib/local-auth"; import { NextRequest, NextResponse } from "next/server"; import { createErrorResponse } from "@/lib/api/errors"; import { logger } from "@/lib/logger"; @@ -19,20 +20,13 @@ export async function POST(request: NextRequest) { logger.warn("Failed login attempt", { route: "POST /api/auth/login", email }); return NextResponse.json({ success: false, message: "Invalid email or password" }, { status: 401 }); } catch (error) { - // Server is not configured for local login — surface a clear, actionable - // message on the login screen (503, not a generic 500) so the operator - // knows exactly what to fix rather than seeing "Invalid email or password". + // Server is not configured for authentication (missing ADMIN_PASSWORD, or a + // missing/too-short JWT_SECRET) — surface the error's actionable message on + // the login screen as a 503, not a generic 500, so the operator knows exactly + // what to fix rather than seeing a misleading "Invalid email or password". if (error instanceof AuthConfigError) { - logger.error("Local authentication is not configured", error, { route: "POST /api/auth/login" }); - return NextResponse.json( - { - success: false, - message: - "Login is unavailable: this server has no administrator password configured. " + - "Set the ADMIN_PASSWORD environment variable and restart the server.", - }, - { status: 503 }, - ); + logger.error("Authentication is not configured", error, { route: "POST /api/auth/login" }); + return NextResponse.json({ success: false, message: error.message }, { status: 503 }); } return createErrorResponse(error, { route: "POST /api/auth/login" }); } diff --git a/src/lib/auth-errors.ts b/src/lib/auth-errors.ts new file mode 100644 index 00000000..2ea7cd05 --- /dev/null +++ b/src/lib/auth-errors.ts @@ -0,0 +1,22 @@ +/** + * Shared authentication error types. Kept in a dependency-free leaf module (like + * `@/lib/db/errors`) so both the shared auth layer (`auth.ts`) and the local + * provider (`local-auth.ts`) can throw it without creating a circular + * provider <-> shared dependency. + */ + +/** + * Raised when authentication cannot be served because the server is misconfigured + * (e.g. no ADMIN_PASSWORD, or a missing/too-short JWT_SECRET). This is a + * deployment/operator error, not a bad-credentials error. + * + * Its `message` is written to be shown directly to the operator on the login + * screen, so the login route turns it into a clear, actionable 503 instead of a + * misleading "Invalid email or password". + */ +export class AuthConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "AuthConfigError"; + } +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 4f4abd10..87a799ca 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,13 +1,24 @@ import { SignJWT, jwtVerify } from "jose"; import { cookies } from "next/headers"; import { logger } from "@/lib/logger"; +import { AuthConfigError } from "@/lib/auth-errors"; + +// Single-line messages, hoisted to module scope: bun's line coverage under-counts +// the continuation lines of multi-line string concatenation, which would show as +// uncovered "new code" in SonarCloud even though the throw is exercised by tests. +const JWT_SECRET_MISSING_MESSAGE = + "Login is unavailable: the server's JWT_SECRET is not configured. Set JWT_SECRET (at least 32 characters) and restart the server."; +const JWT_SECRET_TOO_SHORT_MESSAGE = + "Login is unavailable: the server's JWT_SECRET is too short; it must be at least 32 characters. Update JWT_SECRET and restart the server."; function getJwtSecret(): Uint8Array { const secret = process.env.JWT_SECRET; if (!secret) { if (process.env.NODE_ENV === "production") { - throw new Error("JWT_SECRET environment variable is required in production"); + // Thrown lazily (at sign/verify time), so the login route can turn it into + // a clear on-screen 503 instead of a misleading "Invalid email or password". + throw new AuthConfigError(JWT_SECRET_MISSING_MESSAGE); } // Development fallback - only for local development console.warn("⚠️ JWT_SECRET not set, using development fallback. Set JWT_SECRET in production!"); @@ -15,7 +26,7 @@ function getJwtSecret(): Uint8Array { } if (secret.length < 32) { - throw new Error("JWT_SECRET must be at least 32 characters long"); + throw new AuthConfigError(JWT_SECRET_TOO_SHORT_MESSAGE); } return new TextEncoder().encode(secret); diff --git a/src/lib/local-auth.ts b/src/lib/local-auth.ts index 80e1afea..07da3b0f 100644 --- a/src/lib/local-auth.ts +++ b/src/lib/local-auth.ts @@ -4,6 +4,7 @@ * shared session/JWT concerns live in `auth.ts`. */ import type { Role } from "@/lib/auth"; +import { AuthConfigError } from "@/lib/auth-errors"; export interface AuthUser { email: string; @@ -11,19 +12,10 @@ export interface AuthUser { role: Role; } -/** - * Raised when local authentication cannot be served because the server is - * missing required config (no ADMIN_PASSWORD). It is a deployment/operator - * error, not a bad-credentials error — the login route turns it into a clear, - * actionable 503 so the operator sees WHAT to fix on the login screen instead - * of a misleading "Invalid email or password". - */ -export class AuthConfigError extends Error { - constructor(message: string) { - super(message); - this.name = "AuthConfigError"; - } -} +// Single-line and module-scoped so bun's line coverage credits it cleanly (it +// under-counts continuation lines of multi-line string concatenation). +const ADMIN_PASSWORD_MISSING_MESSAGE = + "Login is unavailable: this server has no administrator password configured. Set the ADMIN_PASSWORD environment variable and restart the server."; /** * Build the list of accounts that can authenticate against the local provider. @@ -38,7 +30,7 @@ export function getAuthUsers(): AuthUser[] { const adminPassword = process.env.ADMIN_PASSWORD; if (!adminPassword) { - throw new AuthConfigError("ADMIN_PASSWORD is not set"); + throw new AuthConfigError(ADMIN_PASSWORD_MISSING_MESSAGE); } const users: AuthUser[] = [{ email: adminEmail, password: adminPassword, role: "admin" }]; diff --git a/tests/api/auth/login.test.ts b/tests/api/auth/login.test.ts index 613bbe98..5f3b9b09 100644 --- a/tests/api/auth/login.test.ts +++ b/tests/api/auth/login.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect, mock, beforeEach } from "bun:test"; import { createMockRequest, parseResponseJSON } from "../../helpers/mock-next"; +import { AuthConfigError } from "@/lib/auth-errors"; // ─── Mock @/lib/auth BEFORE importing the route ───────────────────────────── // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -169,6 +170,31 @@ describe("POST /api/auth/login", () => { process.env.ADMIN_PASSWORD = origAdminPassword!; }); + test("surfaces a JWT_SECRET config error as a 503 with its message (credentials are valid)", async () => { + // Credentials match, but signing the session fails because JWT_SECRET is + // missing/too short: login() throws AuthConfigError. The route must surface + // that actionable message, not the misleading "Invalid email or password". + const jwtMessage = + "Login is unavailable: the server's JWT_SECRET is not configured. " + + "Set JWT_SECRET (at least 32 characters) and restart the server."; + mockLogin.mockImplementationOnce(async () => { + throw new AuthConfigError(jwtMessage); + }); + + 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; message: string }>(res); + + expect(res.status).toBe(503); + expect(data.success).toBe(false); + expect(data.message).toBe(jwtMessage); + expect(data.message).not.toBe("Invalid email or password"); + }); + test("still authenticates admin when USER_PASSWORD is not set", async () => { const origUserPassword = process.env.USER_PASSWORD; delete process.env.USER_PASSWORD; diff --git a/tests/unit/lib/auth-jwt-config.test.ts b/tests/unit/lib/auth-jwt-config.test.ts new file mode 100644 index 00000000..6844b6d3 --- /dev/null +++ b/tests/unit/lib/auth-jwt-config.test.ts @@ -0,0 +1,52 @@ +import { describe, test, expect, mock, afterEach } from "bun:test"; +import { AuthConfigError } from "@/lib/auth-errors"; + +// auth.ts imports `cookies` from next/headers at module load; stub it so the +// module imports cleanly in the test runtime (signJWT itself never uses it). +mock.module("next/headers", () => ({ + cookies: async () => ({ get: () => undefined, set: () => {}, delete: () => {} }), +})); + +const { signJWT } = await import("@/lib/auth"); + +// NOTE ON ORDERING: getJwtSecret() memoizes the first *successful* result in a +// module-level cache. The throwing cases below never cache, so they are safe in +// any order — but the dev-fallback success case must run LAST, otherwise its +// cached key would mask the throws. This file runs in its own process (see +// tests/run-core.sh), so the cache starts empty. +describe("auth JWT_SECRET config guard", () => { + const origSecret = process.env.JWT_SECRET; + const origNodeEnv = process.env.NODE_ENV; + + afterEach(() => { + setEnv("JWT_SECRET", origSecret); + setEnv("NODE_ENV", origNodeEnv); + }); + + function setEnv(key: string, value: string | undefined): void { + if (value === undefined) delete (process.env as Record)[key]; + else (process.env as Record)[key] = value; + } + + test("throws AuthConfigError when JWT_SECRET is missing in production", async () => { + delete (process.env as Record).JWT_SECRET; + (process.env as Record).NODE_ENV = "production"; + + await expect(signJWT({ role: "admin", username: "admin" })).rejects.toThrow(AuthConfigError); + }); + + test("throws AuthConfigError when JWT_SECRET is shorter than 32 characters", async () => { + (process.env as Record).JWT_SECRET = "too-short"; + (process.env as Record).NODE_ENV = "production"; + + await expect(signJWT({ role: "admin", username: "admin" })).rejects.toThrow(AuthConfigError); + }); + + // Must run last — this is the only case that populates the memoized cache. + test("uses the dev fallback (no throw) when JWT_SECRET is missing outside production", async () => { + delete (process.env as Record).JWT_SECRET; + (process.env as Record).NODE_ENV = "development"; + + await expect(signJWT({ role: "admin", username: "admin" })).resolves.toBeString(); + }); +}); diff --git a/tests/unit/lib/local-auth.test.ts b/tests/unit/lib/local-auth.test.ts index 4c693b2b..40a2ba60 100644 --- a/tests/unit/lib/local-auth.test.ts +++ b/tests/unit/lib/local-auth.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test"; -import { AuthConfigError, getAuthUsers } from "@/lib/local-auth"; +import { AuthConfigError } from "@/lib/auth-errors"; +import { getAuthUsers } from "@/lib/local-auth"; describe("local-auth getAuthUsers()", () => { let origAdminEmail: string | undefined; From af1a50bcaa1f21be38b791318664aaaf8a58998d Mon Sep 17 00:00:00 2001 From: cevheri Date: Wed, 1 Jul 2026 22:41:37 +0300 Subject: [PATCH 3/5] test(auth): restore env vars safely in login tests (Copilot review) 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. --- tests/api/auth/login.test.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/api/auth/login.test.ts b/tests/api/auth/login.test.ts index 5f3b9b09..a1b1a6b2 100644 --- a/tests/api/auth/login.test.ts +++ b/tests/api/auth/login.test.ts @@ -23,6 +23,13 @@ describe("POST /api/auth/login", () => { mockLogin.mockClear(); }); + // Restore an env var to its original state. Deleting on undefined avoids + // setting it to the string "undefined" (which would leak into later tests). + function restore(key: string, value: string | undefined): void { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + test("returns 200 with role admin when admin credentials are provided", async () => { const req = createMockRequest("/api/auth/login", { method: "POST", @@ -167,7 +174,7 @@ describe("POST /api/auth/login", () => { expect(data.message).toContain("ADMIN_PASSWORD"); expect(data.message).not.toBe("Invalid email or password"); - process.env.ADMIN_PASSWORD = origAdminPassword!; + restore("ADMIN_PASSWORD", origAdminPassword); }); test("surfaces a JWT_SECRET config error as a 503 with its message (credentials are valid)", async () => { @@ -211,7 +218,7 @@ describe("POST /api/auth/login", () => { expect(data.success).toBe(true); expect(data.role).toBe("admin"); - process.env.USER_PASSWORD = origUserPassword!; + restore("USER_PASSWORD", origUserPassword); }); test("rejects user login when USER_PASSWORD is not set (account is optional, no default)", async () => { @@ -230,6 +237,6 @@ describe("POST /api/auth/login", () => { expect(data.success).toBe(false); expect(data.message).toBe("Invalid email or password"); - process.env.USER_PASSWORD = origUserPassword!; + restore("USER_PASSWORD", origUserPassword); }); }); From edbd227d8dab63f80f417f8b7f431a8057e51a18 Mon Sep 17 00:00:00 2001 From: cevheri Date: Wed, 1 Jul 2026 22:47:29 +0300 Subject: [PATCH 4/5] chore(package): bump version to 0.9.38 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6c31e7f6..882b5c4b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@libredb/studio", - "version": "0.9.37", + "version": "0.9.38", "private": false, "publishConfig": { "access": "public" From cd745a8ab80c00617037ecaec7f2df4b4377583f Mon Sep 17 00:00:00 2001 From: cevheri Date: Wed, 1 Jul 2026 23:15:42 +0300 Subject: [PATCH 5/5] test(auth): guarantee env restoration via afterEach in login tests (Copilot 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. --- tests/api/auth/login.test.ts | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/tests/api/auth/login.test.ts b/tests/api/auth/login.test.ts index a1b1a6b2..fc4a5afb 100644 --- a/tests/api/auth/login.test.ts +++ b/tests/api/auth/login.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, mock, beforeEach } from "bun:test"; +import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test"; import { createMockRequest, parseResponseJSON } from "../../helpers/mock-next"; import { AuthConfigError } from "@/lib/auth-errors"; @@ -19,16 +19,26 @@ const { POST } = await import("@/app/api/auth/login/route"); // ─── Tests ────────────────────────────────────────────────────────────────── describe("POST /api/auth/login", () => { + // Snapshot the env vars these tests mutate and always restore them in + // afterEach — so a failing assertion mid-test can never leak env state into + // later tests (a plain restore() at the end of a test body would be skipped + // when an earlier expect() throws). + const MUTATED_ENV_KEYS = ["ADMIN_PASSWORD", "USER_PASSWORD"] as const; + const envSnapshot: Record = {}; + beforeEach(() => { mockLogin.mockClear(); + for (const key of MUTATED_ENV_KEYS) envSnapshot[key] = process.env[key]; }); - // Restore an env var to its original state. Deleting on undefined avoids - // setting it to the string "undefined" (which would leak into later tests). - function restore(key: string, value: string | undefined): void { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } + afterEach(() => { + // Delete-on-undefined so an originally-unset var is never set to the literal string "undefined". + for (const key of MUTATED_ENV_KEYS) { + const value = envSnapshot[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); test("returns 200 with role admin when admin credentials are provided", async () => { const req = createMockRequest("/api/auth/login", { @@ -155,7 +165,6 @@ describe("POST /api/auth/login", () => { }); 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", { @@ -173,8 +182,6 @@ describe("POST /api/auth/login", () => { expect(data.success).toBe(false); expect(data.message).toContain("ADMIN_PASSWORD"); expect(data.message).not.toBe("Invalid email or password"); - - restore("ADMIN_PASSWORD", origAdminPassword); }); test("surfaces a JWT_SECRET config error as a 503 with its message (credentials are valid)", async () => { @@ -203,7 +210,6 @@ describe("POST /api/auth/login", () => { }); 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", { @@ -217,12 +223,9 @@ describe("POST /api/auth/login", () => { expect(res.status).toBe(200); expect(data.success).toBe(true); expect(data.role).toBe("admin"); - - restore("USER_PASSWORD", origUserPassword); }); 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", { @@ -236,7 +239,5 @@ describe("POST /api/auth/login", () => { expect(res.status).toBe(401); expect(data.success).toBe(false); expect(data.message).toBe("Invalid email or password"); - - restore("USER_PASSWORD", origUserPassword); }); });