diff --git a/README.md b/README.md
index 0873647..b1f0414 100644
--- a/README.md
+++ b/README.md
@@ -84,6 +84,17 @@ KoInsight can be configured using the following environment variables:
*Default:* `100`
- `DATA_PATH`: Path to the directory where KoInsight data (such as stats or uploads) will be stored.
*Default:* `../../../data` or `/app/data` in Docker.
+- `AUTH_USERNAME` / `AUTH_PASSWORD`: Enable HTTP Basic Auth for the web UI and API. Both must be set.
+ *Default:* unset (no authentication)
+
+## Authentication
+KoInsight has no authentication by default. If you expose it to the internet, set `AUTH_USERNAME` and `AUTH_PASSWORD`.
+
+- **Web UI:** your browser will ask for the username and password.
+- **KOReader plugin:** put the credentials in the server URL, e.g. `https://username:password@koinsight.example.com`. URL-encode any special characters in the password.
+- **KOReader progress sync:** keeps working with its own KoSync username and password. Registering a new KoSync user requires the Basic Auth credentials, so include them in the progress sync server URL when registering.
+
+Always use HTTPS when authentication is enabled, since Basic Auth sends credentials with every request.
# Usage
diff --git a/apps/server/.env.example b/apps/server/.env.example
index 2ecbe65..e71684d 100644
--- a/apps/server/.env.example
+++ b/apps/server/.env.example
@@ -32,3 +32,11 @@ MAX_FILE_SIZE_MB=100
# OPENAI_API_KEY=
# OPENAI_PROJECT_ID=
# OPENAI_ORG_ID=
+
+# ---------------------------------------------------------------------------
+# Optional - HTTP Basic Auth for the web UI and API. Enabled only when both
+# are set. The KOReader plugin authenticates via the server URL:
+# https://username:password@koinsight.example.com
+# ---------------------------------------------------------------------------
+# AUTH_USERNAME=
+# AUTH_PASSWORD=
diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts
index 283079e..20d455a 100644
--- a/apps/server/src/app.ts
+++ b/apps/server/src/app.ts
@@ -4,6 +4,7 @@ import { Server } from 'http';
import morgan from 'morgan';
import path from 'path';
import { openAiRouter } from './ai/open-ai-router';
+import { basicAuth } from './auth/basic-auth-middleware';
import { booksRouter } from './books/books-router';
import { appConfig } from './config';
import { devicesRouter } from './devices/devices-router';
@@ -26,6 +27,11 @@ async function setupServer() {
app.use(cors({ origin: '*' }));
// }
+ if (appConfig.auth) {
+ app.use(basicAuth(appConfig.auth));
+ console.info('Basic Auth enabled');
+ }
+
app.use('/', kosyncRouter); // Needs to be mounted at root to follow KoSync API
app.use('/api/plugin', kopluginRouter);
app.use('/api/devices', devicesRouter);
diff --git a/apps/server/src/auth/basic-auth-middleware.test.ts b/apps/server/src/auth/basic-auth-middleware.test.ts
new file mode 100644
index 0000000..1bbade5
--- /dev/null
+++ b/apps/server/src/auth/basic-auth-middleware.test.ts
@@ -0,0 +1,83 @@
+import express from 'express';
+import request from 'supertest';
+import { basicAuth } from './basic-auth-middleware';
+
+describe('basicAuth', () => {
+ const app = express();
+ app.use(basicAuth({ username: 'reader', password: 's3cret' }));
+ app.all(/.*/, (_req, res) => {
+ res.status(200).json({ ok: true });
+ });
+
+ it('allows requests with valid credentials', async () => {
+ const response = await request(app).get('/api/books').auth('reader', 's3cret');
+
+ expect(response.status).toBe(200);
+ });
+
+ it('rejects requests without credentials and prompts the browser', async () => {
+ const response = await request(app).get('/api/books');
+
+ expect(response.status).toBe(401);
+ expect(response.headers['www-authenticate']).toBe('Basic realm="KoInsight", charset="UTF-8"');
+ expect(response.body).toEqual({ error: 'Unauthorized' });
+ });
+
+ it('rejects a wrong password', async () => {
+ const response = await request(app).get('/api/books').auth('reader', 'wrong');
+
+ expect(response.status).toBe(401);
+ });
+
+ it('rejects a wrong username', async () => {
+ const response = await request(app).get('/api/books').auth('someone', 's3cret');
+
+ expect(response.status).toBe(401);
+ });
+
+ it('rejects a malformed authorization header', async () => {
+ const response = await request(app)
+ .get('/api/books')
+ .set('Authorization', 'Basic bm9jb2xvbg==');
+
+ expect(response.status).toBe(401);
+ });
+
+ it('allows passwords containing colons', async () => {
+ const colonApp = express();
+ colonApp.use(basicAuth({ username: 'reader', password: 'a:b:c' }));
+ colonApp.get('/', (_req, res) => {
+ res.status(200).json({ ok: true });
+ });
+
+ const response = await request(colonApp).get('/').auth('reader', 'a:b:c');
+
+ expect(response.status).toBe(200);
+ });
+
+ it('protects the web UI and plugin upload endpoints', async () => {
+ for (const path of ['/', '/api/plugin/import', '/api/plugin/download']) {
+ const response = await request(app).get(path);
+ expect(response.status).toBe(401);
+ }
+ });
+
+ it.each([
+ ['GET', '/users/auth'],
+ ['PUT', '/syncs/progress'],
+ ['GET', '/syncs/progress/abc123'],
+ ])('leaves self-authenticated KoSync endpoint %s %s open', async (method, path) => {
+ const response = await request(app)[method.toLowerCase() as 'get' | 'put'](path);
+
+ expect(response.status).toBe(200);
+ });
+
+ it.each([
+ ['POST', '/users/create'],
+ ['GET', '/syncs/progress'],
+ ])('protects KoSync endpoint %s %s that has no auth of its own', async (method, path) => {
+ const response = await request(app)[method.toLowerCase() as 'get' | 'post'](path);
+
+ expect(response.status).toBe(401);
+ });
+});
diff --git a/apps/server/src/auth/basic-auth-middleware.ts b/apps/server/src/auth/basic-auth-middleware.ts
new file mode 100644
index 0000000..e6c6fa0
--- /dev/null
+++ b/apps/server/src/auth/basic-auth-middleware.ts
@@ -0,0 +1,66 @@
+import { createHash, timingSafeEqual } from 'crypto';
+import { NextFunction, Request, Response } from 'express';
+
+type Credentials = { username: string; password: string };
+
+/**
+ * KoSync endpoints that authenticate with their own x-auth-user / x-auth-key
+ * headers. They are left open so the built-in KOReader progress sync keeps working.
+ */
+const KOSYNC_SELF_AUTHENTICATED = [
+ { method: 'GET', pattern: /^\/users\/auth\/?$/ },
+ { method: 'PUT', pattern: /^\/syncs\/progress\/?$/ },
+ { method: 'GET', pattern: /^\/syncs\/progress\/[^/]+\/?$/ },
+];
+
+// Hashing first gives equal-length buffers, so the comparison is constant-time
+function safeEqual(a: string, b: string): boolean {
+ const hash = (value: string) => createHash('sha256').update(value).digest();
+ return timingSafeEqual(hash(a), hash(b));
+}
+
+function parseBasicAuth(header: string | undefined): Credentials | null {
+ if (!header?.startsWith('Basic ')) {
+ return null;
+ }
+
+ const decoded = Buffer.from(header.slice('Basic '.length), 'base64').toString('utf8');
+ const separator = decoded.indexOf(':');
+ if (separator === -1) {
+ return null;
+ }
+
+ return { username: decoded.slice(0, separator), password: decoded.slice(separator + 1) };
+}
+
+function isKosyncSelfAuthenticated(req: Request): boolean {
+ return KOSYNC_SELF_AUTHENTICATED.some(
+ ({ method, pattern }) => req.method === method && pattern.test(req.path)
+ );
+}
+
+/**
+ * Protects the web UI and API with HTTP Basic Auth.
+ * The KOReader plugin authenticates by embedding the credentials in the server URL,
+ * e.g. https://user:password@koinsight.example.com
+ */
+export function basicAuth(expected: Credentials) {
+ return (req: Request, res: Response, next: NextFunction) => {
+ if (isKosyncSelfAuthenticated(req)) {
+ next();
+ return;
+ }
+
+ const credentials = parseBasicAuth(req.header('authorization'));
+ const usernameMatches = safeEqual(credentials?.username ?? '', expected.username);
+ const passwordMatches = safeEqual(credentials?.password ?? '', expected.password);
+
+ if (credentials && usernameMatches && passwordMatches) {
+ next();
+ return;
+ }
+
+ res.set('WWW-Authenticate', 'Basic realm="KoInsight", charset="UTF-8"');
+ res.status(401).json({ error: 'Unauthorized' });
+ };
+}
diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts
index f81d7f5..cdbe173 100644
--- a/apps/server/src/config.ts
+++ b/apps/server/src/config.ts
@@ -25,6 +25,12 @@ export const appConfig = {
maxFileSizeMegaBytes: MAX_FILE_SIZE_MB,
},
+ // Basic Auth is enabled only when both are set
+ auth:
+ process.env.AUTH_USERNAME && process.env.AUTH_PASSWORD
+ ? { username: process.env.AUTH_USERNAME, password: process.env.AUTH_PASSWORD }
+ : null,
+
db: {
dev: path.resolve(DATA_PATH, 'dev.sqlite3'),
prod: path.resolve(DATA_PATH, 'prod.sqlite3'),