Authenticate browser media with durable HttpOnly login cookies

Adapted cookie, origin, URL validation and secret handling from MikaelKW/nodecast-tv-plus. Protect API roles, revoke logins across restart, preserve existing accounts, and isolate transcode sessions by account.

Co-authored-by: Mikael <mikaelkwessel@gmail.com>
This commit is contained in:
Technomancer702
2026-10-12 16:53:15 -07:00
parent 2999d6542f
commit 1f665dcb11
24 changed files with 553 additions and 111 deletions

View File

@ -3,6 +3,9 @@ const jwt = require('jsonwebtoken');
const passport = require('passport');
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
const { Strategy: LocalStrategy } = require('passport-local');
const security = require('./config/security');
const authTokens = require('./services/authTokens');
const crypto = require('node:crypto');
/**
* Authentication and Authorization Module
@ -11,7 +14,7 @@ const { Strategy: LocalStrategy } = require('passport-local');
*/
// JWT Secret - In production, use environment variable
const JWT_SECRET = process.env.JWT_SECRET || 'nodecast-tv-secret-key-change-in-production';
const JWT_SECRET = security.jwtSecret;
const JWT_EXPIRY = '24h';
/**
@ -26,6 +29,7 @@ async function hashPassword(password) {
* Verify password against hash
*/
async function verifyPassword(password, hash) {
if (typeof password !== 'string' || typeof hash !== 'string') return false;
return bcrypt.compare(password, hash);
}
@ -37,10 +41,12 @@ function generateToken(user) {
{
id: user.id,
username: user.username,
role: user.role
role: user.role,
authVersion: user.authVersion || 0,
kind: 'access'
},
JWT_SECRET,
{ expiresIn: JWT_EXPIRY }
{ expiresIn: JWT_EXPIRY, algorithm: 'HS256', audience: 'nodecast-tv', jwtid: crypto.randomUUID() }
);
}
@ -49,7 +55,8 @@ function generateToken(user) {
*/
function verifyToken(token) {
try {
return jwt.verify(token, JWT_SECRET);
const payload = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'], audience: 'nodecast-tv' });
return payload.kind === 'access' && typeof payload.jti === 'string' ? payload : null;
} catch (err) {
return null;
}
@ -87,15 +94,18 @@ function configureLocalStrategy(getUserByUsername, verifyUserPassword) {
*/
function configureJwtStrategy(getUserById) {
const options = {
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: JWT_SECRET
jwtFromRequest: extractToken,
secretOrKey: JWT_SECRET,
algorithms: ['HS256'],
audience: 'nodecast-tv'
};
passport.use(new JwtStrategy(options, async (payload, done) => {
try {
const user = await getUserById(payload.id);
if (!user) {
if (!user || payload.kind !== 'access' || typeof payload.jti !== 'string'
|| (payload.authVersion || 0) !== (user.authVersion || 0) || authTokens.isRevoked(payload)) {
return done(null, false);
}
@ -195,7 +205,7 @@ function configureOidcStrategy(findUserByOidcId, findUserByEmail, createUser) {
// Extract email - handle both profile.emails[] (Google) and profile.email (others)
const email = profile.emails?.[0]?.value || profile.email || profile._json?.email;
if (!user && email) {
if (!user && email && profile?._json?.email_verified === true) {
user = await findUserByEmail(email);
// If found by email but no OIDC ID, link them
@ -228,7 +238,50 @@ function configureOidcStrategy(findUserByOidcId, findUserByEmail, createUser) {
/**
* Middleware: Require authentication using Passport JWT
*/
const requireAuth = passport.authenticate('jwt', { session: false });
function requireAuth(req, res, next) {
if (req.authenticatedUser) { req.user = req.authenticatedUser; return next(); }
passport.authenticate('jwt', { session: false }, (error, user) => {
if (error) return next(error);
if (!user) return res.status(401).json({ error: 'Authentication required' });
req.user = req.authenticatedUser = user;
next();
})(req, res, next);
}
function extractToken(req) {
const bearer = ExtractJwt.fromAuthHeaderAsBearerToken()(req);
if (bearer) return bearer;
for (const part of (req.headers.cookie || '').split(';')) {
const separator = part.indexOf('=');
if (part.slice(0, separator).trim() === security.authCookieName) {
try { return decodeURIComponent(part.slice(separator + 1)); } catch { return null; }
}
}
return null;
}
function cookieOptions(req) {
return { httpOnly: true, sameSite: 'lax', secure: process.env.AUTH_COOKIE_SECURE === 'true'
|| (process.env.AUTH_COOKIE_SECURE !== 'false' && Boolean(req.secure)), path: '/' };
}
function setAuthCookie(req, res, token) {
res.cookie(security.authCookieName, token, { ...cookieOptions(req), maxAge: 24 * 60 * 60 * 1000 });
}
function clearAuthCookie(req, res) {
res.clearCookie(security.authCookieName, cookieOptions(req));
}
function requireSameOrigin(req, res, next) {
if (req.path.startsWith('/auth/oidc/')) return next();
if (req.get('sec-fetch-site') === 'cross-site') return res.status(403).json({ error: 'Cross-site request blocked' });
const origin = req.get('origin') || (!['GET', 'HEAD', 'OPTIONS'].includes(req.method) ? req.get('referer') : null);
if (!origin) return next(); // Non-browser API clients still require authentication.
try {
const allowed = process.env.APP_ORIGIN ? new URL(process.env.APP_ORIGIN).origin
: new URL(`${req.protocol}://${req.get('host')}`).origin;
if (new URL(origin).origin === allowed) return next();
} catch { /* Invalid origins are rejected. */ }
res.status(403).json({ error: 'Cross-site request blocked' });
}
/**
* Middleware: Require admin role
@ -263,6 +316,10 @@ module.exports = {
configureSessionSerialization,
configureOidcStrategy,
requireAuth,
extractToken,
setAuthCookie,
clearAuthCookie,
requireSameOrigin,
requireAdmin,
requireRole
};