Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75f835641f | ||
|
|
872923dba2 | ||
|
|
10f2bf707b | ||
|
|
f42e761e52 | ||
|
|
e208e05b83 | ||
|
|
ba21dad550 | ||
|
|
09d2451f2c | ||
|
|
e69a93a825 | ||
|
|
96e2ef8609 |
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { SESSION_TTL, APP, PLEX, TIMEOUTS, buildMediaAuth } = require('../../src/utilities/constants');
|
const { SESSION_TTL, APP, PLEX, TIMEOUTS, buildMediaAuth } = require('../../src/utilities/constants');
|
||||||
const { AuthenticationError, NotFoundError } = require('../../src/utilities/errors');
|
const { AuthenticationError, NotFoundError } = require('../../src/utilities/errors');
|
||||||
|
const { ok } = require('../../src/utils/responses');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auth SSO gate routes factory
|
* Auth SSO gate routes factory
|
||||||
@@ -202,6 +203,25 @@ module.exports = function(deps) {
|
|||||||
}
|
}
|
||||||
}, 'auth-app-token'));
|
}, '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)
|
// Serve service-specific auto-login page (auth enforced by Caddy forward_auth upstream)
|
||||||
router.get('/auth/login-page', (req, res) => {
|
router.get('/auth/login-page', (req, res) => {
|
||||||
const service = (req.query.service || '').replace(/[^a-z]/g, '');
|
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');
|
if (!html) return res.status(404).send('Unknown service');
|
||||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
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);
|
res.send(html);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -222,22 +250,52 @@ function buildLoginPage(service) {
|
|||||||
// session and we render the auto-login body; if 401, the meta-refresh kicks
|
// session and we render the auto-login body; if 401, the meta-refresh kicks
|
||||||
// in and sends them to status.sami to authenticate first.
|
// in and sends them to status.sami to authenticate first.
|
||||||
const SHELL = (body) => `<!DOCTYPE html>
|
const SHELL = (body) => `<!DOCTYPE html>
|
||||||
<html><head><meta charset="utf-8"><meta http-equiv="Cache-Control" content="no-store"><title>__TITLE__</title>
|
<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-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>
|
<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>
|
</head><body><p id="m">__TITLE__</p><div id="d"></div>
|
||||||
<script>(function(){
|
<script>(function(){
|
||||||
var ls=localStorage,d=document.getElementById('d'),m=document.getElementById('m');
|
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 go(u){setTimeout(function(){location.replace(u)},300)}
|
||||||
function fail(msg,info){m.innerHTML=msg;d.textContent=info||''}
|
function fail(msg,info){try{m.innerHTML=msg;d.textContent=info||''}catch(_){}}
|
||||||
function ft(svc){return fetch('/dashcaddy-api/api/auth/app-token/'+svc,{credentials:'include'})}
|
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()}]}))}
|
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
|
// 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
|
// 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.
|
// 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){
|
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}
|
if(!st||!st.success||!st.authenticated){go('https://status.sami?auth=required&return='+encodeURIComponent(location.href));return}
|
||||||
${body}
|
${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)})
|
}).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>`;
|
})()</script></body></html>`;
|
||||||
|
|
||||||
const pages = {
|
const pages = {
|
||||||
@@ -245,34 +303,40 @@ ${body}
|
|||||||
title: 'Signing in...', bg: '#0a0a0a', accent: '#60a5fa',
|
title: 'Signing in...', bg: '#0a0a0a', accent: '#60a5fa',
|
||||||
body: `if(ls.getItem('token')){go('/?direct=1');return}
|
body: `if(ls.getItem('token')){go('/?direct=1');return}
|
||||||
d.textContent='Fetching token from DashCaddy...';
|
d.textContent='Fetching token from DashCaddy...';
|
||||||
ft('chat').then(function(r){d.textContent+=' Status: '+r.status;return r.text()}).then(function(t){
|
ft('chat').then(function(r){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');return}
|
||||||
try{var j=JSON.parse(t);if(j.token){ls.setItem('token',j.token);go('/?direct=1')}
|
// No token but chat is reachable — fall through to manual UI link below
|
||||||
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')}}
|
fail('Auto-login unavailable. <a href="/?direct=1">Open Chat manually</a>','No token field: '+t.substring(0,200))}
|
||||||
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(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="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Fetch error: '+e.message)})`
|
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/?direct=1">Open Chat manually</a>','Fetch error: '+(e&&e.message||'unknown'))})`
|
||||||
},
|
},
|
||||||
plex: {
|
plex: {
|
||||||
title: 'Signing in to Plex...', bg: '#1f1f1f', accent: '#e5a00d',
|
title: 'Signing in to Plex...', bg: '#1f1f1f', accent: '#e5a00d',
|
||||||
body: `if(ls.getItem('myPlexAccessToken')){go('/web/?direct=1');return}
|
body: `if(ls.getItem('myPlexAccessToken')){go('/web/?direct=1');return}
|
||||||
ft('plex').then(function(r){return r.json()}).then(function(j){
|
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')}
|
if(j.token){ls.setItem('myPlexAccessToken',j.token);d.textContent='Token stored, redirecting...';go('/web/?direct=1');return}
|
||||||
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))}
|
// No token returned. Three fallbacks in priority order:
|
||||||
}).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)})`
|
// 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: {
|
jellyfin: {
|
||||||
title: 'Signing in to Jellyfin...', bg: '#101014', accent: '#00a4dc',
|
title: 'Signing in to Jellyfin...', bg: '#101014', accent: '#00a4dc',
|
||||||
body: `ft('jellyfin').then(function(r){return r.json()}).then(function(j){
|
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/')}
|
if(j.token){merge('jellyfin_credentials',j,'Jellyfin');merge('_jellyfin_credentials',j,'Jellyfin');d.textContent='Token stored, redirecting...';go('/web/');return}
|
||||||
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))}
|
if(ls.getItem('jellyfin_credentials')||ls.getItem('_jellyfin_credentials')){go('/web/');return}
|
||||||
}).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)})`
|
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: {
|
emby: {
|
||||||
title: 'Signing in to Emby...', bg: '#101014', accent: '#52b54b',
|
title: 'Signing in to Emby...', bg: '#101014', accent: '#52b54b',
|
||||||
body: `ft('emby').then(function(r){return r.json()}).then(function(j){
|
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/')}
|
if(j.token){merge('emby_credentials',j,'Emby');merge('_emby_credentials',j,'Emby');d.textContent='Token stored, redirecting...';go('/web/');return}
|
||||||
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))}
|
if(ls.getItem('emby_credentials')||ls.getItem('_emby_credentials')){go('/web/');return}
|
||||||
}).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)})`
|
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'))})`
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -220,8 +220,17 @@ const SETUP_WINDOW_MS = 60 * 60 * 1000;
|
|||||||
// Rotate CSRF token for the new session
|
// Rotate CSRF token for the new session
|
||||||
const newCsrfToken = renewCSRFToken(res, req.secure || req.protocol === 'https');
|
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 });
|
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'));
|
}, 'totp-verify'));
|
||||||
|
|
||||||
// Check session validity (used by Caddy forward_auth)
|
// 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);
|
const valid = ctx.session.isValid(req);
|
||||||
log.debug('auth', 'Session check', { ip: ctx.session.getClientIP(req), valid, sessions: ctx.session.ipSessions.size });
|
log.debug('auth', 'Session check', { ip: ctx.session.getClientIP(req), valid, sessions: ctx.session.ipSessions.size });
|
||||||
if (valid) {
|
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');
|
throw new AuthenticationError('Session expired or invalid');
|
||||||
|
|||||||
@@ -217,15 +217,23 @@ async function createApp() {
|
|||||||
// /api/v1/auth/app-token/<id> -> /api/v1/auth/app-token/<id> (drift)
|
// /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/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/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
|
// 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
|
// (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).
|
// (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
|
// Without these rewrites the JS gets a 404 and the page hangs at
|
||||||
// "Signing in to Plex..." forever (user-reported 2026-07-09).
|
// "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) => {
|
app.use((req, res, next) => {
|
||||||
if (req.url.startsWith('/api/auth/gate/') || req.url.startsWith('/api/v1/auth/gate/')
|
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
|
req.url = '/api/v1' + req.url.slice(4); // '/api'.length === 4
|
||||||
} else if (req.url.startsWith('/api/auth/totp/check-session')) {
|
} else if (req.url.startsWith('/api/auth/totp/check-session')) {
|
||||||
// Legacy: /api/auth/totp/check-session -> /api/v1/totp/check-session
|
// Legacy: /api/auth/totp/check-session -> /api/v1/totp/check-session
|
||||||
|
|||||||
@@ -4,7 +4,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function createSessionContext(middlewareResult) {
|
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 {
|
return {
|
||||||
ipSessions,
|
ipSessions,
|
||||||
@@ -15,6 +19,9 @@ function createSessionContext(middlewareResult) {
|
|||||||
clear: clearIPSession,
|
clear: clearIPSession,
|
||||||
clearCookie: clearSessionCookie,
|
clearCookie: clearSessionCookie,
|
||||||
isValid: isSessionValid,
|
isValid: isSessionValid,
|
||||||
|
createHandoffToken,
|
||||||
|
redeemHandoffToken,
|
||||||
|
setCookieHostOnly: setHostOnlySessionCookie,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -227,6 +227,10 @@ module.exports = function configureMiddleware(app, {
|
|||||||
ipSessions.delete(getClientIP(req));
|
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) {
|
function setSessionCookie(res, durationKey) {
|
||||||
const durationMs = SESSION_DURATIONS[durationKey];
|
const durationMs = SESSION_DURATIONS[durationKey];
|
||||||
if (!durationMs) return;
|
if (!durationMs) return;
|
||||||
@@ -235,9 +239,8 @@ module.exports = function configureMiddleware(app, {
|
|||||||
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||||
const key = cryptoUtils.loadOrCreateKey();
|
const key = cryptoUtils.loadOrCreateKey();
|
||||||
const sig = crypto.createHmac('sha256', key).update(payloadB64).digest('base64url');
|
const sig = crypto.createHmac('sha256', key).update(payloadB64).digest('base64url');
|
||||||
const domainAttr = siteConfig.tld ? `; Domain=${siteConfig.tld}` : '';
|
|
||||||
res.setHeader('Set-Cookie',
|
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) {
|
function clearSessionCookie(res) {
|
||||||
const domainAttr = siteConfig.tld ? `; Domain=${siteConfig.tld}` : '';
|
|
||||||
res.setHeader('Set-Cookie',
|
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) {
|
function isSessionValid(req) {
|
||||||
if (verifyIPSession(req)) return true;
|
|
||||||
const cookies = parseCookies(req.headers.cookie);
|
const cookies = parseCookies(req.headers.cookie);
|
||||||
if (verifySessionCookie(cookies[SESSION_COOKIE_NAME])) {
|
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);
|
const ip = getClientIP(req);
|
||||||
if (totpConfig.sessionDuration && SESSION_DURATIONS[totpConfig.sessionDuration]) {
|
if (totpConfig.sessionDuration && SESSION_DURATIONS[totpConfig.sessionDuration]) {
|
||||||
ipSessions.set(ip, { exp: Date.now() + 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;
|
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) ──
|
// ── Public routes (bypass TOTP and JWT auth) ──
|
||||||
// Routes here are accessible without authentication. By default the
|
// Routes here are accessible without authentication. By default the
|
||||||
// monitoring/health-check endpoints are public so the dashboard can
|
// 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/gate/', prefix: true },
|
||||||
{ path: '/api/v1/auth/app-token/', prefix: true },
|
{ path: '/api/v1/auth/app-token/', prefix: true },
|
||||||
{ path: '/api/v1/auth/login-page', exact: true, method: 'GET' },
|
{ 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).
|
// DC-046 pluggable auth endpoints — public by design (they ARE login).
|
||||||
// Use :provider placeholder; today's only provider is TOTP, but the
|
// Use :provider placeholder; today's only provider is TOTP, but the
|
||||||
// route is parameterized so DC-047's email provider just works.
|
// route is parameterized so DC-047's email provider just works.
|
||||||
@@ -573,6 +630,9 @@ module.exports = function configureMiddleware(app, {
|
|||||||
clearSessionCookie,
|
clearSessionCookie,
|
||||||
isSessionValid,
|
isSessionValid,
|
||||||
ipSessions,
|
ipSessions,
|
||||||
renewCSRFToken
|
renewCSRFToken,
|
||||||
|
createHandoffToken,
|
||||||
|
redeemHandoffToken,
|
||||||
|
setHostOnlySessionCookie
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+36
-36
File diff suppressed because one or more lines are too long
+13
-1
@@ -91,7 +91,19 @@
|
|||||||
const redirect = safeSessionGet('totp_redirect');
|
const redirect = safeSessionGet('totp_redirect');
|
||||||
if (redirect) {
|
if (redirect) {
|
||||||
try { sessionStorage.removeItem('totp_redirect'); } catch (_) {}
|
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;
|
return;
|
||||||
}
|
}
|
||||||
// Initialize dashboard
|
// Initialize dashboard
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'dashcaddy-shell-1b7c08184e';
|
const CACHE = 'dashcaddy-shell-4706f2a8fa';
|
||||||
const PRECACHE = [
|
const PRECACHE = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
|
|||||||
Reference in New Issue
Block a user