2026-03-05 00:04:26 -06:00
|
|
|
import { SignJWT, jwtVerify } from "jose";
|
|
|
|
|
import { getConfig } from "./config";
|
|
|
|
|
import {
|
|
|
|
|
verifyPassword,
|
|
|
|
|
getUserByUsername,
|
|
|
|
|
getUserById,
|
|
|
|
|
type User,
|
|
|
|
|
type UserRole,
|
|
|
|
|
} from "./users";
|
2026-03-21 17:03:42 -05:00
|
|
|
import {
|
|
|
|
|
persistRevokedToken,
|
|
|
|
|
isTokenRevoked,
|
|
|
|
|
pruneExpiredRevokedTokens,
|
2026-04-13 17:15:06 -05:00
|
|
|
createSession,
|
|
|
|
|
hashTokenForSession,
|
|
|
|
|
revokeSessionByTokenHash,
|
|
|
|
|
updateSessionActivity,
|
|
|
|
|
pruneExpiredSessions,
|
2026-03-21 17:03:42 -05:00
|
|
|
} from "./db";
|
2026-03-05 00:04:26 -06:00
|
|
|
|
|
|
|
|
const JWT_ISSUER = "overlord-server";
|
|
|
|
|
const JWT_AUDIENCE = "overlord-client";
|
|
|
|
|
|
|
|
|
|
const tokenCache = new Map<
|
|
|
|
|
string,
|
|
|
|
|
{ payload: JWTPayload; timestamp: number }
|
|
|
|
|
>();
|
2026-04-08 21:24:23 -05:00
|
|
|
const TOKEN_CACHE_TTL = 500;
|
2026-03-05 00:04:26 -06:00
|
|
|
|
|
|
|
|
function getSecretKey(): Uint8Array {
|
|
|
|
|
const config = getConfig();
|
|
|
|
|
return new TextEncoder().encode(config.auth.jwtSecret);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 20:50:02 -06:00
|
|
|
export function getSessionTtlHours(): number {
|
|
|
|
|
const configured = Number(getConfig().security.sessionTtlHours) || 168;
|
|
|
|
|
return Math.min(24 * 30, Math.max(1, configured));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getSessionTtlSeconds(): number {
|
|
|
|
|
return getSessionTtlHours() * 60 * 60;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 00:04:26 -06:00
|
|
|
export interface JWTPayload {
|
|
|
|
|
sub: string;
|
|
|
|
|
userId: number;
|
|
|
|
|
role: UserRole;
|
|
|
|
|
iat: number;
|
|
|
|
|
exp: number;
|
|
|
|
|
iss: string;
|
|
|
|
|
aud: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface AuthenticatedUser {
|
|
|
|
|
username: string;
|
|
|
|
|
userId: number;
|
|
|
|
|
role: UserRole;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 17:15:06 -05:00
|
|
|
export async function generateToken(
|
|
|
|
|
user: User,
|
|
|
|
|
sessionMeta?: { ip?: string; userAgent?: string },
|
|
|
|
|
): Promise<string> {
|
2026-03-06 20:50:02 -06:00
|
|
|
const expiration = `${getSessionTtlHours()}h`;
|
2026-03-05 00:04:26 -06:00
|
|
|
const token = await new SignJWT({
|
|
|
|
|
sub: user.username,
|
|
|
|
|
userId: user.id,
|
|
|
|
|
role: user.role,
|
|
|
|
|
})
|
|
|
|
|
.setProtectedHeader({ alg: "HS256" })
|
|
|
|
|
.setIssuedAt()
|
|
|
|
|
.setIssuer(JWT_ISSUER)
|
|
|
|
|
.setAudience(JWT_AUDIENCE)
|
2026-03-06 20:50:02 -06:00
|
|
|
.setExpirationTime(expiration)
|
2026-03-05 00:04:26 -06:00
|
|
|
.sign(getSecretKey());
|
|
|
|
|
|
2026-04-13 17:15:06 -05:00
|
|
|
const tokenHash = hashTokenForSession(token);
|
|
|
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
|
const expiresAt = now + getSessionTtlSeconds();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
createSession({
|
|
|
|
|
id: crypto.randomUUID(),
|
|
|
|
|
userId: user.id,
|
|
|
|
|
tokenHash,
|
|
|
|
|
ip: sessionMeta?.ip || null,
|
|
|
|
|
userAgent: sessionMeta?.userAgent || null,
|
|
|
|
|
createdAt: now,
|
|
|
|
|
expiresAt,
|
|
|
|
|
});
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error("[auth] Failed to create session record:", e);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 00:04:26 -06:00
|
|
|
return token;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function verifyToken(token: string): Promise<JWTPayload | null> {
|
2026-03-21 17:03:42 -05:00
|
|
|
if (isTokenRevoked(token)) {
|
2026-03-05 00:04:26 -06:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const cached = tokenCache.get(token);
|
|
|
|
|
if (cached) {
|
|
|
|
|
const age = Date.now() - cached.timestamp;
|
|
|
|
|
if (age < TOKEN_CACHE_TTL) {
|
|
|
|
|
return cached.payload;
|
|
|
|
|
} else {
|
|
|
|
|
tokenCache.delete(token);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const { payload } = await jwtVerify(token, getSecretKey(), {
|
|
|
|
|
issuer: JWT_ISSUER,
|
|
|
|
|
audience: JWT_AUDIENCE,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const jwtPayload: JWTPayload = {
|
|
|
|
|
sub: payload.sub as string,
|
|
|
|
|
userId: payload.userId as number,
|
|
|
|
|
role: payload.role as UserRole,
|
|
|
|
|
iat: payload.iat as number,
|
|
|
|
|
exp: payload.exp as number,
|
|
|
|
|
iss: payload.iss as string,
|
|
|
|
|
aud: payload.aud as string,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
tokenCache.set(token, { payload: jwtPayload, timestamp: Date.now() });
|
|
|
|
|
|
|
|
|
|
return jwtPayload;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function revokeToken(token: string): void {
|
2026-03-21 17:03:42 -05:00
|
|
|
let expiresAt = Math.floor(Date.now() / 1000) + getSessionTtlSeconds();
|
|
|
|
|
try {
|
|
|
|
|
const parts = token.split(".");
|
|
|
|
|
if (parts.length === 3) {
|
|
|
|
|
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString());
|
|
|
|
|
if (typeof payload.exp === "number") expiresAt = payload.exp;
|
|
|
|
|
}
|
|
|
|
|
} catch { /* keep fallback */ }
|
|
|
|
|
|
|
|
|
|
persistRevokedToken(token, expiresAt);
|
2026-03-05 00:04:26 -06:00
|
|
|
tokenCache.delete(token);
|
2026-04-13 17:15:06 -05:00
|
|
|
|
|
|
|
|
const tokenHash = hashTokenForSession(token);
|
|
|
|
|
revokeSessionByTokenHash(tokenHash);
|
|
|
|
|
|
2026-03-21 17:03:42 -05:00
|
|
|
console.log("[auth] Token revoked (persisted)");
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function cleanupBlacklist(): Promise<void> {
|
2026-03-21 17:03:42 -05:00
|
|
|
const pruned = pruneExpiredRevokedTokens();
|
2026-04-13 17:15:06 -05:00
|
|
|
const prunedSessions = pruneExpiredSessions();
|
2026-03-05 00:04:26 -06:00
|
|
|
|
|
|
|
|
const cacheNow = Date.now();
|
|
|
|
|
let cacheCleared = 0;
|
|
|
|
|
for (const [token, entry] of tokenCache.entries()) {
|
|
|
|
|
if (cacheNow - entry.timestamp > TOKEN_CACHE_TTL * 2) {
|
|
|
|
|
tokenCache.delete(token);
|
|
|
|
|
cacheCleared++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 17:15:06 -05:00
|
|
|
const activityCutoff = cacheNow - SESSION_ACTIVITY_THROTTLE * 2;
|
|
|
|
|
for (const [token, ts] of lastActivityUpdate.entries()) {
|
|
|
|
|
if (ts < activityCutoff) {
|
|
|
|
|
lastActivityUpdate.delete(token);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (pruned > 0 || cacheCleared > 0 || prunedSessions > 0) {
|
2026-03-05 00:04:26 -06:00
|
|
|
console.log(
|
2026-04-13 17:15:06 -05:00
|
|
|
`[auth] Cleaned up ${pruned} expired tokens from blacklist, ${prunedSessions} expired sessions, ${cacheCleared} from cache`,
|
2026-03-05 00:04:26 -06:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setInterval(cleanupBlacklist, 60 * 60 * 1000);
|
|
|
|
|
|
|
|
|
|
export async function authenticateUser(
|
|
|
|
|
username: string,
|
|
|
|
|
password: string,
|
|
|
|
|
): Promise<User | null> {
|
|
|
|
|
return await verifyPassword(username, password);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function extractTokenFromHeader(
|
|
|
|
|
authHeader: string | null,
|
|
|
|
|
): string | null {
|
|
|
|
|
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return authHeader.substring(7);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function extractTokenFromCookie(
|
|
|
|
|
cookieHeader: string | null,
|
|
|
|
|
): string | null {
|
|
|
|
|
if (!cookieHeader) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const cookies = cookieHeader.split(/;\s*/);
|
|
|
|
|
for (const cookie of cookies) {
|
|
|
|
|
const [name, value] = cookie.split("=");
|
|
|
|
|
if (name === "overlord_token") {
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 17:15:06 -05:00
|
|
|
const SESSION_ACTIVITY_THROTTLE = 60_000; // 60s
|
|
|
|
|
const lastActivityUpdate = new Map<string, number>();
|
|
|
|
|
|
2026-03-05 00:04:26 -06:00
|
|
|
export async function authenticateRequest(
|
|
|
|
|
req: Request,
|
|
|
|
|
): Promise<AuthenticatedUser | null> {
|
|
|
|
|
const authHeader = req.headers.get("Authorization");
|
|
|
|
|
let token = extractTokenFromHeader(authHeader);
|
|
|
|
|
|
|
|
|
|
if (!token) {
|
|
|
|
|
const cookieHeader = req.headers.get("Cookie");
|
|
|
|
|
token = extractTokenFromCookie(cookieHeader);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!token) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const payload = await verifyToken(token);
|
|
|
|
|
if (!payload) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const user = getUserById(payload.userId);
|
|
|
|
|
if (!user) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 17:15:06 -05:00
|
|
|
const now = Date.now();
|
|
|
|
|
const lastUpdate = lastActivityUpdate.get(token) || 0;
|
|
|
|
|
if (now - lastUpdate > SESSION_ACTIVITY_THROTTLE) {
|
|
|
|
|
lastActivityUpdate.set(token, now);
|
|
|
|
|
try {
|
|
|
|
|
updateSessionActivity(hashTokenForSession(token));
|
|
|
|
|
} catch { }
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 00:04:26 -06:00
|
|
|
return {
|
|
|
|
|
username: payload.sub,
|
|
|
|
|
userId: payload.userId,
|
2026-03-28 00:12:29 -05:00
|
|
|
role: user.role,
|
2026-03-05 00:04:26 -06:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getUserFromRequest(
|
|
|
|
|
req: Request,
|
|
|
|
|
): Promise<AuthenticatedUser | null> {
|
|
|
|
|
return await authenticateRequest(req);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function extractTokenFromRequest(req: Request): string | null {
|
|
|
|
|
const authHeader = req.headers.get("Authorization");
|
|
|
|
|
let token = extractTokenFromHeader(authHeader);
|
|
|
|
|
|
|
|
|
|
if (!token) {
|
|
|
|
|
const cookieHeader = req.headers.get("Cookie");
|
|
|
|
|
token = extractTokenFromCookie(cookieHeader);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return token;
|
|
|
|
|
}
|