Files
dashcaddy/status/js/auth-gate.js
Krystie 003b152230
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
[grade=A] fix(auth): preserve cross-host SSO return URLs
Codex: urn:ump:endpmb3rgtqogn2u2jkbbjmsaha6ysjjcxl46fd27ayig5yosawq
2026-07-24 14:36:28 -07:00

288 lines
13 KiB
JavaScript

// ===== 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 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 = `<a href="#" id="auth-gate-email-alt-link"
style="color: var(--accent); text-decoration: none;">
Or sign in with email instead →
</a>`;
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;
})();