+
+
+
+
+
+ {isFirstInstall ? "đź”’ First-Install Security Setup" : "đź”’ Authenticated Access Required"}
+
+
+
+ {isFirstInstall ? "Create Administrator Account" : "Sign in to PubScript"}
+
+
+ {isFirstInstall
+ ? "Welcome to PubScript. Create your initial Administrator account to secure your local studio and self-hosted web access."
+ : "Enter your credentials to unlock your manuscripts and studio workspace."}
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {isFirstInstall ? (
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/web/src/components/Topbar.tsx b/apps/web/src/components/Topbar.tsx
index 8466661..70f3e4b 100644
--- a/apps/web/src/components/Topbar.tsx
+++ b/apps/web/src/components/Topbar.tsx
@@ -2,6 +2,7 @@ import { useMemo, useRef, useState, useEffect } from "react";
import { useLocation, NavLink, useNavigate } from "react-router-dom";
import { useSettings } from "../store/settings.js";
import { useProjectStore } from "../store/project-store.js";
+import { useAuth } from "./AuthGate.js";
import { Badge } from "@pubscript/ui";
import type { Entity } from "@pubscript/core";
@@ -30,6 +31,7 @@ function entityName(e: Entity): string {
export function Topbar({ onOpenExport }: TopbarProps) {
const location = useLocation();
const navigate = useNavigate();
+ const { user, logout } = useAuth();
const autoSave = useSettings((s) => s.autoSave);
const aiSidebarOpen = useSettings((s) => s.aiSidebarOpen);
const setSettings = useSettings((s) => s.set);
@@ -186,6 +188,23 @@ export function Topbar({ onOpenExport }: TopbarProps) {
âš™
+
+ {user && (
+
+
+ {`👤 ${user.displayName}`}
+
+ void logout()}
+ style={{ width: "auto", padding: "5px 9px", fontSize: 12.5 }}
+ title="Sign out of PubScript"
+ >
+ Sign out
+
+
+ )}
);
}
diff --git a/server/src/auth.ts b/server/src/auth.ts
index c64a801..69385d5 100644
--- a/server/src/auth.ts
+++ b/server/src/auth.ts
@@ -10,11 +10,30 @@ import { toUser, type UserRow } from "./mapping.js";
const SESSION_COOKIE = "ps_session";
const SESSION_TTL_MS = 30 * 24 * 3600 * 1000; // 30 days
-// ---- password hashing ----
-export const hashPassword = (pw: string): Promise
=> argon2.hash(pw, { type: argon2.argon2id });
-export const verifyPassword = (hash: string, pw: string): Promise => argon2.verify(hash, pw);
+// ---- password hashing (OWASP Argon2id parameters) ----
+const ARGON2_OPTS: argon2.Options = {
+ type: argon2.argon2id,
+ memoryCost: 65536, // 64 MiB
+ timeCost: 3,
+ parallelism: 4,
+};
-// ---- session tokens (store only the SHA-256 hash) ----
+export const hashPassword = (pw: string): Promise =>
+ argon2.hash(pw, ARGON2_OPTS);
+
+export const verifyPassword = (hash: string, pw: string): Promise =>
+ argon2.verify(hash, pw);
+
+// Precomputed dummy hash to prevent timing-based user enumeration on login
+let dummyHashPromise: Promise | null = null;
+function getDummyHash(): Promise {
+ if (!dummyHashPromise) {
+ dummyHashPromise = hashPassword("pubscript-dummy-anti-enumeration-secret");
+ }
+ return dummyHashPromise;
+}
+
+// ---- session tokens (256-bit CSPRNG; store only the SHA-256 hash in SQLite) ----
export function newToken(): string {
return crypto.randomBytes(32).toString("hex");
}
@@ -25,11 +44,16 @@ function tokenHash(token: string): string {
export function createSession(db: Db, userId: string): string {
const token = newToken();
const now = Date.now();
- db.prepare("INSERT INTO sessions (token_hash, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)").run(
+ const nowIso = new Date(now).toISOString();
+ // Prune expired sessions opportunistically
+ db.prepare("DELETE FROM sessions WHERE expires_at <= ?").run(nowIso);
+ db.prepare(
+ "INSERT INTO sessions (token_hash, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)",
+ ).run(
tokenHash(token),
userId,
new Date(now + SESSION_TTL_MS).toISOString(),
- new Date(now).toISOString(),
+ nowIso,
);
return token;
}
@@ -51,7 +75,7 @@ export function userFromToken(db: Db, token: string | null | undefined): UserRow
return row ?? null;
}
-// ---- tiny in-memory fixed-window rate limiter ----
+// ---- fixed-window rate limiter ----
export function makeRateLimiter(windowMs: number, max: number): (key: string) => boolean {
const hits = new Map();
return (key: string): boolean => {
@@ -74,19 +98,48 @@ export function bearerToken(req: FastifyRequest): string | null {
}
export function registerAuthHook(app: FastifyInstance): void {
+ // Purge legacy auto-bootstrapped placeholder user if it is the only user in DB
+ try {
+ const users = app.db
+ .prepare("SELECT id, email FROM users")
+ .all() as Array<{ id: string; email: string }>;
+ if (users.length === 1 && users[0]?.email === "admin@pubscript.local" && !process.env.VITEST) {
+ const legacyId = users[0].id;
+ app.db.prepare("DELETE FROM sessions WHERE user_id = ?").run(legacyId);
+ app.db.prepare("DELETE FROM users WHERE id = ?").run(legacyId);
+ }
+ } catch {
+ /* ignore */
+ }
+
app.addHook("onRequest", async (req) => {
const db = app.db;
- const token = bearerToken(req) ?? (req.cookies[SESSION_COOKIE] as string | undefined) ?? null;
+ const token =
+ bearerToken(req) ?? (req.cookies[SESSION_COOKIE] as string | undefined) ?? null;
const row = userFromToken(db, token);
req.user = row ? toUser(row) : null;
});
}
-export function setSessionCookie(reply: FastifyReply, token: string): void {
+function isSecureRequest(req: FastifyRequest): boolean {
+ if (req.protocol === "https") return true;
+ const proto = req.headers["x-forwarded-proto"];
+ if (typeof proto === "string" && proto.split(",")[0]?.trim().toLowerCase() === "https") {
+ return true;
+ }
+ return false;
+}
+
+export function setSessionCookie(
+ reply: FastifyReply,
+ token: string,
+ req?: FastifyRequest,
+): void {
reply.setCookie(SESSION_COOKIE, token, {
path: "/",
httpOnly: true,
sameSite: "lax",
+ secure: req ? isSecureRequest(req) : false,
maxAge: SESSION_TTL_MS / 1000,
});
}
@@ -100,9 +153,16 @@ export function requireAuth(req: FastifyRequest): void {
}
export function requireAdmin(req: FastifyRequest): void {
requireAuth(req);
- if (req.user!.role !== "admin") throw req.server.httpErrors.create(403, { error: "forbidden" });
+ if (req.user!.role !== "admin") {
+ throw req.server.httpErrors.create(403, { error: "forbidden" });
+ }
}
-export function canAccessProject(db: Db, user: User, projectId: string, needWrite = false): boolean {
+export function canAccessProject(
+ db: Db,
+ user: User,
+ projectId: string,
+ needWrite = false,
+): boolean {
const proj = db.prepare("SELECT owner_id FROM projects WHERE id = ?").get(projectId) as
| { owner_id: string }
| undefined;
@@ -116,24 +176,85 @@ export function canAccessProject(db: Db, user: User, projectId: string, needWrit
}
// ---- auth routes ----
-const registerSchema = z.object({
- email: z.string().email(),
- password: z.string().min(8),
- displayName: z.string().min(1).max(80),
+const setupSchema = z.object({
+ email: z.string().trim().toLowerCase().email(),
+ password: z.string().min(8).max(256),
+ displayName: z.string().trim().min(1).max(80),
+});
+const loginSchema = z.object({
+ email: z.string().trim().toLowerCase().email(),
+ password: z.string().min(1).max(256),
+});
+const changePasswordSchema = z.object({
+ currentPassword: z.string().min(1),
+ newPassword: z.string().min(8).max(256),
});
-const loginSchema = z.object({ email: z.string().email(), password: z.string() });
export function registerAuthRoutes(app: FastifyInstance): void {
const authLimiter = makeRateLimiter(60_000, 10); // 10 auth attempts / min / IP
- app.post("/api/auth/register", async (req, reply) => {
- if (!authLimiter(req.ip)) throw app.httpErrors.create(429, { error: "rate_limited" });
- const body = registerSchema.parse(req.body);
+ // Check whether the instance has been initialized with an admin user and whether the caller is authenticated
+ app.get("/api/auth/status", async (req) => {
+ const count = (
+ app.db.prepare("SELECT COUNT(*) AS n FROM users").get() as { n: number }
+ ).n;
+ return {
+ initialized: count > 0,
+ authenticated: Boolean(req.user),
+ user: req.user ?? null,
+ };
+ });
+
+ // First-install administrator setup (only permitted when 0 users exist in DB)
+ app.post("/api/auth/setup", async (req, reply) => {
+ if (!authLimiter(req.ip)) {
+ throw app.httpErrors.create(429, { error: "rate_limited" });
+ }
const db = app.db;
+ const userCount = (
+ db.prepare("SELECT COUNT(*) AS n FROM users").get() as { n: number }
+ ).n;
+ if (userCount > 0) {
+ throw app.httpErrors.create(403, {
+ error: "already_initialized",
+ message: "Administrator account has already been created. Please sign in.",
+ });
+ }
+
+ const body = setupSchema.parse(req.body);
+ const id = ulid();
+ const now = new Date().toISOString();
+ const passHash = await hashPassword(body.password);
+
+ db.prepare(
+ "INSERT INTO users (id, email, pass_hash, display_name, role, disabled, created_at) VALUES (?, ?, ?, ?, 'admin', 0, ?)",
+ ).run(id, body.email, passHash, body.displayName, now);
+ db.prepare(
+ "INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'auth.setup', ?)",
+ ).run(ulid(), now, id, JSON.stringify({ email: body.email, ip: req.ip }));
+
+ const row = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow;
+ const token = createSession(db, id);
+ setSessionCookie(reply, token, req);
+ return { token, user: toUser(row) };
+ });
+
+ // Register: creates first admin if DB has 0 users; otherwise requires an authenticated Admin
+ app.post("/api/auth/register", async (req, reply) => {
+ if (!authLimiter(req.ip)) {
+ throw app.httpErrors.create(429, { error: "rate_limited" });
+ }
+ const db = app.db;
+ const isFirst =
+ (db.prepare("SELECT COUNT(*) AS n FROM users").get() as { n: number }).n === 0;
+ if (!isFirst) {
+ requireAdmin(req);
+ }
+
+ const body = setupSchema.parse(req.body);
const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(body.email);
if (existing) throw app.httpErrors.create(409, { error: "email_taken" });
- const isFirst = (db.prepare("SELECT COUNT(*) AS n FROM users").get() as { n: number }).n === 0;
const role = isFirst ? "admin" : "author";
const id = ulid();
const now = new Date().toISOString();
@@ -141,49 +262,107 @@ export function registerAuthRoutes(app: FastifyInstance): void {
db.prepare(
"INSERT INTO users (id, email, pass_hash, display_name, role, disabled, created_at) VALUES (?, ?, ?, ?, ?, 0, ?)",
).run(id, body.email, passHash, body.displayName, role, now);
- db.prepare("INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'auth.register', ?)").run(
- ulid(),
- now,
- id,
- JSON.stringify({ email: body.email, first: isFirst }),
- );
+ db.prepare(
+ "INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'auth.register', ?)",
+ ).run(ulid(), now, id, JSON.stringify({ email: body.email, first: isFirst }));
const row = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow;
const token = createSession(db, id);
- setSessionCookie(reply, token);
+ setSessionCookie(reply, token, req);
return { token, user: toUser(row) };
});
app.post("/api/auth/login", async (req, reply) => {
- if (!authLimiter(req.ip)) throw app.httpErrors.create(429, { error: "rate_limited" });
const body = loginSchema.parse(req.body);
+ const rateKey = `${req.ip}:${body.email}`;
+ if (!authLimiter(rateKey)) {
+ throw app.httpErrors.create(429, { error: "rate_limited" });
+ }
const db = app.db;
- const row = db.prepare("SELECT * FROM users WHERE email = ?").get(body.email) as UserRow | undefined;
- if (!row || row.disabled || !(await verifyPassword(row.pass_hash, body.password))) {
+ const row = db
+ .prepare("SELECT * FROM users WHERE LOWER(email) = ?")
+ .get(body.email) as UserRow | undefined;
+
+ if (!row) {
+ await verifyPassword(await getDummyHash(), body.password);
throw app.httpErrors.create(401, { error: "bad_credentials" });
}
+
+ const valid = await verifyPassword(row.pass_hash, body.password);
+ if (!valid || row.disabled) {
+ db.prepare(
+ "INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'auth.login_failed', ?)",
+ ).run(
+ ulid(),
+ new Date().toISOString(),
+ row.id,
+ JSON.stringify({ email: body.email, ip: req.ip }),
+ );
+ throw app.httpErrors.create(401, { error: "bad_credentials" });
+ }
+
+ db.prepare(
+ "INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'auth.login', ?)",
+ ).run(
+ ulid(),
+ new Date().toISOString(),
+ row.id,
+ JSON.stringify({ email: body.email, ip: req.ip }),
+ );
+
const token = createSession(db, row.id);
- setSessionCookie(reply, token);
+ setSessionCookie(reply, token, req);
return { token, user: toUser(row) };
});
app.post("/api/auth/logout", async (req, reply) => {
- const token = bearerToken(req) ?? (req.cookies[SESSION_COOKIE] as string | undefined) ?? null;
+ const token =
+ bearerToken(req) ?? (req.cookies[SESSION_COOKIE] as string | undefined) ?? null;
if (token) destroySession(app.db, token);
clearSessionCookie(reply);
return { ok: true };
});
+ app.post("/api/auth/change-password", async (req, reply) => {
+ requireAuth(req);
+ const body = changePasswordSchema.parse(req.body);
+ const db = app.db;
+ const row = db
+ .prepare("SELECT * FROM users WHERE id = ?")
+ .get(req.user!.id) as UserRow | undefined;
+ if (!row || !(await verifyPassword(row.pass_hash, body.currentPassword))) {
+ throw app.httpErrors.create(401, { error: "bad_credentials" });
+ }
+ const nextHash = await hashPassword(body.newPassword);
+ db.prepare("UPDATE users SET pass_hash = ? WHERE id = ?").run(nextHash, row.id);
+ // Revoke all existing sessions for this user and issue a fresh one
+ db.prepare("DELETE FROM sessions WHERE user_id = ?").run(row.id);
+ const token = createSession(db, row.id);
+ setSessionCookie(reply, token, req);
+ db.prepare(
+ "INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'auth.password_change', ?)",
+ ).run(ulid(), new Date().toISOString(), row.id, JSON.stringify({ ip: req.ip }));
+ return { ok: true, token };
+ });
+
app.get("/api/auth/me", async (req) => {
requireAuth(req);
return { user: req.user };
});
- // Bootstrap or sign into the local admin account when no admin session is active yet.
- app.post("/api/auth/bootstrap-admin", async (_req, reply) => {
+ // Test-only bootstrap helper (disabled in production once initialized)
+ app.post("/api/auth/bootstrap-admin", async (req, reply) => {
+ if (!process.env.VITEST && process.env.NODE_ENV !== "test") {
+ throw app.httpErrors.create(403, {
+ error: "bootstrap_disabled",
+ message: "Automatic bootstrap is disabled. Use /api/auth/setup or /api/auth/login.",
+ });
+ }
const db = app.db;
let adminRow = db
- .prepare("SELECT * FROM users WHERE role = 'admin' AND disabled = 0 ORDER BY created_at ASC LIMIT 1")
+ .prepare(
+ "SELECT * FROM users WHERE role = 'admin' AND disabled = 0 ORDER BY created_at ASC LIMIT 1",
+ )
.get() as UserRow | undefined;
if (!adminRow) {
@@ -195,17 +374,11 @@ export function registerAuthRoutes(app: FastifyInstance): void {
db.prepare(
"INSERT INTO users (id, email, pass_hash, display_name, role, disabled, created_at) VALUES (?, ?, ?, ?, 'admin', 0, ?)",
).run(id, email, passHash, "Local Admin", now);
- db.prepare("INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'admin.bootstrap', ?)").run(
- ulid(),
- now,
- id,
- JSON.stringify({ email }),
- );
adminRow = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow;
}
const token = createSession(db, adminRow.id);
- setSessionCookie(reply, token);
+ setSessionCookie(reply, token, req);
return { token, user: toUser(adminRow) };
});
}
diff --git a/server/src/config.ts b/server/src/config.ts
index 01acf56..87e75dd 100644
--- a/server/src/config.ts
+++ b/server/src/config.ts
@@ -33,7 +33,12 @@ export interface ServerConfig {
}
export const config: ServerConfig = {
- dbPath: env("PUBSCRIPT_DB", "./data/pubscript.sqlite"),
+ dbPath: env(
+ "PUBSCRIPT_DB",
+ process.env.VITEST || process.env.NODE_ENV === "test"
+ ? ":memory:"
+ : "./data/pubscript.sqlite",
+ ),
port: envInt("PORT", 8080),
origin: env("PUBSCRIPT_ORIGIN", "http://localhost:5173"),
adminEmail: env("PUBSCRIPT_ADMIN_EMAIL", "admin@pubscript.local"),
diff --git a/server/src/index.ts b/server/src/index.ts
index e72778e..3698ec5 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -20,7 +20,13 @@ import websocket from "@fastify/websocket";
import { ZodError } from "zod";
import { config } from "./config.js";
import { openDb, ensureAdmin } from "./db.js";
-import { hashPassword, registerAuthHook, registerAuthRoutes } from "./auth.js";
+import {
+ hashPassword,
+ registerAuthHook,
+ registerAuthRoutes,
+ requireAdmin,
+ requireAuth,
+} from "./auth.js";
import { registerRoutes } from "./routes.js";
import { registerWs } from "./ws.js";
import { registerAdminRoutes } from "./admin.js";
@@ -201,6 +207,11 @@ export async function buildApp() {
}
app.addHook("onRequest", async (req, reply) => {
+ reply.header("X-Content-Type-Options", "nosniff");
+ reply.header("X-Frame-Options", "SAMEORIGIN");
+ reply.header("Referrer-Policy", "strict-origin-when-cross-origin");
+ reply.header("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
+
const cfg = loadPersistedServerSysConfig();
const forwardedHost = (req.headers["x-forwarded-host"] as string | undefined) || req.headers.host;
if (!isRequestHostAllowed(forwardedHost, cfg.allowedHosts)) {
@@ -322,7 +333,8 @@ export async function buildApp() {
}
}
- app.get("/api/system/server-config", async () => {
+ app.get("/api/system/server-config", async (req) => {
+ requireAuth(req);
const persisted = loadPersistedServerSysConfig();
return {
ok: true,
@@ -342,6 +354,7 @@ export async function buildApp() {
});
app.post("/api/system/server-config", async (req, reply) => {
+ requireAdmin(req);
const body = (req.body ?? {}) as Partial;
const current = loadPersistedServerSysConfig();
const nextPort =
@@ -397,6 +410,7 @@ export async function buildApp() {
});
app.get("/api/system/list-dirs", async (req) => {
+ requireAuth(req);
const query = (req.query ?? {}) as { path?: string };
const rawTarget = query.path ? expandTilde(query.path) : homedir();
const target = existsSync(rawTarget) ? rawTarget : homedir();
@@ -429,6 +443,7 @@ export async function buildApp() {
});
app.post("/api/system/save-export", async (req, reply) => {
+ requireAuth(req);
const body = (req.body ?? {}) as {
directory?: string;
fileName?: string;
diff --git a/server/src/server.test.ts b/server/src/server.test.ts
index ebcfb1f..01223e2 100644
--- a/server/src/server.test.ts
+++ b/server/src/server.test.ts
@@ -1,4 +1,5 @@
import { describe, it, expect, afterEach } from "vitest";
+process.env.PUBSCRIPT_DB = ":memory:";
import { buildApp } from "./index.js";
afterEach(async () => {
@@ -96,4 +97,79 @@ describe("admin endpoints", () => {
await app.close();
}
});
+
+ it("enforces first-install setup lock, login authentication, and protected system routes", async () => {
+ const app = await buildApp();
+ try {
+ // Unauthenticated system config request must be rejected with 401
+ const unauthSys = await app.inject({ method: "GET", url: "/api/system/server-config" });
+ expect(unauthSys.statusCode).toBe(401);
+
+ // Clear any users so we test fresh first-install state
+ app.db.prepare("DELETE FROM sessions").run();
+ app.db.prepare("DELETE FROM project_shares").run();
+ app.db.prepare("DELETE FROM projects").run();
+ app.db.prepare("DELETE FROM users").run();
+
+ const statusBefore = await app.inject({ method: "GET", url: "/api/auth/status" });
+ expect(statusBefore.statusCode).toBe(200);
+ expect(statusBefore.json()).toEqual({
+ initialized: false,
+ authenticated: false,
+ user: null,
+ });
+
+ const setupRes = await app.inject({
+ method: "POST",
+ url: "/api/auth/setup",
+ payload: {
+ email: "owner@example.com",
+ displayName: "Studio Owner",
+ password: "StrongPassword!2026",
+ },
+ });
+ expect(setupRes.statusCode).toBe(200);
+ const { token, user } = setupRes.json() as { token: string; user: { role: string } };
+ expect(token).toBeTruthy();
+ expect(user.role).toBe("admin");
+
+ // Second setup attempt must be forbidden once initialized
+ const secondSetup = await app.inject({
+ method: "POST",
+ url: "/api/auth/setup",
+ payload: {
+ email: "attacker@example.com",
+ displayName: "Attacker",
+ password: "StrongPassword!2026",
+ },
+ });
+ expect(secondSetup.statusCode).toBe(403);
+
+ // Login with wrong password must fail with 401
+ const badLogin = await app.inject({
+ method: "POST",
+ url: "/api/auth/login",
+ payload: { email: "owner@example.com", password: "wrongpassword" },
+ });
+ expect(badLogin.statusCode).toBe(401);
+
+ // Login with valid password succeeds
+ const goodLogin = await app.inject({
+ method: "POST",
+ url: "/api/auth/login",
+ payload: { email: "owner@example.com", password: "StrongPassword!2026" },
+ });
+ expect(goodLogin.statusCode).toBe(200);
+
+ // System config works with valid auth token
+ const authSys = await app.inject({
+ method: "GET",
+ url: "/api/system/server-config",
+ headers: { authorization: `Bearer ${token}` },
+ });
+ expect(authSys.statusCode).toBe(200);
+ } finally {
+ await app.close();
+ }
+ });
});