Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions DOCKERHUB.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ docker run -d \

Open <http://localhost:3000> 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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions docker-compose.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)}

Expand Down
2 changes: 1 addition & 1 deletion docs/API_DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@libredb/studio",
"version": "0.9.37",
"version": "0.9.38",
"private": false,
"publishConfig": {
"access": "public"
Expand Down
43 changes: 10 additions & 33 deletions src/app/api/auth/login/route.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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" });
}
}
22 changes: 22 additions & 0 deletions src/lib/auth-errors.ts
Original file line number Diff line number Diff line change
@@ -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";
}
}
15 changes: 13 additions & 2 deletions src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,32 @@
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!");
return new TextEncoder().encode("development-fallback-secret-32ch");
}

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);
Expand Down
45 changes: 45 additions & 0 deletions src/lib/local-auth.ts
Original file line number Diff line number Diff line change
@@ -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;
}
92 changes: 84 additions & 8 deletions tests/api/auth/login.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<string, string | undefined> = {};

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 () => {
Expand Down Expand Up @@ -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", {
Expand All @@ -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", {
Comment on lines +228 to +231
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");
});
});
Loading
Loading