Add a Settings action to manually rerun Live TV stream analysis: it triggers an admin-guarded projection rebuild endpoint that groups streams into logical channels (covering channels present before the projection existed) and reruns language/Laya detection, with progress presented via liveAnalysisPresentation. Harden the Laya batch path that failed with 'Local AI language matching failed' by surfacing the underlying cause and falling back to standard matching cleanly. Guard admin-only rebuild through a new authz helper and cover routes, service and presentation with tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
270 lines
8.2 KiB
JavaScript
270 lines
8.2 KiB
JavaScript
const bcrypt = require('bcryptjs');
|
|
const jwt = require('jsonwebtoken');
|
|
const passport = require('passport');
|
|
const { isAdmin } = require('./authz');
|
|
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
|
|
const { Strategy: LocalStrategy } = require('passport-local');
|
|
|
|
/**
|
|
* Authentication and Authorization Module
|
|
* Handles user authentication, session management, and role-based access control
|
|
* Using Passport.js with JWT tokens
|
|
*/
|
|
|
|
// JWT Secret - In production, use environment variable
|
|
const JWT_SECRET = process.env.JWT_SECRET || 'nodecast-tv-secret-key-change-in-production';
|
|
const JWT_EXPIRY = '24h';
|
|
|
|
/**
|
|
* Hash password using bcrypt
|
|
*/
|
|
async function hashPassword(password) {
|
|
const salt = await bcrypt.genSalt(10);
|
|
return bcrypt.hash(password, salt);
|
|
}
|
|
|
|
/**
|
|
* Verify password against hash
|
|
*/
|
|
async function verifyPassword(password, hash) {
|
|
return bcrypt.compare(password, hash);
|
|
}
|
|
|
|
/**
|
|
* Generate JWT token
|
|
*/
|
|
function generateToken(user) {
|
|
return jwt.sign(
|
|
{
|
|
id: user.id,
|
|
username: user.username,
|
|
role: user.role
|
|
},
|
|
JWT_SECRET,
|
|
{ expiresIn: JWT_EXPIRY }
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Verify JWT token
|
|
*/
|
|
function verifyToken(token) {
|
|
try {
|
|
return jwt.verify(token, JWT_SECRET);
|
|
} catch (err) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Configure Passport Local Strategy for username/password authentication
|
|
*/
|
|
function configureLocalStrategy(getUserByUsername, verifyUserPassword) {
|
|
passport.use(new LocalStrategy(
|
|
async (username, password, done) => {
|
|
try {
|
|
const user = await getUserByUsername(username);
|
|
|
|
if (!user) {
|
|
return done(null, false, { message: 'Invalid credentials' });
|
|
}
|
|
|
|
const isValid = await verifyUserPassword(password, user.passwordHash);
|
|
|
|
if (!isValid) {
|
|
return done(null, false, { message: 'Invalid credentials' });
|
|
}
|
|
|
|
return done(null, user);
|
|
} catch (err) {
|
|
return done(err);
|
|
}
|
|
}
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Configure Passport JWT Strategy for token-based authentication
|
|
*/
|
|
function configureJwtStrategy(getUserById) {
|
|
const options = {
|
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
|
secretOrKey: JWT_SECRET
|
|
};
|
|
|
|
passport.use(new JwtStrategy(options, async (payload, done) => {
|
|
try {
|
|
const user = await getUserById(payload.id);
|
|
|
|
if (!user) {
|
|
return done(null, false);
|
|
}
|
|
|
|
return done(null, {
|
|
id: user.id,
|
|
username: user.username,
|
|
role: user.role
|
|
});
|
|
} catch (err) {
|
|
return done(err, false);
|
|
}
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Configure Passport session serialization
|
|
* Required for OIDC flow which uses sessions
|
|
*/
|
|
function configureSessionSerialization(getUserById) {
|
|
passport.serializeUser((user, done) => {
|
|
done(null, user.id);
|
|
});
|
|
|
|
passport.deserializeUser(async (id, done) => {
|
|
try {
|
|
const user = await getUserById(id);
|
|
done(null, user);
|
|
} catch (err) {
|
|
done(err, null);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Configure Passport OpenID Connect Strategy
|
|
*/
|
|
function configureOidcStrategy(findUserByOidcId, findUserByEmail, createUser) {
|
|
if (!process.env.OIDC_ISSUER_URL || !process.env.OIDC_CLIENT_ID || !process.env.OIDC_CLIENT_SECRET) {
|
|
console.warn('OIDC configuration missing - SSO disabled');
|
|
return;
|
|
}
|
|
|
|
const { Strategy: OpenIDConnectStrategy } = require('passport-openidconnect');
|
|
|
|
passport.use(new OpenIDConnectStrategy({
|
|
issuer: process.env.OIDC_ISSUER_URL || 'https://mock-issuer.com', // Dummy default for mock
|
|
authorizationURL: process.env.OIDC_AUTH_URL || `${process.env.OIDC_ISSUER_URL}/protocol/openid-connect/auth`,
|
|
tokenURL: process.env.OIDC_TOKEN_URL || `${process.env.OIDC_ISSUER_URL}/protocol/openid-connect/token`,
|
|
userInfoURL: process.env.OIDC_USERINFO_URL || `${process.env.OIDC_ISSUER_URL}/protocol/openid-connect/userinfo`,
|
|
clientID: process.env.OIDC_CLIENT_ID || 'mock-client-id',
|
|
clientSecret: process.env.OIDC_CLIENT_SECRET || 'mock-secret',
|
|
callbackURL: process.env.OIDC_CALLBACK_URL || '/api/auth/oidc/callback',
|
|
scope: ['openid', 'profile', 'email']
|
|
},
|
|
async (...args) => {
|
|
// The done callback is always the last argument
|
|
const done = args[args.length - 1];
|
|
|
|
// Map known arguments
|
|
// Standard: issuer, sub, profile, accessToken, refreshToken, done
|
|
// Some versions: issuer, sub, profile, accessToken, refreshToken, params, done
|
|
|
|
let issuer, sub, profile;
|
|
|
|
if (args.length === 3) {
|
|
// Scenario: (issuer, profile, done)
|
|
const arg0 = args[0];
|
|
const arg1 = args[1];
|
|
|
|
if (typeof arg1 === 'object' && arg1.id) {
|
|
issuer = arg0;
|
|
profile = arg1;
|
|
sub = profile.id;
|
|
} else if (typeof arg0 === 'string' && typeof arg1 === 'string') {
|
|
issuer = arg0;
|
|
sub = arg1;
|
|
profile = { id: sub, displayName: 'Unknown' };
|
|
}
|
|
} else if (args.length >= 4) {
|
|
// Assume standard: iss, sub, profile...
|
|
issuer = args[0];
|
|
sub = args[1];
|
|
profile = args[2];
|
|
}
|
|
|
|
if (!sub && profile && profile.id) sub = profile.id;
|
|
|
|
if (!sub) {
|
|
return done(new Error('Could not identify OIDC Subject (sub) from arguments'));
|
|
}
|
|
|
|
try {
|
|
// 1. Try to find by OIDC ID (sub)
|
|
let user = await findUserByOidcId(sub);
|
|
|
|
// 2. If not found, try to match by email
|
|
// 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) {
|
|
user = await findUserByEmail(email);
|
|
|
|
// If found by email but no OIDC ID, link them
|
|
if (user && !user.oidcId) {
|
|
// We don't have a direct update method for specific fields without full user object in this context
|
|
// Ideally we'd update the user here. For now, we'll just log in.
|
|
// Future: Update user with oidcId
|
|
}
|
|
}
|
|
|
|
// 3. If still not found, create new user (JIT Provisioning)
|
|
if (!user) {
|
|
const username = profile.username || profile.displayName || (email ? email.split('@')[0] : `user_${sub.substring(0, 8)}`);
|
|
|
|
user = await createUser({
|
|
username: username,
|
|
role: 'viewer', // Default role for SSO users
|
|
oidcId: sub,
|
|
email: email || null
|
|
});
|
|
}
|
|
|
|
return done(null, user);
|
|
} catch (err) {
|
|
return done(err);
|
|
}
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Middleware: Require authentication using Passport JWT
|
|
*/
|
|
const requireAuth = passport.authenticate('jwt', { session: false });
|
|
|
|
/**
|
|
* Middleware: Require admin role
|
|
*/
|
|
function requireAdmin(req, res, next) {
|
|
if (!isAdmin(req.user)) {
|
|
return res.status(403).json({ error: 'Forbidden - Admin access required' });
|
|
}
|
|
next();
|
|
}
|
|
|
|
/**
|
|
* Middleware: Check for specific role
|
|
*/
|
|
function requireRole(role) {
|
|
return (req, res, next) => {
|
|
if (!req.user || req.user.role !== role) {
|
|
return res.status(403).json({ error: `Forbidden - ${role} access required` });
|
|
}
|
|
next();
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
passport,
|
|
hashPassword,
|
|
verifyPassword,
|
|
generateToken,
|
|
verifyToken,
|
|
configureLocalStrategy,
|
|
configureJwtStrategy,
|
|
configureSessionSerialization,
|
|
configureOidcStrategy,
|
|
requireAuth,
|
|
requireAdmin,
|
|
requireRole
|
|
};
|