// ===== PLUGGABLE AUTH GATE (DC-049) =====
//
// On Caddy redirect to ?auth=required, this module queries
// GET /api/v1/auth/login/methods to discover which AuthProviders are enabled.
// If only one provider is enabled, jump straight to its challenge UI
// (TOTP-only installations today). If multiple providers exist, render a
// selector first so the user picks how to sign in.
//
// Today:
// - totp: challenge is 6-digit code via the existing TOTP overlay
// - email: challenge is email address → POST /api/v1/auth/login/email/initiate
// → server emails magic link (or logs it to console in dev) →
// waiting for verification (no auto-promotion; user reloads via
// email link, which routes through /api/v1/auth/login/email/verify)
//
// This module wires into the existing totp-auth.js submitTotpCode() flow so
// the legacy TOTP happy-path is unchanged: when methods returns only
// `totp`, this module just shows the TOTP overlay and exits.
(function() {
// ---- DOM refs (created lazily below; existing TOTP markup supplies them) ----
let methodsCache = null;
async function fetchMethods() {
if (methodsCache) return methodsCache;
try {
const res = await fetch('/api/v1/auth/login/methods', { cache: 'no-store' });
if (!res.ok) throw new Error(`methods HTTP ${res.status}`);
const data = await res.json();
methodsCache = Array.isArray(data.providers) ? data.providers : [];
return methodsCache;
} catch (e) {
// Public endpoint — if it fails, fall through to legacy TOTP path.
console.warn('[auth-gate] methods fetch failed; falling back to TOTP-only', e);
return [];
}
}
function showProviderSelector(providers) {
const overlay = document.getElementById('totp-overlay');
if (!overlay) return;
const card = overlay.querySelector('.totp-card');
if (!card) return;
// Save the existing TOTP-challenge body so we can restore it on cancel.
const originalBody = card.innerHTML;
if (!card.dataset.originalBody) card.dataset.originalBody = originalBody;
const providerButtons = providers.map(p => {
const label = (p.config && (p.config.label || p.name)) || p.name;
const btn = ``;
return btn;
}).join('\n');
card.innerHTML = `
Choose how to sign in
Sign in with email
We'll email you a one-time sign-in link.
`; overlay.classList.add('show'); const input = card.querySelector('#auth-gate-email-input'); const submit = card.querySelector('#auth-gate-email-submit'); const status = card.querySelector('#auth-gate-email-status'); const back = card.querySelector('#auth-gate-back'); submit.addEventListener('click', async () => { const email = (input.value || '').trim(); if (!email || !email.includes('@')) { status.textContent = 'Enter a valid email address.'; status.style.color = 'var(--error, #d33)'; return; } submit.disabled = true; status.textContent = 'Sending…'; status.style.color = ''; try { const res = await fetch('/api/v1/auth/login/email/initiate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), }); const data = await res.json(); if (data.success) { const via = data.deliveredVia || 'email'; const masked = data.maskedEmail || email; if (via === 'dev-console') { status.innerHTML = ` Check the server logs for your one-time link. (Dev mode: no SMTP configured. In production this would email ${masked}.)`; } else { status.innerHTML = `Sign-in link sent to ${masked}. Check your inbox (and spam folder).`; } status.style.color = 'var(--success, #2a7)'; } else { status.textContent = data.error || 'Could not send link.'; status.style.color = 'var(--error, #d33)'; submit.disabled = false; } } catch (e) { status.textContent = 'Connection error. Try again.'; status.style.color = 'var(--error, #d33)'; submit.disabled = false; } }); input.addEventListener('keydown', e => { if (e.key === 'Enter') submit.click(); }); back.addEventListener('click', e => { e.preventDefault(); showProviderSelector(providers); }); return; } // Unknown provider → restore TOTP path as a last resort console.warn('[auth-gate] unknown provider', provider.name); window.location.reload(); } async function show() { const overlay = document.getElementById('totp-overlay'); if (!overlay) return; // TOTP-only build without our overlay changes const providers = await fetchMethods(); if (providers.length === 0) { // Either the endpoint isn't reachable OR no provider reports enabled. // Fall back to the legacy TOTP overlay — existing totp-auth.js handles it. if (typeof window._showTotpOverlay === 'function') window._showTotpOverlay(); return; } if (providers.length === 1 && providers[0].name === 'totp') { // Single TOTP provider → show the original TOTP overlay unchanged, // but with an "Or sign in with email" link below so the email path // is reachable as the recovery / phone-friendly alternative. Most // users still want their primary method (TOTP) front-and-center. showTotpWithEmailFallback(providers[0]); return; } showProviderSelector(providers); } function showTotpWithEmailFallback(totpProvider) { const emailEnabled = methodsCache && methodsCache.find(p => p.name === 'email'); if (!emailEnabled) { // Truly single-provider path: legacy TOTP overlay, no alt link. if (typeof window._showTotpOverlay === 'function') window._showTotpOverlay(); return; } const overlay = document.getElementById('totp-overlay'); const card = overlay.querySelector('.totp-card'); if (!card) return; // Save the original TOTP markup so we can restore on alt-link click off. if (!card.dataset.originalBody) card.dataset.originalBody = card.innerHTML; // Add a small "or" link at the bottom of the existing card WITHOUT // touching the TOTP input markup — keeps totp-auth.js's submitTotpCode // binding intact. let alt = card.querySelector('#auth-gate-email-alt'); if (!alt) { const div = document.createElement('div'); div.id = 'auth-gate-email-alt'; div.style.cssText = 'margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); font-size: 0.85rem;'; div.innerHTML = ` Or sign in with email instead → `; card.appendChild(div); div.querySelector('#auth-gate-email-alt-link').addEventListener('click', e => { e.preventDefault(); renderProviderChallenge(emailEnabled); }); } overlay.classList.add('show'); } // ---- Trigger points ---- // 1. SSO redirect from Caddy: ?auth=required // We claim ownership here (set window.__dc_049_handled = true) // BEFORE totp-auth.js's own ?auth=required check runs, so the legacy // TOTP-only overlay doesn't flicker in for multi-provider installs. // Single-provider TOTP-only installs work because we still delegate // back to window._showTotpOverlay() in `show()` below. window.__dc_049_handled = true; function isAllowedReturnUrl(returnUrl) { try { const parsed = new URL(returnUrl, window.location.origin); if (!['http:', 'https:'].includes(parsed.protocol)) return false; if (parsed.origin === window.location.origin) return true; if (parsed.protocol !== 'https:') return false; // globals.js is concatenated before this module in core.js, so SITE is // available here. Permit exact hosts and subdomains under the configured // private TLD (for example plex.sami), while rejecting lookalikes such as // plex.sami.evil.example. const suffix = SITE.tld.startsWith('.') ? SITE.tld : `.${SITE.tld}`; return parsed.hostname === suffix.slice(1) || parsed.hostname.endsWith(suffix); } catch (_) { return false; } } function buildSsoHandoffTarget(returnUrl, token) { const parsed = new URL(returnUrl, window.location.origin); if (parsed.origin === window.location.origin) return parsed.toString(); const suffix = SITE.tld.startsWith('.') ? SITE.tld : `.${SITE.tld}`; const isPrivateHost = parsed.hostname === suffix.slice(1) || parsed.hostname.endsWith(suffix); if (parsed.protocol !== 'https:' || !isPrivateHost || !token) return null; const returnPath = `${parsed.pathname}${parsed.search}${parsed.hash}`; parsed.pathname = '/dashcaddy-sso'; parsed.search = ''; parsed.hash = ''; parsed.searchParams.set('token', token); parsed.searchParams.set('return', returnPath); return parsed.toString(); } async function resumeExistingSession(returnUrl) { if (!returnUrl || !isAllowedReturnUrl(returnUrl)) return false; try { const parsedReturn = new URL(returnUrl, window.location.origin); const suffix = SITE.tld.startsWith('.') ? SITE.tld : `.${SITE.tld}`; const serviceId = parsedReturn.hostname.slice(0, -suffix.length); if (!/^[a-z0-9][a-z0-9-]*$/.test(serviceId)) return false; const res = await fetch(`/api/v1/auth/sso-handoff?serviceId=${encodeURIComponent(serviceId)}`, { credentials: 'include', cache: 'no-store', }); if (!res.ok) return false; const data = await res.json(); const target = data.success && buildSsoHandoffTarget(returnUrl, data.ssoToken); if (!target) return false; try { sessionStorage.removeItem('totp_redirect'); } catch (_) {} window.location.replace(target); return true; } catch (_) { return false; } } const urlParams = new URLSearchParams(window.location.search); if (urlParams.get('auth') === 'required') { // Preserve the gated service destination so submitTotpCode() can append // the one-time SSO handoff token and return the browser to that host. const returnUrl = urlParams.get('return'); if (returnUrl && isAllowedReturnUrl(returnUrl)) { try { sessionStorage.setItem('totp_redirect', returnUrl); } catch (_) {} } // Clean URL — happens after we've captured the redirect window.history.replaceState({}, '', window.location.pathname); // Reuse the valid status.sami session first. Only show the TOTP/provider // challenge when that session is genuinely absent or expired. setTimeout(async () => { if (await resumeExistingSession(returnUrl)) return; await show(); }, 0); } // Expose for hot-trigger from other modules (e.g. logout) window._showAuthGate = show; window._authGateMethods = fetchMethods; })();