MyKindlebox/mykindlebox/server/auth.ts

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: '/' });
}