84 lines
2.6 KiB
TypeScript
84 lines
2.6 KiB
TypeScript
import { randomUUID } from 'crypto';
|
|
import { NextFunction, Request, Response } from 'express';
|
|
|
|
// Single-user session-token auth (no OAuth, per tech-stack.md).
|
|
//
|
|
// A single shared password is read from the AUTH_PASSWORD env var. If it's
|
|
// unset/empty, auth is DISABLED — every request passes through — which keeps
|
|
// local dev frictionless. When set, clients must POST it to /api/auth/login to
|
|
// receive an httpOnly session cookie, checked on every protected route.
|
|
//
|
|
// Tokens live in memory only: a server restart invalidates all sessions
|
|
// (you log in again). That's an acceptable trade for a single-user, self-hosted
|
|
// app and avoids persisting a session table.
|
|
|
|
const COOKIE_NAME = 'mkb_session';
|
|
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
|
|
const password = process.env.AUTH_PASSWORD ?? '';
|
|
export const authEnabled = password.length > 0;
|
|
|
|
const sessions = new Set<string>();
|
|
|
|
export function logAuthStatus() {
|
|
if (authEnabled) {
|
|
console.log('Auth: enabled (AUTH_PASSWORD is set).');
|
|
} else {
|
|
console.warn(
|
|
'Auth: DISABLED — AUTH_PASSWORD is not set, so the API is open to anyone ' +
|
|
'who can reach it. Set AUTH_PASSWORD to require a login.'
|
|
);
|
|
}
|
|
}
|
|
|
|
function parseCookies(header: string | undefined): Record<string, string> {
|
|
const out: Record<string, string> = {};
|
|
if (!header) return out;
|
|
for (const part of header.split(';')) {
|
|
const eq = part.indexOf('=');
|
|
if (eq === -1) continue;
|
|
const key = part.slice(0, eq).trim();
|
|
if (key) out[key] = decodeURIComponent(part.slice(eq + 1).trim());
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function readSessionToken(req: Request): string | undefined {
|
|
return parseCookies(req.headers.cookie)[COOKIE_NAME];
|
|
}
|
|
|
|
export function isAuthenticated(req: Request): boolean {
|
|
if (!authEnabled) return true;
|
|
const token = readSessionToken(req);
|
|
return token != null && sessions.has(token);
|
|
}
|
|
|
|
export function requireAuth(req: Request, res: Response, next: NextFunction) {
|
|
if (isAuthenticated(req)) {
|
|
next();
|
|
return;
|
|
}
|
|
res.status(401).json({ error: 'Unauthorized' });
|
|
}
|
|
|
|
export function login(res: Response, attempt: unknown): boolean {
|
|
if (!authEnabled) return true;
|
|
if (typeof attempt !== 'string' || attempt !== password) return false;
|
|
|
|
const token = randomUUID();
|
|
sessions.add(token);
|
|
res.cookie(COOKIE_NAME, token, {
|
|
httpOnly: true,
|
|
sameSite: 'lax',
|
|
path: '/',
|
|
maxAge: SESSION_TTL_MS,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
export function logout(req: Request, res: Response) {
|
|
const token = readSessionToken(req);
|
|
if (token) sessions.delete(token);
|
|
res.clearCookie(COOKIE_NAME, { path: '/' });
|
|
}
|