diff --git a/status/build.js b/status/build.js index dc941b6..dfcd8b2 100644 --- a/status/build.js +++ b/status/build.js @@ -18,6 +18,12 @@ const bundles = { JS('globals.js'), JS('skeleton-loader.js'), JS('theme.js'), + // DC-049: pluggable auth gate — claims ownership of the + // ?auth=required flow by setting window.__dc_049_handled BEFORE + // totp-auth.js runs, so the legacy TOTP-only overlay doesn't flicker + // in for multi-provider installs. Single-provider TOTP-only installs + // work because this module delegates back to window._showTotpOverlay(). + JS('auth-gate.js'), JS('totp-auth.js'), // totp-recovery.js registers window._refreshRecoveryLink which totp-auth.js // calls from showTotpOverlay(). Must come after totp-auth.js. diff --git a/status/js/auth-gate.js b/status/js/auth-gate.js new file mode 100644 index 0000000..b1ddd47 --- /dev/null +++ b/status/js/auth-gate.js @@ -0,0 +1,239 @@ +// ===== 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 only TOTP is enabled (which + // isEnabled() returns false until set up). Either way, 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 + if (typeof window._showTotpOverlay === 'function') window._showTotpOverlay(); + return; + } + showProviderSelector(providers); + } + + // ---- 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; + + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.get('auth') === 'required') { + // Save returnUrl the same way totp-auth.js does, so both paths share state. + // We don't have access to the SITE constant here (it lives in globals.js's + // module scope), so we use a conservative origin-only check. Caddy's + // forward_auth already validates the request origin upstream. + const returnUrl = urlParams.get('return'); + if (returnUrl) { + try { + const parsed = new URL(returnUrl, window.location.origin); + if (parsed.origin === window.location.origin) { + try { sessionStorage.setItem('totp_redirect', returnUrl); } catch (_) {} + } + } 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; +})(); diff --git a/status/js/totp-auth.js b/status/js/totp-auth.js index c5a7465..96d8f96 100644 --- a/status/js/totp-auth.js +++ b/status/js/totp-auth.js @@ -111,9 +111,13 @@ } } - // Handle ?auth=required redirect from Caddy SSO - const urlParams = new URLSearchParams(window.location.search); - if (urlParams.get('auth') === 'required') { + // 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