const express = require('express'); const { SESSION_TTL, APP, PLEX, TIMEOUTS, buildMediaAuth } = require('../../constants'); const { AuthenticationError, NotFoundError } = require('../errors'); module.exports = function(ctx, getAppSession, appSessionCache) { const router = express.Router(); // Caddy forward_auth gate: checks TOTP session + injects service credentials router.get('/auth/gate/:serviceId', ctx.asyncHandler(async (req, res) => { res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); const serviceId = req.params.serviceId; // Check TOTP session first if (ctx.totpConfig.enabled && ctx.totpConfig.sessionDuration !== 'never') { const valid = ctx.session.isValid(req); if (!valid) return ctx.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) { ctx.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'), ctx.asyncHandler(async (req, res) => { const { serviceId } = req.params; if (ctx.totpConfig.enabled && ctx.totpConfig.sessionDuration !== 'never') { if (!ctx.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 ctx.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 ctx.errorResponse(res, 500, '[DC-501] Authentication failed'); } catch (e) { ctx.log.warn('auth', 'Browser token error', { serviceId, error: e.message }); return ctx.errorResponse(res, 500, e.message); } } // Check cache first const cached = appSessionCache.get(serviceId); if (cached && cached.exp > Date.now()) { if (cached.failed) return ctx.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 }); } ctx.errorResponse(res, 500, '[DC-501] Login failed'); } catch (e) { ctx.log.warn('auth', 'App-token error', { error: e.message }); ctx.errorResponse(res, 500, e.message); } }, 'auth-app-token')); return router; };