Skip to content
This repository was archived by the owner on Oct 9, 2026. It is now read-only.
Open
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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<br>
*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.<br>
*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

Expand Down
8 changes: 8 additions & 0 deletions apps/server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
6 changes: 6 additions & 0 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand Down
83 changes: 83 additions & 0 deletions apps/server/src/auth/basic-auth-middleware.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
66 changes: 66 additions & 0 deletions apps/server/src/auth/basic-auth-middleware.ts
Original file line number Diff line number Diff line change
@@ -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' });
};
}
6 changes: 6 additions & 0 deletions apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
Loading