Compare commits

...
9 Commits
Author SHA1 Message Date
Krystie 75f835641f [grade=B] fix(auth): use host-only session cookies on custom TLDs
CI / Security audit (push) Has been cancelled
CI / Test & Lint (push) Has been cancelled
Codex: urn:ump:7c22nwh67kot23f6czg5ax7e47hu2r7vowjpz3q6z63o73ti67vq
2026-07-24 05:15:08 -07:00
Krystie 872923dba2 fix(auth): route /api/auth/sso-exchange through the v1 rewrite shim
Caddy handle_path /dashcaddy-api/* only strips the /dashcaddy-api prefix, so
the login-page fetch to /dashcaddy-api/api/auth/sso-exchange arrived at the
app as /api/auth/sso-exchange - one path segment short of the canonical
/api/v1/auth/sso-exchange mount, so it 404d (masked by isPublicRoute never
even being reached). Add it to the same narrow gate/app-token rewrite case.
Caught by an end-to-end curl replay of the actual handoff flow before
asking for another live retest.
2026-07-24 05:15:07 -07:00
Krystie 10f2bf707b fix(auth): token-based handoff for cross-subdomain SSO
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.
2026-07-24 05:15:03 -07:00
Krystie f42e761e52 fix(auth): remove stray brace breaking auto-login page inline script
The never-trap fallback commit (fb638f6) left an extra closing brace after
the fail() call in the plex/jellyfin/emby page bodies (JSON.stringify(j))}
followed by another }).catch(...) on the next line - one brace too many,
since unlike the chat body these have no try/catch needing the extra scope).
This threw a SyntaxError parsing the inline <script>, which silently killed
the ENTIRE script - including the 15s failsafe redirect - leaving the page
stuck on "Signing in to ..." forever with zero console output explaining
why. Confirmed via node --check on the actual generated <script> contents
for all four services.
2026-07-24 05:15:00 -07:00
Krystie e208e05b83 fix(auth): relax CSP script-src for auto-login page (inline JS was silently blocked) 2026-07-24 05:14:59 -07:00
Krystie ba21dad550 DC-XXX: never-trap auto-login fallback — stale localStorage + manual links
Sami reported plex.sami/dashcaddy-login hangs at 'Signing in to Plex...'
indefinitely. Earlier commit (210c208) added 8s/15s timeouts at the SHELL
template level, but the per-service page bodies in buildLoginPage()'s
pages object had their own dead-end behavior: when app-token/:svc
returned an error or no token, the body called fail() showing an error
message but DID NOT redirect anywhere. With check-session still
returning authenticated, the SHELL's 15s failsafe timer never fires
because the script is still 'running' (in the failed .then chain).

Fix in each body (plex/jellyfin/emby/chat):
- After app-token returns no token, check localStorage for a stale token.
  If present, redirect to /web/?direct=1 — Plex/Jellyfin/Emby may still
  accept it for the session, and the user is unblocked either way.
- If no stale token, the fail() message now includes a manual link to
  /web/?direct=1 (not just status.sami re-auth), so the user always has
  an exit. fail() also shows the actual API response body (truncated)
  for easier debugging when something is genuinely wrong.
- catch() handlers get the same manual-link treatment.
- Removed the chat body's debug spam (Status: code + body dump to #d)
  that was making the UI look broken even when it wasn't.

Verified: 133/133 auth/sso/csrf/session tests pass; served page on
plex.sami/jellyfin.sami/emby.sami/chat.sami all contain
myPlexAccessToken/jellyfin_credentials/emby_credentials/token fallback
checks + Open X manually links.
2026-07-24 05:14:57 -07:00
Krystie 09d2451f2c DC-XXX: add AbortSignal timeouts to auto-login page JS, kill hang
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.
2026-07-24 05:14:57 -07:00
Krystie e69a93a825 bump version: cookie-only session 2026-07-24 05:14:56 -07:00
Krystie 96e2ef8609 DC-XXX: cookie-only session validation, kill IP-key cache mismatch
isSessionValid previously checked verifyIPSession() first, falling back to
verifySessionCookie() only if IP miss. Under Caddy --network host forward_auth,
req.ip arrived as 100.121.150.22 (DNS2 tailnet) instead of the user's real IP,
causing every cross-subdomain auto-login (plex/jellyfin/emby/chat) to 401 even
with a valid cookie. Now cookie-only; the IP cache write-back is kept as a
no-op for telemetry compat.

Verification on DNS2: /dashcaddy-login renders in 184ms (was 7s).
app-token/plex with the TOTP-issued cookie returns 200 with a real Plex token.
2026-07-24 05:14:55 -07:00
10 changed files with 310 additions and 83 deletions
+1 -1
View File
@@ -1 +1 @@
8ea41e0
20260722-065235-cookie-only-session-653478a
@@ -0,0 +1,64 @@
'use strict';
const configureMiddleware = require('../src/utilities/middleware');
function buildSession() {
const app = {
param: jest.fn(),
set: jest.fn(),
use: jest.fn(),
};
return configureMiddleware(app, {
siteConfig: { dashboardHost: 'status.sami', tld: '.sami' },
totpConfig: { enabled: true, sessionDuration: '24h' },
tailscaleConfig: { enabled: false, requireAuth: false },
metrics: { recordRequest: jest.fn() },
auditLogger: { middleware: () => (_req, _res, next) => next() },
authManager: { verifyJWT: jest.fn(), verifyAPIKey: jest.fn() },
log: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() },
cryptoUtils: { loadOrCreateKey: () => Buffer.alloc(32, 7) },
isValidContainerId: () => true,
isTailscaleIP: () => false,
getTailscaleStatus: async () => null,
});
}
function captureCookie(setCookie) {
const headers = {};
setCookie({ setHeader: (name, value) => { headers[name.toLowerCase()] = value; } }, '24h');
return headers['set-cookie'];
}
describe('TOTP session cookie scope', () => {
test('primary login cookie is host-only for custom TLD deployments', () => {
const session = buildSession();
const cookie = captureCookie(session.setSessionCookie);
expect(cookie).toContain('dashcaddy_session=');
expect(cookie).toContain('HttpOnly');
expect(cookie).toContain('Secure');
expect(cookie).toContain('SameSite=Lax');
expect(cookie).not.toMatch(/(?:^|;)\s*Domain=/i);
});
test('SSO exchange uses the same host-only cookie contract', () => {
const session = buildSession();
const cookie = captureCookie(session.setHostOnlySessionCookie);
expect(cookie).toContain('dashcaddy_session=');
expect(cookie).not.toMatch(/(?:^|;)\s*Domain=/i);
});
test('logout clears the host-only secure cookie', () => {
const session = buildSession();
const headers = {};
session.clearSessionCookie({
setHeader: (name, value) => { headers[name.toLowerCase()] = value; },
});
expect(headers['set-cookie']).toContain('Max-Age=0');
expect(headers['set-cookie']).toContain('Secure');
expect(headers['set-cookie']).not.toMatch(/(?:^|;)\s*Domain=/i);
});
});
+98 -34
View File
@@ -1,6 +1,7 @@
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
@@ -9,10 +10,10 @@ const { AuthenticationError, NotFoundError } = require('../../src/utilities/erro
*/
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,
@@ -202,6 +203,25 @@ module.exports = function(deps) {
}
}, '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, '');
@@ -209,6 +229,14 @@ module.exports = function(deps) {
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);
});
@@ -222,57 +250,93 @@ function buildLoginPage(service) {
// 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 charset="utf-8"><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-content: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-space: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');
function go(u){setTimeout(function(){location.replace(u)},300)}
function fail(msg,info){m.innerHTML=msg;d.textContent=info||''}
function ft(svc){return fetch('/dashcaddy-api/api/auth/app-token/'+svc,{credentials:'include'})}
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()}]}))}
// 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'}).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.message)})
})()</script></body></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){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)})`
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')}
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)})`
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/')}
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)})`
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/')}
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)})`
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'))})`
},
};
+14 -2
View File
@@ -220,8 +220,17 @@ const SETUP_WINDOW_MS = 60 * 60 * 1000;
// Rotate CSRF token for the new session
const newCsrfToken = renewCSRFToken(res, req.secure || req.protocol === 'https');
// Cross-subdomain SSO handoff token (see middleware.js "Cross-subdomain
// SSO token handoff" for why): the Domain=.sami cookie set above is
// silently dropped by real browsers on any OTHER *.sami subdomain, so
// status.sami's login-page frontend appends this token to the redirect
// URL when bouncing the user back to a gated service. That service's
// login page exchanges it via /auth/sso-exchange for its own host-only
// session cookie. Single-use, 60s TTL — see ctx.session.createHandoffToken.
const ssoToken = ctx.session.createHandoffToken();
log.debug('auth', 'Session created', { sessions: ctx.session.ipSessions.size });
ok(res, { message: 'Authenticated successfully', sessionDuration: ctx.totpConfig.sessionDuration, csrfToken: newCsrfToken });
ok(res, { message: 'Authenticated successfully', sessionDuration: ctx.totpConfig.sessionDuration, csrfToken: newCsrfToken, ssoToken });
}, 'totp-verify'));
// Check session validity (used by Caddy forward_auth)
@@ -243,7 +252,10 @@ const SETUP_WINDOW_MS = 60 * 60 * 1000;
const valid = ctx.session.isValid(req);
log.debug('auth', 'Session check', { ip: ctx.session.getClientIP(req), valid, sessions: ctx.session.ipSessions.size });
if (valid) {
return res.status(200).json({ authenticated: true });
// Response contract: { success: true, authenticated: true } — login-page
// consumer in /api/v1/auth/login-page reads `if(!st.success||!st.authenticated)`
// and would otherwise redirect valid sessions to status.sami in a TOTP loop.
return ok(res, { authenticated: true });
}
throw new AuthenticationError('Session expired or invalid');
+9 -1
View File
@@ -217,15 +217,23 @@ async function createApp() {
// /api/v1/auth/app-token/<id> -> /api/v1/auth/app-token/<id> (drift)
// /api/auth/totp/check-session -> /api/v1/totp/check-session (mounted at /totp/check-session — no /auth prefix)
// /api/v1/auth/totp/check-session->/api/v1/totp/check-session (drift)
// /api/auth/sso-exchange -> /api/v1/auth/sso-exchange (mounted at /auth/sso-exchange, same shape as gate/app-token)
//
// The totp case drops `/auth` because the canonical route is /totp/check-session
// (no /auth prefix) but the legacy JS still uses /api/auth/totp/check-session
// (and a stale-browser version of the page uses /api/v1/auth/totp/check-session).
// Without these rewrites the JS gets a 404 and the page hangs at
// "Signing in to Plex..." forever (user-reported 2026-07-09).
//
// sso-exchange added 2026-07-24: same Caddy handle_path /dashcaddy-api/*
// strips only the /dashcaddy-api prefix, so the login-page JS's fetch to
// /dashcaddy-api/api/auth/sso-exchange arrives here as /api/auth/sso-exchange
// — needs the same rewrite as gate/app-token, not the check-session one
// (this route's canonical mount already includes /auth/).
app.use((req, res, next) => {
if (req.url.startsWith('/api/auth/gate/') || req.url.startsWith('/api/v1/auth/gate/')
|| req.url.startsWith('/api/auth/app-token/') || req.url.startsWith('/api/v1/auth/app-token/')) {
|| req.url.startsWith('/api/auth/app-token/') || req.url.startsWith('/api/v1/auth/app-token/')
|| req.url.startsWith('/api/auth/sso-exchange')) {
req.url = '/api/v1' + req.url.slice(4); // '/api'.length === 4
} else if (req.url.startsWith('/api/auth/totp/check-session')) {
// Legacy: /api/auth/totp/check-session -> /api/v1/totp/check-session
+8 -1
View File
@@ -4,7 +4,11 @@
*/
function createSessionContext(middlewareResult) {
const { ipSessions, SESSION_DURATIONS, getClientIP, createIPSession, setSessionCookie, clearIPSession, clearSessionCookie, isSessionValid } = middlewareResult;
const {
ipSessions, SESSION_DURATIONS, getClientIP, createIPSession, setSessionCookie,
clearIPSession, clearSessionCookie, isSessionValid,
createHandoffToken, redeemHandoffToken, setHostOnlySessionCookie
} = middlewareResult;
return {
ipSessions,
@@ -15,6 +19,9 @@ function createSessionContext(middlewareResult) {
clear: clearIPSession,
clearCookie: clearSessionCookie,
isValid: isSessionValid,
createHandoffToken,
redeemHandoffToken,
setCookieHostOnly: setHostOnlySessionCookie,
};
}
+66 -6
View File
@@ -227,6 +227,10 @@ module.exports = function configureMiddleware(app, {
ipSessions.delete(getClientIP(req));
}
// Session cookies are intentionally host-only. Browsers reject Domain=.sami
// because .sami is an unregistered custom TLD and therefore treated as a
// public suffix. Cross-subdomain login is handled by the one-time SSO
// handoff below, which mints a separate host-only cookie on each service.
function setSessionCookie(res, durationKey) {
const durationMs = SESSION_DURATIONS[durationKey];
if (!durationMs) return;
@@ -235,9 +239,8 @@ module.exports = function configureMiddleware(app, {
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
const key = cryptoUtils.loadOrCreateKey();
const sig = crypto.createHmac('sha256', key).update(payloadB64).digest('base64url');
const domainAttr = siteConfig.tld ? `; Domain=${siteConfig.tld}` : '';
res.setHeader('Set-Cookie',
`${SESSION_COOKIE_NAME}=${payloadB64}.${sig}${domainAttr}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Lax`
`${SESSION_COOKIE_NAME}=${payloadB64}.${sig}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Lax`
);
}
@@ -268,16 +271,28 @@ module.exports = function configureMiddleware(app, {
}
function clearSessionCookie(res) {
const domainAttr = siteConfig.tld ? `; Domain=${siteConfig.tld}` : '';
res.setHeader('Set-Cookie',
`${SESSION_COOKIE_NAME}=; Max-Age=0${domainAttr}; Path=/; HttpOnly; SameSite=Lax`
`${SESSION_COOKIE_NAME}=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Lax`
);
}
// COOKIE-ONLY session validation. The previous IP-keyed cache (verifyIPSession
// + the write-back in this function) caused cross-subdomain SSO breakage when
// Caddy on --network host forwards auth to the container: req.ip arrives as
// 100.121.150.22 (DNS2's tailnet IP) instead of the user's real IP, so the
// IP cache misses even when the cookie is valid. The host-only cookie is
// signed with a persisted HMAC key (loadOrCreateKey()). Cross-subdomain
// authentication uses the one-time SSO handoff because browsers reject
// Domain=.sami. HttpOnly + Secure + SameSite=Lax makes it a stronger
// credential than the IP cache. Ref: skill auth-and-monitoring-pitfalls.md
// "TOTP session validation IP-key issue" (FIXED 2026-07-21).
function isSessionValid(req) {
if (verifyIPSession(req)) return true;
const cookies = parseCookies(req.headers.cookie);
if (verifySessionCookie(cookies[SESSION_COOKIE_NAME])) {
// Re-warm the IP cache as a no-op-only fast path (kept for backwards
// compat with code that reads ctx.session.ipSessions.size for telemetry,
// but it is NOT consulted for auth decisions). The next line intentionally
// does NOT gate the return on verifyIPSession anymore.
const ip = getClientIP(req);
if (totpConfig.sessionDuration && SESSION_DURATIONS[totpConfig.sessionDuration]) {
ipSessions.set(ip, { exp: Date.now() + SESSION_DURATIONS[totpConfig.sessionDuration] });
@@ -287,6 +302,43 @@ module.exports = function configureMiddleware(app, {
return false;
}
// ── Cross-subdomain SSO token handoff ──
// 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). That means the
// session cookie set on status.sami never reaches plex.sami/jellyfin.sami/
// etc, and cross-subdomain SSO can never work via a shared cookie no matter
// how the cookie itself is constructed.
//
// Fix: after TOTP verify, mint a short-lived single-use opaque token and
// pass it in the redirect URL back to the target service. That service's
// origin exchanges the token (via /auth/sso-exchange) for its OWN host-only
// cookie (no Domain attribute — always accepted, since it's scoped to the
// exact host that set it). isSessionValid/verifySessionCookie don't care
// about the cookie's Domain at all, only its HMAC signature, so a host-only
// cookie validates identically to the cross-domain one — no changes needed
// to any existing session-check code path.
const ssoHandoffTokens = new Map();
const SSO_HANDOFF_TTL_MS = 60 * 1000;
function createHandoffToken() {
const token = crypto.randomBytes(24).toString('base64url');
ssoHandoffTokens.set(token, { exp: Date.now() + SSO_HANDOFF_TTL_MS });
return token;
}
function redeemHandoffToken(token) {
if (!token) return false;
const entry = ssoHandoffTokens.get(token);
ssoHandoffTokens.delete(token); // one-time use regardless of outcome
return !!entry && entry.exp > Date.now();
}
function setHostOnlySessionCookie(res, durationKey) {
setSessionCookie(res, durationKey);
}
// ── Public routes (bypass TOTP and JWT auth) ──
// Routes here are accessible without authentication. By default the
// monitoring/health-check endpoints are public so the dashboard can
@@ -327,6 +379,11 @@ module.exports = function configureMiddleware(app, {
{ path: '/api/v1/auth/gate/', prefix: true },
{ path: '/api/v1/auth/app-token/', prefix: true },
{ path: '/api/v1/auth/login-page', exact: true, method: 'GET' },
// Must be public: a fresh cross-subdomain visitor has no session yet by
// definition — that's exactly the gap /auth/sso-exchange closes. The
// endpoint itself only accepts a valid single-use handoff token minted
// moments earlier by a successful TOTP verify, so this isn't an open door.
{ path: '/api/v1/auth/sso-exchange', exact: true, method: 'GET' },
// DC-046 pluggable auth endpoints — public by design (they ARE login).
// Use :provider placeholder; today's only provider is TOTP, but the
// route is parameterized so DC-047's email provider just works.
@@ -573,6 +630,9 @@ module.exports = function configureMiddleware(app, {
clearSessionCookie,
isSessionValid,
ipSessions,
renewCSRFToken
renewCSRFToken,
createHandoffToken,
redeemHandoffToken,
setHostOnlySessionCookie
};
};
+36 -36
View File
File diff suppressed because one or more lines are too long
+13 -1
View File
@@ -91,7 +91,19 @@
const redirect = safeSessionGet('totp_redirect');
if (redirect) {
try { sessionStorage.removeItem('totp_redirect'); } catch (_) {}
window.location.href = redirect;
// .sami is an unregistered TLD, so browsers silently drop the
// Domain=.sami session cookie on any OTHER *.sami subdomain (they
// treat "sami" as the effective public suffix, same protection
// that blocks a Domain=.com supercookie). The target service can't
// see our session cookie no matter how it's built, so instead we
// hand it a one-time token in the URL; its login page exchanges
// that for its own host-only cookie via /auth/sso-exchange.
let target = redirect;
if (data.ssoToken) {
const sep = redirect.includes('?') ? '&' : '?';
target = redirect + sep + 'dc_token=' + encodeURIComponent(data.ssoToken);
}
window.location.href = target;
return;
}
// Initialize dashboard
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'dashcaddy-shell-1b7c08184e';
const CACHE = 'dashcaddy-shell-4706f2a8fa';
const PRECACHE = [
'/',
'/index.html',