Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2285a2550 | ||
|
|
54a1df5ac4 | ||
|
|
eb546bf468 | ||
|
|
84e051d975 | ||
|
|
628bbe32f6 | ||
|
|
f8b99f9b5a | ||
|
|
eab2b00b13 | ||
|
|
84edb035e3 | ||
|
|
d313b1e872 |
@@ -22,11 +22,17 @@ process.env.HEALTH_DATA_DIR = tmpDir;
|
||||
process.env.HEALTH_CONFIG_FILE = path.join(tmpDir, 'health-config.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
|
||||
// src/monitoring/health-checker.js. The test creates fresh state by replacing
|
||||
// the relevant maps on the singleton in beforeEach.
|
||||
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') {
|
||||
return {
|
||||
@@ -68,6 +74,15 @@ describe('DC-086: hysteresis on the dashboard badge', () => {
|
||||
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', () => {
|
||||
hc.recordStatus('svc1', makeUp());
|
||||
expect(emitSpy).toHaveBeenCalledTimes(1);
|
||||
@@ -88,6 +103,19 @@ describe('DC-086: hysteresis on the dashboard badge', () => {
|
||||
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', () => {
|
||||
hc.recordStatus('svc1', makeUp()); // baseline: green
|
||||
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', () => {
|
||||
hc.recordStatus('svc1', makeUp());
|
||||
hc.recordStatus('svc1', makeDown()); // raw=down, displayed=up
|
||||
const displayedUp = makeUp();
|
||||
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();
|
||||
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)', () => {
|
||||
@@ -142,7 +188,6 @@ describe('DC-086: hysteresis on the dashboard badge', () => {
|
||||
});
|
||||
|
||||
test('DOWN_THRESHOLD env var is honored', () => {
|
||||
const origDown = process.env.HEALTH_DOWN_THRESHOLD;
|
||||
process.env.HEALTH_DOWN_THRESHOLD = '3';
|
||||
jest.resetModules();
|
||||
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
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
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');
|
||||
});
|
||||
|
||||
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 () => {
|
||||
healthChecker._doRequest = jest.fn().mockRejectedValue(new Error('fail'));
|
||||
|
||||
|
||||
@@ -26,6 +26,19 @@ jest.mock('dockerode', () => {
|
||||
function buildApp({ configOk = true, servicesOk = true, dockerOk = true, caddyOk = true } = {}) {
|
||||
process.env.MOCK_DOCKER_DOWN = dockerOk ? '0' : '1';
|
||||
|
||||
// DC-087 — mirror src/app.js faithfully: the caddy check goes through
|
||||
// fetchT (which injects the Origin header Caddy's enforce_origin allowlist
|
||||
// requires), and is MOCKED so the suite is hermetic — no live request to a
|
||||
// real Caddy admin on :2019. The previous raw-`fetch` mirror sent an
|
||||
// Origin-less probe to the LIVE admin whenever the full suite ran on the
|
||||
// prod host (adversarial cron every 30 min): 12 journal 403 lines per run,
|
||||
// ~700/day of `client is not allowed to access from origin ''` noise,
|
||||
// plus a false checks.caddy.ok=false in the mirrored readiness payload.
|
||||
const fetchT = jest.spyOn(require('../src/utils/http'), 'fetchT')
|
||||
.mockImplementation(async () => (caddyOk
|
||||
? { ok: true, status: 200 }
|
||||
: { ok: false, status: 403 }));
|
||||
|
||||
const app = express();
|
||||
const config = {
|
||||
CONFIG_FILE: '/tmp/dc-test-config.json',
|
||||
@@ -103,9 +116,13 @@ function buildApp({ configOk = true, servicesOk = true, dockerOk = true, caddyOk
|
||||
allOk = false;
|
||||
}
|
||||
|
||||
// DC-087 — mirror src/app.js exactly (fetchT, not raw fetch). fetchT is
|
||||
// mocked at buildApp() scope, so this stays hermetic: no live probe to a
|
||||
// real Caddy admin (the old raw-fetch mirror 403-spammed the prod journal
|
||||
// every time the adversarial cron ran the full suite on this host).
|
||||
try {
|
||||
const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
|
||||
const response = await fetch(`${caddyUrl}/config/apps/http/servers/srv0/listen`, { signal: AbortSignal.timeout(10000) });
|
||||
const response = await fetchT(`${caddyUrl}/config/apps/http/servers/srv0/listen`, {}, 10000);
|
||||
checks.caddy = { ok: response.ok, status: response.status };
|
||||
if (!response.ok) allOk = false;
|
||||
} catch (e) {
|
||||
|
||||
@@ -33,9 +33,18 @@ jest.mock('dockerode', () => {
|
||||
|
||||
// Mirror the canonical handler block from src/app.js — if this drifts from
|
||||
// the real handler, these tests will start failing and force a sync.
|
||||
function buildApp({ configOk = true, servicesOk = true, dockerOk = true } = {}) {
|
||||
function buildApp({ configOk = true, servicesOk = true, dockerOk = true, caddyOk = true } = {}) {
|
||||
process.env.MOCK_DOCKER_DOWN = dockerOk ? '0' : '1';
|
||||
|
||||
// DC-087 — mirror src/app.js: caddy check via fetchT (Origin-injecting),
|
||||
// mocked here so the suite is hermetic. The old raw-fetch mirror probed the
|
||||
// LIVE Caddy admin on :2019 whenever the full suite ran on the prod host
|
||||
// (adversarial cron): Origin-less → 403 → 12 journal error lines per run.
|
||||
const fetchT = jest.spyOn(require('../src/utils/http'), 'fetchT')
|
||||
.mockImplementation(async () => (caddyOk
|
||||
? { ok: true, status: 200 }
|
||||
: { ok: false, status: 403 }));
|
||||
|
||||
const app = express();
|
||||
const config = {
|
||||
CONFIG_FILE: '/tmp/dc-test-config.json',
|
||||
@@ -108,8 +117,10 @@ function buildApp({ configOk = true, servicesOk = true, dockerOk = true } = {})
|
||||
allOk = false;
|
||||
}
|
||||
try {
|
||||
// DC-087 — mirror src/app.js exactly: fetchT (mocked above), not raw
|
||||
// fetch. Hermetic: no live request to a real Caddy admin.
|
||||
const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
|
||||
const response = await fetch(`${caddyUrl}/config/apps/http/servers/srv0/listen`, { signal: AbortSignal.timeout(10000) });
|
||||
const response = await fetchT(`${caddyUrl}/config/apps/http/servers/srv0/listen`, {}, 10000);
|
||||
checks.caddy = { ok: response.ok, status: response.status };
|
||||
if (!response.ok) allOk = false;
|
||||
} catch (e) {
|
||||
|
||||
@@ -112,6 +112,7 @@ function createApp(depsOverride = {}) {
|
||||
errorResponse: jest.fn(),
|
||||
log,
|
||||
renewCSRFToken,
|
||||
siteConfig: { tld: '.sami', dashboardHost: 'status.sami' },
|
||||
...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 () => {
|
||||
const secret = await setupTOTP();
|
||||
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.body.success).toBe(true);
|
||||
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.setCookie).toHaveBeenCalled();
|
||||
expect(deps.session.createHandoffToken).toHaveBeenCalledTimes(1);
|
||||
expect(deps.session.createHandoffToken).toHaveBeenCalledWith('plex.sami');
|
||||
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)
|
||||
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.body.csrfToken).toBeDefined();
|
||||
expect(loginRes.body.ssoToken).toBe('mock-sso-handoff-token');
|
||||
|
||||
@@ -288,6 +288,21 @@ describe('Services Routes', () => {
|
||||
expect(res.status).toBe(200);
|
||||
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 =====
|
||||
|
||||
@@ -50,6 +50,17 @@ describe('TOTP session cookie scope', () => {
|
||||
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', () => {
|
||||
const session = buildSession();
|
||||
const headers = {};
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
const request = require('supertest');
|
||||
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 session = {
|
||||
redeemHandoffToken: jest.fn().mockReturnValue(redeem),
|
||||
redeemHandoffToken: jest.fn((token) => (typeof redeem === 'function' ? redeem(token) : redeem)),
|
||||
setCookieHostOnly: jest.fn((res) => {
|
||||
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 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() },
|
||||
getAppSession: jest.fn(),
|
||||
appSessionCache: new Map(),
|
||||
credentialManager: { retrieve: jest.fn() },
|
||||
credentialManager: { retrieve: jest.fn((key) => Promise.resolve(storedCredentials[key] || null)) },
|
||||
fetchT: jest.fn(),
|
||||
getServiceById: jest.fn(),
|
||||
getServiceById: jest.fn((id) => Promise.resolve({ id, url: `https://${id}.sami` })),
|
||||
licenseManager: {
|
||||
hasFeature: jest.fn().mockReturnValue(true),
|
||||
requirePremium: jest.fn(() => (_req, _res, next) => next()),
|
||||
},
|
||||
servicesStateManager: { read: jest.fn().mockResolvedValue([]) },
|
||||
siteConfig: { dashboardHost },
|
||||
});
|
||||
app.use('/api/v1', router);
|
||||
return { app, session };
|
||||
@@ -44,7 +59,7 @@ describe('cross-host SSO exchange redirect', () => {
|
||||
expect(res.status).toBe(303);
|
||||
expect(res.headers.location).toBe('/settings?tab=network#dns');
|
||||
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([
|
||||
@@ -82,3 +97,105 @@ describe('cross-host SSO exchange redirect', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,6 +118,67 @@ describe('Caddyfile + utils/http.js — Origin header construction (DC-051)', ()
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
test('all :2019 call sites in TESTS use fetchT or a mocked fetchT (not raw fetch)', () => {
|
||||
// DC-087 — the same rule, extended into __tests__. The api-code walk above
|
||||
// skips __tests__, which let two mirrored health-handler test files keep a
|
||||
// raw await-fetch caddy probe long after src/app.js moved to fetchT. On a
|
||||
// host where the suite runs alongside a live Caddy admin (the prod box
|
||||
// runs the full jest suite every 30 min via a cron adversarial check),
|
||||
// that Origin-less raw fetch 403-spammed the Caddy journal (~700
|
||||
// client-not-allowed error lines per day) while the tests still passed —
|
||||
// checks.caddy.ok=false was silently accepted as sandbox noise. Mirrors
|
||||
// MUST call fetchT (mocked at buildApp scope for hermeticity). A raw
|
||||
// await-fetch at a Caddy-admin-URL call site in a test is an offender.
|
||||
// NOTE: keep this comment free of backticks — stripComments pairs
|
||||
// backtick spans across lines, and a stray pair shields real code from
|
||||
// the comment stripper (this test self-flagged its first draft).
|
||||
//
|
||||
// Detection is deliberately FILE-LEVEL, not call-window: the historical
|
||||
// drift kept the fetch call itself token-free (the URL came from a
|
||||
// caddyUrl variable defined on a PREVIOUS line from CADDY_ADMIN_URL),
|
||||
// so a call-window regex never fired. Any raw await-fetch in a file
|
||||
// that also references the Caddy admin anywhere is an offender.
|
||||
// Escape hatch for future tests that intentionally assert Origin-less
|
||||
// 403 behavior against their own local listener: put the marker
|
||||
// DC-087-ALLOW-RAW-FETCH in the file and it is skipped.
|
||||
const testsRoot = path.join(__dirname);
|
||||
const offenders = [];
|
||||
const skipped = [];
|
||||
function walk(dir) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules') continue;
|
||||
const p = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(p);
|
||||
else if (entry.name.endsWith('.js')) {
|
||||
const rawText = fs.readFileSync(p, 'utf8');
|
||||
// Escape hatch (checked on RAW text so a comment marker works —
|
||||
// comments are stripped below): a file carrying the
|
||||
// DC-087-ALLOW-RAW-FETCH marker declares it intentionally
|
||||
// raw-fetches the Caddy admin (e.g. asserting Origin-less 403
|
||||
// against its own local listener). The guard file itself is
|
||||
// always scanned (never skipped) so the hatch can't be used to
|
||||
// blind this very test.
|
||||
if (p !== __filename && /DC-087-ALLOW-RAW-FETCH/.test(rawText)) {
|
||||
skipped.push(p);
|
||||
continue;
|
||||
}
|
||||
const text = stripComments(rawText);
|
||||
const hasAdminToken = /:2019|adminUrl|admin_api_url|CADDY_ADMIN/.test(text);
|
||||
const hasRawAwaitFetch = /await\s+fetch\(/.test(text);
|
||||
if (hasAdminToken && hasRawAwaitFetch) {
|
||||
offenders.push(`${p}: raw await-fetch in a file referencing the Caddy admin (mock fetchT instead; documented escape-hatch marker available for intentional 403 tests)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(testsRoot);
|
||||
if (skipped.length) {
|
||||
// Visibility for hatch use — shows up in jest output for reviewers.
|
||||
console.info('[DC-087 guard] escape-hatch skipped:', skipped.join(', '));
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
test('readiness handler in src/app.js probes the exact URL the watcher needs', () => {
|
||||
const raw = fs.readFileSync(
|
||||
path.join(__dirname, '../src/app.js'),
|
||||
@@ -127,9 +188,9 @@ describe('Caddyfile + utils/http.js — Origin header construction (DC-051)', ()
|
||||
expect(raw).toMatch(/\/config\/apps\/http\/servers\/srv0\/listen/);
|
||||
// Goes through fetchT, NOT bare fetch — that's how the Origin injection
|
||||
// takes effect. Look at the 800 chars BEFORE the probe URL on the same
|
||||
// line / call site — the call must be `fetchT(...)`, not `await fetch(...)`.
|
||||
// (We look backward because the URL sits inside the call's argument list,
|
||||
// so the call site comes before the URL token.)
|
||||
// line / call site — the call must be fetchT(...), never a raw await of
|
||||
// the global fetch. (We look backward because the URL sits inside the
|
||||
// call's argument list, so the call site comes before the URL token.)
|
||||
const idx = raw.indexOf('srv0/listen');
|
||||
const around = raw.substr(Math.max(0, idx - 400), 800);
|
||||
expect(around).toMatch(/fetchT\(/);
|
||||
|
||||
@@ -12,7 +12,7 @@ module.exports = function(deps) {
|
||||
const router = express.Router();
|
||||
|
||||
// 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
|
||||
const ctx = {
|
||||
@@ -126,7 +126,12 @@ module.exports = function(deps) {
|
||||
try {
|
||||
const username = await ctx.credentialManager.retrieve(`service.${serviceId}.username`).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 baseUrl = 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);
|
||||
}
|
||||
|
||||
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);
|
||||
if (appCookies) {
|
||||
@@ -203,8 +213,28 @@ module.exports = function(deps) {
|
||||
}
|
||||
}, '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
|
||||
// (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
|
||||
// silently rejected by real browsers (.sami is an unregistered TLD, so
|
||||
// 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) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
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');
|
||||
}
|
||||
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)
|
||||
router.get('/auth/login-page', (req, res) => {
|
||||
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');
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
@@ -255,7 +292,7 @@ module.exports = function(deps) {
|
||||
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
|
||||
// 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
|
||||
@@ -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>
|
||||
</head><body><p id="m">__TITLE__</p><div id="d"></div>
|
||||
<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
|
||||
// (default 8s) so a hung upstream can NEVER leave the page stuck on
|
||||
// "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
|
||||
// stale token exists in localStorage, instead of failing silently.
|
||||
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 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 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
|
||||
// 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
|
||||
// with this origin (Domain=.sami cookies are silently rejected by real
|
||||
// browsers - .sami isn't a registered TLD, so browsers treat "sami" as the
|
||||
@@ -305,18 +345,17 @@ function buildLoginPage(service) {
|
||||
preExchange.then(function(){
|
||||
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){
|
||||
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}
|
||||
}).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>`;
|
||||
|
||||
const pages = {
|
||||
chat: {
|
||||
title: 'Signing in...', bg: '#0a0a0a', accent: '#60a5fa',
|
||||
body: `if(ls.getItem('token')){go('/?direct=1');return}
|
||||
d.textContent='Fetching token from DashCaddy...';
|
||||
body: `d.textContent='Fetching token from DashCaddy...';
|
||||
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
|
||||
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))}
|
||||
@@ -324,30 +363,29 @@ ft('chat').then(function(r){return r.text()}).then(function(t){
|
||||
},
|
||||
plex: {
|
||||
title: 'Signing in to Plex...', bg: '#1f1f1f', accent: '#e5a00d',
|
||||
body: `if(ls.getItem('myPlexAccessToken')){go('/web/?direct=1');return}
|
||||
ft('plex').then(function(r){return r.json()}).then(function(j){
|
||||
if(j.token){ls.setItem('myPlexAccessToken',j.token);d.textContent='Token stored, redirecting...';go('/web/?direct=1');return}
|
||||
body: `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}
|
||||
// No token returned. Three fallbacks in priority order:
|
||||
// 1. Stale token in localStorage — Plex may still accept it.
|
||||
if(ls.getItem('myPlexAccessToken')){go('/web/?direct=1');return}
|
||||
// 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'))})`
|
||||
},
|
||||
jellyfin: {
|
||||
title: 'Signing in to Jellyfin...', bg: '#101014', accent: '#00a4dc',
|
||||
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}
|
||||
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'))})`
|
||||
},
|
||||
emby: {
|
||||
title: 'Signing in to Emby...', bg: '#101014', accent: '#52b54b',
|
||||
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}
|
||||
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'))})`
|
||||
},
|
||||
};
|
||||
@@ -357,5 +395,6 @@ ft('plex').then(function(r){return r.json()}).then(function(j){
|
||||
return SHELL(cfg.body)
|
||||
.replace(/__TITLE__/g, cfg.title)
|
||||
.replace('__BG__', cfg.bg)
|
||||
.replace('__ACCENT__', cfg.accent);
|
||||
.replace('__ACCENT__', cfg.accent)
|
||||
.replace('__DASHBOARD_ORIGIN__', JSON.stringify(dashboardOrigin));
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ const { ok, successMessage } = require('../../src/utils/responses');
|
||||
* @param {Object} deps.log - Logger instance
|
||||
* @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();
|
||||
|
||||
// Ctx shim for backward compatibility
|
||||
@@ -23,7 +23,8 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp
|
||||
credentialManager,
|
||||
totpConfig,
|
||||
saveTotpConfig,
|
||||
session
|
||||
session,
|
||||
siteConfig
|
||||
};
|
||||
|
||||
// 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
|
||||
router.post('/totp/verify', asyncHandler(async (req, res) => {
|
||||
const { authenticator } = require('otplib');
|
||||
const { code } = req.body;
|
||||
const { code, serviceId } = req.body;
|
||||
|
||||
if (!code || !/^\d{6}$/.test(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) {
|
||||
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
|
||||
// login page exchanges it via /auth/sso-exchange for its own host-only
|
||||
// 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 });
|
||||
ok(res, { message: 'Authenticated successfully', sessionDuration: ctx.totpConfig.sessionDuration, csrfToken: newCsrfToken, ssoToken });
|
||||
|
||||
@@ -263,9 +263,10 @@ module.exports = function({
|
||||
const arrKey = await credentialManager.retrieve(`arr.${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 password = await credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
|
||||
success(res, {
|
||||
hasApiKey: !!(arrKey || svcKey),
|
||||
hasBasicAuth: !!username,
|
||||
hasBasicAuth: !!username && !!password,
|
||||
username: username || null
|
||||
});
|
||||
}, 'service-creds'));
|
||||
|
||||
@@ -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
|
||||
// Single probe flips to green on purpose — false-positive-green is much less
|
||||
// painful than perpetual-red (operators notice red, ignore green).
|
||||
const DOWN_THRESHOLD = Math.max(1, parseInt(process.env.HEALTH_DOWN_THRESHOLD || '2', 10));
|
||||
const UP_THRESHOLD = Math.max(1, parseInt(process.env.HEALTH_UP_THRESHOLD || '1', 10));
|
||||
function readPositiveIntEnv(name, fallback) {
|
||||
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 {
|
||||
constructor() {
|
||||
@@ -63,6 +70,8 @@ class HealthChecker extends EventEmitter {
|
||||
this.checkInterval = null;
|
||||
this.consecutiveFailures = new Map(); // serviceId -> failure count
|
||||
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) {
|
||||
const startTime = Date.now();
|
||||
const generation = this.serviceGenerations.get(serviceId) || 0;
|
||||
|
||||
try {
|
||||
const result = await this.performHealthCheck(config);
|
||||
@@ -150,6 +160,10 @@ class HealthChecker extends EventEmitter {
|
||||
details: result.details
|
||||
};
|
||||
|
||||
if ((this.serviceGenerations.get(serviceId) || 0) !== generation) {
|
||||
return status;
|
||||
}
|
||||
|
||||
// Track consecutive failures for exponential backoff
|
||||
if (result.healthy) {
|
||||
this.consecutiveFailures.delete(serviceId);
|
||||
@@ -157,8 +171,9 @@ class HealthChecker extends EventEmitter {
|
||||
this.consecutiveFailures.set(serviceId, (this.consecutiveFailures.get(serviceId) || 0) + 1);
|
||||
}
|
||||
|
||||
const previousStatus = this.currentStatus.get(serviceId);
|
||||
this.recordStatus(serviceId, status);
|
||||
this.checkForIncidents(serviceId, status, config);
|
||||
this.checkForIncidents(serviceId, status, config, previousStatus);
|
||||
|
||||
return status;
|
||||
} catch (error) {
|
||||
@@ -175,8 +190,13 @@ class HealthChecker extends EventEmitter {
|
||||
error: error.message
|
||||
};
|
||||
|
||||
if ((this.serviceGenerations.get(serviceId) || 0) !== generation) {
|
||||
return status;
|
||||
}
|
||||
|
||||
const previousStatus = this.currentStatus.get(serviceId);
|
||||
this.recordStatus(serviceId, status);
|
||||
this.checkForIncidents(serviceId, status, config);
|
||||
this.checkForIncidents(serviceId, status, config, previousStatus);
|
||||
|
||||
return status;
|
||||
}
|
||||
@@ -319,7 +339,7 @@ class HealthChecker extends EventEmitter {
|
||||
// a brief blip doesn't accumulate against the displayed state.
|
||||
if (rawStatus.status === previousStatus) {
|
||||
this.consecutiveSinceChange.set(serviceId, 0);
|
||||
return currentDisplayed;
|
||||
return rawStatus;
|
||||
}
|
||||
|
||||
// Probe disagrees with displayed. Bump the streak counter — this counts
|
||||
@@ -334,6 +354,9 @@ class HealthChecker extends EventEmitter {
|
||||
// with the displayed "up" state.
|
||||
if (previousStatus === 'up' && next < DOWN_THRESHOLD) {
|
||||
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;
|
||||
}
|
||||
// Threshold met (or already down) — flip to red.
|
||||
@@ -402,8 +425,7 @@ class HealthChecker extends EventEmitter {
|
||||
/**
|
||||
* Check for incidents (downtime, slow response, etc.)
|
||||
*/
|
||||
checkForIncidents(serviceId, status, config) {
|
||||
const previous = this.currentStatus.get(serviceId);
|
||||
checkForIncidents(serviceId, status, config, previous = this.currentStatus.get(serviceId)) {
|
||||
|
||||
// Check for status change (up -> down or down -> up)
|
||||
if (previous && previous.status !== status.status) {
|
||||
@@ -638,6 +660,7 @@ class HealthChecker extends EventEmitter {
|
||||
this.config.services = {};
|
||||
}
|
||||
|
||||
this.serviceGenerations.set(serviceId, (this.serviceGenerations.get(serviceId) || 0) + 1);
|
||||
this.config.services[serviceId] = {
|
||||
enabled: config.enabled !== false,
|
||||
name: config.name || serviceId,
|
||||
@@ -660,12 +683,19 @@ class HealthChecker extends EventEmitter {
|
||||
* Remove service configuration
|
||||
*/
|
||||
removeService(serviceId) {
|
||||
this.serviceGenerations.set(serviceId, (this.serviceGenerations.get(serviceId) || 0) + 1);
|
||||
if (this.config.services) {
|
||||
delete this.config.services[serviceId];
|
||||
this.saveConfig();
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
|
||||
@@ -330,17 +330,22 @@ module.exports = function configureMiddleware(app, {
|
||||
const ssoHandoffTokens = new Map();
|
||||
const SSO_HANDOFF_TTL_MS = 60 * 1000;
|
||||
|
||||
function createHandoffToken() {
|
||||
function createHandoffToken(expectedHost = null) {
|
||||
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;
|
||||
}
|
||||
|
||||
function redeemHandoffToken(token) {
|
||||
function redeemHandoffToken(token, actualHost = null) {
|
||||
if (!token) return false;
|
||||
const entry = ssoHandoffTokens.get(token);
|
||||
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) {
|
||||
|
||||
@@ -630,10 +630,15 @@ generate_caddyfile() {
|
||||
SNIP
|
||||
|
||||
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]}
|
||||
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}
|
||||
|
||||
@@ -51,10 +51,15 @@ class CaddyfileGenerator {
|
||||
_authSnippet(apiPort) {
|
||||
return `# DashCaddy SSO auth snippet
|
||||
(dashcaddy_auth) {
|
||||
forward_auth localhost:${apiPort} {
|
||||
@needsAuth not path /dashcaddy-sso
|
||||
forward_auth @needsAuth localhost:${apiPort} {
|
||||
uri /api/v1/auth/gate/{args[0]}
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ const bundles = {
|
||||
// totp-recovery.js registers window._refreshRecoveryLink which totp-auth.js
|
||||
// calls from showTotpOverlay(). Must come after totp-auth.js.
|
||||
JS('totp-recovery.js'),
|
||||
JS('credential-vault-handoff.js'),
|
||||
JS('service-credentials.js'),
|
||||
JS('totp-settings.js'),
|
||||
// DC-048 admin panel — modal-overlay UI for user/invite management.
|
||||
|
||||
Vendored
+108
-108
File diff suppressed because one or more lines are too long
Vendored
+7
-7
File diff suppressed because one or more lines are too long
+46
-2
@@ -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);
|
||||
if (urlParams.get('auth') === 'required') {
|
||||
// Preserve the gated service destination so submitTotpCode() can append
|
||||
@@ -277,8 +317,12 @@
|
||||
}
|
||||
// Clean URL — happens after we've captured the redirect
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
// Show on next tick so the DOM (the .totp-card) is ready
|
||||
setTimeout(show, 0);
|
||||
// Reuse the valid status.sami session first. Only show the TOTP/provider
|
||||
// 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)
|
||||
|
||||
@@ -33,6 +33,20 @@
|
||||
return server?.name || dnsId.toUpperCase();
|
||||
}
|
||||
|
||||
async function requireSuccessfulDnsMutation(response, label) {
|
||||
if (!response) throw new Error(`${label} failed: no response`);
|
||||
let data;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (_) {
|
||||
throw new Error(`${label} failed: invalid server response`);
|
||||
}
|
||||
if (!response.ok || data?.success !== true) {
|
||||
throw new Error(data?.error || `${label} failed (${response.status || 'unknown status'})`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Build per-server credential form sections from SITE.dnsServers */
|
||||
function buildCredentialSections() {
|
||||
const container = document.getElementById('dns-cred-sections');
|
||||
@@ -258,14 +272,6 @@
|
||||
document.getElementById('token-save')?.addEventListener('click', async () => {
|
||||
const dnsIds = getDnsIds();
|
||||
|
||||
// Save all to localStorage
|
||||
dnsIds.forEach(dnsId => {
|
||||
setUsername(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-username`).value.trim());
|
||||
setToken(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-token`).value.trim());
|
||||
setUsername(dnsId, 'admin', document.getElementById(`${dnsId}-admin-username`).value.trim());
|
||||
setToken(dnsId, 'admin', document.getElementById(`${dnsId}-admin-token`).value.trim());
|
||||
});
|
||||
|
||||
// Build per-server credentials payload for backend sync
|
||||
const servers = {};
|
||||
let hasAnyCreds = false;
|
||||
@@ -304,45 +310,36 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ servers })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
const data = await requireSuccessfulDnsMutation(res, 'DNS credential save');
|
||||
if (data.results) {
|
||||
dnsIds.forEach(dnsId => {
|
||||
const statusEl = document.getElementById(`${dnsId}-token-status`);
|
||||
if (!servers[dnsId]) { statusEl.textContent = ''; return; }
|
||||
const result = data.results[dnsId];
|
||||
if (result?.success) {
|
||||
statusEl.textContent = '\u2713 Verified & saved';
|
||||
statusEl.className = 'token-status success';
|
||||
} else if (result?.partial) {
|
||||
statusEl.textContent = '\u2713 ' + result.partial;
|
||||
statusEl.className = 'token-status success';
|
||||
} else {
|
||||
statusEl.textContent = '\u2717 ' + (result?.error || 'Login failed');
|
||||
statusEl.className = 'token-status error';
|
||||
}
|
||||
});
|
||||
} else if (data.success) {
|
||||
dnsIds.forEach(dnsId => {
|
||||
if (servers[dnsId]) {
|
||||
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Saved';
|
||||
document.getElementById(`${dnsId}-token-status`).className = 'token-status success';
|
||||
}
|
||||
});
|
||||
} else {
|
||||
dnsIds.forEach(dnsId => {
|
||||
if (servers[dnsId]) {
|
||||
document.getElementById(`${dnsId}-token-status`).textContent = '\u2717 ' + (data.error || 'Failed');
|
||||
document.getElementById(`${dnsId}-token-status`).className = 'token-status error';
|
||||
}
|
||||
});
|
||||
const failed = Object.keys(servers).filter(dnsId => data.results[dnsId]?.success !== true);
|
||||
if (failed.length) {
|
||||
const details = failed.map(dnsId => data.results[dnsId]?.error || `${dnsId} failed`).join('; ');
|
||||
throw new Error(details);
|
||||
}
|
||||
}
|
||||
|
||||
// Cache locally only after the encrypted server vault confirms success.
|
||||
dnsIds.forEach(dnsId => {
|
||||
setUsername(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-username`).value.trim());
|
||||
setToken(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-token`).value.trim());
|
||||
setUsername(dnsId, 'admin', document.getElementById(`${dnsId}-admin-username`).value.trim());
|
||||
setToken(dnsId, 'admin', document.getElementById(`${dnsId}-admin-token`).value.trim());
|
||||
});
|
||||
|
||||
dnsIds.forEach(dnsId => {
|
||||
const statusEl = document.getElementById(`${dnsId}-token-status`);
|
||||
if (!servers[dnsId]) { statusEl.textContent = ''; return; }
|
||||
const result = data.results?.[dnsId];
|
||||
statusEl.textContent = result?.partial ? '\u2713 ' + result.partial : '\u2713 Verified & saved';
|
||||
statusEl.className = 'token-status success';
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to sync DNS credentials to backend:', e);
|
||||
dnsIds.forEach(dnsId => {
|
||||
if (servers[dnsId]) {
|
||||
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Saved locally (sync failed)';
|
||||
document.getElementById(`${dnsId}-token-status`).className = 'token-status';
|
||||
document.getElementById(`${dnsId}-token-status`).textContent = '\u2717 ' + (e.message || 'Save failed');
|
||||
document.getElementById(`${dnsId}-token-status`).className = 'token-status error';
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -368,18 +365,24 @@
|
||||
|
||||
document.getElementById('token-clear-all')?.addEventListener('click', async () => {
|
||||
if (confirm('Clear all stored DNS credentials? This cannot be undone.')) {
|
||||
clearAllCredentials();
|
||||
getDnsIds().forEach(dnsId => {
|
||||
document.getElementById(`${dnsId}-readonly-username`).value = '';
|
||||
document.getElementById(`${dnsId}-readonly-token`).value = '';
|
||||
document.getElementById(`${dnsId}-admin-username`).value = '';
|
||||
document.getElementById(`${dnsId}-admin-token`).value = '';
|
||||
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Cleared';
|
||||
document.getElementById(`${dnsId}-token-status`).className = 'token-status success';
|
||||
});
|
||||
try {
|
||||
await secureFetch('/api/v1/dns/credentials', { method: 'DELETE' });
|
||||
} catch (_) {}
|
||||
const response = await secureFetch('/api/v1/dns/credentials', { method: 'DELETE' });
|
||||
await requireSuccessfulDnsMutation(response, 'DNS credential removal');
|
||||
clearAllCredentials();
|
||||
getDnsIds().forEach(dnsId => {
|
||||
document.getElementById(`${dnsId}-readonly-username`).value = '';
|
||||
document.getElementById(`${dnsId}-readonly-token`).value = '';
|
||||
document.getElementById(`${dnsId}-admin-username`).value = '';
|
||||
document.getElementById(`${dnsId}-admin-token`).value = '';
|
||||
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Cleared';
|
||||
document.getElementById(`${dnsId}-token-status`).className = 'token-status success';
|
||||
});
|
||||
} catch (e) {
|
||||
getDnsIds().forEach(dnsId => {
|
||||
document.getElementById(`${dnsId}-token-status`).textContent = '\u2717 ' + (e.message || 'Clear failed');
|
||||
document.getElementById(`${dnsId}-token-status`).className = 'token-status error';
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -61,6 +61,9 @@
|
||||
await window.loadServices();
|
||||
await loadTemplateCategories();
|
||||
window.buildGrid();
|
||||
if (typeof window.openRequestedCredentialForm === 'function') {
|
||||
window.openRequestedCredentialForm();
|
||||
}
|
||||
animateTopCards();
|
||||
window.refreshAll();
|
||||
setInterval(() => {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// ===== ENCRYPTED VAULT -> SERVICE SSO HANDOFF =====
|
||||
(function() {
|
||||
function isAllowedReturnUrl(returnUrl, expectedServiceId) {
|
||||
if (!returnUrl || !expectedServiceId || !/^[a-z0-9][a-z0-9-]*$/.test(expectedServiceId)) return false;
|
||||
try {
|
||||
const parsed = new URL(returnUrl, window.location.origin);
|
||||
const suffix = SITE.tld.startsWith('.') ? SITE.tld : `.${SITE.tld}`;
|
||||
const expectedHost = `${expectedServiceId}${suffix}`;
|
||||
return parsed.protocol === 'https:' && parsed.hostname === expectedHost;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildHandoffTarget(returnUrl, token, expectedServiceId) {
|
||||
if (!isAllowedReturnUrl(returnUrl, expectedServiceId)) return null;
|
||||
const parsed = new URL(returnUrl, window.location.origin);
|
||||
if (!token) return null;
|
||||
|
||||
const returnPath = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
// The shared (dashcaddy_auth) Caddy snippet installs this public landing
|
||||
// route on every protected host. It rewrites to /api/v1/auth/sso-exchange.
|
||||
parsed.pathname = '/dashcaddy-sso';
|
||||
parsed.search = '';
|
||||
parsed.hash = '';
|
||||
parsed.searchParams.set('token', token);
|
||||
parsed.searchParams.set('return', returnPath);
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
async function resume(returnUrl, expectedServiceId, runtime = {}) {
|
||||
if (!isAllowedReturnUrl(returnUrl, expectedServiceId)) return false;
|
||||
const fetchFn = runtime.fetch || window.fetch.bind(window);
|
||||
const locationObj = runtime.location || window.location;
|
||||
try {
|
||||
const response = await fetchFn(`/api/v1/auth/sso-handoff?serviceId=${encodeURIComponent(expectedServiceId)}`, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) return false;
|
||||
const data = await response.json();
|
||||
const target = data.success && buildHandoffTarget(returnUrl, data.ssoToken, expectedServiceId);
|
||||
if (!target) return false;
|
||||
locationObj.replace(target);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
window.DCCredentialVault = { isAllowedReturnUrl, buildHandoffTarget, resume };
|
||||
})();
|
||||
@@ -32,8 +32,8 @@
|
||||
|
||||
injectModal('service-creds-modal', `<div id="service-creds-modal">
|
||||
<div class="service-creds-content">
|
||||
<h3 id="svc-creds-title" style="margin: 0 0 4px; font-size: 1.05rem;">Service Credentials</h3>
|
||||
<p id="svc-creds-desc" style="font-size: 0.75rem; color: var(--muted); margin: 0 0 14px;">Credentials are injected automatically when accessing this service.</p>
|
||||
<h3 id="svc-creds-title" style="margin: 0 0 4px; font-size: 1.05rem;">Encrypted Credential Vault</h3>
|
||||
<p id="svc-creds-desc" style="font-size: 0.75rem; color: var(--muted); margin: 0 0 14px;">Passwords are encrypted at rest and used automatically when you open this service.</p>
|
||||
|
||||
<!-- Status indicator -->
|
||||
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 12px;">
|
||||
@@ -91,7 +91,7 @@
|
||||
<!-- Buttons -->
|
||||
<div style="display: flex; gap: 8px; margin-top: 14px;">
|
||||
<button id="svc-creds-save" class="btn-accent-solid" style="flex: 1; padding: 9px; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem;">
|
||||
Save
|
||||
Save to encrypted vault
|
||||
</button>
|
||||
<button id="svc-creds-clear" style="padding: 9px 14px; background: transparent; color: var(--bad-fg, #ff9aa3); border: 1px solid var(--bad-fg, #ff9aa3); border-radius: 6px; cursor: pointer; font-size: 0.85rem; display: none;">
|
||||
Clear
|
||||
@@ -105,6 +105,8 @@
|
||||
|
||||
const modal = document.getElementById('service-creds-modal');
|
||||
let currentService = null;
|
||||
let credentialReturnUrl = null;
|
||||
let currentServiceHadCreds = false;
|
||||
const arrServices = ['sonarr', 'radarr', 'prowlarr', 'overseerr'];
|
||||
const qualityProfileServices = ['sonarr', 'radarr'];
|
||||
|
||||
@@ -124,8 +126,28 @@
|
||||
el.style.display = 'none';
|
||||
}
|
||||
|
||||
window.openServiceCredsModal = async function(service) {
|
||||
async function requireSuccessfulWrite(response, label) {
|
||||
if (!response) throw new Error(`${label} failed: no response`);
|
||||
let data;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (_) {
|
||||
throw new Error(`${label} failed: invalid server response`);
|
||||
}
|
||||
if (!response.ok || data?.success !== true) {
|
||||
throw new Error(data?.error || `${label} failed (${response.status || 'unknown status'})`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function isAllowedCredentialReturnUrl(returnUrl, serviceId) {
|
||||
return !!window.DCCredentialVault?.isAllowedReturnUrl(returnUrl, serviceId);
|
||||
}
|
||||
|
||||
window.openServiceCredsModal = async function(service, options = {}) {
|
||||
currentService = service;
|
||||
credentialReturnUrl = isAllowedCredentialReturnUrl(options.returnUrl, service.id) ? options.returnUrl : null;
|
||||
currentServiceHadCreds = false;
|
||||
hideError();
|
||||
const title = document.getElementById('svc-creds-title');
|
||||
const desc = document.getElementById('svc-creds-desc');
|
||||
@@ -134,7 +156,10 @@
|
||||
const basicSection = document.getElementById('svc-creds-basic');
|
||||
const qualitySection = document.getElementById('svc-creds-quality');
|
||||
|
||||
title.textContent = service.name + ' Credentials';
|
||||
title.textContent = service.name + ' — Encrypted Vault';
|
||||
document.getElementById('svc-creds-save').textContent = credentialReturnUrl
|
||||
? 'Save to vault & open service'
|
||||
: 'Save to encrypted vault';
|
||||
// Determine which sections to show
|
||||
const isExt = !!service.isExternal;
|
||||
const isArr = arrServices.includes(service.id) || arrServices.includes(service.appTemplate);
|
||||
@@ -214,6 +239,7 @@
|
||||
}
|
||||
|
||||
if (hasCreds) {
|
||||
currentServiceHadCreds = true;
|
||||
dot.style.background = 'var(--ok-fg, #74dfc4)';
|
||||
status.style.color = 'var(--ok-fg, #74dfc4)';
|
||||
status.textContent = 'Credentials stored';
|
||||
@@ -352,16 +378,35 @@
|
||||
const isArr = arrServices.includes(currentService.id) || arrServices.includes(currentService.appTemplate);
|
||||
const svcId = currentService.id || currentService.appTemplate;
|
||||
|
||||
if (credentialReturnUrl && !currentServiceHadCreds) {
|
||||
const externalUser = document.getElementById('svc-seedhost-user').value.trim();
|
||||
const externalPass = document.getElementById('svc-seedhost-pass').value;
|
||||
const apiKeyInput = document.getElementById('svc-apikey-input');
|
||||
const requestedApiKey = apiKeyInput?.value.trim();
|
||||
const basicUser = document.getElementById('svc-basic-user').value.trim();
|
||||
const basicPass = document.getElementById('svc-basic-pass').value;
|
||||
const hasExternalLogin = currentService.isExternal && externalUser && externalPass;
|
||||
const hasApiKey = isArr && requestedApiKey && requestedApiKey !== '••••••••';
|
||||
const hasBasicLogin = !currentService.isExternal && basicUser && basicPass;
|
||||
if (!hasExternalLogin && !hasApiKey && !hasBasicLogin) {
|
||||
showError('Enter the login or API key DashCaddy should store for this service.');
|
||||
saveBtn.textContent = 'Save to vault & open service';
|
||||
saveBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Save seedhost creds (shared username + per-service password)
|
||||
if (currentService.isExternal) {
|
||||
const user = document.getElementById('svc-seedhost-user').value.trim();
|
||||
const pass = document.getElementById('svc-seedhost-pass').value;
|
||||
if (user) {
|
||||
await secureFetch('/api/v1/seedhost-creds', {
|
||||
const response = await secureFetch('/api/v1/seedhost-creds', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: user, password: pass || undefined, serviceId: currentService.id })
|
||||
});
|
||||
await requireSuccessfulWrite(response, 'Seedhost credential save');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,23 +432,18 @@
|
||||
qualityProfileName: qualityProfileName || undefined
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) {
|
||||
showError(data.error || 'Failed to save API key');
|
||||
saveBtn.textContent = 'Save';
|
||||
saveBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
const data = await requireSuccessfulWrite(res, 'ARR credential save');
|
||||
if (data.connectionTest && !data.connectionTest.success) {
|
||||
showError(`API key saved but connection test failed: ${data.connectionTest.error}`);
|
||||
}
|
||||
} else {
|
||||
// Non-arr services use the generic endpoint
|
||||
await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
|
||||
const response = await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ apiKey })
|
||||
});
|
||||
await requireSuccessfulWrite(response, 'API key save');
|
||||
}
|
||||
} else if (isArr && qualityProfileServices.includes(svcId)) {
|
||||
// API key unchanged but user may have changed quality profile — save profile only
|
||||
@@ -411,11 +451,12 @@
|
||||
const qualityProfileId = qualSelect?.value ? parseInt(qualSelect.value) : undefined;
|
||||
const qualityProfileName = qualSelect?.selectedOptions?.[0]?.textContent || undefined;
|
||||
if (qualityProfileId) {
|
||||
await secureFetch('/api/v1/arr/quality-profiles', {
|
||||
const response = await secureFetch('/api/v1/arr/quality-profiles', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ service: svcId, qualityProfileId, qualityProfileName })
|
||||
});
|
||||
await requireSuccessfulWrite(response, 'Quality profile save');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,20 +465,28 @@
|
||||
const user = document.getElementById('svc-basic-user').value.trim();
|
||||
const pass = document.getElementById('svc-basic-pass').value;
|
||||
if (user && pass) {
|
||||
await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
|
||||
const response = await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: user, password: pass })
|
||||
});
|
||||
await requireSuccessfulWrite(response, 'Service credential save');
|
||||
}
|
||||
}
|
||||
|
||||
await loadServiceCreds(currentService);
|
||||
if (credentialReturnUrl) {
|
||||
const returnUrl = credentialReturnUrl;
|
||||
const resumed = await window.DCCredentialVault?.resume(returnUrl, currentService.id);
|
||||
if (!resumed) throw new Error('Credential saved, but the secure service handoff failed. Try opening the service again.');
|
||||
credentialReturnUrl = null;
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
errorHandler.logError('[ServiceCredentials] Save', e, { function: 'saveCredentials' });
|
||||
showError('Failed to save: ' + (e.message || 'Unknown error'));
|
||||
}
|
||||
saveBtn.textContent = 'Save';
|
||||
saveBtn.textContent = credentialReturnUrl ? 'Save to vault & open service' : 'Save to encrypted vault';
|
||||
saveBtn.disabled = false;
|
||||
});
|
||||
|
||||
@@ -450,12 +499,15 @@
|
||||
const svcId = currentService.id || currentService.appTemplate;
|
||||
const isArr = arrServices.includes(svcId);
|
||||
if (currentService.isExternal) {
|
||||
await secureFetch(`/api/v1/seedhost-creds?serviceId=${currentService.id}`, { method: 'DELETE' });
|
||||
const response = await secureFetch(`/api/v1/seedhost-creds?serviceId=${currentService.id}`, { method: 'DELETE' });
|
||||
await requireSuccessfulWrite(response, 'Seedhost credential removal');
|
||||
}
|
||||
// Delete from both namespaces
|
||||
await secureFetch(`/api/v1/services/${currentService.id}/credentials`, { method: 'DELETE' });
|
||||
const response = await secureFetch(`/api/v1/services/${currentService.id}/credentials`, { method: 'DELETE' });
|
||||
await requireSuccessfulWrite(response, 'Service credential removal');
|
||||
if (isArr) {
|
||||
await secureFetch(`/api/v1/arr/credentials/${svcId}`, { method: 'DELETE' });
|
||||
const arrResponse = await secureFetch(`/api/v1/arr/credentials/${svcId}`, { method: 'DELETE' });
|
||||
await requireSuccessfulWrite(arrResponse, 'ARR credential removal');
|
||||
}
|
||||
const btn = document.getElementById(`creds-btn-${currentService.id}`);
|
||||
if (btn) btn.classList.remove('has-creds');
|
||||
@@ -470,11 +522,13 @@
|
||||
document.getElementById('svc-creds-close')?.addEventListener('click', () => {
|
||||
modal.classList.remove('show');
|
||||
currentService = null;
|
||||
credentialReturnUrl = null;
|
||||
});
|
||||
modal?.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
modal.classList.remove('show');
|
||||
currentService = null;
|
||||
credentialReturnUrl = null;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -501,4 +555,18 @@
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
};
|
||||
|
||||
// Protected service login pages send missing credentials here. Reuse the
|
||||
// normal vault form, then resume through the existing one-time SSO handoff.
|
||||
window.openRequestedCredentialForm = function() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const serviceId = params.get('credentials');
|
||||
if (!serviceId) return false;
|
||||
const service = (window.APPS || []).find(app => app.id === serviceId || app.appTemplate === serviceId);
|
||||
if (!service) return false;
|
||||
const returnUrl = params.get('return');
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
window.openServiceCredsModal(service, { returnUrl });
|
||||
return true;
|
||||
};
|
||||
})();
|
||||
|
||||
+12
-2
@@ -90,11 +90,22 @@
|
||||
errorEl.textContent = 'Verifying...';
|
||||
errorEl.className = 'totp-error verifying';
|
||||
|
||||
const redirect = safeSessionGet('totp_redirect');
|
||||
let serviceId = null;
|
||||
if (redirect) {
|
||||
try {
|
||||
const parsed = new URL(redirect, window.location.origin);
|
||||
const suffix = SITE.tld.startsWith('.') ? SITE.tld : `.${SITE.tld}`;
|
||||
const candidate = parsed.hostname.slice(0, -suffix.length);
|
||||
if (parsed.hostname.endsWith(suffix) && /^[a-z0-9][a-z0-9-]*$/.test(candidate)) serviceId = candidate;
|
||||
} catch (_) { /* invalid redirect is handled by the normal auth flow */ }
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await secureFetch('/api/v1/totp/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code })
|
||||
body: JSON.stringify({ code, serviceId })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
@@ -106,7 +117,6 @@
|
||||
}
|
||||
hideTotpOverlay();
|
||||
// Check if redirected here from another service
|
||||
const redirect = safeSessionGet('totp_redirect');
|
||||
if (redirect) {
|
||||
try { sessionStorage.removeItem('totp_redirect'); } catch (_) {}
|
||||
// .sami is an unregistered TLD, so browsers silently drop the
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'dashcaddy-shell-a24ef15882';
|
||||
const CACHE = 'dashcaddy-shell-497e1f671c';
|
||||
const PRECACHE = [
|
||||
'/',
|
||||
'/index.html',
|
||||
|
||||
@@ -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('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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user