const express = require('express'); const { SESSION_TTL, APP, PLEX, TIMEOUTS, buildMediaAuth } = require('../../src/utilities/constants'); const { AuthenticationError, NotFoundError } = require('../../src/utilities/errors'); /** * Auth SSO gate routes factory * @param {Object} deps - Explicit dependencies (includes session helpers) * @returns {express.Router} */ module.exports = function(deps) { const router = express.Router(); // Extract dependencies const { authManager, totpConfig, session, asyncHandler, errorResponse, log, getAppSession, appSessionCache, credentialManager, fetchT, getServiceById, licenseManager, servicesStateManager } = deps; // Create ctx-like object for compatibility const ctx = { credentialManager, fetchT, getServiceById, licenseManager, servicesStateManager }; // Caddy forward_auth gate: checks TOTP session + injects service credentials router.get('/auth/gate/:serviceId', asyncHandler(async (req, res) => { res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); const serviceId = req.params.serviceId; // SECURITY [DC-026]: Session is required whenever TOTP is enabled, regardless // of sessionDuration. Previously the check was gated on `!== 'never'`, which // meant an admin setting TOTP to never-expire accidentally created an // authentication-free path to credential injection. Even with a non-expiring // session, the request itself must still present a valid session cookie. if (totpConfig.enabled) { const valid = session.isValid(req); if (!valid) return errorResponse(res, 401, 'Session expired or invalid', { authenticated: false }); } // Session valid (or TOTP disabled) - inject credentials if premium SSO is active let injected = false; const ssoEnabled = ctx.licenseManager.hasFeature('sso'); if (!ssoEnabled) { // Free tier: TOTP gate passes but no credential injection return res.status(200).json({ authenticated: true, credentialsInjected: false, premiumRequired: true }); } try { const services = await ctx.servicesStateManager.read(); const service = services.find(s => s.id === serviceId); // External services: inject seedhost Basic Auth if (service && service.isExternal) { const sharedUser = await ctx.credentialManager.retrieve('seedhost.username').catch(() => null); const svcPass = await ctx.credentialManager.retrieve(`seedhost.password.${serviceId}`).catch(() => null); const sharedPass = await ctx.credentialManager.retrieve('seedhost.password').catch(() => null); const password = svcPass || sharedPass; if (sharedUser && password) { const basicAuth = Buffer.from(`${sharedUser}:${password}`).toString('base64'); res.setHeader('Authorization', `Basic ${basicAuth}`); injected = true; if (service.externalUrl) { const appCookies = await getAppSession(serviceId, service.externalUrl, sharedUser, password); if (appCookies) res.setHeader('X-App-Cookie', appCookies); } } } // Non-external services: check per-service Basic Auth if (!service || !service.isExternal) { const username = await ctx.credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null); const password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null); if (username && password) { const basicAuth = Buffer.from(`${username}:${password}`).toString('base64'); res.setHeader('Authorization', `Basic ${basicAuth}`); injected = true; if (service && service.url) { const appCookies = await getAppSession(serviceId, service.url, username, password); if (appCookies) res.setHeader('X-App-Cookie', appCookies); if (serviceId === 'plex') { const plexCached = appSessionCache.get('plex'); if (plexCached && plexCached.token) res.setHeader('X-Plex-Token', plexCached.token); } if (serviceId === 'jellyfin' || serviceId === 'emby') { const mediaCached = appSessionCache.get(serviceId); if (mediaCached && mediaCached.token) res.setHeader('X-Emby-Token', mediaCached.token); } } } } // Inject API key const arrKey = await ctx.credentialManager.retrieve(`arr.${serviceId}.apikey`).catch(() => null); const svcKey = await ctx.credentialManager.retrieve(`service.${serviceId}.apikey`).catch(() => null); const apiKey = arrKey || svcKey; if (apiKey) { res.setHeader('X-Api-Key', apiKey); injected = true; } } catch (e) { log.warn('auth', 'Credential error', { serviceId, error: e.message }); } res.status(200).json({ authenticated: true, credentialsInjected: injected }); }, 'auth-gate')); // Return cached app session token for client-side auth (Premium SSO feature) router.get('/auth/app-token/:serviceId', ctx.licenseManager.requirePremium('sso'), asyncHandler(async (req, res) => { const { serviceId } = req.params; // SECURITY [DC-026]: Same gate fix as /auth/gate — drop the sessionDuration // exception. TOTP-enabled means session is required, period. if (totpConfig.enabled) { if (!session.isValid(req)) throw new AuthenticationError('Not authenticated'); } // Jellyfin/Emby: separate browser-specific token if (serviceId === 'jellyfin' || serviceId === 'emby') { const browserCacheKey = `${serviceId}_browser`; const browserCached = appSessionCache.get(browserCacheKey); if (browserCached && browserCached.exp > Date.now()) { if (browserCached.failed) return errorResponse(res, 500, 'Login recently failed'); if (browserCached.token) { const resp = { token: browserCached.token }; if (browserCached.tokenData) Object.assign(resp, browserCached.tokenData); return res.json(resp); } } try { const username = await ctx.credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null); const password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null); if (!username || !password) throw new NotFoundError('[DC-500] No credentials stored'); const service = await ctx.getServiceById(serviceId); const baseUrl = service?.url; if (!baseUrl) throw new NotFoundError('No service URL'); const mediaAuth = buildMediaAuth(APP.DEVICE_IDS.BROWSER); const authResp = await ctx.fetchT(`${baseUrl}/Users/AuthenticateByName`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Emby-Authorization': mediaAuth }, body: JSON.stringify({ Username: username, Pw: password }), }, TIMEOUTS.HTTP_LONG); const authData = await authResp.json(); if (authData.AccessToken) { const tokenData = { userId: authData.User?.Id, serverId: authData.ServerId, serverName: authData.User?.ServerName || serviceId }; appSessionCache.set(browserCacheKey, { token: authData.AccessToken, tokenData, exp: Date.now() + SESSION_TTL.TOKEN_SESSION }); return res.json({ token: authData.AccessToken, ...tokenData }); } return errorResponse(res, 500, '[DC-501] Authentication failed'); } catch (e) { log.warn('auth', 'Browser token error', { serviceId, error: e.message }); return errorResponse(res, 500, e.message); } } // Check cache first const cached = appSessionCache.get(serviceId); if (cached && cached.exp > Date.now()) { if (cached.failed) return errorResponse(res, 500, '[DC-501] Login recently failed, retrying in a few minutes'); if (cached.token) { const resp = { token: cached.token }; if (cached.tokenData) Object.assign(resp, cached.tokenData); return res.json(resp); } const m = cached.cookies.match(/^token=(.+)$/); if (m) return res.json({ token: m[1] }); return res.json({ cookies: cached.cookies }); } // No cache — get fresh session try { const service = await ctx.getServiceById(serviceId); if (!service) throw new NotFoundError('Service not found'); const baseUrl = service.externalUrl || service.url; if (!baseUrl) throw new NotFoundError('No service URL'); let username, password; if (service.isExternal) { username = await ctx.credentialManager.retrieve('seedhost.username').catch(() => null); const svcPass = await ctx.credentialManager.retrieve(`seedhost.password.${serviceId}`).catch(() => null); const sharedPass = await ctx.credentialManager.retrieve('seedhost.password').catch(() => null); password = svcPass || sharedPass; } else { username = await ctx.credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null); password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null); } if (!username || !password) throw new NotFoundError('[DC-500] No credentials stored'); const appCookies = await getAppSession(serviceId, baseUrl, username, password); if (appCookies) { const freshCached = appSessionCache.get(serviceId); if (freshCached && freshCached.token) { const resp = { token: freshCached.token }; if (freshCached.tokenData) Object.assign(resp, freshCached.tokenData); return res.json(resp); } const m = appCookies.match(/^token=(.+)$/); if (m) return res.json({ token: m[1] }); return res.json({ cookies: appCookies }); } errorResponse(res, 500, '[DC-501] Login failed'); } catch (e) { log.warn('auth', 'App-token error', { error: e.message }); errorResponse(res, 500, e.message); } }, 'auth-app-token')); // Serve service-specific auto-login page (auth enforced by Caddy forward_auth upstream) router.get('/auth/login-page', (req, res) => { const service = (req.query.service || '').replace(/[^a-z]/g, ''); const html = buildLoginPage(service); if (!html) return res.status(404).send('Unknown service'); res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.setHeader('Cache-Control', 'no-store'); // This page is a server-rendered shell whose entire auto-login logic runs // in an inline `; const pages = { chat: { title: 'Signing in...', bg: '#0a0a0a', accent: '#60a5fa', body: `if(ls.getItem('token')){go('/?direct=1');return} d.textContent='Fetching token from DashCaddy...'; ft('chat').then(function(r){return r.text()}).then(function(t){ try{var j=JSON.parse(t);if(j.token){ls.setItem('token',j.token);go('/?direct=1');return} // No token but chat is reachable — fall through to manual UI link below fail('Auto-login unavailable. Open Chat manually','No token field: '+t.substring(0,200))} catch(e){fail('Auto-login parse error. Open Chat manually','Error: '+e.message+' / body: '+t.substring(0,200))} }).catch(function(e){fail('Could not reach DashCaddy. Open Chat manually','Fetch error: '+(e&&e.message||'unknown'))})` }, plex: { title: 'Signing in to Plex...', bg: '#1f1f1f', accent: '#e5a00d', body: `if(ls.getItem('myPlexAccessToken')){go('/web/?direct=1');return} ft('plex').then(function(r){return r.json()}).then(function(j){ if(j.token){ls.setItem('myPlexAccessToken',j.token);d.textContent='Token stored, redirecting...';go('/web/?direct=1');return} // No token returned. Three fallbacks in priority order: // 1. Stale token in localStorage — Plex may still accept it. if(ls.getItem('myPlexAccessToken')){go('/web/?direct=1');return} // 2. Manual link so the user is never trapped on this page. fail('Auto-login unavailable. Open Plex manually or re-authenticate at DashCaddy','API: '+JSON.stringify(j)) }).catch(function(e){fail('Could not reach DashCaddy. Open Plex manually','Error: '+(e&&e.message||'unknown'))})` }, jellyfin: { title: 'Signing in to Jellyfin...', bg: '#101014', accent: '#00a4dc', body: `ft('jellyfin').then(function(r){return r.json()}).then(function(j){ if(j.token){merge('jellyfin_credentials',j,'Jellyfin');merge('_jellyfin_credentials',j,'Jellyfin');d.textContent='Token stored, redirecting...';go('/web/');return} if(ls.getItem('jellyfin_credentials')||ls.getItem('_jellyfin_credentials')){go('/web/');return} fail('Auto-login unavailable. Open Jellyfin manually or re-authenticate at DashCaddy','API: '+JSON.stringify(j)) }).catch(function(e){fail('Could not reach DashCaddy. Open Jellyfin manually','Error: '+(e&&e.message||'unknown'))})` }, emby: { title: 'Signing in to Emby...', bg: '#101014', accent: '#52b54b', body: `ft('emby').then(function(r){return r.json()}).then(function(j){ if(j.token){merge('emby_credentials',j,'Emby');merge('_emby_credentials',j,'Emby');d.textContent='Token stored, redirecting...';go('/web/');return} if(ls.getItem('emby_credentials')||ls.getItem('_emby_credentials')){go('/web/');return} fail('Auto-login unavailable. Open Emby manually or re-authenticate at DashCaddy','API: '+JSON.stringify(j)) }).catch(function(e){fail('Could not reach DashCaddy. Open Emby manually','Error: '+(e&&e.message||'unknown'))})` }, }; const cfg = pages[service]; if (!cfg) return null; return SHELL(cfg.body) .replace(/__TITLE__/g, cfg.title) .replace('__BG__', cfg.bg) .replace('__ACCENT__', cfg.accent); }