Domain=.sami cookies are silently rejected by real browsers - .sami is an unregistered custom TLD, so browsers treat sami itself as the effective public suffix and refuse to set a cookie scoped to it (the same rule that stops a site from setting a supercookie for all of .com). Confirmed via curl verbose (cookie dropped, domain must not set cookies for sami) and via the Firefox console on the actual device (Cookie rejected for invalid domain) for the same cookie. The session cookie set on status.sami after TOTP verify could never reach plex.sami/jellyfin.sami/emby.sami/chat.sami no matter how the cookie itself was built - prior fixes tonight left this mechanism untouched, which is why the loop persisted. Fix: /totp/verify mints a short-lived (60s) single-use opaque token. The status.sami frontend appends it to the redirect URL when bouncing the user back to a gated service. That services login page exchanges the token via the new public GET /api/v1/auth/sso-exchange for a host-only session cookie (no Domain attribute - always accepted). isSessionValid only checks the cookies HMAC signature, never its Domain, so the host-only cookie validates identically to the cross-domain one on every existing check with zero changes to that logic.
350 lines
20 KiB
JavaScript
350 lines
20 KiB
JavaScript
const express = require('express');
|
|
const { SESSION_TTL, APP, PLEX, TIMEOUTS, buildMediaAuth } = require('../../src/utilities/constants');
|
|
const { AuthenticationError, NotFoundError } = require('../../src/utilities/errors');
|
|
const { ok } = require('../../src/utils/responses');
|
|
|
|
/**
|
|
* 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'));
|
|
|
|
// Cross-subdomain SSO handoff: exchanges a short-lived single-use token
|
|
// (minted by /totp/verify) for a HOST-ONLY session cookie on whichever
|
|
// *.sami origin calls this. Needed because Domain=.sami cookies are
|
|
// silently rejected by real browsers (.sami is an unregistered TLD, so
|
|
// browsers treat "sami" as the effective public suffix and refuse to set
|
|
// a cookie scoped to it) — see middleware.js for the full explanation.
|
|
// Public route (no session required to call it) since a fresh visitor to
|
|
// a gated service has no session yet by definition; the token itself is
|
|
// the credential, and it's one-time-use with a 60s TTL.
|
|
router.get('/auth/sso-exchange', (req, res) => {
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
const token = req.query.token;
|
|
if (!session.redeemHandoffToken(token)) {
|
|
return errorResponse(res, 401, 'Invalid or expired handoff token');
|
|
}
|
|
session.setCookieHostOnly(res, totpConfig.sessionDuration);
|
|
ok(res, { authenticated: true });
|
|
});
|
|
|
|
// 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 <script> (no external bundle - it's built per-service in
|
|
// buildLoginPage()). The app-wide Helmet CSP sets script-src 'self' with
|
|
// no inline exception, which silently blocks that script from ever
|
|
// running - no console-visible error on the page, no JS timeout fires,
|
|
// it just sits on "Signing in to ..." forever. Relax script-src for this
|
|
// one response only; every other route keeps the strict app-wide policy.
|
|
res.setHeader('Content-Security-Policy', "default-src 'self'; style-src 'self'; script-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; font-src 'self' data:; object-src 'none'; media-src 'self'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'");
|
|
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);
|
|
// Cross-subdomain SSO handoff: status.sami can't share its session cookie
|
|
// with this origin (Domain=.sami cookies are silently rejected by real
|
|
// browsers - .sami isn't a registered TLD, so browsers treat "sami" as the
|
|
// effective public suffix). Instead status.sami hands us a one-time token
|
|
// in the URL after a successful TOTP verify; exchange it here for a cookie
|
|
// scoped to just this host, then strip it from the URL so it can't be
|
|
// reused or leak via history/referrer. If there's no token (or the
|
|
// exchange fails - expired, already used, etc.) this is a no-op and we
|
|
// fall through to the normal check-session flow below exactly as before.
|
|
var dcParams=new URLSearchParams(location.search);
|
|
var dcToken=dcParams.get('dc_token');
|
|
var preExchange=Promise.resolve();
|
|
if(dcToken){
|
|
dcParams.delete('dc_token');
|
|
var dcQs=dcParams.toString();
|
|
try{history.replaceState({},'',location.pathname+(dcQs?'?'+dcQs:''))}catch(_){}
|
|
preExchange=fetch('/dashcaddy-api/api/auth/sso-exchange?token='+encodeURIComponent(dcToken),{credentials:'include',signal:withTimeout(5000)}).catch(function(){});
|
|
}
|
|
// 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.
|
|
preExchange.then(function(){
|
|
return 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){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. <a href="/?direct=1">Open Chat manually</a>','No token field: '+t.substring(0,200))}
|
|
catch(e){fail('Auto-login parse error. <a href="/?direct=1">Open Chat manually</a>','Error: '+e.message+' / body: '+t.substring(0,200))}
|
|
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/?direct=1">Open Chat manually</a>','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. <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>','API: '+JSON.stringify(j))
|
|
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/?direct=1">Open Plex manually</a>','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. <a href="/web/">Open Jellyfin manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>','API: '+JSON.stringify(j))
|
|
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Jellyfin manually</a>','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. <a href="/web/">Open Emby manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>','API: '+JSON.stringify(j))
|
|
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Emby manually</a>','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);
|
|
}
|