[grade=B] feat(auth): onboard missing credentials into encrypted vault
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s

This commit is contained in:
Hermes
2026-08-22 05:41:38 -07:00
parent d313b1e872
commit 84edb035e3
24 changed files with 959 additions and 235 deletions
+1
View File
@@ -28,6 +28,7 @@ const bundles = {
// totp-recovery.js registers window._refreshRecoveryLink which totp-auth.js
// calls from showTotpOverlay(). Must come after totp-auth.js.
JS('totp-recovery.js'),
JS('credential-vault-handoff.js'),
JS('service-credentials.js'),
JS('totp-settings.js'),
// DC-048 admin panel — modal-overlay UI for user/invite management.
+108 -108
View File
File diff suppressed because one or more lines are too long
+7 -7
View File
File diff suppressed because one or more lines are too long
+5 -1
View File
@@ -287,7 +287,11 @@
async function resumeExistingSession(returnUrl) {
if (!returnUrl || !isAllowedReturnUrl(returnUrl)) return false;
try {
const res = await fetch('/api/v1/auth/sso-handoff', {
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',
});
+55 -52
View File
@@ -33,6 +33,20 @@
return server?.name || dnsId.toUpperCase();
}
async function requireSuccessfulDnsMutation(response, label) {
if (!response) throw new Error(`${label} failed: no response`);
let data;
try {
data = await response.json();
} catch (_) {
throw new Error(`${label} failed: invalid server response`);
}
if (!response.ok || data?.success !== true) {
throw new Error(data?.error || `${label} failed (${response.status || 'unknown status'})`);
}
return data;
}
/** Build per-server credential form sections from SITE.dnsServers */
function buildCredentialSections() {
const container = document.getElementById('dns-cred-sections');
@@ -258,14 +272,6 @@
document.getElementById('token-save')?.addEventListener('click', async () => {
const dnsIds = getDnsIds();
// Save all to localStorage
dnsIds.forEach(dnsId => {
setUsername(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-username`).value.trim());
setToken(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-token`).value.trim());
setUsername(dnsId, 'admin', document.getElementById(`${dnsId}-admin-username`).value.trim());
setToken(dnsId, 'admin', document.getElementById(`${dnsId}-admin-token`).value.trim());
});
// Build per-server credentials payload for backend sync
const servers = {};
let hasAnyCreds = false;
@@ -304,45 +310,36 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ servers })
});
const data = await res.json();
const data = await requireSuccessfulDnsMutation(res, 'DNS credential save');
if (data.results) {
dnsIds.forEach(dnsId => {
const statusEl = document.getElementById(`${dnsId}-token-status`);
if (!servers[dnsId]) { statusEl.textContent = ''; return; }
const result = data.results[dnsId];
if (result?.success) {
statusEl.textContent = '\u2713 Verified & saved';
statusEl.className = 'token-status success';
} else if (result?.partial) {
statusEl.textContent = '\u2713 ' + result.partial;
statusEl.className = 'token-status success';
} else {
statusEl.textContent = '\u2717 ' + (result?.error || 'Login failed');
statusEl.className = 'token-status error';
}
});
} else if (data.success) {
dnsIds.forEach(dnsId => {
if (servers[dnsId]) {
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Saved';
document.getElementById(`${dnsId}-token-status`).className = 'token-status success';
}
});
} else {
dnsIds.forEach(dnsId => {
if (servers[dnsId]) {
document.getElementById(`${dnsId}-token-status`).textContent = '\u2717 ' + (data.error || 'Failed');
document.getElementById(`${dnsId}-token-status`).className = 'token-status error';
}
});
const failed = Object.keys(servers).filter(dnsId => data.results[dnsId]?.success !== true);
if (failed.length) {
const details = failed.map(dnsId => data.results[dnsId]?.error || `${dnsId} failed`).join('; ');
throw new Error(details);
}
}
// Cache locally only after the encrypted server vault confirms success.
dnsIds.forEach(dnsId => {
setUsername(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-username`).value.trim());
setToken(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-token`).value.trim());
setUsername(dnsId, 'admin', document.getElementById(`${dnsId}-admin-username`).value.trim());
setToken(dnsId, 'admin', document.getElementById(`${dnsId}-admin-token`).value.trim());
});
dnsIds.forEach(dnsId => {
const statusEl = document.getElementById(`${dnsId}-token-status`);
if (!servers[dnsId]) { statusEl.textContent = ''; return; }
const result = data.results?.[dnsId];
statusEl.textContent = result?.partial ? '\u2713 ' + result.partial : '\u2713 Verified & saved';
statusEl.className = 'token-status success';
});
} catch (e) {
console.error('Failed to sync DNS credentials to backend:', e);
dnsIds.forEach(dnsId => {
if (servers[dnsId]) {
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Saved locally (sync failed)';
document.getElementById(`${dnsId}-token-status`).className = 'token-status';
document.getElementById(`${dnsId}-token-status`).textContent = '\u2717 ' + (e.message || 'Save failed');
document.getElementById(`${dnsId}-token-status`).className = 'token-status error';
}
});
}
@@ -368,18 +365,24 @@
document.getElementById('token-clear-all')?.addEventListener('click', async () => {
if (confirm('Clear all stored DNS credentials? This cannot be undone.')) {
clearAllCredentials();
getDnsIds().forEach(dnsId => {
document.getElementById(`${dnsId}-readonly-username`).value = '';
document.getElementById(`${dnsId}-readonly-token`).value = '';
document.getElementById(`${dnsId}-admin-username`).value = '';
document.getElementById(`${dnsId}-admin-token`).value = '';
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Cleared';
document.getElementById(`${dnsId}-token-status`).className = 'token-status success';
});
try {
await secureFetch('/api/v1/dns/credentials', { method: 'DELETE' });
} catch (_) {}
const response = await secureFetch('/api/v1/dns/credentials', { method: 'DELETE' });
await requireSuccessfulDnsMutation(response, 'DNS credential removal');
clearAllCredentials();
getDnsIds().forEach(dnsId => {
document.getElementById(`${dnsId}-readonly-username`).value = '';
document.getElementById(`${dnsId}-readonly-token`).value = '';
document.getElementById(`${dnsId}-admin-username`).value = '';
document.getElementById(`${dnsId}-admin-token`).value = '';
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Cleared';
document.getElementById(`${dnsId}-token-status`).className = 'token-status success';
});
} catch (e) {
getDnsIds().forEach(dnsId => {
document.getElementById(`${dnsId}-token-status`).textContent = '\u2717 ' + (e.message || 'Clear failed');
document.getElementById(`${dnsId}-token-status`).className = 'token-status error';
});
}
}
});
+3
View File
@@ -61,6 +61,9 @@
await window.loadServices();
await loadTemplateCategories();
window.buildGrid();
if (typeof window.openRequestedCredentialForm === 'function') {
window.openRequestedCredentialForm();
}
animateTopCards();
window.refreshAll();
setInterval(() => {
+52
View File
@@ -0,0 +1,52 @@
// ===== ENCRYPTED VAULT -> SERVICE SSO HANDOFF =====
(function() {
function isAllowedReturnUrl(returnUrl, expectedServiceId) {
if (!returnUrl || !expectedServiceId || !/^[a-z0-9][a-z0-9-]*$/.test(expectedServiceId)) return false;
try {
const parsed = new URL(returnUrl, window.location.origin);
const suffix = SITE.tld.startsWith('.') ? SITE.tld : `.${SITE.tld}`;
const expectedHost = `${expectedServiceId}${suffix}`;
return parsed.protocol === 'https:' && parsed.hostname === expectedHost;
} catch (_) {
return false;
}
}
function buildHandoffTarget(returnUrl, token, expectedServiceId) {
if (!isAllowedReturnUrl(returnUrl, expectedServiceId)) return null;
const parsed = new URL(returnUrl, window.location.origin);
if (!token) return null;
const returnPath = `${parsed.pathname}${parsed.search}${parsed.hash}`;
// The shared (dashcaddy_auth) Caddy snippet installs this public landing
// route on every protected host. It rewrites to /api/v1/auth/sso-exchange.
parsed.pathname = '/dashcaddy-sso';
parsed.search = '';
parsed.hash = '';
parsed.searchParams.set('token', token);
parsed.searchParams.set('return', returnPath);
return parsed.toString();
}
async function resume(returnUrl, expectedServiceId, runtime = {}) {
if (!isAllowedReturnUrl(returnUrl, expectedServiceId)) return false;
const fetchFn = runtime.fetch || window.fetch.bind(window);
const locationObj = runtime.location || window.location;
try {
const response = await fetchFn(`/api/v1/auth/sso-handoff?serviceId=${encodeURIComponent(expectedServiceId)}`, {
credentials: 'include',
cache: 'no-store',
});
if (!response.ok) return false;
const data = await response.json();
const target = data.success && buildHandoffTarget(returnUrl, data.ssoToken, expectedServiceId);
if (!target) return false;
locationObj.replace(target);
return true;
} catch (_) {
return false;
}
}
window.DCCredentialVault = { isAllowedReturnUrl, buildHandoffTarget, resume };
})();
+88 -20
View File
@@ -32,8 +32,8 @@
injectModal('service-creds-modal', `<div id="service-creds-modal">
<div class="service-creds-content">
<h3 id="svc-creds-title" style="margin: 0 0 4px; font-size: 1.05rem;">Service Credentials</h3>
<p id="svc-creds-desc" style="font-size: 0.75rem; color: var(--muted); margin: 0 0 14px;">Credentials are injected automatically when accessing this service.</p>
<h3 id="svc-creds-title" style="margin: 0 0 4px; font-size: 1.05rem;">Encrypted Credential Vault</h3>
<p id="svc-creds-desc" style="font-size: 0.75rem; color: var(--muted); margin: 0 0 14px;">Passwords are encrypted at rest and used automatically when you open this service.</p>
<!-- Status indicator -->
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 12px;">
@@ -91,7 +91,7 @@
<!-- Buttons -->
<div style="display: flex; gap: 8px; margin-top: 14px;">
<button id="svc-creds-save" class="btn-accent-solid" style="flex: 1; padding: 9px; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem;">
Save
Save to encrypted vault
</button>
<button id="svc-creds-clear" style="padding: 9px 14px; background: transparent; color: var(--bad-fg, #ff9aa3); border: 1px solid var(--bad-fg, #ff9aa3); border-radius: 6px; cursor: pointer; font-size: 0.85rem; display: none;">
Clear
@@ -105,6 +105,8 @@
const modal = document.getElementById('service-creds-modal');
let currentService = null;
let credentialReturnUrl = null;
let currentServiceHadCreds = false;
const arrServices = ['sonarr', 'radarr', 'prowlarr', 'overseerr'];
const qualityProfileServices = ['sonarr', 'radarr'];
@@ -124,8 +126,28 @@
el.style.display = 'none';
}
window.openServiceCredsModal = async function(service) {
async function requireSuccessfulWrite(response, label) {
if (!response) throw new Error(`${label} failed: no response`);
let data;
try {
data = await response.json();
} catch (_) {
throw new Error(`${label} failed: invalid server response`);
}
if (!response.ok || data?.success !== true) {
throw new Error(data?.error || `${label} failed (${response.status || 'unknown status'})`);
}
return data;
}
function isAllowedCredentialReturnUrl(returnUrl, serviceId) {
return !!window.DCCredentialVault?.isAllowedReturnUrl(returnUrl, serviceId);
}
window.openServiceCredsModal = async function(service, options = {}) {
currentService = service;
credentialReturnUrl = isAllowedCredentialReturnUrl(options.returnUrl, service.id) ? options.returnUrl : null;
currentServiceHadCreds = false;
hideError();
const title = document.getElementById('svc-creds-title');
const desc = document.getElementById('svc-creds-desc');
@@ -134,7 +156,10 @@
const basicSection = document.getElementById('svc-creds-basic');
const qualitySection = document.getElementById('svc-creds-quality');
title.textContent = service.name + ' Credentials';
title.textContent = service.name + ' — Encrypted Vault';
document.getElementById('svc-creds-save').textContent = credentialReturnUrl
? 'Save to vault & open service'
: 'Save to encrypted vault';
// Determine which sections to show
const isExt = !!service.isExternal;
const isArr = arrServices.includes(service.id) || arrServices.includes(service.appTemplate);
@@ -214,6 +239,7 @@
}
if (hasCreds) {
currentServiceHadCreds = true;
dot.style.background = 'var(--ok-fg, #74dfc4)';
status.style.color = 'var(--ok-fg, #74dfc4)';
status.textContent = 'Credentials stored';
@@ -352,16 +378,35 @@
const isArr = arrServices.includes(currentService.id) || arrServices.includes(currentService.appTemplate);
const svcId = currentService.id || currentService.appTemplate;
if (credentialReturnUrl && !currentServiceHadCreds) {
const externalUser = document.getElementById('svc-seedhost-user').value.trim();
const externalPass = document.getElementById('svc-seedhost-pass').value;
const apiKeyInput = document.getElementById('svc-apikey-input');
const requestedApiKey = apiKeyInput?.value.trim();
const basicUser = document.getElementById('svc-basic-user').value.trim();
const basicPass = document.getElementById('svc-basic-pass').value;
const hasExternalLogin = currentService.isExternal && externalUser && externalPass;
const hasApiKey = isArr && requestedApiKey && requestedApiKey !== '••••••••';
const hasBasicLogin = !currentService.isExternal && basicUser && basicPass;
if (!hasExternalLogin && !hasApiKey && !hasBasicLogin) {
showError('Enter the login or API key DashCaddy should store for this service.');
saveBtn.textContent = 'Save to vault & open service';
saveBtn.disabled = false;
return;
}
}
// Save seedhost creds (shared username + per-service password)
if (currentService.isExternal) {
const user = document.getElementById('svc-seedhost-user').value.trim();
const pass = document.getElementById('svc-seedhost-pass').value;
if (user) {
await secureFetch('/api/v1/seedhost-creds', {
const response = await secureFetch('/api/v1/seedhost-creds', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user, password: pass || undefined, serviceId: currentService.id })
});
await requireSuccessfulWrite(response, 'Seedhost credential save');
}
}
@@ -387,23 +432,18 @@
qualityProfileName: qualityProfileName || undefined
})
});
const data = await res.json();
if (!data.success) {
showError(data.error || 'Failed to save API key');
saveBtn.textContent = 'Save';
saveBtn.disabled = false;
return;
}
const data = await requireSuccessfulWrite(res, 'ARR credential save');
if (data.connectionTest && !data.connectionTest.success) {
showError(`API key saved but connection test failed: ${data.connectionTest.error}`);
}
} else {
// Non-arr services use the generic endpoint
await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
const response = await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiKey })
});
await requireSuccessfulWrite(response, 'API key save');
}
} else if (isArr && qualityProfileServices.includes(svcId)) {
// API key unchanged but user may have changed quality profile — save profile only
@@ -411,11 +451,12 @@
const qualityProfileId = qualSelect?.value ? parseInt(qualSelect.value) : undefined;
const qualityProfileName = qualSelect?.selectedOptions?.[0]?.textContent || undefined;
if (qualityProfileId) {
await secureFetch('/api/v1/arr/quality-profiles', {
const response = await secureFetch('/api/v1/arr/quality-profiles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service: svcId, qualityProfileId, qualityProfileName })
});
await requireSuccessfulWrite(response, 'Quality profile save');
}
}
@@ -424,20 +465,28 @@
const user = document.getElementById('svc-basic-user').value.trim();
const pass = document.getElementById('svc-basic-pass').value;
if (user && pass) {
await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
const response = await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user, password: pass })
});
await requireSuccessfulWrite(response, 'Service credential save');
}
}
await loadServiceCreds(currentService);
if (credentialReturnUrl) {
const returnUrl = credentialReturnUrl;
const resumed = await window.DCCredentialVault?.resume(returnUrl, currentService.id);
if (!resumed) throw new Error('Credential saved, but the secure service handoff failed. Try opening the service again.');
credentialReturnUrl = null;
return;
}
} catch (e) {
errorHandler.logError('[ServiceCredentials] Save', e, { function: 'saveCredentials' });
showError('Failed to save: ' + (e.message || 'Unknown error'));
}
saveBtn.textContent = 'Save';
saveBtn.textContent = credentialReturnUrl ? 'Save to vault & open service' : 'Save to encrypted vault';
saveBtn.disabled = false;
});
@@ -450,12 +499,15 @@
const svcId = currentService.id || currentService.appTemplate;
const isArr = arrServices.includes(svcId);
if (currentService.isExternal) {
await secureFetch(`/api/v1/seedhost-creds?serviceId=${currentService.id}`, { method: 'DELETE' });
const response = await secureFetch(`/api/v1/seedhost-creds?serviceId=${currentService.id}`, { method: 'DELETE' });
await requireSuccessfulWrite(response, 'Seedhost credential removal');
}
// Delete from both namespaces
await secureFetch(`/api/v1/services/${currentService.id}/credentials`, { method: 'DELETE' });
const response = await secureFetch(`/api/v1/services/${currentService.id}/credentials`, { method: 'DELETE' });
await requireSuccessfulWrite(response, 'Service credential removal');
if (isArr) {
await secureFetch(`/api/v1/arr/credentials/${svcId}`, { method: 'DELETE' });
const arrResponse = await secureFetch(`/api/v1/arr/credentials/${svcId}`, { method: 'DELETE' });
await requireSuccessfulWrite(arrResponse, 'ARR credential removal');
}
const btn = document.getElementById(`creds-btn-${currentService.id}`);
if (btn) btn.classList.remove('has-creds');
@@ -470,11 +522,13 @@
document.getElementById('svc-creds-close')?.addEventListener('click', () => {
modal.classList.remove('show');
currentService = null;
credentialReturnUrl = null;
});
modal?.addEventListener('click', (e) => {
if (e.target === modal) {
modal.classList.remove('show');
currentService = null;
credentialReturnUrl = null;
}
});
@@ -501,4 +555,18 @@
}
} catch (e) { /* ignore */ }
};
// Protected service login pages send missing credentials here. Reuse the
// normal vault form, then resume through the existing one-time SSO handoff.
window.openRequestedCredentialForm = function() {
const params = new URLSearchParams(window.location.search);
const serviceId = params.get('credentials');
if (!serviceId) return false;
const service = (window.APPS || []).find(app => app.id === serviceId || app.appTemplate === serviceId);
if (!service) return false;
const returnUrl = params.get('return');
window.history.replaceState({}, '', window.location.pathname);
window.openServiceCredsModal(service, { returnUrl });
return true;
};
})();
+12 -2
View File
@@ -90,11 +90,22 @@
errorEl.textContent = 'Verifying...';
errorEl.className = 'totp-error verifying';
const redirect = safeSessionGet('totp_redirect');
let serviceId = null;
if (redirect) {
try {
const parsed = new URL(redirect, window.location.origin);
const suffix = SITE.tld.startsWith('.') ? SITE.tld : `.${SITE.tld}`;
const candidate = parsed.hostname.slice(0, -suffix.length);
if (parsed.hostname.endsWith(suffix) && /^[a-z0-9][a-z0-9-]*$/.test(candidate)) serviceId = candidate;
} catch (_) { /* invalid redirect is handled by the normal auth flow */ }
}
try {
const res = await secureFetch('/api/v1/totp/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code })
body: JSON.stringify({ code, serviceId })
});
const data = await res.json();
@@ -106,7 +117,6 @@
}
hideTotpOverlay();
// Check if redirected here from another service
const redirect = safeSessionGet('totp_redirect');
if (redirect) {
try { sessionStorage.removeItem('totp_redirect'); } catch (_) {}
// .sami is an unregistered TLD, so browsers silently drop the
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'dashcaddy-shell-5dbd809d3b';
const CACHE = 'dashcaddy-shell-c25cea8485';
const PRECACHE = [
'/',
'/index.html',
+1 -1
View File
@@ -113,7 +113,7 @@ test('an existing status.sami session returns to a service without another TOTP
setTimeout(fn) { scheduled = fn; },
console,
fetch: async (url) => {
assert.equal(url, '/api/v1/auth/sso-handoff');
assert.equal(url, '/api/v1/auth/sso-handoff?serviceId=plex');
return { ok: true, json: async () => ({ success: true, ssoToken: 'existing-session-token' }) };
},
window: {
@@ -0,0 +1,311 @@
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { JSDOM } = require('jsdom');
const test = require('node:test');
const assert = require('node:assert/strict');
const handoffSource = fs.readFileSync(path.join(__dirname, '..', 'js', 'credential-vault-handoff.js'), 'utf8');
const formSource = fs.readFileSync(path.join(__dirname, '..', 'js', 'service-credentials.js'), 'utf8');
const initSource = fs.readFileSync(path.join(__dirname, '..', 'js', 'core', 'init.js'), 'utf8');
function loadVault() {
const window = { location: { origin: 'https://status.sami' } };
const context = vm.createContext({ window, SITE: { tld: '.sami' }, URL });
vm.runInContext(handoffSource, context);
return window.DCCredentialVault;
}
async function exerciseFailedModalWrite({
service,
fetchJson,
setupInputs,
expectedEndpoint,
writeResponse,
expectedError = /vault write rejected/,
}) {
const dom = new JSDOM('<!doctype html><body></body>', {
url: 'https://status.sami/',
runScripts: 'outside-only',
});
const { window } = dom;
const writeUrls = [];
let resumeCalls = 0;
window.ErrorHandler = class { logError() {} };
window.SITE = { tld: '.sami' };
window.injectModal = (_id, html) => window.document.body.insertAdjacentHTML('beforeend', html);
window.fetch = async (url) => ({ ok: true, json: async () => fetchJson(url) });
window.secureFetch = async (url) => {
writeUrls.push(url);
return writeResponse || {
ok: false,
status: 500,
json: async () => ({ success: false, error: 'vault write rejected' }),
};
};
window.DCCredentialVault = {
isAllowedReturnUrl: () => true,
resume: async () => { resumeCalls++; return true; },
};
window.confirm = () => true;
window.eval(formSource);
await window.openServiceCredsModal(service, { returnUrl: `https://${service.id}.sami/` });
setupInputs(window.document);
window.document.getElementById('svc-creds-save').click();
await new Promise(resolve => setTimeout(resolve, 20));
assert.equal(writeUrls[0], expectedEndpoint);
assert.equal(resumeCalls, 0);
assert.match(window.document.getElementById('svc-creds-error').textContent, expectedError);
}
test('existing dashboard session mints a one-time token and resumes on the target host', async () => {
const vault = loadVault();
const calls = [];
const replacements = [];
const resumed = await vault.resume('https://plex.sami/web/?direct=1#home', 'plex', {
fetch: async (url, options) => {
calls.push({ url, options });
return {
ok: true,
json: async () => ({ success: true, ssoToken: 'one-time-token' }),
};
},
location: { replace: (target) => replacements.push(target) },
});
assert.equal(resumed, true);
assert.equal(calls.length, 1);
assert.equal(calls[0].url, '/api/v1/auth/sso-handoff?serviceId=plex');
assert.equal(calls[0].options.credentials, 'include');
assert.equal(calls[0].options.cache, 'no-store');
assert.equal(
replacements[0],
'https://plex.sami/dashcaddy-sso?token=one-time-token&return=%2Fweb%2F%3Fdirect%3D1%23home',
);
});
test('vault handoff rejects an external return URL before minting a token', async () => {
const vault = loadVault();
let fetchCalled = false;
const resumed = await vault.resume('https://plex.sami.evil.example/phish', 'plex', {
fetch: async () => { fetchCalled = true; },
location: { replace: () => assert.fail('must not navigate') },
});
assert.equal(resumed, false);
assert.equal(fetchCalled, false);
});
test('credential request opens the form and save path calls the tested handoff helper', () => {
assert.match(formSource, /params\.get\('credentials'\)/);
assert.match(formSource, /openServiceCredsModal\(service, \{ returnUrl \}\)/);
assert.match(formSource, /DCCredentialVault\?\.resume\(returnUrl, currentService\.id\)/);
assert.match(initSource, /openRequestedCredentialForm\(\)/);
assert.match(formSource, /Save to vault & open service/);
});
test('actual vault modal save handler stores credentials then resumes the handoff', async () => {
const dom = new JSDOM('<!doctype html><body></body>', {
url: 'https://status.sami/?credentials=plex&return=https%3A%2F%2Fplex.sami%2Fweb%2F',
runScripts: 'outside-only',
});
const { window } = dom;
let stored = false;
const writes = [];
const resumed = [];
window.ErrorHandler = class { logError() {} };
window.SITE = { tld: '.sami' };
window.APPS = [{ id: 'plex', name: 'Plex', appTemplate: 'plex', url: 'https://plex.sami' }];
window.injectModal = (_id, html) => window.document.body.insertAdjacentHTML('beforeend', html);
window.fetch = async () => ({
ok: true,
json: async () => ({
success: true,
hasApiKey: false,
hasBasicAuth: stored,
username: stored ? 'vault-user' : null,
}),
});
window.secureFetch = async (url, options) => {
writes.push({ url, body: JSON.parse(options.body) });
stored = true;
return { ok: true, json: async () => ({ success: true }) };
};
window.DCCredentialVault = {
isAllowedReturnUrl: () => true,
resume: async (returnUrl, serviceId) => { resumed.push({ returnUrl, serviceId }); return true; },
};
window.confirm = () => true;
window.eval(formSource);
await window.openServiceCredsModal(window.APPS[0], { returnUrl: 'https://plex.sami/web/' });
window.document.getElementById('svc-basic-user').value = 'vault-user';
window.document.getElementById('svc-basic-pass').value = 'vault-password';
window.document.getElementById('svc-creds-save').click();
await new Promise(resolve => setTimeout(resolve, 20));
assert.deepEqual(writes, [{
url: '/api/v1/services/plex/credentials',
body: { username: 'vault-user', password: 'vault-password' },
}]);
assert.deepEqual(resumed, [{ returnUrl: 'https://plex.sami/web/', serviceId: 'plex' }]);
});
test('failed credential write does not mint a handoff or navigate', async () => {
const dom = new JSDOM('<!doctype html><body></body>', {
url: 'https://status.sami/',
runScripts: 'outside-only',
});
const { window } = dom;
let resumeCalls = 0;
window.ErrorHandler = class { logError() {} };
window.SITE = { tld: '.sami' };
window.injectModal = (_id, html) => window.document.body.insertAdjacentHTML('beforeend', html);
window.fetch = async () => ({
ok: true,
json: async () => ({ success: true, hasApiKey: false, hasBasicAuth: false, username: null }),
});
window.secureFetch = async () => ({
ok: false,
status: 500,
json: async () => ({ success: false, error: 'vault write rejected' }),
});
window.DCCredentialVault = {
isAllowedReturnUrl: () => true,
resume: async () => { resumeCalls++; return true; },
};
window.confirm = () => true;
window.eval(formSource);
const service = { id: 'plex', name: 'Plex', appTemplate: 'plex', url: 'https://plex.sami' };
await window.openServiceCredsModal(service, { returnUrl: 'https://plex.sami/web/' });
window.document.getElementById('svc-basic-user').value = 'vault-user';
window.document.getElementById('svc-basic-pass').value = 'vault-password';
window.document.getElementById('svc-creds-save').click();
await new Promise(resolve => setTimeout(resolve, 20));
assert.equal(resumeCalls, 0);
assert.match(window.document.getElementById('svc-creds-error').textContent, /vault write rejected/);
});
test('failed ARR credential write does not mint a handoff or navigate', async () => {
await exerciseFailedModalWrite({
service: { id: 'radarr', name: 'Radarr', appTemplate: 'radarr', url: 'https://radarr.sami' },
fetchJson: (url) => url.includes('/services/')
? { success: true, hasApiKey: false, hasBasicAuth: false, username: null }
: { success: true, profiles: [] },
setupInputs: (document) => { document.getElementById('svc-apikey-input').value = 'arr-key'; },
expectedEndpoint: '/api/v1/arr/credentials',
});
});
test('failed ARR quality-profile write does not mint a handoff or navigate', async () => {
await exerciseFailedModalWrite({
service: { id: 'radarr', name: 'Radarr', appTemplate: 'radarr', url: 'https://radarr.sami' },
fetchJson: (url) => url.includes('/services/')
? { success: true, hasApiKey: true, hasBasicAuth: false, username: null }
: { success: true, profiles: [{ id: 1, name: 'Default' }], storedProfileId: 1 },
setupInputs: () => {},
expectedEndpoint: '/api/v1/arr/quality-profiles',
});
});
test('failed seedhost write does not mint a handoff or navigate', async () => {
await exerciseFailedModalWrite({
service: { id: 'torrent', name: 'qBittorrent', isExternal: true, externalUrl: 'https://torrent.sami' },
fetchJson: (url) => url.includes('/seedhost-creds')
? { success: true, hasCredentials: false, username: null }
: { success: true, hasApiKey: false, hasBasicAuth: false, username: null },
setupInputs: (document) => {
document.getElementById('svc-seedhost-user').value = 'seed-user';
document.getElementById('svc-seedhost-pass').value = 'seed-password';
},
expectedEndpoint: '/api/v1/seedhost-creds',
});
});
test('failed generic API-key write does not mint a handoff or navigate', async () => {
await exerciseFailedModalWrite({
service: { id: 'custom', name: 'Custom', url: 'https://custom.sami' },
fetchJson: () => ({ success: true, hasApiKey: false, hasBasicAuth: false, username: null }),
setupInputs: (document) => {
document.getElementById('svc-apikey-input').value = 'custom-key';
document.getElementById('svc-basic-user').value = 'user';
document.getElementById('svc-basic-pass').value = 'password';
},
expectedEndpoint: '/api/v1/services/custom/credentials',
});
});
test('HTTP 2xx with malformed JSON does not mint a handoff or navigate', async () => {
await exerciseFailedModalWrite({
service: { id: 'plex', name: 'Plex', appTemplate: 'plex', url: 'https://plex.sami' },
fetchJson: () => ({ success: true, hasApiKey: false, hasBasicAuth: false, username: null }),
setupInputs: (document) => {
document.getElementById('svc-basic-user').value = 'user';
document.getElementById('svc-basic-pass').value = 'password';
},
expectedEndpoint: '/api/v1/services/plex/credentials',
writeResponse: { ok: true, status: 200, json: async () => { throw new Error('bad json'); } },
expectedError: /invalid server response/,
});
});
test('HTTP 2xx without success:true does not mint a handoff or navigate', async () => {
await exerciseFailedModalWrite({
service: { id: 'plex', name: 'Plex', appTemplate: 'plex', url: 'https://plex.sami' },
fetchJson: () => ({ success: true, hasApiKey: false, hasBasicAuth: false, username: null }),
setupInputs: (document) => {
document.getElementById('svc-basic-user').value = 'user';
document.getElementById('svc-basic-pass').value = 'password';
},
expectedEndpoint: '/api/v1/services/plex/credentials',
writeResponse: { ok: true, status: 200, json: async () => ({ message: 'ambiguous' }) },
expectedError: /failed \(200\)/,
});
});
test('failed credential clear remains visibly failed and keeps stored-state UI', async () => {
const dom = new JSDOM('<!doctype html><body><button id="creds-btn-plex" class="has-creds"></button></body>', {
url: 'https://status.sami/',
runScripts: 'outside-only',
});
const { window } = dom;
window.ErrorHandler = class { logError() {} };
window.SITE = { tld: '.sami' };
window.injectModal = (_id, html) => window.document.body.insertAdjacentHTML('beforeend', html);
window.fetch = async () => ({
ok: true,
json: async () => ({ success: true, hasApiKey: false, hasBasicAuth: true, username: 'vault-user' }),
});
window.secureFetch = async () => ({
ok: false,
status: 500,
json: async () => ({ success: false, error: 'clear rejected' }),
});
window.DCCredentialVault = { isAllowedReturnUrl: () => false };
window.confirm = () => true;
window.eval(formSource);
const service = { id: 'plex', name: 'Plex', appTemplate: 'plex', url: 'https://plex.sami' };
await window.openServiceCredsModal(service);
window.document.getElementById('svc-creds-clear').click();
await new Promise(resolve => setTimeout(resolve, 20));
assert.match(window.document.getElementById('svc-creds-error').textContent, /clear rejected/);
assert.equal(window.document.getElementById('creds-btn-plex').classList.contains('has-creds'), true);
});
test('handoff rejects a private-TLD host that is not the requested protected service', async () => {
const vault = loadVault();
let fetchCalled = false;
const resumed = await vault.resume('https://dns1.sami/', 'plex', {
fetch: async () => { fetchCalled = true; },
location: { replace: () => assert.fail('must not navigate') },
});
assert.equal(resumed, false);
assert.equal(fetchCalled, false);
});
+67
View File
@@ -0,0 +1,67 @@
const fs = require('node:fs');
const path = require('node:path');
const { JSDOM } = require('jsdom');
const test = require('node:test');
const assert = require('node:assert/strict');
const source = fs.readFileSync(path.join(__dirname, '..', 'js', 'core', 'credentials.js'), 'utf8');
function buildDnsCredentialUi() {
const dom = new JSDOM('<!doctype html><body><button id="manage-tokens"></button></body>', {
url: 'https://status.sami/',
runScripts: 'outside-only',
});
const { window } = dom;
const local = new Map();
const session = new Map();
window.SITE = { dnsServers: { dns1: { name: 'Primary DNS' } } };
window.injectModal = (_id, html) => window.document.body.insertAdjacentHTML('beforeend', html);
window.safeGet = key => local.get(key) || null;
window.safeSet = (key, value) => local.set(key, value);
window.safeRemove = key => local.delete(key);
window.safeSessionGet = key => session.get(key) || null;
window.safeSessionSet = (key, value) => session.set(key, value);
window.closeModal = () => {};
window.confirm = () => true;
window.TextEncoder = TextEncoder;
window.setTimeout = () => 1;
window.eval(source);
window.document.getElementById('manage-tokens').click();
return { window, local };
}
test('failed DNS credential save never populates browser cache or success UI', async () => {
const { window, local } = buildDnsCredentialUi();
window.secureFetch = async () => ({
ok: false,
status: 500,
json: async () => ({ success: false, error: 'DNS vault rejected' }),
});
window.document.getElementById('dns1-admin-username').value = 'dns-admin';
window.document.getElementById('dns1-admin-token').value = 'dns-password';
window.document.getElementById('token-save').click();
await new Promise(resolve => setTimeout(resolve, 20));
assert.equal(local.has('dns1-admin-username-enc'), false);
assert.equal(local.has('dns1-admin-token-enc'), false);
assert.match(window.document.getElementById('dns1-token-status').textContent, /DNS vault rejected/);
assert.equal(window.document.getElementById('dns1-token-status').classList.contains('success'), false);
});
test('failed DNS credential clear preserves cached state and shows error', async () => {
const { window, local } = buildDnsCredentialUi();
local.set('dns1-admin-username-enc', 'existing-user');
local.set('dns1-admin-token-enc', 'existing-password');
window.secureFetch = async () => ({
ok: true,
status: 200,
json: async () => ({ message: 'ambiguous response' }),
});
window.document.getElementById('token-clear-all').click();
await new Promise(resolve => setTimeout(resolve, 20));
assert.equal(local.has('dns1-admin-username-enc'), true);
assert.equal(local.has('dns1-admin-token-enc'), true);
assert.match(window.document.getElementById('dns1-token-status').textContent, /DNS credential removal failed/);
assert.equal(window.document.getElementById('dns1-token-status').classList.contains('success'), false);
});