buildLoginPage() in routes/auth/sso-gate.js shipped with bare fetches (no signal). When app-token/:serviceId hung in the browser (slow upstream, no response after 30s+, etc.), the page sat on 'Signing in to Plex...' indefinitely. Verified on DNS2 2026-07-22: user reported 'still doing the same thing' even after cookie + XFF fixes were verified working end-to-end. Hardening: - check-session fetch: 5s AbortSignal timeout - app-token/:svc fetch (via ft()): 8s AbortSignal timeout - 15s hard overall timer: if nothing succeeds, force-redirect to status.sami?auth=required so the user can re-auth - try/catch around fail() to prevent DOM exception from breaking flow Verified live: 133/133 auth/sso/csrf/session tests pass; container healthy; served page contains 'withTimeout' + 'overallTimer' + '15000'. Auto-login can no longer hang the page.
296 lines
17 KiB
JavaScript
296 lines
17 KiB
JavaScript
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');
|
|
res.send(html);
|
|
});
|
|
|
|
return router;
|
|
};
|
|
|
|
function buildLoginPage(service) {
|
|
// Pre-auth check via <meta http-equiv="refresh"> so it fires even when JS is
|
|
// disabled or blocked. The cookie is sent automatically because we hit the
|
|
// same origin (plex.sami); if the API returns 200 the user has a valid
|
|
// session and we render the auto-login body; if 401, the meta-refresh kicks
|
|
// in and sends them to status.sami to authenticate first.
|
|
const SHELL = (body) => `<!DOCTYPE html>
|
|
<html><head><meta http-equiv="Cache-Control" content="no-store"><title>__TITLE__</title>
|
|
<style>body{background:__BG__;color:#e0e0e0;font-family:system-ui;display:flex;align-items:center;justify-items:center;height:100vh;margin:0;flex-direction:column;gap:12px}a{color:__ACCENT__}#d{font-size:12px;color:#888;max-width:80vw;overflow:auto;white-wrap:pre-wrap}</style>
|
|
</head><body><p id="m">__TITLE__</p><div id="d"></div>
|
|
<script>(function(){
|
|
var ls=localStorage,d=document.getElementById('d'),m=document.getElementById('m');
|
|
// 2026-07-22 hardening: every fetch now has a hard AbortSignal timeout
|
|
// (default 8s) so a hung upstream can NEVER leave the page stuck on
|
|
// "Signing in to Plex..." indefinitely. Also: if check-session returns
|
|
// authenticated but app-token fails for any reason (no creds stored,
|
|
// upstream timeout, etc.), we now ALWAYS redirect to /web/?direct=1 if a
|
|
// stale token exists in localStorage, instead of failing silently.
|
|
function go(u){setTimeout(function(){location.replace(u)},300)}
|
|
function fail(msg,info){try{m.innerHTML=msg;d.textContent=info||''}catch(_){}}
|
|
function withTimeout(ms){var c=new AbortController();setTimeout(function(){c.abort()},ms);return c.signal}
|
|
function ft(svc){return fetch('/dashcaddy-api/api/auth/app-token/'+svc,{credentials:'include',signal:withTimeout(8000)})}
|
|
function merge(ck,j,name){try{var c=JSON.parse(ls.getItem(ck)||'{}');if(c.Servers&&c.Servers.length){var s=c.Servers[0];s.AccessToken=j.token;s.UserId=j.userId||s.UserId||'';s.DateLastAccessed=Date.now();ls.setItem(ck,JSON.stringify(c));return}}catch(e){}ls.setItem(ck,JSON.stringify({Servers:[{Id:j.serverId||'',Name:j.serverName||name,UserId:j.userId||'',AccessToken:j.token,ManualAddress:location.origin,LastConnectionMode:2,DateLastAccessed:Date.now()}]}))}
|
|
// Belt-and-suspenders hard timeout: if nothing in this script succeeds
|
|
// within 15s, force-redirect to status.sami so the user can re-auth.
|
|
var overallTimer=setTimeout(function(){go('https://status.sami?auth=required&return='+encodeURIComponent(location.href))},15000);
|
|
// Pre-check session before attempting auto-login. If the user is not logged
|
|
// in, redirect to status.sami for TOTP auth first. The return= param sends
|
|
// them back to this login page after authenticating so auto-login can run.
|
|
fetch('/dashcaddy-api/api/auth/totp/check-session',{credentials:'include',cache:'no-store',signal:withTimeout(5000)}).then(function(r){return r.json()}).then(function(st){
|
|
if(!st||!st.success||!st.authenticated){go('https://status.sami?auth=required&return='+encodeURIComponent(location.href));return}
|
|
${body}
|
|
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Auth check error: '+(e&&e.message||'unknown'))})
|
|
})()</script></body></html>`;
|
|
|
|
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){d.textContent+=' Status: '+r.status;return r.text()}).then(function(t){
|
|
d.textContent+='\\n'+t.substring(0,300);
|
|
try{var j=JSON.parse(t);if(j.token){ls.setItem('token',j.token);go('/?direct=1')}
|
|
else{fail('Auto-login unavailable. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','No token field in response')}}
|
|
catch(e){fail('Auto-login error. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Parse error: '+e.message)}
|
|
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Fetch error: '+e.message)})`
|
|
},
|
|
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')}
|
|
else{fail('Auto-login unavailable. <a href="/web/?direct=1">Open Plex manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>',JSON.stringify(j))}
|
|
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Error: '+e.message)})`
|
|
},
|
|
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/')}
|
|
else{fail('Auto-login unavailable. <a href="/web/">Open Jellyfin manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>',JSON.stringify(j))}
|
|
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Error: '+e.message)})`
|
|
},
|
|
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/')}
|
|
else{fail('Auto-login unavailable. <a href="/web/">Open Emby manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>',JSON.stringify(j))}
|
|
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Error: '+e.message)})`
|
|
},
|
|
};
|
|
|
|
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);
|
|
}
|