DC-049 auth gate UI: pluggable provider selector + email challenge
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

New module status/js/auth-gate.js owns the Caddy ?auth=required flow.
On load it queries GET /api/v1/auth/login/methods to discover which
AuthProviders are configured. Three branches:

  * 0 providers  → legacy TOTP overlay (delegates to window._showTotpOverlay)
  * 1 provider (totp only) → legacy TOTP overlay (delegates, no UI change)
  * 2+ providers → provider selector with 'Sign in with …' buttons

Email provider challenge is a single email input + 'Send sign-in link'
button. POST to /api/v1/auth/login/email/initiate. On success the UI
shows 'check the server logs' message if deliveredVia == 'dev-console'
(production hosts without SMTP fall back gracefully) or 'check your
inbox' when SMTP is configured.

TOTP button just calls window.location.reload() — simplest path because
totp-auth.js wires the 6-digit input handlers at module-load time, and
a reload re-runs all IIFEs with the original markup. Same behavior as
the legacy single-provider path.

Coordination with totp-auth.js: auth-gate.js sets window.__dc_049_handled
= true at IIFE entry. totp-auth.js's top-level ?auth=required check
reads that flag and skips its own UI when set — eliminates the flicker
in multi-provider installs. Single-provider installs still work because
the legacy code path is unchanged (auth-gate delegates to it).

Bundle order in build.js: auth-gate.js BEFORE totp-auth.js so the flag
is set in time.

Webpack-style bundle markers verified offline: __dc_049_handled,
auth-gate-email-input, provider-btn, _showAuthGate, totp_redirect all
present in dist/core.js (now 20 files, 248KB raw / 153KB min). New SW
cache hash dashcaddy-shell-680e230383 (was 743f9c17b0).
This commit is contained in:
Hermes
2026-07-20 02:17:12 -07:00
parent 56f1a001f2
commit 923ce8c300
3 changed files with 252 additions and 3 deletions
+6
View File
@@ -18,6 +18,12 @@ const bundles = {
JS('globals.js'), JS('globals.js'),
JS('skeleton-loader.js'), JS('skeleton-loader.js'),
JS('theme.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'), JS('totp-auth.js'),
// totp-recovery.js registers window._refreshRecoveryLink which totp-auth.js // totp-recovery.js registers window._refreshRecoveryLink which totp-auth.js
// calls from showTotpOverlay(). Must come after totp-auth.js. // calls from showTotpOverlay(). Must come after totp-auth.js.
+239
View File
@@ -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 = `<button class="provider-btn" data-provider="${p.name}"
style="margin: 6px 4px; padding: 10px 16px; background: var(--accent); color: white; border: 0; border-radius: 6px; cursor: pointer; font-size: 0.95rem;">
Sign in with ${label}
</button>`;
return btn;
}).join('\n');
card.innerHTML = `
<img class="totp-logo totp-logo-dark" src="/assets/dashcaddy-logo-dark.png" alt="DashCaddy" onerror="this.style.display='none'">
<img class="totp-logo totp-logo-light" src="/assets/dashcaddy-logo-light.png" alt="DashCaddy" onerror="this.style.display='none'">
<p class="subtitle">Choose how to sign in</p>
<div id="auth-gate-providers" style="margin: 14px 0 10px;">${providerButtons}</div>
<div id="auth-gate-message" style="margin-top: 8px; color: var(--muted); font-size: 0.8rem;"></div>
`;
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 = `
<img class="totp-logo totp-logo-dark" src="/assets/dashcaddy-logo-dark.png" alt="DashCaddy" onerror="this.style.display='none'">
<img class="totp-logo totp-logo-light" src="/assets/dashcaddy-logo-light.png" alt="DashCaddy" onerror="this.style.display='none'">
<p class="subtitle">Sign in with email</p>
<p style="margin: 6px 0 14px; font-size: 0.85rem; color: var(--muted);">
We'll email you a one-time sign-in link.
</p>
<input id="auth-gate-email-input" type="email" inputmode="email" autocomplete="email"
placeholder="you@example.com"
style="width: 100%; padding: 10px 12px; font-size: 1rem; background: var(--input-bg); color: var(--text); border: 1px solid var(--border); border-radius: 6px; box-sizing: border-box;">
<button id="auth-gate-email-submit" class="provider-btn"
style="margin-top: 14px; padding: 10px 16px; background: var(--accent); color: white; border: 0; border-radius: 6px; cursor: pointer; font-size: 0.95rem; width: 100%;">
Send sign-in link
</button>
<div id="auth-gate-email-status" style="margin-top: 12px; font-size: 0.85rem; line-height: 1.4;"></div>
<div style="margin-top: 16px;">
<a href="#" id="auth-gate-back" style="color: var(--muted); font-size: 0.8rem;">← Back</a>
</div>
`;
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 <strong>${masked}</strong>.)`;
} else {
status.innerHTML = `Sign-in link sent to <strong>${masked}</strong>.
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;
})();
+7 -3
View File
@@ -111,9 +111,13 @@
} }
} }
// Handle ?auth=required redirect from Caddy SSO // Handle ?auth=required redirect from Caddy SSO.
const urlParams = new URLSearchParams(window.location.search); // DC-049: if the auth-gate module will handle this (multi-provider
if (urlParams.get('auth') === 'required') { // 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'); const returnUrl = urlParams.get('return');
if (returnUrl) { if (returnUrl) {
// Validate redirect URL: must be same-origin or hostname must end with our TLD // Validate redirect URL: must be same-origin or hostname must end with our TLD