Files
lidify/backend/src/middleware/auth.ts
T
Your Name cc8d0f6969 Release v1.3.0: Multi-source downloads, audio analyzer resilience, mobile improvements
Major Features:
- Multi-source download system (Soulseek/Lidarr with fallback)
- Configurable enrichment speed control (1-5x)
- Mobile touch drag support for seek sliders
- iOS PWA media controls (Control Center, Lock Screen)
- Artist name alias resolution via Last.fm
- Circuit breaker pattern for audio analysis

Critical Fixes:
- Audio analyzer stability (non-ASCII, BrokenProcessPool, OOM)
- Discovery system race conditions and import failures
- Radio decade categorization using originalYear
- LastFM API response normalization
- Mood bucket infinite loop prevention

Security:
- Bull Board admin authentication
- Lidarr webhook signature verification
- JWT token expiration and refresh
- Encryption key validation on startup

Closes #2, #6, #9, #13, #21, #26, #31, #34, #35, #37, #40, #43
2026-01-06 20:07:33 -06:00

312 lines
9.7 KiB
TypeScript

import { Request, Response, NextFunction } from "express";
import { logger } from "../utils/logger";
import { prisma } from "../utils/db";
import jwt from "jsonwebtoken";
// JWT_SECRET is required - SESSION_SECRET is used as fallback since docker-entrypoint.sh generates it
const JWT_SECRET = process.env.JWT_SECRET || process.env.SESSION_SECRET;
if (!JWT_SECRET) {
throw new Error(
"JWT_SECRET or SESSION_SECRET environment variable is required for authentication"
);
}
// Type assertion after validation - JWT_SECRET is guaranteed to be a string
const JWT_SECRET_VALIDATED: string = JWT_SECRET;
declare global {
namespace Express {
interface Request {
user?: {
id: string;
username: string;
role: string;
};
}
}
}
export interface AuthenticatedRequest extends Request {
user: {
id: string;
username: string;
role: string;
};
}
export interface JWTPayload {
userId: string;
username: string;
role: string;
tokenVersion?: number;
type?: string;
}
export function generateToken(user: {
id: string;
username: string;
role: string;
tokenVersion: number;
}): string {
return jwt.sign(
{
userId: user.id,
username: user.username,
role: user.role,
tokenVersion: user.tokenVersion
},
JWT_SECRET_VALIDATED,
{ expiresIn: "24h" }
);
}
export function generateRefreshToken(user: {
id: string;
tokenVersion: number;
}): string {
return jwt.sign(
{
userId: user.id,
tokenVersion: user.tokenVersion,
type: "refresh"
},
JWT_SECRET_VALIDATED,
{ expiresIn: "30d" }
);
}
/**
* Helper function to authenticate a request using session, API key, or JWT
* @param req Express request object
* @param checkQueryToken Whether to check for token in query params (for streaming)
* @returns User object if authenticated, null otherwise
*/
async function authenticateRequest(
req: Request,
checkQueryToken: boolean = false
): Promise<{ id: string; username: string; role: string } | null> {
// Check session-based auth
if (req.session?.userId) {
try {
const user = await prisma.user.findUnique({
where: { id: req.session.userId },
select: { id: true, username: true, role: true },
});
if (user) return user;
} catch (error) {
logger.error("Session auth error:", error);
}
}
// Check for API key in X-API-Key header
const apiKey = req.headers["x-api-key"] as string;
if (apiKey) {
try {
const apiKeyRecord = await prisma.apiKey.findUnique({
where: { key: apiKey },
include: {
user: { select: { id: true, username: true, role: true } },
},
});
if (apiKeyRecord && apiKeyRecord.user) {
// Update last used timestamp (async, don't block)
prisma.apiKey
.update({
where: { id: apiKeyRecord.id },
data: { lastUsed: new Date() },
})
.catch(() => {});
return apiKeyRecord.user;
}
} catch (error) {
logger.error("API key auth error:", error);
}
}
// Check for token in query param (for streaming URLs)
if (checkQueryToken) {
const tokenParam = req.query.token as string;
if (tokenParam) {
try {
const decoded = jwt.verify(
tokenParam,
JWT_SECRET_VALIDATED
) as unknown as JWTPayload;
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: { id: true, username: true, role: true, tokenVersion: true },
});
if (user) {
// Validate tokenVersion - reject if password was changed
if (decoded.tokenVersion === undefined || decoded.tokenVersion !== user.tokenVersion) {
return null;
}
return { id: user.id, username: user.username, role: user.role };
}
} catch (error) {
// Token invalid, try other methods
}
}
}
// Check JWT token in Authorization header
const authHeader = req.headers.authorization;
const token = authHeader?.startsWith("Bearer ")
? authHeader.substring(7)
: null;
if (token) {
try {
const decoded = jwt.verify(token, JWT_SECRET_VALIDATED) as unknown as JWTPayload;
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: { id: true, username: true, role: true, tokenVersion: true },
});
if (user) {
// Validate tokenVersion - reject if password was changed
if (decoded.tokenVersion === undefined || decoded.tokenVersion !== user.tokenVersion) {
return null;
}
return { id: user.id, username: user.username, role: user.role };
}
} catch (error) {
// Token invalid
}
}
return null;
}
export async function requireAuth(
req: Request,
res: Response,
next: NextFunction
) {
const user = await authenticateRequest(req, false);
if (user) {
req.user = user;
return next();
}
return res.status(401).json({ error: "Not authenticated" });
}
export async function requireAdmin(
req: Request,
res: Response,
next: NextFunction
) {
if (!req.user || req.user.role !== "admin") {
return res.status(403).json({ error: "Admin access required" });
}
next();
}
// For streaming URLs that may use query params or need special handling
export async function requireAuthOrToken(
req: Request,
res: Response,
next: NextFunction
) {
// First, check session-based auth (primary method for web)
if (req.session?.userId) {
try {
const user = await prisma.user.findUnique({
where: { id: req.session.userId },
select: { id: true, username: true, role: true },
});
if (user) {
req.user = user;
return next();
}
} catch (error) {
logger.error("Session auth error:", error);
}
}
// Check for API key in X-API-Key header (for mobile/external apps)
const apiKey = req.headers["x-api-key"] as string;
if (apiKey) {
try {
const apiKeyRecord = await prisma.apiKey.findUnique({
where: { key: apiKey },
include: {
user: { select: { id: true, username: true, role: true } },
},
});
if (apiKeyRecord && apiKeyRecord.user) {
// Update last used timestamp (async, don't block)
prisma.apiKey
.update({
where: { id: apiKeyRecord.id },
data: { lastUsed: new Date() },
})
.catch(() => {}); // Ignore errors on lastUsed update
req.user = apiKeyRecord.user;
return next();
}
} catch (error) {
logger.error("API key auth error:", error);
}
}
// Check for token in query param (for streaming URLs from audio elements)
const tokenParam = req.query.token as string;
if (tokenParam) {
try {
const decoded = jwt.verify(tokenParam, JWT_SECRET_VALIDATED) as unknown as JWTPayload;
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: { id: true, username: true, role: true, tokenVersion: true },
});
if (user) {
// Validate tokenVersion - reject if password was changed
if (decoded.tokenVersion === undefined || decoded.tokenVersion !== user.tokenVersion) {
// Token was issued before password change, reject
} else {
req.user = { id: user.id, username: user.username, role: user.role };
return next();
}
}
} catch (error) {
// Token invalid, try other methods
}
}
// Fallback: check JWT token in Authorization header
const authHeader = req.headers.authorization;
const token = authHeader?.startsWith("Bearer ")
? authHeader.substring(7)
: null;
if (token) {
try {
const decoded = jwt.verify(token, JWT_SECRET_VALIDATED) as unknown as JWTPayload;
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: { id: true, username: true, role: true, tokenVersion: true },
});
if (user) {
// Validate tokenVersion - reject if password was changed
if (decoded.tokenVersion === undefined || decoded.tokenVersion !== user.tokenVersion) {
// Token was issued before password change, reject
} else {
req.user = { id: user.id, username: user.username, role: user.role };
return next();
}
}
} catch (error) {
// Token invalid, continue to error
}
}
return res.status(401).json({ error: "Not authenticated" });
}