'use strict'; // Auth proxy against the garagedoor-node-ws service. // We never hold the JWT secret here — login and validation are delegated to the // auth service, with a short-lived validation cache to avoid hammering it. // // garagedoor quirk: HTTP status is 200 even on bad credentials and invalid // tokens. Always branch on `body.result`, never on `res.ok`. const config = require('./config'); const { grants, clients } = require('./db'); const VALIDATE_CACHE_TTL_MS = 60 * 1000; // token -> { username, level, expiresAt } const validateCache = new Map(); async function login(username, password) { let res; try { res = await fetch(`${config.authUrl}/authenticate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), }); } catch { throw Object.assign(new Error('auth service unreachable'), { status: 502 }); } if (!res.ok) throw Object.assign(new Error('auth service error'), { status: 502 }); const body = await res.json(); if (body.result !== 'success' || !body.token) { throw Object.assign(new Error(body.message || 'Authentication failed'), { status: 401 }); } // Cache the level from login so isAdmin() has it without an extra round-trip. validateCache.set(body.token, { username: body.username, level: body.level, expiresAt: Date.now() + VALIDATE_CACHE_TTL_MS, }); return { token: body.token, username: body.username, level: body.level }; } async function validateToken(token) { if (!token) return null; const cached = validateCache.get(token); if (cached && cached.expiresAt > Date.now()) return { username: cached.username, level: cached.level }; let res; try { res = await fetch(`${config.authUrl}/validate`, { headers: { Authorization: `Bearer ${token}` } }); } catch { throw Object.assign(new Error('auth service unreachable'), { status: 502 }); } if (!res.ok) return null; const body = await res.json(); if (body.result !== 'success') return null; // /validate does not return level; carry over whatever login cached, if anything. const level = cached ? cached.level : undefined; validateCache.set(token, { username: body.username, level, expiresAt: Date.now() + VALIDATE_CACHE_TTL_MS }); if (validateCache.size > 500) { const t = Date.now(); for (const [k, v] of validateCache) if (v.expiresAt <= t) validateCache.delete(k); } return { username: body.username, level }; } function isAdmin(user) { if (!user) return false; if (config.adminUsers.length && config.adminUsers.includes(String(user.username).toLowerCase())) return true; if (config.adminLevel !== null && user.level !== undefined && Number(user.level) >= config.adminLevel) return true; // No admin policy configured at all: any authenticated user is an admin. This // keeps a fresh single-operator install usable; set ADMIN_USERS to lock down. return !config.adminUsers.length && config.adminLevel === null; } function extractToken(req) { const header = req.headers['authorization']; if (header && header.startsWith('Bearer ')) return header.slice(7); if (req.query && req.query.token) return String(req.query.token); return null; } async function requireAuth(req, res, next) { const token = extractToken(req); if (!token) return res.status(401).json({ error: 'missing token' }); try { const user = await validateToken(token); if (!user) return res.status(401).json({ error: 'invalid or expired session' }); req.user = user; req.username = user.username; req.isAdmin = isAdmin(user); next(); } catch (e) { res.status(e.status === 502 ? 502 : 500).json({ error: 'auth service unreachable' }); } } function requireAdmin(req, res, next) { if (!req.isAdmin) return res.status(403).json({ error: 'admin only' }); next(); } /** * Effective role for a user on a client: 'admin' | 'operator' | 'viewer' | null. * Admins get full control on everything; everyone else needs an unexpired grant. */ function roleForClient(user, clientId, admin) { if (admin ?? isAdmin(user)) return 'admin'; const g = grants.find(clientId, user.username); if (!g) return null; if (g.expires_at && g.expires_at < Date.now()) return null; return g.role === 'operator' ? 'operator' : 'viewer'; } // Roles that may send keyboard/mouse input. Everything else is filtered to view-only. function canControl(role) { return role === 'admin' || role === 'operator'; } function visibleClients(user, admin) { return (admin ?? isAdmin(user)) ? clients.list() : clients.listForUser(user.username); } module.exports = { login, validateToken, requireAuth, requireAdmin, isAdmin, extractToken, roleForClient, canControl, visibleClients, };