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/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" diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 4dc2335d..dadd5d8b 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,41 +1,10 @@ import { login } from "@/lib/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"; -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 +20,14 @@ 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 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("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 new file mode 100644 index 00000000..07da3b0f --- /dev/null +++ b/src/lib/local-auth.ts @@ -0,0 +1,45 @@ +/** + * 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"; +import { AuthConfigError } from "@/lib/auth-errors"; + +export interface AuthUser { + email: string; + password: string; + role: Role; +} + +// 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. + * 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_MISSING_MESSAGE); + } + + 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..fc4a5afb 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 { describe, test, expect, mock, beforeEach, afterEach } 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 @@ -18,8 +19,25 @@ 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]; + }); + + 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 () => { @@ -146,8 +164,7 @@ describe("POST /api/auth/login", () => { expect(mockLogin).toHaveBeenCalledWith("user", "user@libredb.org"); }); - test("returns 500 when required password env vars are missing", async () => { - const origAdminPassword = process.env.ADMIN_PASSWORD; + test("returns 503 with an actionable message when ADMIN_PASSWORD is missing", async () => { delete process.env.ADMIN_PASSWORD; const req = createMockRequest("/api/auth/login", { @@ -156,12 +173,71 @@ 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("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 () => { + 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"); + }); + + test("rejects user login when USER_PASSWORD is not set (account is optional, no default)", async () => { + 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"); }); }); 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 new file mode 100644 index 00000000..40a2ba60 --- /dev/null +++ b/tests/unit/lib/local-auth.test.ts @@ -0,0 +1,71 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { AuthConfigError } from "@/lib/auth-errors"; +import { 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"); + }); +});