// ===== 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

${providerButtons}
`; overlay.classList.add('show'); // Wire each button card.querySelectorAll('.provider-btn').forEach(btn => { btn.addEventListener('click', () => { const name = btn.dataset.provider; const provider = providers.find(p => p.name === name); renderProviderChallenge(provider); }); }); } function restoreOriginalBody() { const overlay = document.getElementById('totp-overlay'); if (!overlay) return; const card = overlay.querySelector('.totp-card'); if (!card || !card.dataset.originalBody) return; card.innerHTML = card.dataset.originalBody; // Re-bind the original digit input handlers by re-running the totp-auth // bootstrap. The simplest path: reload the page, which re-runs all IIFEs. window.location.reload(); } function renderProviderChallenge(provider) { const overlay = document.getElementById('totp-overlay'); if (!overlay) return; const card = overlay.querySelector('.totp-card'); if (!card) return; if (provider.name === 'totp') { // Restore the original TOTP markup → existing totp-auth.js submitTotpCode // path handles the rest. (Reload is the cleanest path because // totp-auth.js wires its input handlers at top-level IIFE time.) window.location.reload(); return; } if (provider.name === 'email') { card.innerHTML = `

Sign in with email

We'll email you a one-time sign-in link.

← Back
`; 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; } } 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); // Show on next tick so the DOM (the .totp-card) is ready setTimeout(show, 0); } // Expose for hot-trigger from other modules (e.g. logout) window._showAuthGate = show; window._authGateMethods = fetchMethods; })();