const express = require('express'); const axios = require('axios'); const crypto = require('crypto'); const rateLimit = require('express-rate-limit'); const router = express.Router(); const EMBY_URL = process.env.EMBY_URL; // Server-side token store: userId -> { accessToken } // Keeps AccessToken off the client; required for logout revocation. const tokenStore = new Map(); function storeToken(userId, accessToken) { tokenStore.set(userId, { accessToken }); } function getToken(userId) { return tokenStore.get(userId) || null; } function clearToken(userId) { tokenStore.delete(userId); } const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 10, standardHeaders: true, legacyHeaders: false, message: { success: false, error: 'Too many login attempts, please try again later' } }); // Authenticate user with Emby router.post('/login', loginLimiter, async (req, res) => { try { const { username, password } = req.body; console.log(`[Auth] Attempting login for user: ${username}`); // Authenticate with Emby using a stable DeviceId derived from the username. // Using a deterministic DeviceId causes Emby to reuse the existing session // for this device rather than creating a new one on each login. const stableDeviceId = 'sofarr-' + crypto.createHash('sha256').update(username.toLowerCase()).digest('hex').slice(0, 16); const authResponse = await axios.post(`${EMBY_URL}/Users/authenticatebyname`, { Username: username, Pw: password }, { headers: { 'X-Emby-Authorization': `MediaBrowser Client="sofarr", Device="sofarr", DeviceId="${stableDeviceId}", Version="1.0.0"` } }); const authData = authResponse.data; // Get user info using the access token const userResponse = await axios.get(`${EMBY_URL}/Users/${authData.User.Id || authData.User.id}`, { headers: { 'X-MediaBrowser-Token': authData.AccessToken } }); const user = userResponse.data; const isAdmin = !!(user.Policy && user.Policy.IsAdministrator); console.log(`[Auth] Login successful for user: ${user.Name}, isAdmin: ${isAdmin}`); // Store token server-side; it is never sent to the client. storeToken(user.Id, authData.AccessToken); // Set authentication cookie (signed when COOKIE_SECRET is set). const cookiePayload = JSON.stringify({ id: user.Id, name: user.Name, isAdmin }); const signed = !!process.env.COOKIE_SECRET; res.cookie('emby_user', cookiePayload, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict', signed, maxAge: 24 * 60 * 60 * 1000 // 24 hours }); res.json({ success: true, user: { id: user.Id, name: user.Name, isAdmin } }); } catch (error) { console.error(`[Auth] Login failed:`, error.message); res.status(401).json({ success: false, error: 'Invalid username or password' }); } }); function parseSessionCookie(req) { const signed = !!process.env.COOKIE_SECRET; const raw = signed ? req.signedCookies.emby_user : req.cookies.emby_user; if (!raw || raw === false) return null; // false = tampered signed cookie try { const u = JSON.parse(raw); // Schema validation: require id (string), name (string), isAdmin (boolean) if (typeof u.id !== 'string' || !u.id) return null; if (typeof u.name !== 'string' || !u.name) return null; if (typeof u.isAdmin !== 'boolean') u.isAdmin = !!u.isAdmin; return u; } catch { return null; } } // Get current authenticated user router.get('/me', (req, res) => { const user = parseSessionCookie(req); if (!user) return res.json({ authenticated: false }); res.json({ authenticated: true, user: { id: user.id, name: user.name, isAdmin: user.isAdmin } }); }); // Logout router.post('/logout', async (req, res) => { const user = parseSessionCookie(req); if (user) { const stored = getToken(user.id); if (stored) { try { await axios.post(`${EMBY_URL}/Sessions/Logout`, {}, { headers: { 'X-MediaBrowser-Token': stored.accessToken } }); console.log(`[Auth] Revoked Emby token for user: ${user.name}`); } catch (err) { console.warn(`[Auth] Failed to revoke Emby token for ${user.name}:`, err.message); } clearToken(user.id); } } res.clearCookie('emby_user', { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict', signed: !!process.env.COOKIE_SECRET }); res.json({ success: true }); }); module.exports = router;