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.
156 lines
6.0 KiB
JavaScript
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;
|
|
})();
|