Files
dashcaddy/status/js/totp-auth.js
T
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

156 lines
6.0 KiB
JavaScript

// ===== TOTP AUTHENTICATION GATE =====
(function() {
function updateTotpLogo() {
const card = document.querySelector('.totp-card');
if (!card) return;
const bg = getComputedStyle(card).backgroundColor;
const m = bg.match(/\d+/g);
if (!m) return;
const lum = (0.299 * +m[0] + 0.587 * +m[1] + 0.114 * +m[2]) / 255;
const dark = card.querySelector('.totp-logo-dark');
const light = card.querySelector('.totp-logo-light');
if (dark) dark.style.display = lum > 0.5 ? 'none' : '';
if (light) light.style.display = lum > 0.5 ? '' : 'none';
}
function showTotpOverlay() {
const overlay = document.getElementById('totp-overlay');
if (overlay) {
overlay.classList.add('show');
setTimeout(updateTotpLogo, 50);
const firstInput = overlay.querySelector('.totp-digits input');
if (firstInput) setTimeout(() => firstInput.focus(), 100);
}
// Refresh the "Lost access?" recovery link visibility based on server state.
// Hides itself if TOTP is healthy; shows if unreadable/corrupt. The user
// can still click it even when healthy — but the panel will explain there's
// no recovery needed. Cheaper than gating it.
if (typeof window._refreshRecoveryLink === 'function') {
window._refreshRecoveryLink();
}
}
function hideTotpOverlay() {
const overlay = document.getElementById('totp-overlay');
if (overlay) overlay.classList.remove('show');
}
// Setup digit input UX
const container = document.getElementById('totp-digits');
if (container) {
const inputs = container.querySelectorAll('input');
inputs.forEach((input, idx) => {
input.addEventListener('input', (e) => {
const val = e.target.value.replace(/\D/g, '');
e.target.value = val.slice(0, 1);
if (val && idx < inputs.length - 1) inputs[idx + 1].focus();
const code = Array.from(inputs).map(i => i.value).join('');
if (code.length === 6) submitTotpCode(code);
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' && !e.target.value && idx > 0) {
inputs[idx - 1].focus();
inputs[idx - 1].value = '';
}
});
input.addEventListener('paste', (e) => {
e.preventDefault();
const pasted = (e.clipboardData.getData('text') || '').replace(/\D/g, '');
if (pasted.length >= 6) {
inputs.forEach((inp, i) => { inp.value = pasted[i] || ''; });
inputs[5].focus();
submitTotpCode(pasted.slice(0, 6));
}
});
});
}
async function submitTotpCode(code) {
const errorEl = document.getElementById('totp-error');
errorEl.textContent = 'Verifying...';
errorEl.className = 'totp-error verifying';
try {
const res = await secureFetch('/api/v1/totp/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code })
});
const data = await res.json();
if (data.success) {
errorEl.textContent = '';
// Update cached CSRF token from TOTP response (server rotates it on login)
if (data.csrfToken) {
csrfToken = data.csrfToken;
}
hideTotpOverlay();
// Check if redirected here from another service
const redirect = safeSessionGet('totp_redirect');
if (redirect) {
try { sessionStorage.removeItem('totp_redirect'); } catch (_) {}
// .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
if (typeof window.initializeDashboard === 'function') {
window.initializeDashboard();
}
} else {
errorEl.textContent = data.error || 'Invalid code';
errorEl.className = 'totp-error';
const inputs = document.querySelectorAll('#totp-digits input');
inputs.forEach(i => { i.value = ''; });
inputs[0]?.focus();
}
} catch (e) {
errorEl.textContent = 'Connection error';
errorEl.className = 'totp-error';
}
}
// Handle ?auth=required redirect from Caddy SSO.
// DC-049: if the auth-gate module will handle this (multi-provider
// installs), skip our TOTP-only branch entirely. Single-provider
// TOTP-only installs continue to use this path because auth-gate.js
// delegates back to window._showTotpOverlay().
const __dc_049_skip = !!(window.__dc_049_handled);
if (!__dc_049_skip && urlParams.get('auth') === 'required') {
const returnUrl = urlParams.get('return');
if (returnUrl) {
// Validate redirect URL: must be same-origin or hostname must end with our TLD
// (prevents open redirect via includes() bypass like evil.com?q=.sami)
try {
const parsed = new URL(returnUrl, window.location.origin);
const hostname = parsed.hostname;
const isSameOrigin = parsed.origin === window.location.origin;
const tldSuffix = SITE.tld.startsWith('.') ? SITE.tld : '.' + SITE.tld;
const isOurTld = hostname.endsWith(tldSuffix) || hostname === tldSuffix.substring(1);
if (isSameOrigin || isOurTld) {
safeSessionSet('totp_redirect', returnUrl);
}
} catch (_) {
// Invalid URL — reject redirect
}
}
// Clean URL
window.history.replaceState({}, '', window.location.pathname);
}
window._showTotpOverlay = showTotpOverlay;
})();