Compare commits

...
6 Commits
Author SHA1 Message Date
Hermes 84e051d975 Merge earlier-tick staging branch (duplicate DC-085/086 cherry-picks + auth-gate/vault tests) — content identical to eab2b00/f8b99f9
# Conflicts:
#	dashcaddy-api/__tests__/health-checker-hysteresis.test.js
#	dashcaddy-api/src/monitoring/health-checker.js
2026-08-22 14:27:40 -07:00
Hermes 628bbe32f6 [glm-grade=A] fix(monitoring): DC-086 round-2 — probe/config race hardening + env parse + incident compare
Round-2 folds the judge-round fixes into DC-086:

- serviceGenerations map: checkService captures the config generation at
  entry and re-validates it before ANY state write (success + error
  paths). In-flight probes that resolve after removeService/updateService
  are discarded — deleted services can no longer resurrect status entries,
  fire incidents, or poke consecutiveFailures from beyond the grave.
- removeService now purges ALL per-service state: displayedStatus,
  consecutiveSinceChange, consecutiveFailures, pending backoff timers,
  and the serviceTimers entry (leaked a live setTimeout before).
- readPositiveIntEnv(): HEALTH_DOWN_THRESHOLD / HEALTH_UP_THRESHOLD
  parsing hardened — empty, non-numeric, fractional, zero, and negative
  values all fall back to defaults instead of Math.max(1, NaN)=NaN.
- previousStatus is captured BEFORE recordStatus() writes the new probe,
  so checkForIncidents() compares against the true prior state instead
  of the just-overwritten one (latent incident-suppression bug).
- Same-status hysteresis path returns the raw consistent snapshot
  (not the stale displayed one) so timestamps stay current without
  mixing contradictory fields.
- Tests: +14 (86 total across the two suites). New coverage: streak
  reset on agreement, malformed env fallbacks (each.of not-a-number/0/
  -2/1.5), in-flight probe after removeService does not resurrect state,
  getCurrentStatus serves internally-consistent displayed snapshot while
  raw currentStatus keeps the suppressed failure. Full suite 2601/2601.

Judge: GLM-5.3 cold-read via delegate_task (deleg_c9fd5900 task-0),
grade A round 1, zero blocking issues. Verdict URN:
urn:ump:quhs33ph2hhmsxjti63eg3ro4aiy34r6nws7z66ofk3bg3rcb3ca
(Codex primary quota-walled until 2026-08-29; GLM-4.6 direct 401;
stand-in chain per codex-as-judge SKILL.md, Sami 2026-08-17.)
2026-08-22 14:23:25 -07:00
Hermes f8b99f9b5a DC-086 service-status flicker fix — asymmetric hysteresis
Dashboard badges perpetually flip green/red for a few seconds at a time,
never stable. Root cause: health-checker emitted 'status-check' on every
probe (every 30s) and dashboard-ws forwarded every one as 'status-change'
to the browser with no diff; live-events.js then unconditionally called
setBadge(). A single transient 5xx (Caddy reload, container restart, TLS
handshake blip) flipped the badge and the next green probe flipped back.

Fix: _computeDisplayedStatus applies asymmetric hysteresis — DOWN_THRESHOLD
(default 2, env-tunable HEALTH_DOWN_THRESHOLD) consecutive probes that
disagree with the displayed 'up' state flip to red; UP_THRESHOLD (default
1, HEALTH_UP_THRESHOLD) flips back to green. History and consecutiveFailures
still record every raw probe so postmortem analysis is unchanged. Only the
SSE broadcast is filtered. getCurrentStatus now returns the displayed
status so a page reload shows the same badge as the live stream.

10 new tests cover first-emit, same-status-dedup, the actual flicker bug
(one-down-then-up keeps green), two-down flips red, one-up recovers fast,
long-steady-green produces exactly one emit, and env-var tuning. All 63
existing health-checker tests still pass. Full suite: 2484/2484.
2026-08-22 06:14:48 -07:00
Hermes eab2b00b13 DC-085 link-first invite — Discord-style share it however you want
Flip POST /api/v1/auth/admin/invites default to no email; always return
the link. Operators copy + share via iMessage/WhatsApp/SMS/Signal/Telegram/
Discord/paste-in-email. Email becomes an opt-in checkbox (was the default).
Add shareText field with pre-formatted message for one-tap paste. Stop
logging raw invite URLs to error.log when SMTP is unconfigured (was just
a dev fallback — link is now in the response). Frontend flips the
checkbox default to unchecked and renders shareText + native share sheet
button (navigator.share) alongside the raw copy-link button. 9 new tests
covering default-no-send, link-always-returned, shareText-shape, opt-in
SMTP send, failed-SMTP-no-leak. Full suite: 2474/2474 + 9 new = 2483.
2026-08-22 06:14:47 -07:00
Hermes 84edb035e3 [grade=B] feat(auth): onboard missing credentials into encrypted vault
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
2026-08-22 05:41:38 -07:00
Hermes d313b1e872 [grade=B] fix(auth): reuse valid session for cross-host SSO
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
2026-08-22 04:06:27 -07:00
27 changed files with 1308 additions and 248 deletions
@@ -22,11 +22,17 @@ process.env.HEALTH_DATA_DIR = tmpDir;
process.env.HEALTH_CONFIG_FILE = path.join(tmpDir, 'health-config.json'); process.env.HEALTH_CONFIG_FILE = path.join(tmpDir, 'health-config.json');
process.env.HEALTH_HISTORY_FILE = path.join(tmpDir, 'health-history.json'); process.env.HEALTH_HISTORY_FILE = path.join(tmpDir, 'health-history.json');
const { HealthChecker } = require('../src/monitoring/health-checker');
// Module exports a singleton instance, not a class — see module.exports in // Module exports a singleton instance, not a class — see module.exports in
// src/monitoring/health-checker.js. The test creates fresh state by replacing // src/monitoring/health-checker.js. The test creates fresh state by replacing
// the relevant maps on the singleton in beforeEach. // the relevant maps on the singleton in beforeEach.
const healthCheckerSingleton = require('../src/monitoring/health-checker'); const healthCheckerSingleton = require('../src/monitoring/health-checker');
const originalDownThreshold = process.env.HEALTH_DOWN_THRESHOLD;
const originalUpThreshold = process.env.HEALTH_UP_THRESHOLD;
function restoreEnv(name, value) {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
function makeUp(serviceId = 'svc1') { function makeUp(serviceId = 'svc1') {
return { return {
@@ -68,6 +74,15 @@ describe('DC-086: hysteresis on the dashboard badge', () => {
hc = healthCheckerSingleton; hc = healthCheckerSingleton;
}); });
afterEach(() => {
restoreEnv('HEALTH_DOWN_THRESHOLD', originalDownThreshold);
restoreEnv('HEALTH_UP_THRESHOLD', originalUpThreshold);
});
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('first probe (no prior state) emits', () => { test('first probe (no prior state) emits', () => {
hc.recordStatus('svc1', makeUp()); hc.recordStatus('svc1', makeUp());
expect(emitSpy).toHaveBeenCalledTimes(1); expect(emitSpy).toHaveBeenCalledTimes(1);
@@ -88,6 +103,19 @@ describe('DC-086: hysteresis on the dashboard badge', () => {
expect(hc.displayedStatus.get('svc1').status).toBe('up'); expect(hc.displayedStatus.get('svc1').status).toBe('up');
}); });
test('up, down, up, down, down resets the first streak before flipping', () => {
hc.recordStatus('svc1', makeUp());
hc.recordStatus('svc1', makeDown());
hc.recordStatus('svc1', makeUp());
hc.recordStatus('svc1', makeDown());
expect(hc.displayedStatus.get('svc1').status).toBe('up');
expect(emitSpy).toHaveBeenCalledTimes(1);
hc.recordStatus('svc1', makeDown());
expect(hc.displayedStatus.get('svc1').status).toBe('down');
expect(emitSpy).toHaveBeenCalledTimes(2);
});
test('two consecutive "down" probes flip the badge to red', () => { test('two consecutive "down" probes flip the badge to red', () => {
hc.recordStatus('svc1', makeUp()); // baseline: green hc.recordStatus('svc1', makeUp()); // baseline: green
hc.recordStatus('svc1', makeDown()); // blip #1 — keep green (counter=1) hc.recordStatus('svc1', makeDown()); // blip #1 — keep green (counter=1)
@@ -120,10 +148,28 @@ describe('DC-086: hysteresis on the dashboard badge', () => {
}); });
test('getCurrentStatus returns the displayed status, not the raw probe', () => { test('getCurrentStatus returns the displayed status, not the raw probe', () => {
hc.recordStatus('svc1', makeUp()); const displayedUp = makeUp();
hc.recordStatus('svc1', makeDown()); // raw=down, displayed=up displayedUp.timestamp = '2026-08-22T09:59:00.000Z';
displayedUp.statusCode = 200;
displayedUp.message = 'healthy';
displayedUp.details = { source: 'accepted-up' };
hc.recordStatus('svc1', displayedUp);
const latestRaw = makeDown();
latestRaw.timestamp = '2026-08-22T10:00:00.000Z';
latestRaw.responseTime = 987;
latestRaw.statusCode = 500;
latestRaw.message = 'failed probe';
latestRaw.error = 'upstream failure';
latestRaw.details = { source: 'suppressed-down' };
hc.recordStatus('svc1', latestRaw); // raw=down, displayed=up
const out = hc.getCurrentStatus(); const out = hc.getCurrentStatus();
expect(out['svc1'].status).toBe('up'); // shown to API consumers expect(out['svc1'].status).toBe('up'); // shown to API consumers
expect(out['svc1'].timestamp).toBe(displayedUp.timestamp);
expect(out['svc1'].statusCode).toBe(200);
expect(out['svc1'].message).toBe('healthy');
expect(out['svc1'].error).toBeUndefined();
expect(out['svc1'].details).toEqual({ source: 'accepted-up' });
expect(hc.currentStatus.get('svc1')).toBe(latestRaw);
}); });
test('a long steady-green run produces exactly ONE emit (no per-probe spam)', () => { test('a long steady-green run produces exactly ONE emit (no per-probe spam)', () => {
@@ -142,7 +188,6 @@ describe('DC-086: hysteresis on the dashboard badge', () => {
}); });
test('DOWN_THRESHOLD env var is honored', () => { test('DOWN_THRESHOLD env var is honored', () => {
const origDown = process.env.HEALTH_DOWN_THRESHOLD;
process.env.HEALTH_DOWN_THRESHOLD = '3'; process.env.HEALTH_DOWN_THRESHOLD = '3';
jest.resetModules(); jest.resetModules();
const HC2Module = require('../src/monitoring/health-checker'); const HC2Module = require('../src/monitoring/health-checker');
@@ -164,7 +209,89 @@ describe('DC-086: hysteresis on the dashboard badge', () => {
hc2.recordStatus('svc1', makeDown()); // 3 — flip hc2.recordStatus('svc1', makeDown()); // 3 — flip
expect(spy).toHaveBeenCalledTimes(2); expect(spy).toHaveBeenCalledTimes(2);
expect(hc2.displayedStatus.get('svc1').status).toBe('down'); expect(hc2.displayedStatus.get('svc1').status).toBe('down');
if (origDown === undefined) delete process.env.HEALTH_DOWN_THRESHOLD; });
else process.env.HEALTH_DOWN_THRESHOLD = origDown;
test.each(['not-a-number', '0', '-2', '1.5'])('malformed DOWN_THRESHOLD %s falls back to 2', value => {
process.env.HEALTH_DOWN_THRESHOLD = value;
jest.resetModules();
const hc2 = require('../src/monitoring/health-checker');
hc2.displayedStatus = new Map();
hc2.consecutiveSinceChange = new Map();
hc2.currentStatus = new Map();
hc2.history = {};
hc2.removeAllListeners('status-check');
const spy = jest.fn();
hc2.on('status-check', spy);
hc2.recordStatus('svc1', makeUp());
hc2.recordStatus('svc1', makeDown());
expect(spy).toHaveBeenCalledTimes(1);
hc2.recordStatus('svc1', makeDown());
expect(spy).toHaveBeenCalledTimes(2);
});
test('UP_THRESHOLD env var greater than 1 is honored', () => {
process.env.HEALTH_UP_THRESHOLD = '2';
jest.resetModules();
const hc2 = require('../src/monitoring/health-checker');
hc2.displayedStatus = new Map();
hc2.consecutiveSinceChange = new Map();
hc2.currentStatus = new Map();
hc2.history = {};
hc2.removeAllListeners('status-check');
const spy = jest.fn();
hc2.on('status-check', spy);
hc2.recordStatus('svc1', makeDown());
hc2.recordStatus('svc1', makeUp());
expect(spy).toHaveBeenCalledTimes(1);
expect(hc2.displayedStatus.get('svc1').status).toBe('down');
hc2.recordStatus('svc1', makeUp());
expect(spy).toHaveBeenCalledTimes(2);
expect(hc2.displayedStatus.get('svc1').status).toBe('up');
});
test.each(['not-a-number', '0', '-2', '1.5'])('malformed UP_THRESHOLD %s falls back to 1', value => {
process.env.HEALTH_UP_THRESHOLD = value;
jest.resetModules();
const hc2 = require('../src/monitoring/health-checker');
hc2.displayedStatus = new Map();
hc2.consecutiveSinceChange = new Map();
hc2.currentStatus = new Map();
hc2.history = {};
hc2.removeAllListeners('status-check');
const spy = jest.fn();
hc2.on('status-check', spy);
hc2.recordStatus('svc1', makeDown());
hc2.recordStatus('svc1', makeUp());
expect(spy).toHaveBeenCalledTimes(2);
expect(hc2.displayedStatus.get('svc1').status).toBe('up');
});
test('removeService clears hysteresis state before the same ID is re-added', () => {
hc.config.services.svc1 = { name: 'Service 1' };
hc.recordStatus('svc1', makeUp());
hc.recordStatus('svc1', makeDown());
expect(hc.displayedStatus.has('svc1')).toBe(true);
expect(hc.consecutiveSinceChange.get('svc1')).toBe(1);
hc.consecutiveFailures.set('svc1', 3);
const timer = setTimeout(() => {}, 60_000);
hc.serviceTimers.set('svc1', timer);
hc.saveConfig = jest.fn();
hc.removeService('svc1');
expect(hc.displayedStatus.has('svc1')).toBe(false);
expect(hc.consecutiveSinceChange.has('svc1')).toBe(false);
expect(hc.currentStatus.has('svc1')).toBe(false);
expect(hc.consecutiveFailures.has('svc1')).toBe(false);
expect(hc.serviceTimers.has('svc1')).toBe(false);
hc.config.services.svc1 = { name: 'Service 1 re-added' };
const emitSpyAfterReAdd = jest.fn();
hc.on('status-check', emitSpyAfterReAdd);
hc.recordStatus('svc1', makeDown());
expect(emitSpyAfterReAdd).toHaveBeenCalledTimes(1);
expect(hc.displayedStatus.get('svc1').status).toBe('down');
expect(hc.consecutiveSinceChange.has('svc1')).toBe(false);
}); });
}); });
@@ -203,6 +203,48 @@ describe('HealthChecker', () => {
expect(result.error).toBe('ECONNREFUSED'); expect(result.error).toBe('ECONNREFUSED');
}); });
it('opens and resolves an outage incident across real checkService transitions', async () => {
healthChecker._doRequest = jest.fn()
.mockResolvedValueOnce({ healthy: true, statusCode: 200, message: 'ok', details: {} })
.mockResolvedValueOnce({ healthy: false, statusCode: 500, message: 'down', details: {} })
.mockResolvedValueOnce({ healthy: true, statusCode: 200, message: 'ok', details: {} });
const config = { url: 'http://test.local' };
await healthChecker.checkService('svc1', config);
await healthChecker.checkService('svc1', config);
expect(healthChecker.incidents).toHaveLength(1);
expect(healthChecker.incidents[0]).toMatchObject({
serviceId: 'svc1',
type: 'outage',
status: 'open'
});
await healthChecker.checkService('svc1', config);
expect(healthChecker.incidents[0].status).toBe('resolved');
expect(healthChecker.incidents[0].resolvedAt).toBeDefined();
});
it('does not resurrect state when an in-flight probe resolves after removal', async () => {
let resolveProbe;
healthChecker.config.services.svc1 = { url: 'http://test.local' };
healthChecker._doRequest = jest.fn(() => new Promise(resolve => {
resolveProbe = resolve;
}));
const pending = healthChecker.checkService('svc1', healthChecker.config.services.svc1);
healthChecker.saveConfig = jest.fn();
healthChecker.removeService('svc1');
resolveProbe({ healthy: true, statusCode: 200, message: 'late', details: {} });
await pending;
expect(healthChecker.currentStatus.has('svc1')).toBe(false);
expect(healthChecker.displayedStatus.has('svc1')).toBe(false);
expect(healthChecker.consecutiveFailures.has('svc1')).toBe(false);
expect(healthChecker.history.svc1).toBeUndefined();
expect(healthChecker.incidents).toEqual([]);
});
it('increments consecutive failures on error', async () => { it('increments consecutive failures on error', async () => {
healthChecker._doRequest = jest.fn().mockRejectedValue(new Error('fail')); healthChecker._doRequest = jest.fn().mockRejectedValue(new Error('fail'));
@@ -112,6 +112,7 @@ function createApp(depsOverride = {}) {
errorResponse: jest.fn(), errorResponse: jest.fn(),
log, log,
renewCSRFToken, renewCSRFToken,
siteConfig: { tld: '.sami', dashboardHost: 'status.sami' },
...depsOverride, ...depsOverride,
}; };
@@ -299,7 +300,7 @@ describe('TOTP Auth Routes — DC-006 Integration Test', () => {
it('returns 200 + creates new session + rotates CSRF on valid code (BACKLOG: "valid TOTP → session token → authenticated request succeeds")', async () => { it('returns 200 + creates new session + rotates CSRF on valid code (BACKLOG: "valid TOTP → session token → authenticated request succeeds")', async () => {
const secret = await setupTOTP(); const secret = await setupTOTP();
const token = authenticator.generate(secret); const token = authenticator.generate(secret);
const res = await request(app).post('/api/totp/verify').send({ code: token }); const res = await request(app).post('/api/totp/verify').send({ code: token, serviceId: 'plex' });
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body.success).toBe(true); expect(res.body.success).toBe(true);
expect(res.body.message).toMatch(/Authenticated successfully/); expect(res.body.message).toMatch(/Authenticated successfully/);
@@ -308,8 +309,29 @@ describe('TOTP Auth Routes — DC-006 Integration Test', () => {
expect(deps.session.create).toHaveBeenCalled(); expect(deps.session.create).toHaveBeenCalled();
expect(deps.session.setCookie).toHaveBeenCalled(); expect(deps.session.setCookie).toHaveBeenCalled();
expect(deps.session.createHandoffToken).toHaveBeenCalledTimes(1); expect(deps.session.createHandoffToken).toHaveBeenCalledTimes(1);
expect(deps.session.createHandoffToken).toHaveBeenCalledWith('plex.sami');
expect(deps.renewCSRFToken).toHaveBeenCalled(); expect(deps.renewCSRFToken).toHaveBeenCalled();
}); });
it('does not issue an unbound handoff token for a dashboard-only login', async () => {
const secret = await setupTOTP();
const token = authenticator.generate(secret);
const res = await request(app).post('/api/totp/verify').send({ code: token });
expect(res.status).toBe(200);
expect(res.body.ssoToken).toBeNull();
expect(deps.session.createHandoffToken).not.toHaveBeenCalled();
});
it('rejects an invalid handoff service ID before issuing a token', async () => {
const secret = await setupTOTP();
const token = authenticator.generate(secret);
const res = await request(app).post('/api/totp/verify').send({ code: token, serviceId: 'plex.sami' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/Invalid service ID/);
expect(deps.session.createHandoffToken).not.toHaveBeenCalled();
});
}); });
// ──────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────
@@ -450,7 +472,7 @@ describe('TOTP Auth Routes — DC-006 Integration Test', () => {
// 4. Re-login via /totp/verify (the "login" path) // 4. Re-login via /totp/verify (the "login" path)
const loginCode = authenticator.generate(secret); const loginCode = authenticator.generate(secret);
const loginRes = await request(app).post('/api/totp/verify').send({ code: loginCode }); const loginRes = await request(app).post('/api/totp/verify').send({ code: loginCode, serviceId: 'plex' });
expect(loginRes.status).toBe(200); expect(loginRes.status).toBe(200);
expect(loginRes.body.csrfToken).toBeDefined(); expect(loginRes.body.csrfToken).toBeDefined();
expect(loginRes.body.ssoToken).toBe('mock-sso-handoff-token'); expect(loginRes.body.ssoToken).toBe('mock-sso-handoff-token');
@@ -288,6 +288,21 @@ describe('Services Routes', () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body.hasApiKey).toBe(true); expect(res.body.hasApiKey).toBe(true);
}); });
it('requires both username and password before reporting Basic Auth ready', async () => {
const credentialManager = {
store: jest.fn(),
retrieve: jest.fn().mockImplementation((key) => {
if (key === 'service.radarr.username') return Promise.resolve('admin');
return Promise.resolve(null);
}),
delete: jest.fn(),
};
const { app } = createApp({ credentialManager });
const res = await request(app).get('/api/services/radarr/credentials');
expect(res.status).toBe(200);
expect(res.body.hasBasicAuth).toBe(false);
});
}); });
// ===== SEEDHOST CREDENTIAL ENDPOINTS ===== // ===== SEEDHOST CREDENTIAL ENDPOINTS =====
@@ -50,6 +50,17 @@ describe('TOTP session cookie scope', () => {
expect(cookie).not.toMatch(/(?:^|;)\s*Domain=/i); expect(cookie).not.toMatch(/(?:^|;)\s*Domain=/i);
}); });
test('host-bound SSO token can only be redeemed on its intended service host', () => {
const session = buildSession();
const wrongHostToken = session.createHandoffToken('plex.sami');
expect(session.redeemHandoffToken(wrongHostToken, 'chat.sami')).toBe(false);
expect(session.redeemHandoffToken(wrongHostToken, 'plex.sami')).toBe(false);
const correctHostToken = session.createHandoffToken('plex.sami');
expect(session.redeemHandoffToken(correctHostToken, 'plex.sami')).toBe(true);
expect(session.redeemHandoffToken(correctHostToken, 'plex.sami')).toBe(false);
});
test('logout clears the host-only secure cookie', () => { test('logout clears the host-only secure cookie', () => {
const session = buildSession(); const session = buildSession();
const headers = {}; const headers = {};
@@ -1,15 +1,29 @@
const express = require('express'); const express = require('express');
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const request = require('supertest'); const request = require('supertest');
const createSsoRouter = require('../routes/auth/sso-gate'); const createSsoRouter = require('../routes/auth/sso-gate');
function createApp({ redeem = true } = {}) { function loadCredentialVaultHandoff() {
const source = fs.readFileSync(
path.join(__dirname, '..', '..', 'status', 'js', 'credential-vault-handoff.js'),
'utf8',
);
const window = { location: { origin: 'https://status.sami' } };
vm.runInNewContext(source, { window, SITE: { tld: '.sami' }, URL });
return window.DCCredentialVault;
}
function createApp({ redeem = true, valid = true, storedCredentials = {}, dashboardHost = 'status.sami' } = {}) {
const app = express(); const app = express();
const session = { const session = {
redeemHandoffToken: jest.fn().mockReturnValue(redeem), redeemHandoffToken: jest.fn((token) => (typeof redeem === 'function' ? redeem(token) : redeem)),
setCookieHostOnly: jest.fn((res) => { setCookieHostOnly: jest.fn((res) => {
res.setHeader('Set-Cookie', 'dashcaddy_session=test; Path=/; HttpOnly; Secure; SameSite=Lax'); res.setHeader('Set-Cookie', 'dashcaddy_session=test; Path=/; HttpOnly; Secure; SameSite=Lax');
}), }),
isValid: jest.fn().mockReturnValue(true), isValid: jest.fn().mockReturnValue(valid),
createHandoffToken: jest.fn().mockReturnValue('fresh-sso-handoff-token'),
}; };
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
const errorResponse = (res, status, message, extra = {}) => res.status(status).json({ success: false, error: message, ...extra }); const errorResponse = (res, status, message, extra = {}) => res.status(status).json({ success: false, error: message, ...extra });
@@ -21,14 +35,15 @@ function createApp({ redeem = true } = {}) {
log: { warn: jest.fn(), info: jest.fn(), debug: jest.fn(), error: jest.fn() }, log: { warn: jest.fn(), info: jest.fn(), debug: jest.fn(), error: jest.fn() },
getAppSession: jest.fn(), getAppSession: jest.fn(),
appSessionCache: new Map(), appSessionCache: new Map(),
credentialManager: { retrieve: jest.fn() }, credentialManager: { retrieve: jest.fn((key) => Promise.resolve(storedCredentials[key] || null)) },
fetchT: jest.fn(), fetchT: jest.fn(),
getServiceById: jest.fn(), getServiceById: jest.fn((id) => Promise.resolve({ id, url: `https://${id}.sami` })),
licenseManager: { licenseManager: {
hasFeature: jest.fn().mockReturnValue(true), hasFeature: jest.fn().mockReturnValue(true),
requirePremium: jest.fn(() => (_req, _res, next) => next()), requirePremium: jest.fn(() => (_req, _res, next) => next()),
}, },
servicesStateManager: { read: jest.fn().mockResolvedValue([]) }, servicesStateManager: { read: jest.fn().mockResolvedValue([]) },
siteConfig: { dashboardHost },
}); });
app.use('/api/v1', router); app.use('/api/v1', router);
return { app, session }; return { app, session };
@@ -44,7 +59,7 @@ describe('cross-host SSO exchange redirect', () => {
expect(res.status).toBe(303); expect(res.status).toBe(303);
expect(res.headers.location).toBe('/settings?tab=network#dns'); expect(res.headers.location).toBe('/settings?tab=network#dns');
expect(res.headers['set-cookie'][0]).not.toMatch(/Domain=/i); expect(res.headers['set-cookie'][0]).not.toMatch(/Domain=/i);
expect(session.redeemHandoffToken).toHaveBeenCalledWith('one-time'); expect(session.redeemHandoffToken).toHaveBeenCalledWith('one-time', '127.0.0.1');
}); });
test.each([ test.each([
@@ -82,3 +97,105 @@ describe('cross-host SSO exchange redirect', () => {
expect(session.setCookieHostOnly).not.toHaveBeenCalled(); expect(session.setCookieHostOnly).not.toHaveBeenCalled();
}); });
}); });
describe('existing-session SSO handoff', () => {
test('mints a handoff token without asking for TOTP again', async () => {
const { app, session } = createApp();
const res = await request(app)
.get('/api/v1/auth/sso-handoff?serviceId=plex')
.set('Cookie', 'dashcaddy_session=valid-session');
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ success: true, ssoToken: 'fresh-sso-handoff-token' });
expect(session.createHandoffToken).toHaveBeenCalledTimes(1);
expect(session.createHandoffToken).toHaveBeenCalledWith('plex.sami');
});
test('refuses to mint a handoff token without a valid session', async () => {
const { app, session } = createApp({ valid: false });
const res = await request(app).get('/api/v1/auth/sso-handoff?serviceId=plex');
expect(res.status).toBe(401);
expect(session.createHandoffToken).not.toHaveBeenCalled();
});
test('completes the full mint, exchange, cookie, redirect lifecycle', async () => {
const issued = new Set(['fresh-sso-handoff-token']);
const redeemOnce = (token) => issued.delete(token);
const { app } = createApp({ redeem: redeemOnce });
const mint = await request(app)
.get('/api/v1/auth/sso-handoff?serviceId=plex')
.set('Cookie', 'dashcaddy_session=valid-session');
const exchange = await request(app)
.get('/api/v1/auth/sso-exchange')
.query({ token: mint.body.ssoToken, return: '/web/' });
expect(exchange.status).toBe(303);
expect(exchange.headers.location).toBe('/web/');
expect(exchange.headers['set-cookie'][0]).toContain('dashcaddy_session=');
expect(exchange.headers['set-cookie'][0]).not.toMatch(/Domain=/i);
const replay = await request(app)
.get('/api/v1/auth/sso-exchange')
.query({ token: mint.body.ssoToken, return: '/web/' });
expect(replay.status).toBe(401);
});
});
describe('encrypted-vault credential onboarding', () => {
test('app-token identifies missing credentials as a form requirement', async () => {
const { app } = createApp();
const res = await request(app)
.get('/api/v1/auth/app-token/plex')
.set('Cookie', 'dashcaddy_session=valid-session');
expect(res.status).toBe(428);
expect(res.body).toMatchObject({
success: false,
credentialsRequired: true,
serviceId: 'plex',
});
});
test('service login page sends missing credentials to the encrypted vault form', async () => {
const { app } = createApp();
const res = await request(app).get('/api/v1/auth/login-page?service=plex');
expect(res.status).toBe(200);
expect(res.text).toContain("if(j.credentialsRequired){vault('plex');return}");
expect(res.text).toContain("dashboardOrigin+'?credentials='");
});
test('service login page derives the vault origin from trusted dashboard config', async () => {
const { app } = createApp({ dashboardHost: 'dashboard.home' });
const res = await request(app).get('/api/v1/auth/login-page?service=plex');
expect(res.status).toBe(200);
expect(res.text).toContain('dashboardOrigin="https://dashboard.home"');
});
test('full vault-save handoff lifecycle reaches exchange, cookie, and final service path', async () => {
const issued = new Set(['fresh-sso-handoff-token']);
const { app } = createApp({ redeem: (token) => issued.delete(token) });
const mint = await request(app)
.get('/api/v1/auth/sso-handoff?serviceId=plex')
.set('Cookie', 'dashcaddy_session=valid-session');
const vault = loadCredentialVaultHandoff();
const target = new URL(vault.buildHandoffTarget(
'https://plex.sami/web/?direct=1#home',
mint.body.ssoToken,
'plex',
));
// The shared Caddy snippet rewrites /dashcaddy-sso to the canonical API
// route while preserving the token and relative return query.
const exchange = await request(app).get('/api/v1/auth/sso-exchange' + target.search);
expect(target.pathname).toBe('/dashcaddy-sso');
expect(exchange.status).toBe(303);
expect(exchange.headers.location).toBe('/web/?direct=1#home');
expect(exchange.headers['set-cookie'][0]).toContain('dashcaddy_session=');
expect(exchange.headers['set-cookie'][0]).not.toMatch(/Domain=/i);
});
});
+62 -23
View File
@@ -12,7 +12,7 @@ module.exports = function(deps) {
const router = express.Router(); const router = express.Router();
// Extract dependencies // Extract dependencies
const { authManager, totpConfig, session, asyncHandler, errorResponse, log, getAppSession, appSessionCache, credentialManager, fetchT, getServiceById, licenseManager, servicesStateManager } = deps; const { authManager, totpConfig, session, asyncHandler, errorResponse, log, getAppSession, appSessionCache, credentialManager, fetchT, getServiceById, licenseManager, servicesStateManager, siteConfig } = deps;
// Create ctx-like object for compatibility // Create ctx-like object for compatibility
const ctx = { const ctx = {
@@ -126,7 +126,12 @@ module.exports = function(deps) {
try { try {
const username = await ctx.credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null); const username = await ctx.credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
const password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null); const password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
if (!username || !password) throw new NotFoundError('[DC-500] No credentials stored'); if (!username || !password) {
return errorResponse(res, 428, '[DC-500] No credentials stored', {
credentialsRequired: true,
serviceId,
});
}
const service = await ctx.getServiceById(serviceId); const service = await ctx.getServiceById(serviceId);
const baseUrl = service?.url; const baseUrl = service?.url;
if (!baseUrl) throw new NotFoundError('No service URL'); if (!baseUrl) throw new NotFoundError('No service URL');
@@ -181,7 +186,12 @@ module.exports = function(deps) {
password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null); password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
} }
if (!username || !password) throw new NotFoundError('[DC-500] No credentials stored'); if (!username || !password) {
return errorResponse(res, 428, '[DC-500] No credentials stored', {
credentialsRequired: true,
serviceId,
});
}
const appCookies = await getAppSession(serviceId, baseUrl, username, password); const appCookies = await getAppSession(serviceId, baseUrl, username, password);
if (appCookies) { if (appCookies) {
@@ -203,8 +213,28 @@ module.exports = function(deps) {
} }
}, 'auth-app-token')); }, 'auth-app-token'));
// A browser that already has a valid status.sami session must not be asked
// for TOTP again just because it opened another private-TLD service host.
// Mint a fresh one-time token that the target host can exchange for its own
// host-only cookie. This route is intentionally session-protected both by
// the global middleware and here (defence in depth).
router.get('/auth/sso-handoff', (req, res) => {
res.setHeader('Cache-Control', 'no-store');
if (!session.isValid(req)) {
return errorResponse(res, 401, 'Session expired or invalid');
}
const serviceId = String(req.query.serviceId || '');
if (!/^[a-z0-9][a-z0-9-]*$/.test(serviceId)) {
return errorResponse(res, 400, 'Valid serviceId is required');
}
const suffix = String(siteConfig?.tld || '.sami');
const expectedHost = `${serviceId}${suffix.startsWith('.') ? suffix : `.${suffix}`}`;
ok(res, { ssoToken: session.createHandoffToken(expectedHost) });
});
// Cross-subdomain SSO handoff: exchanges a short-lived single-use token // Cross-subdomain SSO handoff: exchanges a short-lived single-use token
// (minted by /totp/verify) for a HOST-ONLY session cookie on whichever // (minted by /totp/verify or /auth/sso-handoff) for a HOST-ONLY session
// cookie on whichever
// *.sami origin calls this. Needed because Domain=.sami cookies are // *.sami origin calls this. Needed because Domain=.sami cookies are
// silently rejected by real browsers (.sami is an unregistered TLD, so // silently rejected by real browsers (.sami is an unregistered TLD, so
// browsers treat "sami" as the effective public suffix and refuse to set // browsers treat "sami" as the effective public suffix and refuse to set
@@ -215,7 +245,9 @@ module.exports = function(deps) {
router.get('/auth/sso-exchange', (req, res) => { router.get('/auth/sso-exchange', (req, res) => {
res.setHeader('Cache-Control', 'no-store'); res.setHeader('Cache-Control', 'no-store');
const token = req.query.token; const token = req.query.token;
if (!session.redeemHandoffToken(token)) { const forwardedHost = String(req.headers['x-forwarded-host'] || req.headers.host || '')
.split(',')[0].trim().replace(/:\d+$/, '').toLowerCase();
if (!session.redeemHandoffToken(token, forwardedHost)) {
return errorResponse(res, 401, 'Invalid or expired handoff token'); return errorResponse(res, 401, 'Invalid or expired handoff token');
} }
session.setCookieHostOnly(res, totpConfig.sessionDuration); session.setCookieHostOnly(res, totpConfig.sessionDuration);
@@ -237,7 +269,12 @@ module.exports = function(deps) {
// Serve service-specific auto-login page (auth enforced by Caddy forward_auth upstream) // Serve service-specific auto-login page (auth enforced by Caddy forward_auth upstream)
router.get('/auth/login-page', (req, res) => { router.get('/auth/login-page', (req, res) => {
const service = (req.query.service || '').replace(/[^a-z]/g, ''); const service = (req.query.service || '').replace(/[^a-z]/g, '');
const html = buildLoginPage(service); const configuredHost = siteConfig?.dashboardHost;
const dashboardOrigin = typeof configuredHost === 'string'
&& /^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(configuredHost)
? `https://${configuredHost}`
: 'https://status.sami';
const html = buildLoginPage(service, dashboardOrigin);
if (!html) return res.status(404).send('Unknown service'); if (!html) return res.status(404).send('Unknown service');
res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Cache-Control', 'no-store'); res.setHeader('Cache-Control', 'no-store');
@@ -255,7 +292,7 @@ module.exports = function(deps) {
return router; return router;
}; };
function buildLoginPage(service) { function buildLoginPage(service, dashboardOrigin = 'https://status.sami') {
// Pre-auth check via <meta http-equiv="refresh"> so it fires even when JS is // Pre-auth check via <meta http-equiv="refresh"> so it fires even when JS is
// disabled or blocked. The cookie is sent automatically because we hit the // disabled or blocked. The cookie is sent automatically because we hit the
// same origin (plex.sami); if the API returns 200 the user has a valid // same origin (plex.sami); if the API returns 200 the user has a valid
@@ -266,7 +303,7 @@ function buildLoginPage(service) {
<style>body{background:__BG__;color:#e0e0e0;font-family:system-ui;display:flex;align-items:center;justify-items:center;height:100vh;margin:0;flex-direction:column;gap:12px}a{color:__ACCENT__}#d{font-size:12px;color:#888;max-width:80vw;overflow:auto;white-wrap:pre-wrap}</style> <style>body{background:__BG__;color:#e0e0e0;font-family:system-ui;display:flex;align-items:center;justify-items:center;height:100vh;margin:0;flex-direction:column;gap:12px}a{color:__ACCENT__}#d{font-size:12px;color:#888;max-width:80vw;overflow:auto;white-wrap:pre-wrap}</style>
</head><body><p id="m">__TITLE__</p><div id="d"></div> </head><body><p id="m">__TITLE__</p><div id="d"></div>
<script>(function(){ <script>(function(){
var ls=localStorage,d=document.getElementById('d'),m=document.getElementById('m'); var ls=localStorage,d=document.getElementById('d'),m=document.getElementById('m'),dashboardOrigin=__DASHBOARD_ORIGIN__;
// 2026-07-22 hardening: every fetch now has a hard AbortSignal timeout // 2026-07-22 hardening: every fetch now has a hard AbortSignal timeout
// (default 8s) so a hung upstream can NEVER leave the page stuck on // (default 8s) so a hung upstream can NEVER leave the page stuck on
// "Signing in to Plex..." indefinitely. Also: if check-session returns // "Signing in to Plex..." indefinitely. Also: if check-session returns
@@ -274,13 +311,16 @@ function buildLoginPage(service) {
// upstream timeout, etc.), we now ALWAYS redirect to /web/?direct=1 if a // upstream timeout, etc.), we now ALWAYS redirect to /web/?direct=1 if a
// stale token exists in localStorage, instead of failing silently. // stale token exists in localStorage, instead of failing silently.
function go(u){setTimeout(function(){location.replace(u)},300)} function go(u){setTimeout(function(){location.replace(u)},300)}
function authUrl(){return dashboardOrigin+'?auth=required&return='+encodeURIComponent(location.href)}
function authLink(label){return '<a href="'+authUrl()+'">'+label+'</a>'}
function vault(svc){go(dashboardOrigin+'?credentials='+encodeURIComponent(svc)+'&return='+encodeURIComponent(location.href))}
function fail(msg,info){try{m.innerHTML=msg;d.textContent=info||''}catch(_){}} function fail(msg,info){try{m.innerHTML=msg;d.textContent=info||''}catch(_){}}
function withTimeout(ms){var c=new AbortController();setTimeout(function(){c.abort()},ms);return c.signal} function withTimeout(ms){var c=new AbortController();setTimeout(function(){c.abort()},ms);return c.signal}
function ft(svc){return fetch('/dashcaddy-api/api/auth/app-token/'+svc,{credentials:'include',signal:withTimeout(8000)})} function ft(svc){return fetch('/dashcaddy-api/api/auth/app-token/'+svc,{credentials:'include',signal:withTimeout(8000)})}
function merge(ck,j,name){try{var c=JSON.parse(ls.getItem(ck)||'{}');if(c.Servers&&c.Servers.length){var s=c.Servers[0];s.AccessToken=j.token;s.UserId=j.userId||s.UserId||'';s.DateLastAccessed=Date.now();ls.setItem(ck,JSON.stringify(c));return}}catch(e){}ls.setItem(ck,JSON.stringify({Servers:[{Id:j.serverId||'',Name:j.serverName||name,UserId:j.userId||'',AccessToken:j.token,ManualAddress:location.origin,LastConnectionMode:2,DateLastAccessed:Date.now()}]}))} function merge(ck,j,name){try{var c=JSON.parse(ls.getItem(ck)||'{}');if(c.Servers&&c.Servers.length){var s=c.Servers[0];s.AccessToken=j.token;s.UserId=j.userId||s.UserId||'';s.DateLastAccessed=Date.now();ls.setItem(ck,JSON.stringify(c));return}}catch(e){}ls.setItem(ck,JSON.stringify({Servers:[{Id:j.serverId||'',Name:j.serverName||name,UserId:j.userId||'',AccessToken:j.token,ManualAddress:location.origin,LastConnectionMode:2,DateLastAccessed:Date.now()}]}))}
// Belt-and-suspenders hard timeout: if nothing in this script succeeds // Belt-and-suspenders hard timeout: if nothing in this script succeeds
// within 15s, force-redirect to status.sami so the user can re-auth. // within 15s, force-redirect to status.sami so the user can re-auth.
var overallTimer=setTimeout(function(){go('https://status.sami?auth=required&return='+encodeURIComponent(location.href))},15000); var overallTimer=setTimeout(function(){go(authUrl())},15000);
// Cross-subdomain SSO handoff: status.sami can't share its session cookie // Cross-subdomain SSO handoff: status.sami can't share its session cookie
// with this origin (Domain=.sami cookies are silently rejected by real // with this origin (Domain=.sami cookies are silently rejected by real
// browsers - .sami isn't a registered TLD, so browsers treat "sami" as the // browsers - .sami isn't a registered TLD, so browsers treat "sami" as the
@@ -305,18 +345,17 @@ function buildLoginPage(service) {
preExchange.then(function(){ preExchange.then(function(){
return fetch('/dashcaddy-api/api/auth/totp/check-session',{credentials:'include',cache:'no-store',signal:withTimeout(5000)}) return fetch('/dashcaddy-api/api/auth/totp/check-session',{credentials:'include',cache:'no-store',signal:withTimeout(5000)})
}).then(function(r){return r.json()}).then(function(st){ }).then(function(r){return r.json()}).then(function(st){
if(!st||!st.success||!st.authenticated){go('https://status.sami?auth=required&return='+encodeURIComponent(location.href));return} if(!st||!st.success||!st.authenticated){go(authUrl());return}
${body} ${body}
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Auth check error: '+(e&&e.message||'unknown'))}) }).catch(function(e){fail('Could not reach DashCaddy. '+authLink('Sign in at DashCaddy'),'Auth check error: '+(e&&e.message||'unknown'))})
})()</script></body></html>`; })()</script></body></html>`;
const pages = { const pages = {
chat: { chat: {
title: 'Signing in...', bg: '#0a0a0a', accent: '#60a5fa', title: 'Signing in...', bg: '#0a0a0a', accent: '#60a5fa',
body: `if(ls.getItem('token')){go('/?direct=1');return} body: `d.textContent='Fetching token from DashCaddy...';
d.textContent='Fetching token from DashCaddy...';
ft('chat').then(function(r){return r.text()}).then(function(t){ ft('chat').then(function(r){return r.text()}).then(function(t){
try{var j=JSON.parse(t);if(j.token){ls.setItem('token',j.token);go('/?direct=1');return} try{var j=JSON.parse(t);if(j.credentialsRequired){vault('chat');return}if(j.token){ls.setItem('token',j.token);go('/?direct=1');return}
// No token but chat is reachable — fall through to manual UI link below // No token but chat is reachable — fall through to manual UI link below
fail('Auto-login unavailable. <a href="/?direct=1">Open Chat manually</a>','No token field: '+t.substring(0,200))} fail('Auto-login unavailable. <a href="/?direct=1">Open Chat manually</a>','No token field: '+t.substring(0,200))}
catch(e){fail('Auto-login parse error. <a href="/?direct=1">Open Chat manually</a>','Error: '+e.message+' / body: '+t.substring(0,200))} catch(e){fail('Auto-login parse error. <a href="/?direct=1">Open Chat manually</a>','Error: '+e.message+' / body: '+t.substring(0,200))}
@@ -324,30 +363,29 @@ ft('chat').then(function(r){return r.text()}).then(function(t){
}, },
plex: { plex: {
title: 'Signing in to Plex...', bg: '#1f1f1f', accent: '#e5a00d', title: 'Signing in to Plex...', bg: '#1f1f1f', accent: '#e5a00d',
body: `if(ls.getItem('myPlexAccessToken')){go('/web/?direct=1');return} body: `ft('plex').then(function(r){return r.json()}).then(function(j){
ft('plex').then(function(r){return r.json()}).then(function(j){ if(j.credentialsRequired){vault('plex');return}if(j.token){ls.setItem('myPlexAccessToken',j.token);d.textContent='Token stored, redirecting...';go('/web/?direct=1');return}
if(j.token){ls.setItem('myPlexAccessToken',j.token);d.textContent='Token stored, redirecting...';go('/web/?direct=1');return}
// No token returned. Three fallbacks in priority order: // No token returned. Three fallbacks in priority order:
// 1. Stale token in localStorage — Plex may still accept it. // 1. Stale token in localStorage — Plex may still accept it.
if(ls.getItem('myPlexAccessToken')){go('/web/?direct=1');return} if(ls.getItem('myPlexAccessToken')){go('/web/?direct=1');return}
// 2. Manual link so the user is never trapped on this page. // 2. Manual link so the user is never trapped on this page.
fail('Auto-login unavailable. <a href="/web/?direct=1">Open Plex manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>','API: '+JSON.stringify(j)) fail('Auto-login unavailable. <a href="/web/?direct=1">Open Plex manually</a> or '+authLink('re-authenticate at DashCaddy'),'API: '+JSON.stringify(j))
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/?direct=1">Open Plex manually</a>','Error: '+(e&&e.message||'unknown'))})` }).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/?direct=1">Open Plex manually</a>','Error: '+(e&&e.message||'unknown'))})`
}, },
jellyfin: { jellyfin: {
title: 'Signing in to Jellyfin...', bg: '#101014', accent: '#00a4dc', title: 'Signing in to Jellyfin...', bg: '#101014', accent: '#00a4dc',
body: `ft('jellyfin').then(function(r){return r.json()}).then(function(j){ body: `ft('jellyfin').then(function(r){return r.json()}).then(function(j){
if(j.token){merge('jellyfin_credentials',j,'Jellyfin');merge('_jellyfin_credentials',j,'Jellyfin');d.textContent='Token stored, redirecting...';go('/web/');return} if(j.credentialsRequired){vault('jellyfin');return}if(j.token){merge('jellyfin_credentials',j,'Jellyfin');merge('_jellyfin_credentials',j,'Jellyfin');d.textContent='Token stored, redirecting...';go('/web/');return}
if(ls.getItem('jellyfin_credentials')||ls.getItem('_jellyfin_credentials')){go('/web/');return} if(ls.getItem('jellyfin_credentials')||ls.getItem('_jellyfin_credentials')){go('/web/');return}
fail('Auto-login unavailable. <a href="/web/">Open Jellyfin manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>','API: '+JSON.stringify(j)) fail('Auto-login unavailable. <a href="/web/">Open Jellyfin manually</a> or '+authLink('re-authenticate at DashCaddy'),'API: '+JSON.stringify(j))
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Jellyfin manually</a>','Error: '+(e&&e.message||'unknown'))})` }).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Jellyfin manually</a>','Error: '+(e&&e.message||'unknown'))})`
}, },
emby: { emby: {
title: 'Signing in to Emby...', bg: '#101014', accent: '#52b54b', title: 'Signing in to Emby...', bg: '#101014', accent: '#52b54b',
body: `ft('emby').then(function(r){return r.json()}).then(function(j){ body: `ft('emby').then(function(r){return r.json()}).then(function(j){
if(j.token){merge('emby_credentials',j,'Emby');merge('_emby_credentials',j,'Emby');d.textContent='Token stored, redirecting...';go('/web/');return} if(j.credentialsRequired){vault('emby');return}if(j.token){merge('emby_credentials',j,'Emby');merge('_emby_credentials',j,'Emby');d.textContent='Token stored, redirecting...';go('/web/');return}
if(ls.getItem('emby_credentials')||ls.getItem('_emby_credentials')){go('/web/');return} if(ls.getItem('emby_credentials')||ls.getItem('_emby_credentials')){go('/web/');return}
fail('Auto-login unavailable. <a href="/web/">Open Emby manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>','API: '+JSON.stringify(j)) fail('Auto-login unavailable. <a href="/web/">Open Emby manually</a> or '+authLink('re-authenticate at DashCaddy'),'API: '+JSON.stringify(j))
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Emby manually</a>','Error: '+(e&&e.message||'unknown'))})` }).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Emby manually</a>','Error: '+(e&&e.message||'unknown'))})`
}, },
}; };
@@ -357,5 +395,6 @@ ft('plex').then(function(r){return r.json()}).then(function(j){
return SHELL(cfg.body) return SHELL(cfg.body)
.replace(/__TITLE__/g, cfg.title) .replace(/__TITLE__/g, cfg.title)
.replace('__BG__', cfg.bg) .replace('__BG__', cfg.bg)
.replace('__ACCENT__', cfg.accent); .replace('__ACCENT__', cfg.accent)
.replace('__DASHBOARD_ORIGIN__', JSON.stringify(dashboardOrigin));
} }
+13 -4
View File
@@ -15,7 +15,7 @@ const { ok, successMessage } = require('../../src/utils/responses');
* @param {Object} deps.log - Logger instance * @param {Object} deps.log - Logger instance
* @returns {express.Router} * @returns {express.Router}
*/ */
module.exports = function({ authManager, credentialManager, totpConfig, saveTotpConfig, session, asyncHandler, errorResponse, log, renewCSRFToken }) { module.exports = function({ authManager, credentialManager, totpConfig, saveTotpConfig, session, asyncHandler, errorResponse, log, renewCSRFToken, siteConfig }) {
const router = express.Router(); const router = express.Router();
// Ctx shim for backward compatibility // Ctx shim for backward compatibility
@@ -23,7 +23,8 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp
credentialManager, credentialManager,
totpConfig, totpConfig,
saveTotpConfig, saveTotpConfig,
session session,
siteConfig
}; };
// Get current TOTP config (public route) // Get current TOTP config (public route)
@@ -193,11 +194,14 @@ const SETUP_WINDOW_MS = 60 * 60 * 1000;
// Login: verify TOTP code and set session cookie // Login: verify TOTP code and set session cookie
router.post('/totp/verify', asyncHandler(async (req, res) => { router.post('/totp/verify', asyncHandler(async (req, res) => {
const { authenticator } = require('otplib'); const { authenticator } = require('otplib');
const { code } = req.body; const { code, serviceId } = req.body;
if (!code || !/^\d{6}$/.test(code)) { if (!code || !/^\d{6}$/.test(code)) {
throw new ValidationError('Invalid code format', 'code'); throw new ValidationError('Invalid code format', 'code');
} }
if (serviceId != null && !/^[a-z0-9][a-z0-9-]*$/.test(String(serviceId))) {
throw new ValidationError('Invalid service ID', 'serviceId');
}
if (!ctx.totpConfig.enabled || !ctx.totpConfig.isSetUp) { if (!ctx.totpConfig.enabled || !ctx.totpConfig.isSetUp) {
throw new ValidationError('TOTP is not enabled'); throw new ValidationError('TOTP is not enabled');
@@ -227,7 +231,12 @@ const SETUP_WINDOW_MS = 60 * 60 * 1000;
// URL when bouncing the user back to a gated service. That service's // URL when bouncing the user back to a gated service. That service's
// login page exchanges it via /auth/sso-exchange for its own host-only // login page exchanges it via /auth/sso-exchange for its own host-only
// session cookie. Single-use, 60s TTL — see ctx.session.createHandoffToken. // session cookie. Single-use, 60s TTL — see ctx.session.createHandoffToken.
const ssoToken = ctx.session.createHandoffToken(); let ssoToken = null;
if (serviceId) {
const suffix = String(ctx.siteConfig?.tld || '.sami');
const expectedHost = `${serviceId}${suffix.startsWith('.') ? suffix : `.${suffix}`}`;
ssoToken = ctx.session.createHandoffToken(expectedHost);
}
log.debug('auth', 'Session created', { sessions: ctx.session.ipSessions.size }); log.debug('auth', 'Session created', { sessions: ctx.session.ipSessions.size });
ok(res, { message: 'Authenticated successfully', sessionDuration: ctx.totpConfig.sessionDuration, csrfToken: newCsrfToken, ssoToken }); ok(res, { message: 'Authenticated successfully', sessionDuration: ctx.totpConfig.sessionDuration, csrfToken: newCsrfToken, ssoToken });
+2 -1
View File
@@ -263,9 +263,10 @@ module.exports = function({
const arrKey = await credentialManager.retrieve(`arr.${serviceId}.apikey`).catch(() => null); const arrKey = await credentialManager.retrieve(`arr.${serviceId}.apikey`).catch(() => null);
const svcKey = await credentialManager.retrieve(`service.${serviceId}.apikey`).catch(() => null); const svcKey = await credentialManager.retrieve(`service.${serviceId}.apikey`).catch(() => null);
const username = await credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null); const username = await credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
const password = await credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
success(res, { success(res, {
hasApiKey: !!(arrKey || svcKey), hasApiKey: !!(arrKey || svcKey),
hasBasicAuth: !!username, hasBasicAuth: !!username && !!password,
username: username || null username: username || null
}); });
}, 'service-creds')); }, 'service-creds'));
+37 -7
View File
@@ -43,8 +43,15 @@ const HISTORY_RETENTION_DAYS = parseInt(process.env.HEALTH_HISTORY_RETENTION ||
// - UP_THRESHOLD = N consecutive "up" probes before the badge flips back to green // - UP_THRESHOLD = N consecutive "up" probes before the badge flips back to green
// Single probe flips to green on purpose — false-positive-green is much less // Single probe flips to green on purpose — false-positive-green is much less
// painful than perpetual-red (operators notice red, ignore green). // painful than perpetual-red (operators notice red, ignore green).
const DOWN_THRESHOLD = Math.max(1, parseInt(process.env.HEALTH_DOWN_THRESHOLD || '2', 10)); function readPositiveIntEnv(name, fallback) {
const UP_THRESHOLD = Math.max(1, parseInt(process.env.HEALTH_UP_THRESHOLD || '1', 10)); const raw = process.env[name];
if (raw === undefined || raw === '') return fallback;
const value = Number(raw);
return Number.isSafeInteger(value) && value >= 1 ? value : fallback;
}
const DOWN_THRESHOLD = readPositiveIntEnv('HEALTH_DOWN_THRESHOLD', 2);
const UP_THRESHOLD = readPositiveIntEnv('HEALTH_UP_THRESHOLD', 1);
class HealthChecker extends EventEmitter { class HealthChecker extends EventEmitter {
constructor() { constructor() {
@@ -63,6 +70,8 @@ class HealthChecker extends EventEmitter {
this.checkInterval = null; this.checkInterval = null;
this.consecutiveFailures = new Map(); // serviceId -> failure count this.consecutiveFailures = new Map(); // serviceId -> failure count
this.serviceTimers = new Map(); // serviceId -> timer for per-service backoff this.serviceTimers = new Map(); // serviceId -> timer for per-service backoff
// Invalidate probe completions that race with removal/reconfiguration.
this.serviceGenerations = new Map(); // serviceId -> configuration generation
} }
/** /**
@@ -135,6 +144,7 @@ class HealthChecker extends EventEmitter {
*/ */
async checkService(serviceId, config) { async checkService(serviceId, config) {
const startTime = Date.now(); const startTime = Date.now();
const generation = this.serviceGenerations.get(serviceId) || 0;
try { try {
const result = await this.performHealthCheck(config); const result = await this.performHealthCheck(config);
@@ -150,6 +160,10 @@ class HealthChecker extends EventEmitter {
details: result.details details: result.details
}; };
if ((this.serviceGenerations.get(serviceId) || 0) !== generation) {
return status;
}
// Track consecutive failures for exponential backoff // Track consecutive failures for exponential backoff
if (result.healthy) { if (result.healthy) {
this.consecutiveFailures.delete(serviceId); this.consecutiveFailures.delete(serviceId);
@@ -157,8 +171,9 @@ class HealthChecker extends EventEmitter {
this.consecutiveFailures.set(serviceId, (this.consecutiveFailures.get(serviceId) || 0) + 1); this.consecutiveFailures.set(serviceId, (this.consecutiveFailures.get(serviceId) || 0) + 1);
} }
const previousStatus = this.currentStatus.get(serviceId);
this.recordStatus(serviceId, status); this.recordStatus(serviceId, status);
this.checkForIncidents(serviceId, status, config); this.checkForIncidents(serviceId, status, config, previousStatus);
return status; return status;
} catch (error) { } catch (error) {
@@ -175,8 +190,13 @@ class HealthChecker extends EventEmitter {
error: error.message error: error.message
}; };
if ((this.serviceGenerations.get(serviceId) || 0) !== generation) {
return status;
}
const previousStatus = this.currentStatus.get(serviceId);
this.recordStatus(serviceId, status); this.recordStatus(serviceId, status);
this.checkForIncidents(serviceId, status, config); this.checkForIncidents(serviceId, status, config, previousStatus);
return status; return status;
} }
@@ -319,7 +339,7 @@ class HealthChecker extends EventEmitter {
// a brief blip doesn't accumulate against the displayed state. // a brief blip doesn't accumulate against the displayed state.
if (rawStatus.status === previousStatus) { if (rawStatus.status === previousStatus) {
this.consecutiveSinceChange.set(serviceId, 0); this.consecutiveSinceChange.set(serviceId, 0);
return currentDisplayed; return rawStatus;
} }
// Probe disagrees with displayed. Bump the streak counter — this counts // Probe disagrees with displayed. Bump the streak counter — this counts
@@ -334,6 +354,9 @@ class HealthChecker extends EventEmitter {
// with the displayed "up" state. // with the displayed "up" state.
if (previousStatus === 'up' && next < DOWN_THRESHOLD) { if (previousStatus === 'up' && next < DOWN_THRESHOLD) {
this.consecutiveSinceChange.set(serviceId, next); this.consecutiveSinceChange.set(serviceId, next);
// Keep the last internally-consistent displayed snapshot. Mixing the
// raw failure metadata with status="up" would expose contradictory
// API data (for example statusCode=500 on an "up" service).
return currentDisplayed; return currentDisplayed;
} }
// Threshold met (or already down) — flip to red. // Threshold met (or already down) — flip to red.
@@ -402,8 +425,7 @@ class HealthChecker extends EventEmitter {
/** /**
* Check for incidents (downtime, slow response, etc.) * Check for incidents (downtime, slow response, etc.)
*/ */
checkForIncidents(serviceId, status, config) { checkForIncidents(serviceId, status, config, previous = this.currentStatus.get(serviceId)) {
const previous = this.currentStatus.get(serviceId);
// Check for status change (up -> down or down -> up) // Check for status change (up -> down or down -> up)
if (previous && previous.status !== status.status) { if (previous && previous.status !== status.status) {
@@ -638,6 +660,7 @@ class HealthChecker extends EventEmitter {
this.config.services = {}; this.config.services = {};
} }
this.serviceGenerations.set(serviceId, (this.serviceGenerations.get(serviceId) || 0) + 1);
this.config.services[serviceId] = { this.config.services[serviceId] = {
enabled: config.enabled !== false, enabled: config.enabled !== false,
name: config.name || serviceId, name: config.name || serviceId,
@@ -660,12 +683,19 @@ class HealthChecker extends EventEmitter {
* Remove service configuration * Remove service configuration
*/ */
removeService(serviceId) { removeService(serviceId) {
this.serviceGenerations.set(serviceId, (this.serviceGenerations.get(serviceId) || 0) + 1);
if (this.config.services) { if (this.config.services) {
delete this.config.services[serviceId]; delete this.config.services[serviceId];
this.saveConfig(); this.saveConfig();
} }
this.currentStatus.delete(serviceId); this.currentStatus.delete(serviceId);
this.displayedStatus.delete(serviceId);
this.consecutiveSinceChange.delete(serviceId);
this.consecutiveFailures.delete(serviceId);
const timer = this.serviceTimers.get(serviceId);
if (timer) clearTimeout(timer);
this.serviceTimers.delete(serviceId);
delete this.history[serviceId]; delete this.history[serviceId];
} }
+9 -4
View File
@@ -330,17 +330,22 @@ module.exports = function configureMiddleware(app, {
const ssoHandoffTokens = new Map(); const ssoHandoffTokens = new Map();
const SSO_HANDOFF_TTL_MS = 60 * 1000; const SSO_HANDOFF_TTL_MS = 60 * 1000;
function createHandoffToken() { function createHandoffToken(expectedHost = null) {
const token = crypto.randomBytes(24).toString('base64url'); const token = crypto.randomBytes(24).toString('base64url');
ssoHandoffTokens.set(token, { exp: Date.now() + SSO_HANDOFF_TTL_MS }); ssoHandoffTokens.set(token, {
exp: Date.now() + SSO_HANDOFF_TTL_MS,
expectedHost: expectedHost ? String(expectedHost).toLowerCase() : null,
});
return token; return token;
} }
function redeemHandoffToken(token) { function redeemHandoffToken(token, actualHost = null) {
if (!token) return false; if (!token) return false;
const entry = ssoHandoffTokens.get(token); const entry = ssoHandoffTokens.get(token);
ssoHandoffTokens.delete(token); // one-time use regardless of outcome ssoHandoffTokens.delete(token); // one-time use regardless of outcome
return !!entry && entry.exp > Date.now(); if (!entry || entry.exp <= Date.now()) return false;
if (!entry.expectedHost) return true;
return !!actualHost && entry.expectedHost === String(actualHost).toLowerCase();
} }
function setHostOnlySessionCookie(res, durationKey) { function setHostOnlySessionCookie(res, durationKey) {
+6 -1
View File
@@ -630,10 +630,15 @@ generate_caddyfile() {
SNIP SNIP
local auth_snippet="(dashcaddy_auth) { local auth_snippet="(dashcaddy_auth) {
forward_auth localhost:${API_PORT} { @needsAuth not path /dashcaddy-sso
forward_auth @needsAuth localhost:${API_PORT} {
uri /api/v1/auth/gate/{args[0]} uri /api/v1/auth/gate/{args[0]}
copy_headers Authorization X-Api-Key X-App-Cookie X-Emby-Token X-Plex-Token copy_headers Authorization X-Api-Key X-App-Cookie X-Emby-Token X-Plex-Token
} }
handle /dashcaddy-sso {
rewrite * /api/v1/auth/sso-exchange
reverse_proxy localhost:${API_PORT}
}
}" }"
local site_body=" root * ${DASHBOARD_DIR} local site_body=" root * ${DASHBOARD_DIR}
@@ -51,10 +51,15 @@ class CaddyfileGenerator {
_authSnippet(apiPort) { _authSnippet(apiPort) {
return `# DashCaddy SSO auth snippet return `# DashCaddy SSO auth snippet
(dashcaddy_auth) { (dashcaddy_auth) {
forward_auth localhost:${apiPort} { @needsAuth not path /dashcaddy-sso
forward_auth @needsAuth localhost:${apiPort} {
uri /api/v1/auth/gate/{args[0]} uri /api/v1/auth/gate/{args[0]}
copy_headers Authorization X-Api-Key X-App-Cookie X-Emby-Token X-Plex-Token copy_headers Authorization X-Api-Key X-App-Cookie X-Emby-Token X-Plex-Token
} }
handle /dashcaddy-sso {
rewrite * /api/v1/auth/sso-exchange
reverse_proxy localhost:${apiPort}
}
} }
`; `;
} }
@@ -0,0 +1,35 @@
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const CaddyfileGenerator = require('./caddyfile-generator');
describe('cross-host SSO installer contract', () => {
test('generated auth snippet exposes the public one-time exchange landing route', () => {
const snippet = new CaddyfileGenerator()._authSnippet(3001);
expect(snippet).toContain('@needsAuth not path /dashcaddy-sso');
expect(snippet).toContain('handle /dashcaddy-sso');
expect(snippet).toContain('rewrite * /api/v1/auth/sso-exchange');
expect(snippet).toContain('reverse_proxy localhost:3001');
});
test('shell installer emits the same exchange landing contract', () => {
const installer = fs.readFileSync(path.join(__dirname, '..', '..', 'install.sh'), 'utf8');
expect(installer).toContain('@needsAuth not path /dashcaddy-sso');
expect(installer).toContain('handle /dashcaddy-sso');
expect(installer).toContain('rewrite * /api/v1/auth/sso-exchange');
});
test('Caddy parser accepts a complete service config using the generated snippet', () => {
const available = spawnSync('caddy', ['version'], { encoding: 'utf8' });
if (available.status !== 0) return;
const generator = new CaddyfileGenerator();
const config = `${generator._authSnippet(3001)}\nexample.test {\n import dashcaddy_auth plex\n respond "ok" 200\n}\n`;
const result = spawnSync('caddy', ['validate', '--config', '-', '--adapter', 'caddyfile'], {
input: config,
encoding: 'utf8',
});
expect(result.status).toBe(0);
expect(`${result.stdout}\n${result.stderr}`).toContain('Valid configuration');
});
});
+1
View File
@@ -28,6 +28,7 @@ const bundles = {
// 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.
JS('totp-recovery.js'), JS('totp-recovery.js'),
JS('credential-vault-handoff.js'),
JS('service-credentials.js'), JS('service-credentials.js'),
JS('totp-settings.js'), JS('totp-settings.js'),
// DC-048 admin panel — modal-overlay UI for user/invite management. // 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
+46 -2
View File
@@ -267,6 +267,46 @@
} }
} }
function buildSsoHandoffTarget(returnUrl, token) {
const parsed = new URL(returnUrl, window.location.origin);
if (parsed.origin === window.location.origin) return parsed.toString();
const suffix = SITE.tld.startsWith('.') ? SITE.tld : `.${SITE.tld}`;
const isPrivateHost = parsed.hostname === suffix.slice(1) || parsed.hostname.endsWith(suffix);
if (parsed.protocol !== 'https:' || !isPrivateHost || !token) return null;
const returnPath = `${parsed.pathname}${parsed.search}${parsed.hash}`;
parsed.pathname = '/dashcaddy-sso';
parsed.search = '';
parsed.hash = '';
parsed.searchParams.set('token', token);
parsed.searchParams.set('return', returnPath);
return parsed.toString();
}
async function resumeExistingSession(returnUrl) {
if (!returnUrl || !isAllowedReturnUrl(returnUrl)) return false;
try {
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',
});
if (!res.ok) return false;
const data = await res.json();
const target = data.success && buildSsoHandoffTarget(returnUrl, data.ssoToken);
if (!target) return false;
try { sessionStorage.removeItem('totp_redirect'); } catch (_) {}
window.location.replace(target);
return true;
} catch (_) {
return false;
}
}
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('auth') === 'required') { if (urlParams.get('auth') === 'required') {
// Preserve the gated service destination so submitTotpCode() can append // Preserve the gated service destination so submitTotpCode() can append
@@ -277,8 +317,12 @@
} }
// Clean URL — happens after we've captured the redirect // Clean URL — happens after we've captured the redirect
window.history.replaceState({}, '', window.location.pathname); window.history.replaceState({}, '', window.location.pathname);
// Show on next tick so the DOM (the .totp-card) is ready // Reuse the valid status.sami session first. Only show the TOTP/provider
setTimeout(show, 0); // challenge when that session is genuinely absent or expired.
setTimeout(async () => {
if (await resumeExistingSession(returnUrl)) return;
await show();
}, 0);
} }
// Expose for hot-trigger from other modules (e.g. logout) // Expose for hot-trigger from other modules (e.g. logout)
+55 -52
View File
@@ -33,6 +33,20 @@
return server?.name || dnsId.toUpperCase(); 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 */ /** Build per-server credential form sections from SITE.dnsServers */
function buildCredentialSections() { function buildCredentialSections() {
const container = document.getElementById('dns-cred-sections'); const container = document.getElementById('dns-cred-sections');
@@ -258,14 +272,6 @@
document.getElementById('token-save')?.addEventListener('click', async () => { document.getElementById('token-save')?.addEventListener('click', async () => {
const dnsIds = getDnsIds(); 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 // Build per-server credentials payload for backend sync
const servers = {}; const servers = {};
let hasAnyCreds = false; let hasAnyCreds = false;
@@ -304,45 +310,36 @@
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ servers }) body: JSON.stringify({ servers })
}); });
const data = await res.json(); const data = await requireSuccessfulDnsMutation(res, 'DNS credential save');
if (data.results) { if (data.results) {
dnsIds.forEach(dnsId => { const failed = Object.keys(servers).filter(dnsId => data.results[dnsId]?.success !== true);
const statusEl = document.getElementById(`${dnsId}-token-status`); if (failed.length) {
if (!servers[dnsId]) { statusEl.textContent = ''; return; } const details = failed.map(dnsId => data.results[dnsId]?.error || `${dnsId} failed`).join('; ');
const result = data.results[dnsId]; throw new Error(details);
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';
}
});
} }
// 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) { } catch (e) {
console.error('Failed to sync DNS credentials to backend:', e); console.error('Failed to sync DNS credentials to backend:', e);
dnsIds.forEach(dnsId => { dnsIds.forEach(dnsId => {
if (servers[dnsId]) { if (servers[dnsId]) {
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Saved locally (sync failed)'; document.getElementById(`${dnsId}-token-status`).textContent = '\u2717 ' + (e.message || 'Save failed');
document.getElementById(`${dnsId}-token-status`).className = 'token-status'; document.getElementById(`${dnsId}-token-status`).className = 'token-status error';
} }
}); });
} }
@@ -368,18 +365,24 @@
document.getElementById('token-clear-all')?.addEventListener('click', async () => { document.getElementById('token-clear-all')?.addEventListener('click', async () => {
if (confirm('Clear all stored DNS credentials? This cannot be undone.')) { 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 { try {
await secureFetch('/api/v1/dns/credentials', { method: 'DELETE' }); const response = await secureFetch('/api/v1/dns/credentials', { method: 'DELETE' });
} catch (_) {} 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 window.loadServices();
await loadTemplateCategories(); await loadTemplateCategories();
window.buildGrid(); window.buildGrid();
if (typeof window.openRequestedCredentialForm === 'function') {
window.openRequestedCredentialForm();
}
animateTopCards(); animateTopCards();
window.refreshAll(); window.refreshAll();
setInterval(() => { 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"> injectModal('service-creds-modal', `<div id="service-creds-modal">
<div class="service-creds-content"> <div class="service-creds-content">
<h3 id="svc-creds-title" style="margin: 0 0 4px; font-size: 1.05rem;">Service Credentials</h3> <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;">Credentials are injected automatically when accessing this service.</p> <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 --> <!-- Status indicator -->
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 12px;"> <div style="display: flex; align-items: center; gap: 6px; margin-bottom: 12px;">
@@ -91,7 +91,7 @@
<!-- Buttons --> <!-- Buttons -->
<div style="display: flex; gap: 8px; margin-top: 14px;"> <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;"> <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>
<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;"> <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 Clear
@@ -105,6 +105,8 @@
const modal = document.getElementById('service-creds-modal'); const modal = document.getElementById('service-creds-modal');
let currentService = null; let currentService = null;
let credentialReturnUrl = null;
let currentServiceHadCreds = false;
const arrServices = ['sonarr', 'radarr', 'prowlarr', 'overseerr']; const arrServices = ['sonarr', 'radarr', 'prowlarr', 'overseerr'];
const qualityProfileServices = ['sonarr', 'radarr']; const qualityProfileServices = ['sonarr', 'radarr'];
@@ -124,8 +126,28 @@
el.style.display = 'none'; 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; currentService = service;
credentialReturnUrl = isAllowedCredentialReturnUrl(options.returnUrl, service.id) ? options.returnUrl : null;
currentServiceHadCreds = false;
hideError(); hideError();
const title = document.getElementById('svc-creds-title'); const title = document.getElementById('svc-creds-title');
const desc = document.getElementById('svc-creds-desc'); const desc = document.getElementById('svc-creds-desc');
@@ -134,7 +156,10 @@
const basicSection = document.getElementById('svc-creds-basic'); const basicSection = document.getElementById('svc-creds-basic');
const qualitySection = document.getElementById('svc-creds-quality'); 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 // Determine which sections to show
const isExt = !!service.isExternal; const isExt = !!service.isExternal;
const isArr = arrServices.includes(service.id) || arrServices.includes(service.appTemplate); const isArr = arrServices.includes(service.id) || arrServices.includes(service.appTemplate);
@@ -214,6 +239,7 @@
} }
if (hasCreds) { if (hasCreds) {
currentServiceHadCreds = true;
dot.style.background = 'var(--ok-fg, #74dfc4)'; dot.style.background = 'var(--ok-fg, #74dfc4)';
status.style.color = 'var(--ok-fg, #74dfc4)'; status.style.color = 'var(--ok-fg, #74dfc4)';
status.textContent = 'Credentials stored'; status.textContent = 'Credentials stored';
@@ -352,16 +378,35 @@
const isArr = arrServices.includes(currentService.id) || arrServices.includes(currentService.appTemplate); const isArr = arrServices.includes(currentService.id) || arrServices.includes(currentService.appTemplate);
const svcId = currentService.id || 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) // Save seedhost creds (shared username + per-service password)
if (currentService.isExternal) { if (currentService.isExternal) {
const user = document.getElementById('svc-seedhost-user').value.trim(); const user = document.getElementById('svc-seedhost-user').value.trim();
const pass = document.getElementById('svc-seedhost-pass').value; const pass = document.getElementById('svc-seedhost-pass').value;
if (user) { if (user) {
await secureFetch('/api/v1/seedhost-creds', { const response = await secureFetch('/api/v1/seedhost-creds', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user, password: pass || undefined, serviceId: currentService.id }) body: JSON.stringify({ username: user, password: pass || undefined, serviceId: currentService.id })
}); });
await requireSuccessfulWrite(response, 'Seedhost credential save');
} }
} }
@@ -387,23 +432,18 @@
qualityProfileName: qualityProfileName || undefined qualityProfileName: qualityProfileName || undefined
}) })
}); });
const data = await res.json(); const data = await requireSuccessfulWrite(res, 'ARR credential save');
if (!data.success) {
showError(data.error || 'Failed to save API key');
saveBtn.textContent = 'Save';
saveBtn.disabled = false;
return;
}
if (data.connectionTest && !data.connectionTest.success) { if (data.connectionTest && !data.connectionTest.success) {
showError(`API key saved but connection test failed: ${data.connectionTest.error}`); showError(`API key saved but connection test failed: ${data.connectionTest.error}`);
} }
} else { } else {
// Non-arr services use the generic endpoint // 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiKey }) body: JSON.stringify({ apiKey })
}); });
await requireSuccessfulWrite(response, 'API key save');
} }
} else if (isArr && qualityProfileServices.includes(svcId)) { } else if (isArr && qualityProfileServices.includes(svcId)) {
// API key unchanged but user may have changed quality profile — save profile only // 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 qualityProfileId = qualSelect?.value ? parseInt(qualSelect.value) : undefined;
const qualityProfileName = qualSelect?.selectedOptions?.[0]?.textContent || undefined; const qualityProfileName = qualSelect?.selectedOptions?.[0]?.textContent || undefined;
if (qualityProfileId) { if (qualityProfileId) {
await secureFetch('/api/v1/arr/quality-profiles', { const response = await secureFetch('/api/v1/arr/quality-profiles', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service: svcId, qualityProfileId, qualityProfileName }) 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 user = document.getElementById('svc-basic-user').value.trim();
const pass = document.getElementById('svc-basic-pass').value; const pass = document.getElementById('svc-basic-pass').value;
if (user && pass) { if (user && pass) {
await secureFetch(`/api/v1/services/${currentService.id}/credentials`, { const response = await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user, password: pass }) body: JSON.stringify({ username: user, password: pass })
}); });
await requireSuccessfulWrite(response, 'Service credential save');
} }
} }
await loadServiceCreds(currentService); 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) { } catch (e) {
errorHandler.logError('[ServiceCredentials] Save', e, { function: 'saveCredentials' }); errorHandler.logError('[ServiceCredentials] Save', e, { function: 'saveCredentials' });
showError('Failed to save: ' + (e.message || 'Unknown error')); 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; saveBtn.disabled = false;
}); });
@@ -450,12 +499,15 @@
const svcId = currentService.id || currentService.appTemplate; const svcId = currentService.id || currentService.appTemplate;
const isArr = arrServices.includes(svcId); const isArr = arrServices.includes(svcId);
if (currentService.isExternal) { 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 // 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) { 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}`); const btn = document.getElementById(`creds-btn-${currentService.id}`);
if (btn) btn.classList.remove('has-creds'); if (btn) btn.classList.remove('has-creds');
@@ -470,11 +522,13 @@
document.getElementById('svc-creds-close')?.addEventListener('click', () => { document.getElementById('svc-creds-close')?.addEventListener('click', () => {
modal.classList.remove('show'); modal.classList.remove('show');
currentService = null; currentService = null;
credentialReturnUrl = null;
}); });
modal?.addEventListener('click', (e) => { modal?.addEventListener('click', (e) => {
if (e.target === modal) { if (e.target === modal) {
modal.classList.remove('show'); modal.classList.remove('show');
currentService = null; currentService = null;
credentialReturnUrl = null;
} }
}); });
@@ -501,4 +555,18 @@
} }
} catch (e) { /* ignore */ } } 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.textContent = 'Verifying...';
errorEl.className = 'totp-error 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 { try {
const res = await secureFetch('/api/v1/totp/verify', { const res = await secureFetch('/api/v1/totp/verify', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }) body: JSON.stringify({ code, serviceId })
}); });
const data = await res.json(); const data = await res.json();
@@ -106,7 +117,6 @@
} }
hideTotpOverlay(); hideTotpOverlay();
// Check if redirected here from another service // Check if redirected here from another service
const redirect = safeSessionGet('totp_redirect');
if (redirect) { if (redirect) {
try { sessionStorage.removeItem('totp_redirect'); } catch (_) {} try { sessionStorage.removeItem('totp_redirect'); } catch (_) {}
// .sami is an unregistered TLD, so browsers silently drop the // .sami is an unregistered TLD, so browsers silently drop the
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'dashcaddy-shell-a24ef15882'; const CACHE = 'dashcaddy-shell-c25cea8485';
const PRECACHE = [ const PRECACHE = [
'/', '/',
'/index.html', '/index.html',
+38
View File
@@ -93,3 +93,41 @@ test('same-origin and tokenless destinations keep their direct URL', () => {
assert.equal(buildHandoffTarget('/settings', 'one-time'), 'https://status.sami/settings'); assert.equal(buildHandoffTarget('/settings', 'one-time'), 'https://status.sami/settings');
assert.equal(buildHandoffTarget('https://router.sami/config', ''), 'https://router.sami/config'); assert.equal(buildHandoffTarget('https://router.sami/config', ''), 'https://router.sami/config');
}); });
test('an existing status.sami session returns to a service without another TOTP prompt', async () => {
const query = new URLSearchParams({ auth: 'required', return: 'https://plex.sami/web/' });
let scheduled;
let redirected;
const location = {
origin: 'https://status.sami',
pathname: '/',
search: `?${query.toString()}`,
replace(value) { redirected = value; },
};
const context = {
URL,
URLSearchParams,
SITE: { tld: '.sami' },
sessionStorage: { setItem() {} },
document: { getElementById() { return null; } },
setTimeout(fn) { scheduled = fn; },
console,
fetch: async (url) => {
assert.equal(url, '/api/v1/auth/sso-handoff?serviceId=plex');
return { ok: true, json: async () => ({ success: true, ssoToken: 'existing-session-token' }) };
},
window: {
location,
history: { replaceState() {} },
},
};
context.window.window = context.window;
vm.runInNewContext(source, context, { filename: 'auth-gate.js' });
assert.equal(typeof scheduled, 'function');
await scheduled();
assert.equal(
redirected,
'https://plex.sami/dashcaddy-sso?token=existing-session-token&return=%2Fweb%2F',
);
});
@@ -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);
});