[grade=B] feat(auth): onboard missing credentials into encrypted vault
This commit is contained in:
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -61,6 +61,9 @@
|
||||
await window.loadServices();
|
||||
await loadTemplateCategories();
|
||||
window.buildGrid();
|
||||
if (typeof window.openRequestedCredentialForm === 'function') {
|
||||
window.openRequestedCredentialForm();
|
||||
}
|
||||
animateTopCards();
|
||||
window.refreshAll();
|
||||
setInterval(() => {
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user