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.
This commit is contained in:
@@ -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, '');
|
||||||
@@ -249,10 +269,30 @@ function buildLoginPage(service) {
|
|||||||
// Belt-and-suspenders hard timeout: if nothing in this script succeeds
|
// Belt-and-suspenders hard timeout: if nothing in this script succeeds
|
||||||
// within 15s, force-redirect to status.sami so the user can re-auth.
|
// 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);
|
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',signal:withTimeout(5000)}).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&&e.message||'unknown'))})
|
}).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'))})
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -299,6 +299,54 @@ 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) {
|
||||||
|
const durationMs = SESSION_DURATIONS[durationKey];
|
||||||
|
if (!durationMs) return;
|
||||||
|
const maxAge = Math.floor(durationMs / 1000);
|
||||||
|
const payload = { v: true, exp: Date.now() + durationMs };
|
||||||
|
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||||
|
const key = cryptoUtils.loadOrCreateKey();
|
||||||
|
const sig = crypto.createHmac('sha256', key).update(payloadB64).digest('base64url');
|
||||||
|
// No Domain attribute — host-only, so it's always accepted regardless of
|
||||||
|
// the .sami public-suffix issue described above.
|
||||||
|
res.setHeader('Set-Cookie',
|
||||||
|
`${SESSION_COOKIE_NAME}=${payloadB64}.${sig}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Lax`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── 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
|
||||||
@@ -339,6 +387,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.
|
||||||
@@ -585,6 +638,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