[grade=B] feat(auth): onboard missing credentials into encrypted vault
This commit is contained in:
@@ -112,6 +112,7 @@ function createApp(depsOverride = {}) {
|
|||||||
errorResponse: jest.fn(),
|
errorResponse: jest.fn(),
|
||||||
log,
|
log,
|
||||||
renewCSRFToken,
|
renewCSRFToken,
|
||||||
|
siteConfig: { tld: '.sami', dashboardHost: 'status.sami' },
|
||||||
...depsOverride,
|
...depsOverride,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -299,7 +300,7 @@ describe('TOTP Auth Routes — DC-006 Integration Test', () => {
|
|||||||
it('returns 200 + creates new session + rotates CSRF on valid code (BACKLOG: "valid TOTP → session token → authenticated request succeeds")', async () => {
|
it('returns 200 + creates new session + rotates CSRF on valid code (BACKLOG: "valid TOTP → session token → authenticated request succeeds")', async () => {
|
||||||
const secret = await setupTOTP();
|
const secret = await setupTOTP();
|
||||||
const token = authenticator.generate(secret);
|
const token = authenticator.generate(secret);
|
||||||
const res = await request(app).post('/api/totp/verify').send({ code: token });
|
const res = await request(app).post('/api/totp/verify').send({ code: token, serviceId: 'plex' });
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.success).toBe(true);
|
expect(res.body.success).toBe(true);
|
||||||
expect(res.body.message).toMatch(/Authenticated successfully/);
|
expect(res.body.message).toMatch(/Authenticated successfully/);
|
||||||
@@ -308,8 +309,29 @@ describe('TOTP Auth Routes — DC-006 Integration Test', () => {
|
|||||||
expect(deps.session.create).toHaveBeenCalled();
|
expect(deps.session.create).toHaveBeenCalled();
|
||||||
expect(deps.session.setCookie).toHaveBeenCalled();
|
expect(deps.session.setCookie).toHaveBeenCalled();
|
||||||
expect(deps.session.createHandoffToken).toHaveBeenCalledTimes(1);
|
expect(deps.session.createHandoffToken).toHaveBeenCalledTimes(1);
|
||||||
|
expect(deps.session.createHandoffToken).toHaveBeenCalledWith('plex.sami');
|
||||||
expect(deps.renewCSRFToken).toHaveBeenCalled();
|
expect(deps.renewCSRFToken).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not issue an unbound handoff token for a dashboard-only login', async () => {
|
||||||
|
const secret = await setupTOTP();
|
||||||
|
const token = authenticator.generate(secret);
|
||||||
|
const res = await request(app).post('/api/totp/verify').send({ code: token });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.ssoToken).toBeNull();
|
||||||
|
expect(deps.session.createHandoffToken).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid handoff service ID before issuing a token', async () => {
|
||||||
|
const secret = await setupTOTP();
|
||||||
|
const token = authenticator.generate(secret);
|
||||||
|
const res = await request(app).post('/api/totp/verify').send({ code: token, serviceId: 'plex.sami' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/Invalid service ID/);
|
||||||
|
expect(deps.session.createHandoffToken).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────
|
||||||
@@ -450,7 +472,7 @@ describe('TOTP Auth Routes — DC-006 Integration Test', () => {
|
|||||||
|
|
||||||
// 4. Re-login via /totp/verify (the "login" path)
|
// 4. Re-login via /totp/verify (the "login" path)
|
||||||
const loginCode = authenticator.generate(secret);
|
const loginCode = authenticator.generate(secret);
|
||||||
const loginRes = await request(app).post('/api/totp/verify').send({ code: loginCode });
|
const loginRes = await request(app).post('/api/totp/verify').send({ code: loginCode, serviceId: 'plex' });
|
||||||
expect(loginRes.status).toBe(200);
|
expect(loginRes.status).toBe(200);
|
||||||
expect(loginRes.body.csrfToken).toBeDefined();
|
expect(loginRes.body.csrfToken).toBeDefined();
|
||||||
expect(loginRes.body.ssoToken).toBe('mock-sso-handoff-token');
|
expect(loginRes.body.ssoToken).toBe('mock-sso-handoff-token');
|
||||||
|
|||||||
@@ -288,6 +288,21 @@ describe('Services Routes', () => {
|
|||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.hasApiKey).toBe(true);
|
expect(res.body.hasApiKey).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('requires both username and password before reporting Basic Auth ready', async () => {
|
||||||
|
const credentialManager = {
|
||||||
|
store: jest.fn(),
|
||||||
|
retrieve: jest.fn().mockImplementation((key) => {
|
||||||
|
if (key === 'service.radarr.username') return Promise.resolve('admin');
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}),
|
||||||
|
delete: jest.fn(),
|
||||||
|
};
|
||||||
|
const { app } = createApp({ credentialManager });
|
||||||
|
const res = await request(app).get('/api/services/radarr/credentials');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.hasBasicAuth).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== SEEDHOST CREDENTIAL ENDPOINTS =====
|
// ===== SEEDHOST CREDENTIAL ENDPOINTS =====
|
||||||
|
|||||||
@@ -50,6 +50,17 @@ describe('TOTP session cookie scope', () => {
|
|||||||
expect(cookie).not.toMatch(/(?:^|;)\s*Domain=/i);
|
expect(cookie).not.toMatch(/(?:^|;)\s*Domain=/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('host-bound SSO token can only be redeemed on its intended service host', () => {
|
||||||
|
const session = buildSession();
|
||||||
|
const wrongHostToken = session.createHandoffToken('plex.sami');
|
||||||
|
expect(session.redeemHandoffToken(wrongHostToken, 'chat.sami')).toBe(false);
|
||||||
|
expect(session.redeemHandoffToken(wrongHostToken, 'plex.sami')).toBe(false);
|
||||||
|
|
||||||
|
const correctHostToken = session.createHandoffToken('plex.sami');
|
||||||
|
expect(session.redeemHandoffToken(correctHostToken, 'plex.sami')).toBe(true);
|
||||||
|
expect(session.redeemHandoffToken(correctHostToken, 'plex.sami')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test('logout clears the host-only secure cookie', () => {
|
test('logout clears the host-only secure cookie', () => {
|
||||||
const session = buildSession();
|
const session = buildSession();
|
||||||
const headers = {};
|
const headers = {};
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const vm = require('vm');
|
||||||
const request = require('supertest');
|
const request = require('supertest');
|
||||||
const createSsoRouter = require('../routes/auth/sso-gate');
|
const createSsoRouter = require('../routes/auth/sso-gate');
|
||||||
|
|
||||||
function createApp({ redeem = true, valid = true } = {}) {
|
function loadCredentialVaultHandoff() {
|
||||||
|
const source = fs.readFileSync(
|
||||||
|
path.join(__dirname, '..', '..', 'status', 'js', 'credential-vault-handoff.js'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const window = { location: { origin: 'https://status.sami' } };
|
||||||
|
vm.runInNewContext(source, { window, SITE: { tld: '.sami' }, URL });
|
||||||
|
return window.DCCredentialVault;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createApp({ redeem = true, valid = true, storedCredentials = {}, dashboardHost = 'status.sami' } = {}) {
|
||||||
const app = express();
|
const app = express();
|
||||||
const session = {
|
const session = {
|
||||||
redeemHandoffToken: jest.fn((token) => (typeof redeem === 'function' ? redeem(token) : redeem)),
|
redeemHandoffToken: jest.fn((token) => (typeof redeem === 'function' ? redeem(token) : redeem)),
|
||||||
@@ -22,14 +35,15 @@ function createApp({ redeem = true, valid = true } = {}) {
|
|||||||
log: { warn: jest.fn(), info: jest.fn(), debug: jest.fn(), error: jest.fn() },
|
log: { warn: jest.fn(), info: jest.fn(), debug: jest.fn(), error: jest.fn() },
|
||||||
getAppSession: jest.fn(),
|
getAppSession: jest.fn(),
|
||||||
appSessionCache: new Map(),
|
appSessionCache: new Map(),
|
||||||
credentialManager: { retrieve: jest.fn() },
|
credentialManager: { retrieve: jest.fn((key) => Promise.resolve(storedCredentials[key] || null)) },
|
||||||
fetchT: jest.fn(),
|
fetchT: jest.fn(),
|
||||||
getServiceById: jest.fn(),
|
getServiceById: jest.fn((id) => Promise.resolve({ id, url: `https://${id}.sami` })),
|
||||||
licenseManager: {
|
licenseManager: {
|
||||||
hasFeature: jest.fn().mockReturnValue(true),
|
hasFeature: jest.fn().mockReturnValue(true),
|
||||||
requirePremium: jest.fn(() => (_req, _res, next) => next()),
|
requirePremium: jest.fn(() => (_req, _res, next) => next()),
|
||||||
},
|
},
|
||||||
servicesStateManager: { read: jest.fn().mockResolvedValue([]) },
|
servicesStateManager: { read: jest.fn().mockResolvedValue([]) },
|
||||||
|
siteConfig: { dashboardHost },
|
||||||
});
|
});
|
||||||
app.use('/api/v1', router);
|
app.use('/api/v1', router);
|
||||||
return { app, session };
|
return { app, session };
|
||||||
@@ -45,7 +59,7 @@ describe('cross-host SSO exchange redirect', () => {
|
|||||||
expect(res.status).toBe(303);
|
expect(res.status).toBe(303);
|
||||||
expect(res.headers.location).toBe('/settings?tab=network#dns');
|
expect(res.headers.location).toBe('/settings?tab=network#dns');
|
||||||
expect(res.headers['set-cookie'][0]).not.toMatch(/Domain=/i);
|
expect(res.headers['set-cookie'][0]).not.toMatch(/Domain=/i);
|
||||||
expect(session.redeemHandoffToken).toHaveBeenCalledWith('one-time');
|
expect(session.redeemHandoffToken).toHaveBeenCalledWith('one-time', '127.0.0.1');
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
@@ -88,17 +102,18 @@ describe('existing-session SSO handoff', () => {
|
|||||||
test('mints a handoff token without asking for TOTP again', async () => {
|
test('mints a handoff token without asking for TOTP again', async () => {
|
||||||
const { app, session } = createApp();
|
const { app, session } = createApp();
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.get('/api/v1/auth/sso-handoff')
|
.get('/api/v1/auth/sso-handoff?serviceId=plex')
|
||||||
.set('Cookie', 'dashcaddy_session=valid-session');
|
.set('Cookie', 'dashcaddy_session=valid-session');
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body).toMatchObject({ success: true, ssoToken: 'fresh-sso-handoff-token' });
|
expect(res.body).toMatchObject({ success: true, ssoToken: 'fresh-sso-handoff-token' });
|
||||||
expect(session.createHandoffToken).toHaveBeenCalledTimes(1);
|
expect(session.createHandoffToken).toHaveBeenCalledTimes(1);
|
||||||
|
expect(session.createHandoffToken).toHaveBeenCalledWith('plex.sami');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('refuses to mint a handoff token without a valid session', async () => {
|
test('refuses to mint a handoff token without a valid session', async () => {
|
||||||
const { app, session } = createApp({ valid: false });
|
const { app, session } = createApp({ valid: false });
|
||||||
const res = await request(app).get('/api/v1/auth/sso-handoff');
|
const res = await request(app).get('/api/v1/auth/sso-handoff?serviceId=plex');
|
||||||
|
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
expect(session.createHandoffToken).not.toHaveBeenCalled();
|
expect(session.createHandoffToken).not.toHaveBeenCalled();
|
||||||
@@ -110,7 +125,7 @@ describe('existing-session SSO handoff', () => {
|
|||||||
const { app } = createApp({ redeem: redeemOnce });
|
const { app } = createApp({ redeem: redeemOnce });
|
||||||
|
|
||||||
const mint = await request(app)
|
const mint = await request(app)
|
||||||
.get('/api/v1/auth/sso-handoff')
|
.get('/api/v1/auth/sso-handoff?serviceId=plex')
|
||||||
.set('Cookie', 'dashcaddy_session=valid-session');
|
.set('Cookie', 'dashcaddy_session=valid-session');
|
||||||
const exchange = await request(app)
|
const exchange = await request(app)
|
||||||
.get('/api/v1/auth/sso-exchange')
|
.get('/api/v1/auth/sso-exchange')
|
||||||
@@ -127,3 +142,60 @@ describe('existing-session SSO handoff', () => {
|
|||||||
expect(replay.status).toBe(401);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ module.exports = function(deps) {
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Extract dependencies
|
// Extract dependencies
|
||||||
const { authManager, totpConfig, session, asyncHandler, errorResponse, log, getAppSession, appSessionCache, credentialManager, fetchT, getServiceById, licenseManager, servicesStateManager } = deps;
|
const { authManager, totpConfig, session, asyncHandler, errorResponse, log, getAppSession, appSessionCache, credentialManager, fetchT, getServiceById, licenseManager, servicesStateManager, siteConfig } = deps;
|
||||||
|
|
||||||
// Create ctx-like object for compatibility
|
// Create ctx-like object for compatibility
|
||||||
const ctx = {
|
const ctx = {
|
||||||
@@ -126,7 +126,12 @@ module.exports = function(deps) {
|
|||||||
try {
|
try {
|
||||||
const username = await ctx.credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
|
const username = await ctx.credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
|
||||||
const password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
|
const password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
|
||||||
if (!username || !password) throw new NotFoundError('[DC-500] No credentials stored');
|
if (!username || !password) {
|
||||||
|
return errorResponse(res, 428, '[DC-500] No credentials stored', {
|
||||||
|
credentialsRequired: true,
|
||||||
|
serviceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
const service = await ctx.getServiceById(serviceId);
|
const service = await ctx.getServiceById(serviceId);
|
||||||
const baseUrl = service?.url;
|
const baseUrl = service?.url;
|
||||||
if (!baseUrl) throw new NotFoundError('No service URL');
|
if (!baseUrl) throw new NotFoundError('No service URL');
|
||||||
@@ -181,7 +186,12 @@ module.exports = function(deps) {
|
|||||||
password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
|
password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!username || !password) throw new NotFoundError('[DC-500] No credentials stored');
|
if (!username || !password) {
|
||||||
|
return errorResponse(res, 428, '[DC-500] No credentials stored', {
|
||||||
|
credentialsRequired: true,
|
||||||
|
serviceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const appCookies = await getAppSession(serviceId, baseUrl, username, password);
|
const appCookies = await getAppSession(serviceId, baseUrl, username, password);
|
||||||
if (appCookies) {
|
if (appCookies) {
|
||||||
@@ -213,7 +223,13 @@ module.exports = function(deps) {
|
|||||||
if (!session.isValid(req)) {
|
if (!session.isValid(req)) {
|
||||||
return errorResponse(res, 401, 'Session expired or invalid');
|
return errorResponse(res, 401, 'Session expired or invalid');
|
||||||
}
|
}
|
||||||
ok(res, { ssoToken: session.createHandoffToken() });
|
const serviceId = String(req.query.serviceId || '');
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(serviceId)) {
|
||||||
|
return errorResponse(res, 400, 'Valid serviceId is required');
|
||||||
|
}
|
||||||
|
const suffix = String(siteConfig?.tld || '.sami');
|
||||||
|
const expectedHost = `${serviceId}${suffix.startsWith('.') ? suffix : `.${suffix}`}`;
|
||||||
|
ok(res, { ssoToken: session.createHandoffToken(expectedHost) });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cross-subdomain SSO handoff: exchanges a short-lived single-use token
|
// Cross-subdomain SSO handoff: exchanges a short-lived single-use token
|
||||||
@@ -229,7 +245,9 @@ module.exports = function(deps) {
|
|||||||
router.get('/auth/sso-exchange', (req, res) => {
|
router.get('/auth/sso-exchange', (req, res) => {
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
const token = req.query.token;
|
const token = req.query.token;
|
||||||
if (!session.redeemHandoffToken(token)) {
|
const forwardedHost = String(req.headers['x-forwarded-host'] || req.headers.host || '')
|
||||||
|
.split(',')[0].trim().replace(/:\d+$/, '').toLowerCase();
|
||||||
|
if (!session.redeemHandoffToken(token, forwardedHost)) {
|
||||||
return errorResponse(res, 401, 'Invalid or expired handoff token');
|
return errorResponse(res, 401, 'Invalid or expired handoff token');
|
||||||
}
|
}
|
||||||
session.setCookieHostOnly(res, totpConfig.sessionDuration);
|
session.setCookieHostOnly(res, totpConfig.sessionDuration);
|
||||||
@@ -251,7 +269,12 @@ module.exports = function(deps) {
|
|||||||
// Serve service-specific auto-login page (auth enforced by Caddy forward_auth upstream)
|
// Serve service-specific auto-login page (auth enforced by Caddy forward_auth upstream)
|
||||||
router.get('/auth/login-page', (req, res) => {
|
router.get('/auth/login-page', (req, res) => {
|
||||||
const service = (req.query.service || '').replace(/[^a-z]/g, '');
|
const service = (req.query.service || '').replace(/[^a-z]/g, '');
|
||||||
const html = buildLoginPage(service);
|
const configuredHost = siteConfig?.dashboardHost;
|
||||||
|
const dashboardOrigin = typeof configuredHost === 'string'
|
||||||
|
&& /^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(configuredHost)
|
||||||
|
? `https://${configuredHost}`
|
||||||
|
: 'https://status.sami';
|
||||||
|
const html = buildLoginPage(service, dashboardOrigin);
|
||||||
if (!html) return res.status(404).send('Unknown service');
|
if (!html) return res.status(404).send('Unknown service');
|
||||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
@@ -269,7 +292,7 @@ module.exports = function(deps) {
|
|||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildLoginPage(service) {
|
function buildLoginPage(service, dashboardOrigin = 'https://status.sami') {
|
||||||
// Pre-auth check via <meta http-equiv="refresh"> so it fires even when JS is
|
// Pre-auth check via <meta http-equiv="refresh"> so it fires even when JS is
|
||||||
// disabled or blocked. The cookie is sent automatically because we hit the
|
// disabled or blocked. The cookie is sent automatically because we hit the
|
||||||
// same origin (plex.sami); if the API returns 200 the user has a valid
|
// same origin (plex.sami); if the API returns 200 the user has a valid
|
||||||
@@ -280,7 +303,7 @@ function buildLoginPage(service) {
|
|||||||
<style>body{background:__BG__;color:#e0e0e0;font-family:system-ui;display:flex;align-items:center;justify-items:center;height:100vh;margin:0;flex-direction:column;gap:12px}a{color:__ACCENT__}#d{font-size:12px;color:#888;max-width:80vw;overflow:auto;white-wrap:pre-wrap}</style>
|
<style>body{background:__BG__;color:#e0e0e0;font-family:system-ui;display:flex;align-items:center;justify-items:center;height:100vh;margin:0;flex-direction:column;gap:12px}a{color:__ACCENT__}#d{font-size:12px;color:#888;max-width:80vw;overflow:auto;white-wrap:pre-wrap}</style>
|
||||||
</head><body><p id="m">__TITLE__</p><div id="d"></div>
|
</head><body><p id="m">__TITLE__</p><div id="d"></div>
|
||||||
<script>(function(){
|
<script>(function(){
|
||||||
var ls=localStorage,d=document.getElementById('d'),m=document.getElementById('m');
|
var ls=localStorage,d=document.getElementById('d'),m=document.getElementById('m'),dashboardOrigin=__DASHBOARD_ORIGIN__;
|
||||||
// 2026-07-22 hardening: every fetch now has a hard AbortSignal timeout
|
// 2026-07-22 hardening: every fetch now has a hard AbortSignal timeout
|
||||||
// (default 8s) so a hung upstream can NEVER leave the page stuck on
|
// (default 8s) so a hung upstream can NEVER leave the page stuck on
|
||||||
// "Signing in to Plex..." indefinitely. Also: if check-session returns
|
// "Signing in to Plex..." indefinitely. Also: if check-session returns
|
||||||
@@ -288,13 +311,16 @@ function buildLoginPage(service) {
|
|||||||
// upstream timeout, etc.), we now ALWAYS redirect to /web/?direct=1 if a
|
// upstream timeout, etc.), we now ALWAYS redirect to /web/?direct=1 if a
|
||||||
// stale token exists in localStorage, instead of failing silently.
|
// stale token exists in localStorage, instead of failing silently.
|
||||||
function go(u){setTimeout(function(){location.replace(u)},300)}
|
function go(u){setTimeout(function(){location.replace(u)},300)}
|
||||||
|
function authUrl(){return dashboardOrigin+'?auth=required&return='+encodeURIComponent(location.href)}
|
||||||
|
function authLink(label){return '<a href="'+authUrl()+'">'+label+'</a>'}
|
||||||
|
function vault(svc){go(dashboardOrigin+'?credentials='+encodeURIComponent(svc)+'&return='+encodeURIComponent(location.href))}
|
||||||
function fail(msg,info){try{m.innerHTML=msg;d.textContent=info||''}catch(_){}}
|
function fail(msg,info){try{m.innerHTML=msg;d.textContent=info||''}catch(_){}}
|
||||||
function withTimeout(ms){var c=new AbortController();setTimeout(function(){c.abort()},ms);return c.signal}
|
function withTimeout(ms){var c=new AbortController();setTimeout(function(){c.abort()},ms);return c.signal}
|
||||||
function ft(svc){return fetch('/dashcaddy-api/api/auth/app-token/'+svc,{credentials:'include',signal:withTimeout(8000)})}
|
function ft(svc){return fetch('/dashcaddy-api/api/auth/app-token/'+svc,{credentials:'include',signal:withTimeout(8000)})}
|
||||||
function merge(ck,j,name){try{var c=JSON.parse(ls.getItem(ck)||'{}');if(c.Servers&&c.Servers.length){var s=c.Servers[0];s.AccessToken=j.token;s.UserId=j.userId||s.UserId||'';s.DateLastAccessed=Date.now();ls.setItem(ck,JSON.stringify(c));return}}catch(e){}ls.setItem(ck,JSON.stringify({Servers:[{Id:j.serverId||'',Name:j.serverName||name,UserId:j.userId||'',AccessToken:j.token,ManualAddress:location.origin,LastConnectionMode:2,DateLastAccessed:Date.now()}]}))}
|
function merge(ck,j,name){try{var c=JSON.parse(ls.getItem(ck)||'{}');if(c.Servers&&c.Servers.length){var s=c.Servers[0];s.AccessToken=j.token;s.UserId=j.userId||s.UserId||'';s.DateLastAccessed=Date.now();ls.setItem(ck,JSON.stringify(c));return}}catch(e){}ls.setItem(ck,JSON.stringify({Servers:[{Id:j.serverId||'',Name:j.serverName||name,UserId:j.userId||'',AccessToken:j.token,ManualAddress:location.origin,LastConnectionMode:2,DateLastAccessed:Date.now()}]}))}
|
||||||
// Belt-and-suspenders hard timeout: if nothing in this script succeeds
|
// Belt-and-suspenders hard timeout: if nothing in this script succeeds
|
||||||
// within 15s, force-redirect to status.sami so the user can re-auth.
|
// within 15s, force-redirect to status.sami so the user can re-auth.
|
||||||
var overallTimer=setTimeout(function(){go('https://status.sami?auth=required&return='+encodeURIComponent(location.href))},15000);
|
var overallTimer=setTimeout(function(){go(authUrl())},15000);
|
||||||
// Cross-subdomain SSO handoff: status.sami can't share its session cookie
|
// Cross-subdomain SSO handoff: status.sami can't share its session cookie
|
||||||
// with this origin (Domain=.sami cookies are silently rejected by real
|
// with this origin (Domain=.sami cookies are silently rejected by real
|
||||||
// browsers - .sami isn't a registered TLD, so browsers treat "sami" as the
|
// browsers - .sami isn't a registered TLD, so browsers treat "sami" as the
|
||||||
@@ -319,18 +345,17 @@ function buildLoginPage(service) {
|
|||||||
preExchange.then(function(){
|
preExchange.then(function(){
|
||||||
return fetch('/dashcaddy-api/api/auth/totp/check-session',{credentials:'include',cache:'no-store',signal:withTimeout(5000)})
|
return fetch('/dashcaddy-api/api/auth/totp/check-session',{credentials:'include',cache:'no-store',signal:withTimeout(5000)})
|
||||||
}).then(function(r){return r.json()}).then(function(st){
|
}).then(function(r){return r.json()}).then(function(st){
|
||||||
if(!st||!st.success||!st.authenticated){go('https://status.sami?auth=required&return='+encodeURIComponent(location.href));return}
|
if(!st||!st.success||!st.authenticated){go(authUrl());return}
|
||||||
${body}
|
${body}
|
||||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Auth check error: '+(e&&e.message||'unknown'))})
|
}).catch(function(e){fail('Could not reach DashCaddy. '+authLink('Sign in at DashCaddy'),'Auth check error: '+(e&&e.message||'unknown'))})
|
||||||
})()</script></body></html>`;
|
})()</script></body></html>`;
|
||||||
|
|
||||||
const pages = {
|
const pages = {
|
||||||
chat: {
|
chat: {
|
||||||
title: 'Signing in...', bg: '#0a0a0a', accent: '#60a5fa',
|
title: 'Signing in...', bg: '#0a0a0a', accent: '#60a5fa',
|
||||||
body: `if(ls.getItem('token')){go('/?direct=1');return}
|
body: `d.textContent='Fetching token from DashCaddy...';
|
||||||
d.textContent='Fetching token from DashCaddy...';
|
|
||||||
ft('chat').then(function(r){return r.text()}).then(function(t){
|
ft('chat').then(function(r){return r.text()}).then(function(t){
|
||||||
try{var j=JSON.parse(t);if(j.token){ls.setItem('token',j.token);go('/?direct=1');return}
|
try{var j=JSON.parse(t);if(j.credentialsRequired){vault('chat');return}if(j.token){ls.setItem('token',j.token);go('/?direct=1');return}
|
||||||
// No token but chat is reachable — fall through to manual UI link below
|
// No token but chat is reachable — fall through to manual UI link below
|
||||||
fail('Auto-login unavailable. <a href="/?direct=1">Open Chat manually</a>','No token field: '+t.substring(0,200))}
|
fail('Auto-login unavailable. <a href="/?direct=1">Open Chat manually</a>','No token field: '+t.substring(0,200))}
|
||||||
catch(e){fail('Auto-login parse error. <a href="/?direct=1">Open Chat manually</a>','Error: '+e.message+' / body: '+t.substring(0,200))}
|
catch(e){fail('Auto-login parse error. <a href="/?direct=1">Open Chat manually</a>','Error: '+e.message+' / body: '+t.substring(0,200))}
|
||||||
@@ -338,30 +363,29 @@ ft('chat').then(function(r){return r.text()}).then(function(t){
|
|||||||
},
|
},
|
||||||
plex: {
|
plex: {
|
||||||
title: 'Signing in to Plex...', bg: '#1f1f1f', accent: '#e5a00d',
|
title: 'Signing in to Plex...', bg: '#1f1f1f', accent: '#e5a00d',
|
||||||
body: `if(ls.getItem('myPlexAccessToken')){go('/web/?direct=1');return}
|
body: `ft('plex').then(function(r){return r.json()}).then(function(j){
|
||||||
ft('plex').then(function(r){return r.json()}).then(function(j){
|
if(j.credentialsRequired){vault('plex');return}if(j.token){ls.setItem('myPlexAccessToken',j.token);d.textContent='Token stored, redirecting...';go('/web/?direct=1');return}
|
||||||
if(j.token){ls.setItem('myPlexAccessToken',j.token);d.textContent='Token stored, redirecting...';go('/web/?direct=1');return}
|
|
||||||
// No token returned. Three fallbacks in priority order:
|
// No token returned. Three fallbacks in priority order:
|
||||||
// 1. Stale token in localStorage — Plex may still accept it.
|
// 1. Stale token in localStorage — Plex may still accept it.
|
||||||
if(ls.getItem('myPlexAccessToken')){go('/web/?direct=1');return}
|
if(ls.getItem('myPlexAccessToken')){go('/web/?direct=1');return}
|
||||||
// 2. Manual link so the user is never trapped on this page.
|
// 2. Manual link so the user is never trapped on this page.
|
||||||
fail('Auto-login unavailable. <a href="/web/?direct=1">Open Plex manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>','API: '+JSON.stringify(j))
|
fail('Auto-login unavailable. <a href="/web/?direct=1">Open Plex manually</a> or '+authLink('re-authenticate at DashCaddy'),'API: '+JSON.stringify(j))
|
||||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/?direct=1">Open Plex manually</a>','Error: '+(e&&e.message||'unknown'))})`
|
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/?direct=1">Open Plex manually</a>','Error: '+(e&&e.message||'unknown'))})`
|
||||||
},
|
},
|
||||||
jellyfin: {
|
jellyfin: {
|
||||||
title: 'Signing in to Jellyfin...', bg: '#101014', accent: '#00a4dc',
|
title: 'Signing in to Jellyfin...', bg: '#101014', accent: '#00a4dc',
|
||||||
body: `ft('jellyfin').then(function(r){return r.json()}).then(function(j){
|
body: `ft('jellyfin').then(function(r){return r.json()}).then(function(j){
|
||||||
if(j.token){merge('jellyfin_credentials',j,'Jellyfin');merge('_jellyfin_credentials',j,'Jellyfin');d.textContent='Token stored, redirecting...';go('/web/');return}
|
if(j.credentialsRequired){vault('jellyfin');return}if(j.token){merge('jellyfin_credentials',j,'Jellyfin');merge('_jellyfin_credentials',j,'Jellyfin');d.textContent='Token stored, redirecting...';go('/web/');return}
|
||||||
if(ls.getItem('jellyfin_credentials')||ls.getItem('_jellyfin_credentials')){go('/web/');return}
|
if(ls.getItem('jellyfin_credentials')||ls.getItem('_jellyfin_credentials')){go('/web/');return}
|
||||||
fail('Auto-login unavailable. <a href="/web/">Open Jellyfin manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>','API: '+JSON.stringify(j))
|
fail('Auto-login unavailable. <a href="/web/">Open Jellyfin manually</a> or '+authLink('re-authenticate at DashCaddy'),'API: '+JSON.stringify(j))
|
||||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Jellyfin manually</a>','Error: '+(e&&e.message||'unknown'))})`
|
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Jellyfin manually</a>','Error: '+(e&&e.message||'unknown'))})`
|
||||||
},
|
},
|
||||||
emby: {
|
emby: {
|
||||||
title: 'Signing in to Emby...', bg: '#101014', accent: '#52b54b',
|
title: 'Signing in to Emby...', bg: '#101014', accent: '#52b54b',
|
||||||
body: `ft('emby').then(function(r){return r.json()}).then(function(j){
|
body: `ft('emby').then(function(r){return r.json()}).then(function(j){
|
||||||
if(j.token){merge('emby_credentials',j,'Emby');merge('_emby_credentials',j,'Emby');d.textContent='Token stored, redirecting...';go('/web/');return}
|
if(j.credentialsRequired){vault('emby');return}if(j.token){merge('emby_credentials',j,'Emby');merge('_emby_credentials',j,'Emby');d.textContent='Token stored, redirecting...';go('/web/');return}
|
||||||
if(ls.getItem('emby_credentials')||ls.getItem('_emby_credentials')){go('/web/');return}
|
if(ls.getItem('emby_credentials')||ls.getItem('_emby_credentials')){go('/web/');return}
|
||||||
fail('Auto-login unavailable. <a href="/web/">Open Emby manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>','API: '+JSON.stringify(j))
|
fail('Auto-login unavailable. <a href="/web/">Open Emby manually</a> or '+authLink('re-authenticate at DashCaddy'),'API: '+JSON.stringify(j))
|
||||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Emby manually</a>','Error: '+(e&&e.message||'unknown'))})`
|
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Emby manually</a>','Error: '+(e&&e.message||'unknown'))})`
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -371,5 +395,6 @@ ft('plex').then(function(r){return r.json()}).then(function(j){
|
|||||||
return SHELL(cfg.body)
|
return SHELL(cfg.body)
|
||||||
.replace(/__TITLE__/g, cfg.title)
|
.replace(/__TITLE__/g, cfg.title)
|
||||||
.replace('__BG__', cfg.bg)
|
.replace('__BG__', cfg.bg)
|
||||||
.replace('__ACCENT__', cfg.accent);
|
.replace('__ACCENT__', cfg.accent)
|
||||||
|
.replace('__DASHBOARD_ORIGIN__', JSON.stringify(dashboardOrigin));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const { ok, successMessage } = require('../../src/utils/responses');
|
|||||||
* @param {Object} deps.log - Logger instance
|
* @param {Object} deps.log - Logger instance
|
||||||
* @returns {express.Router}
|
* @returns {express.Router}
|
||||||
*/
|
*/
|
||||||
module.exports = function({ authManager, credentialManager, totpConfig, saveTotpConfig, session, asyncHandler, errorResponse, log, renewCSRFToken }) {
|
module.exports = function({ authManager, credentialManager, totpConfig, saveTotpConfig, session, asyncHandler, errorResponse, log, renewCSRFToken, siteConfig }) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Ctx shim for backward compatibility
|
// Ctx shim for backward compatibility
|
||||||
@@ -23,7 +23,8 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp
|
|||||||
credentialManager,
|
credentialManager,
|
||||||
totpConfig,
|
totpConfig,
|
||||||
saveTotpConfig,
|
saveTotpConfig,
|
||||||
session
|
session,
|
||||||
|
siteConfig
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get current TOTP config (public route)
|
// Get current TOTP config (public route)
|
||||||
@@ -193,11 +194,14 @@ const SETUP_WINDOW_MS = 60 * 60 * 1000;
|
|||||||
// Login: verify TOTP code and set session cookie
|
// Login: verify TOTP code and set session cookie
|
||||||
router.post('/totp/verify', asyncHandler(async (req, res) => {
|
router.post('/totp/verify', asyncHandler(async (req, res) => {
|
||||||
const { authenticator } = require('otplib');
|
const { authenticator } = require('otplib');
|
||||||
const { code } = req.body;
|
const { code, serviceId } = req.body;
|
||||||
|
|
||||||
if (!code || !/^\d{6}$/.test(code)) {
|
if (!code || !/^\d{6}$/.test(code)) {
|
||||||
throw new ValidationError('Invalid code format', 'code');
|
throw new ValidationError('Invalid code format', 'code');
|
||||||
}
|
}
|
||||||
|
if (serviceId != null && !/^[a-z0-9][a-z0-9-]*$/.test(String(serviceId))) {
|
||||||
|
throw new ValidationError('Invalid service ID', 'serviceId');
|
||||||
|
}
|
||||||
|
|
||||||
if (!ctx.totpConfig.enabled || !ctx.totpConfig.isSetUp) {
|
if (!ctx.totpConfig.enabled || !ctx.totpConfig.isSetUp) {
|
||||||
throw new ValidationError('TOTP is not enabled');
|
throw new ValidationError('TOTP is not enabled');
|
||||||
@@ -227,7 +231,12 @@ const SETUP_WINDOW_MS = 60 * 60 * 1000;
|
|||||||
// URL when bouncing the user back to a gated service. That service's
|
// URL when bouncing the user back to a gated service. That service's
|
||||||
// login page exchanges it via /auth/sso-exchange for its own host-only
|
// login page exchanges it via /auth/sso-exchange for its own host-only
|
||||||
// session cookie. Single-use, 60s TTL — see ctx.session.createHandoffToken.
|
// session cookie. Single-use, 60s TTL — see ctx.session.createHandoffToken.
|
||||||
const ssoToken = ctx.session.createHandoffToken();
|
let ssoToken = null;
|
||||||
|
if (serviceId) {
|
||||||
|
const suffix = String(ctx.siteConfig?.tld || '.sami');
|
||||||
|
const expectedHost = `${serviceId}${suffix.startsWith('.') ? suffix : `.${suffix}`}`;
|
||||||
|
ssoToken = ctx.session.createHandoffToken(expectedHost);
|
||||||
|
}
|
||||||
|
|
||||||
log.debug('auth', 'Session created', { sessions: ctx.session.ipSessions.size });
|
log.debug('auth', 'Session created', { sessions: ctx.session.ipSessions.size });
|
||||||
ok(res, { message: 'Authenticated successfully', sessionDuration: ctx.totpConfig.sessionDuration, csrfToken: newCsrfToken, ssoToken });
|
ok(res, { message: 'Authenticated successfully', sessionDuration: ctx.totpConfig.sessionDuration, csrfToken: newCsrfToken, ssoToken });
|
||||||
|
|||||||
@@ -263,9 +263,10 @@ module.exports = function({
|
|||||||
const arrKey = await credentialManager.retrieve(`arr.${serviceId}.apikey`).catch(() => null);
|
const arrKey = await credentialManager.retrieve(`arr.${serviceId}.apikey`).catch(() => null);
|
||||||
const svcKey = await credentialManager.retrieve(`service.${serviceId}.apikey`).catch(() => null);
|
const svcKey = await credentialManager.retrieve(`service.${serviceId}.apikey`).catch(() => null);
|
||||||
const username = await credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
|
const username = await credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
|
||||||
|
const password = await credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
|
||||||
success(res, {
|
success(res, {
|
||||||
hasApiKey: !!(arrKey || svcKey),
|
hasApiKey: !!(arrKey || svcKey),
|
||||||
hasBasicAuth: !!username,
|
hasBasicAuth: !!username && !!password,
|
||||||
username: username || null
|
username: username || null
|
||||||
});
|
});
|
||||||
}, 'service-creds'));
|
}, 'service-creds'));
|
||||||
|
|||||||
@@ -330,17 +330,22 @@ module.exports = function configureMiddleware(app, {
|
|||||||
const ssoHandoffTokens = new Map();
|
const ssoHandoffTokens = new Map();
|
||||||
const SSO_HANDOFF_TTL_MS = 60 * 1000;
|
const SSO_HANDOFF_TTL_MS = 60 * 1000;
|
||||||
|
|
||||||
function createHandoffToken() {
|
function createHandoffToken(expectedHost = null) {
|
||||||
const token = crypto.randomBytes(24).toString('base64url');
|
const token = crypto.randomBytes(24).toString('base64url');
|
||||||
ssoHandoffTokens.set(token, { exp: Date.now() + SSO_HANDOFF_TTL_MS });
|
ssoHandoffTokens.set(token, {
|
||||||
|
exp: Date.now() + SSO_HANDOFF_TTL_MS,
|
||||||
|
expectedHost: expectedHost ? String(expectedHost).toLowerCase() : null,
|
||||||
|
});
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
function redeemHandoffToken(token) {
|
function redeemHandoffToken(token, actualHost = null) {
|
||||||
if (!token) return false;
|
if (!token) return false;
|
||||||
const entry = ssoHandoffTokens.get(token);
|
const entry = ssoHandoffTokens.get(token);
|
||||||
ssoHandoffTokens.delete(token); // one-time use regardless of outcome
|
ssoHandoffTokens.delete(token); // one-time use regardless of outcome
|
||||||
return !!entry && entry.exp > Date.now();
|
if (!entry || entry.exp <= Date.now()) return false;
|
||||||
|
if (!entry.expectedHost) return true;
|
||||||
|
return !!actualHost && entry.expectedHost === String(actualHost).toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setHostOnlySessionCookie(res, durationKey) {
|
function setHostOnlySessionCookie(res, durationKey) {
|
||||||
|
|||||||
@@ -630,10 +630,15 @@ generate_caddyfile() {
|
|||||||
SNIP
|
SNIP
|
||||||
|
|
||||||
local auth_snippet="(dashcaddy_auth) {
|
local auth_snippet="(dashcaddy_auth) {
|
||||||
forward_auth localhost:${API_PORT} {
|
@needsAuth not path /dashcaddy-sso
|
||||||
|
forward_auth @needsAuth localhost:${API_PORT} {
|
||||||
uri /api/v1/auth/gate/{args[0]}
|
uri /api/v1/auth/gate/{args[0]}
|
||||||
copy_headers Authorization X-Api-Key X-App-Cookie X-Emby-Token X-Plex-Token
|
copy_headers Authorization X-Api-Key X-App-Cookie X-Emby-Token X-Plex-Token
|
||||||
}
|
}
|
||||||
|
handle /dashcaddy-sso {
|
||||||
|
rewrite * /api/v1/auth/sso-exchange
|
||||||
|
reverse_proxy localhost:${API_PORT}
|
||||||
|
}
|
||||||
}"
|
}"
|
||||||
|
|
||||||
local site_body=" root * ${DASHBOARD_DIR}
|
local site_body=" root * ${DASHBOARD_DIR}
|
||||||
|
|||||||
@@ -51,10 +51,15 @@ class CaddyfileGenerator {
|
|||||||
_authSnippet(apiPort) {
|
_authSnippet(apiPort) {
|
||||||
return `# DashCaddy SSO auth snippet
|
return `# DashCaddy SSO auth snippet
|
||||||
(dashcaddy_auth) {
|
(dashcaddy_auth) {
|
||||||
forward_auth localhost:${apiPort} {
|
@needsAuth not path /dashcaddy-sso
|
||||||
|
forward_auth @needsAuth localhost:${apiPort} {
|
||||||
uri /api/v1/auth/gate/{args[0]}
|
uri /api/v1/auth/gate/{args[0]}
|
||||||
copy_headers Authorization X-Api-Key X-App-Cookie X-Emby-Token X-Plex-Token
|
copy_headers Authorization X-Api-Key X-App-Cookie X-Emby-Token X-Plex-Token
|
||||||
}
|
}
|
||||||
|
handle /dashcaddy-sso {
|
||||||
|
rewrite * /api/v1/auth/sso-exchange
|
||||||
|
reverse_proxy localhost:${apiPort}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
const CaddyfileGenerator = require('./caddyfile-generator');
|
||||||
|
|
||||||
|
describe('cross-host SSO installer contract', () => {
|
||||||
|
test('generated auth snippet exposes the public one-time exchange landing route', () => {
|
||||||
|
const snippet = new CaddyfileGenerator()._authSnippet(3001);
|
||||||
|
expect(snippet).toContain('@needsAuth not path /dashcaddy-sso');
|
||||||
|
expect(snippet).toContain('handle /dashcaddy-sso');
|
||||||
|
expect(snippet).toContain('rewrite * /api/v1/auth/sso-exchange');
|
||||||
|
expect(snippet).toContain('reverse_proxy localhost:3001');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shell installer emits the same exchange landing contract', () => {
|
||||||
|
const installer = fs.readFileSync(path.join(__dirname, '..', '..', 'install.sh'), 'utf8');
|
||||||
|
expect(installer).toContain('@needsAuth not path /dashcaddy-sso');
|
||||||
|
expect(installer).toContain('handle /dashcaddy-sso');
|
||||||
|
expect(installer).toContain('rewrite * /api/v1/auth/sso-exchange');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Caddy parser accepts a complete service config using the generated snippet', () => {
|
||||||
|
const available = spawnSync('caddy', ['version'], { encoding: 'utf8' });
|
||||||
|
if (available.status !== 0) return;
|
||||||
|
|
||||||
|
const generator = new CaddyfileGenerator();
|
||||||
|
const config = `${generator._authSnippet(3001)}\nexample.test {\n import dashcaddy_auth plex\n respond "ok" 200\n}\n`;
|
||||||
|
const result = spawnSync('caddy', ['validate', '--config', '-', '--adapter', 'caddyfile'], {
|
||||||
|
input: config,
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
expect(result.status).toBe(0);
|
||||||
|
expect(`${result.stdout}\n${result.stderr}`).toContain('Valid configuration');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,6 +28,7 @@ const bundles = {
|
|||||||
// totp-recovery.js registers window._refreshRecoveryLink which totp-auth.js
|
// totp-recovery.js registers window._refreshRecoveryLink which totp-auth.js
|
||||||
// calls from showTotpOverlay(). Must come after totp-auth.js.
|
// calls from showTotpOverlay(). Must come after totp-auth.js.
|
||||||
JS('totp-recovery.js'),
|
JS('totp-recovery.js'),
|
||||||
|
JS('credential-vault-handoff.js'),
|
||||||
JS('service-credentials.js'),
|
JS('service-credentials.js'),
|
||||||
JS('totp-settings.js'),
|
JS('totp-settings.js'),
|
||||||
// DC-048 admin panel — modal-overlay UI for user/invite management.
|
// DC-048 admin panel — modal-overlay UI for user/invite management.
|
||||||
|
|||||||
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
@@ -287,7 +287,11 @@
|
|||||||
async function resumeExistingSession(returnUrl) {
|
async function resumeExistingSession(returnUrl) {
|
||||||
if (!returnUrl || !isAllowedReturnUrl(returnUrl)) return false;
|
if (!returnUrl || !isAllowedReturnUrl(returnUrl)) return false;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/v1/auth/sso-handoff', {
|
const parsedReturn = new URL(returnUrl, window.location.origin);
|
||||||
|
const suffix = SITE.tld.startsWith('.') ? SITE.tld : `.${SITE.tld}`;
|
||||||
|
const serviceId = parsedReturn.hostname.slice(0, -suffix.length);
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(serviceId)) return false;
|
||||||
|
const res = await fetch(`/api/v1/auth/sso-handoff?serviceId=${encodeURIComponent(serviceId)}`, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,20 @@
|
|||||||
return server?.name || dnsId.toUpperCase();
|
return server?.name || dnsId.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requireSuccessfulDnsMutation(response, label) {
|
||||||
|
if (!response) throw new Error(`${label} failed: no response`);
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await response.json();
|
||||||
|
} catch (_) {
|
||||||
|
throw new Error(`${label} failed: invalid server response`);
|
||||||
|
}
|
||||||
|
if (!response.ok || data?.success !== true) {
|
||||||
|
throw new Error(data?.error || `${label} failed (${response.status || 'unknown status'})`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
/** Build per-server credential form sections from SITE.dnsServers */
|
/** Build per-server credential form sections from SITE.dnsServers */
|
||||||
function buildCredentialSections() {
|
function buildCredentialSections() {
|
||||||
const container = document.getElementById('dns-cred-sections');
|
const container = document.getElementById('dns-cred-sections');
|
||||||
@@ -258,14 +272,6 @@
|
|||||||
document.getElementById('token-save')?.addEventListener('click', async () => {
|
document.getElementById('token-save')?.addEventListener('click', async () => {
|
||||||
const dnsIds = getDnsIds();
|
const dnsIds = getDnsIds();
|
||||||
|
|
||||||
// Save all to localStorage
|
|
||||||
dnsIds.forEach(dnsId => {
|
|
||||||
setUsername(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-username`).value.trim());
|
|
||||||
setToken(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-token`).value.trim());
|
|
||||||
setUsername(dnsId, 'admin', document.getElementById(`${dnsId}-admin-username`).value.trim());
|
|
||||||
setToken(dnsId, 'admin', document.getElementById(`${dnsId}-admin-token`).value.trim());
|
|
||||||
});
|
|
||||||
|
|
||||||
// Build per-server credentials payload for backend sync
|
// Build per-server credentials payload for backend sync
|
||||||
const servers = {};
|
const servers = {};
|
||||||
let hasAnyCreds = false;
|
let hasAnyCreds = false;
|
||||||
@@ -304,45 +310,36 @@
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ servers })
|
body: JSON.stringify({ servers })
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await requireSuccessfulDnsMutation(res, 'DNS credential save');
|
||||||
|
|
||||||
if (data.results) {
|
if (data.results) {
|
||||||
|
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 => {
|
dnsIds.forEach(dnsId => {
|
||||||
const statusEl = document.getElementById(`${dnsId}-token-status`);
|
const statusEl = document.getElementById(`${dnsId}-token-status`);
|
||||||
if (!servers[dnsId]) { statusEl.textContent = ''; return; }
|
if (!servers[dnsId]) { statusEl.textContent = ''; return; }
|
||||||
const result = data.results[dnsId];
|
const result = data.results?.[dnsId];
|
||||||
if (result?.success) {
|
statusEl.textContent = result?.partial ? '\u2713 ' + result.partial : '\u2713 Verified & saved';
|
||||||
statusEl.textContent = '\u2713 Verified & saved';
|
|
||||||
statusEl.className = 'token-status success';
|
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';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to sync DNS credentials to backend:', e);
|
console.error('Failed to sync DNS credentials to backend:', e);
|
||||||
dnsIds.forEach(dnsId => {
|
dnsIds.forEach(dnsId => {
|
||||||
if (servers[dnsId]) {
|
if (servers[dnsId]) {
|
||||||
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Saved locally (sync failed)';
|
document.getElementById(`${dnsId}-token-status`).textContent = '\u2717 ' + (e.message || 'Save failed');
|
||||||
document.getElementById(`${dnsId}-token-status`).className = 'token-status';
|
document.getElementById(`${dnsId}-token-status`).className = 'token-status error';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -368,6 +365,9 @@
|
|||||||
|
|
||||||
document.getElementById('token-clear-all')?.addEventListener('click', async () => {
|
document.getElementById('token-clear-all')?.addEventListener('click', async () => {
|
||||||
if (confirm('Clear all stored DNS credentials? This cannot be undone.')) {
|
if (confirm('Clear all stored DNS credentials? This cannot be undone.')) {
|
||||||
|
try {
|
||||||
|
const response = await secureFetch('/api/v1/dns/credentials', { method: 'DELETE' });
|
||||||
|
await requireSuccessfulDnsMutation(response, 'DNS credential removal');
|
||||||
clearAllCredentials();
|
clearAllCredentials();
|
||||||
getDnsIds().forEach(dnsId => {
|
getDnsIds().forEach(dnsId => {
|
||||||
document.getElementById(`${dnsId}-readonly-username`).value = '';
|
document.getElementById(`${dnsId}-readonly-username`).value = '';
|
||||||
@@ -377,9 +377,12 @@
|
|||||||
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Cleared';
|
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Cleared';
|
||||||
document.getElementById(`${dnsId}-token-status`).className = 'token-status success';
|
document.getElementById(`${dnsId}-token-status`).className = 'token-status success';
|
||||||
});
|
});
|
||||||
try {
|
} catch (e) {
|
||||||
await secureFetch('/api/v1/dns/credentials', { method: 'DELETE' });
|
getDnsIds().forEach(dnsId => {
|
||||||
} catch (_) {}
|
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 window.loadServices();
|
||||||
await loadTemplateCategories();
|
await loadTemplateCategories();
|
||||||
window.buildGrid();
|
window.buildGrid();
|
||||||
|
if (typeof window.openRequestedCredentialForm === 'function') {
|
||||||
|
window.openRequestedCredentialForm();
|
||||||
|
}
|
||||||
animateTopCards();
|
animateTopCards();
|
||||||
window.refreshAll();
|
window.refreshAll();
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
|
|||||||
@@ -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">
|
injectModal('service-creds-modal', `<div id="service-creds-modal">
|
||||||
<div class="service-creds-content">
|
<div class="service-creds-content">
|
||||||
<h3 id="svc-creds-title" style="margin: 0 0 4px; font-size: 1.05rem;">Service Credentials</h3>
|
<h3 id="svc-creds-title" style="margin: 0 0 4px; font-size: 1.05rem;">Encrypted Credential Vault</h3>
|
||||||
<p id="svc-creds-desc" style="font-size: 0.75rem; color: var(--muted); margin: 0 0 14px;">Credentials are injected automatically when accessing this service.</p>
|
<p id="svc-creds-desc" style="font-size: 0.75rem; color: var(--muted); margin: 0 0 14px;">Passwords are encrypted at rest and used automatically when you open this service.</p>
|
||||||
|
|
||||||
<!-- Status indicator -->
|
<!-- Status indicator -->
|
||||||
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 12px;">
|
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 12px;">
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
<!-- Buttons -->
|
<!-- Buttons -->
|
||||||
<div style="display: flex; gap: 8px; margin-top: 14px;">
|
<div style="display: flex; gap: 8px; margin-top: 14px;">
|
||||||
<button id="svc-creds-save" class="btn-accent-solid" style="flex: 1; padding: 9px; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem;">
|
<button id="svc-creds-save" class="btn-accent-solid" style="flex: 1; padding: 9px; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem;">
|
||||||
Save
|
Save to encrypted vault
|
||||||
</button>
|
</button>
|
||||||
<button id="svc-creds-clear" style="padding: 9px 14px; background: transparent; color: var(--bad-fg, #ff9aa3); border: 1px solid var(--bad-fg, #ff9aa3); border-radius: 6px; cursor: pointer; font-size: 0.85rem; display: none;">
|
<button id="svc-creds-clear" style="padding: 9px 14px; background: transparent; color: var(--bad-fg, #ff9aa3); border: 1px solid var(--bad-fg, #ff9aa3); border-radius: 6px; cursor: pointer; font-size: 0.85rem; display: none;">
|
||||||
Clear
|
Clear
|
||||||
@@ -105,6 +105,8 @@
|
|||||||
|
|
||||||
const modal = document.getElementById('service-creds-modal');
|
const modal = document.getElementById('service-creds-modal');
|
||||||
let currentService = null;
|
let currentService = null;
|
||||||
|
let credentialReturnUrl = null;
|
||||||
|
let currentServiceHadCreds = false;
|
||||||
const arrServices = ['sonarr', 'radarr', 'prowlarr', 'overseerr'];
|
const arrServices = ['sonarr', 'radarr', 'prowlarr', 'overseerr'];
|
||||||
const qualityProfileServices = ['sonarr', 'radarr'];
|
const qualityProfileServices = ['sonarr', 'radarr'];
|
||||||
|
|
||||||
@@ -124,8 +126,28 @@
|
|||||||
el.style.display = 'none';
|
el.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
window.openServiceCredsModal = async function(service) {
|
async function requireSuccessfulWrite(response, label) {
|
||||||
|
if (!response) throw new Error(`${label} failed: no response`);
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await response.json();
|
||||||
|
} catch (_) {
|
||||||
|
throw new Error(`${label} failed: invalid server response`);
|
||||||
|
}
|
||||||
|
if (!response.ok || data?.success !== true) {
|
||||||
|
throw new Error(data?.error || `${label} failed (${response.status || 'unknown status'})`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowedCredentialReturnUrl(returnUrl, serviceId) {
|
||||||
|
return !!window.DCCredentialVault?.isAllowedReturnUrl(returnUrl, serviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.openServiceCredsModal = async function(service, options = {}) {
|
||||||
currentService = service;
|
currentService = service;
|
||||||
|
credentialReturnUrl = isAllowedCredentialReturnUrl(options.returnUrl, service.id) ? options.returnUrl : null;
|
||||||
|
currentServiceHadCreds = false;
|
||||||
hideError();
|
hideError();
|
||||||
const title = document.getElementById('svc-creds-title');
|
const title = document.getElementById('svc-creds-title');
|
||||||
const desc = document.getElementById('svc-creds-desc');
|
const desc = document.getElementById('svc-creds-desc');
|
||||||
@@ -134,7 +156,10 @@
|
|||||||
const basicSection = document.getElementById('svc-creds-basic');
|
const basicSection = document.getElementById('svc-creds-basic');
|
||||||
const qualitySection = document.getElementById('svc-creds-quality');
|
const qualitySection = document.getElementById('svc-creds-quality');
|
||||||
|
|
||||||
title.textContent = service.name + ' Credentials';
|
title.textContent = service.name + ' — Encrypted Vault';
|
||||||
|
document.getElementById('svc-creds-save').textContent = credentialReturnUrl
|
||||||
|
? 'Save to vault & open service'
|
||||||
|
: 'Save to encrypted vault';
|
||||||
// Determine which sections to show
|
// Determine which sections to show
|
||||||
const isExt = !!service.isExternal;
|
const isExt = !!service.isExternal;
|
||||||
const isArr = arrServices.includes(service.id) || arrServices.includes(service.appTemplate);
|
const isArr = arrServices.includes(service.id) || arrServices.includes(service.appTemplate);
|
||||||
@@ -214,6 +239,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hasCreds) {
|
if (hasCreds) {
|
||||||
|
currentServiceHadCreds = true;
|
||||||
dot.style.background = 'var(--ok-fg, #74dfc4)';
|
dot.style.background = 'var(--ok-fg, #74dfc4)';
|
||||||
status.style.color = 'var(--ok-fg, #74dfc4)';
|
status.style.color = 'var(--ok-fg, #74dfc4)';
|
||||||
status.textContent = 'Credentials stored';
|
status.textContent = 'Credentials stored';
|
||||||
@@ -352,16 +378,35 @@
|
|||||||
const isArr = arrServices.includes(currentService.id) || arrServices.includes(currentService.appTemplate);
|
const isArr = arrServices.includes(currentService.id) || arrServices.includes(currentService.appTemplate);
|
||||||
const svcId = currentService.id || currentService.appTemplate;
|
const svcId = currentService.id || currentService.appTemplate;
|
||||||
|
|
||||||
|
if (credentialReturnUrl && !currentServiceHadCreds) {
|
||||||
|
const externalUser = document.getElementById('svc-seedhost-user').value.trim();
|
||||||
|
const externalPass = document.getElementById('svc-seedhost-pass').value;
|
||||||
|
const apiKeyInput = document.getElementById('svc-apikey-input');
|
||||||
|
const requestedApiKey = apiKeyInput?.value.trim();
|
||||||
|
const basicUser = document.getElementById('svc-basic-user').value.trim();
|
||||||
|
const basicPass = document.getElementById('svc-basic-pass').value;
|
||||||
|
const hasExternalLogin = currentService.isExternal && externalUser && externalPass;
|
||||||
|
const hasApiKey = isArr && requestedApiKey && requestedApiKey !== '••••••••';
|
||||||
|
const hasBasicLogin = !currentService.isExternal && basicUser && basicPass;
|
||||||
|
if (!hasExternalLogin && !hasApiKey && !hasBasicLogin) {
|
||||||
|
showError('Enter the login or API key DashCaddy should store for this service.');
|
||||||
|
saveBtn.textContent = 'Save to vault & open service';
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Save seedhost creds (shared username + per-service password)
|
// Save seedhost creds (shared username + per-service password)
|
||||||
if (currentService.isExternal) {
|
if (currentService.isExternal) {
|
||||||
const user = document.getElementById('svc-seedhost-user').value.trim();
|
const user = document.getElementById('svc-seedhost-user').value.trim();
|
||||||
const pass = document.getElementById('svc-seedhost-pass').value;
|
const pass = document.getElementById('svc-seedhost-pass').value;
|
||||||
if (user) {
|
if (user) {
|
||||||
await secureFetch('/api/v1/seedhost-creds', {
|
const response = await secureFetch('/api/v1/seedhost-creds', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ username: user, password: pass || undefined, serviceId: currentService.id })
|
body: JSON.stringify({ username: user, password: pass || undefined, serviceId: currentService.id })
|
||||||
});
|
});
|
||||||
|
await requireSuccessfulWrite(response, 'Seedhost credential save');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,23 +432,18 @@
|
|||||||
qualityProfileName: qualityProfileName || undefined
|
qualityProfileName: qualityProfileName || undefined
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await requireSuccessfulWrite(res, 'ARR credential save');
|
||||||
if (!data.success) {
|
|
||||||
showError(data.error || 'Failed to save API key');
|
|
||||||
saveBtn.textContent = 'Save';
|
|
||||||
saveBtn.disabled = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (data.connectionTest && !data.connectionTest.success) {
|
if (data.connectionTest && !data.connectionTest.success) {
|
||||||
showError(`API key saved but connection test failed: ${data.connectionTest.error}`);
|
showError(`API key saved but connection test failed: ${data.connectionTest.error}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Non-arr services use the generic endpoint
|
// Non-arr services use the generic endpoint
|
||||||
await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
|
const response = await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ apiKey })
|
body: JSON.stringify({ apiKey })
|
||||||
});
|
});
|
||||||
|
await requireSuccessfulWrite(response, 'API key save');
|
||||||
}
|
}
|
||||||
} else if (isArr && qualityProfileServices.includes(svcId)) {
|
} else if (isArr && qualityProfileServices.includes(svcId)) {
|
||||||
// API key unchanged but user may have changed quality profile — save profile only
|
// API key unchanged but user may have changed quality profile — save profile only
|
||||||
@@ -411,11 +451,12 @@
|
|||||||
const qualityProfileId = qualSelect?.value ? parseInt(qualSelect.value) : undefined;
|
const qualityProfileId = qualSelect?.value ? parseInt(qualSelect.value) : undefined;
|
||||||
const qualityProfileName = qualSelect?.selectedOptions?.[0]?.textContent || undefined;
|
const qualityProfileName = qualSelect?.selectedOptions?.[0]?.textContent || undefined;
|
||||||
if (qualityProfileId) {
|
if (qualityProfileId) {
|
||||||
await secureFetch('/api/v1/arr/quality-profiles', {
|
const response = await secureFetch('/api/v1/arr/quality-profiles', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ service: svcId, qualityProfileId, qualityProfileName })
|
body: JSON.stringify({ service: svcId, qualityProfileId, qualityProfileName })
|
||||||
});
|
});
|
||||||
|
await requireSuccessfulWrite(response, 'Quality profile save');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,20 +465,28 @@
|
|||||||
const user = document.getElementById('svc-basic-user').value.trim();
|
const user = document.getElementById('svc-basic-user').value.trim();
|
||||||
const pass = document.getElementById('svc-basic-pass').value;
|
const pass = document.getElementById('svc-basic-pass').value;
|
||||||
if (user && pass) {
|
if (user && pass) {
|
||||||
await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
|
const response = await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ username: user, password: pass })
|
body: JSON.stringify({ username: user, password: pass })
|
||||||
});
|
});
|
||||||
|
await requireSuccessfulWrite(response, 'Service credential save');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadServiceCreds(currentService);
|
await loadServiceCreds(currentService);
|
||||||
|
if (credentialReturnUrl) {
|
||||||
|
const returnUrl = credentialReturnUrl;
|
||||||
|
const resumed = await window.DCCredentialVault?.resume(returnUrl, currentService.id);
|
||||||
|
if (!resumed) throw new Error('Credential saved, but the secure service handoff failed. Try opening the service again.');
|
||||||
|
credentialReturnUrl = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorHandler.logError('[ServiceCredentials] Save', e, { function: 'saveCredentials' });
|
errorHandler.logError('[ServiceCredentials] Save', e, { function: 'saveCredentials' });
|
||||||
showError('Failed to save: ' + (e.message || 'Unknown error'));
|
showError('Failed to save: ' + (e.message || 'Unknown error'));
|
||||||
}
|
}
|
||||||
saveBtn.textContent = 'Save';
|
saveBtn.textContent = credentialReturnUrl ? 'Save to vault & open service' : 'Save to encrypted vault';
|
||||||
saveBtn.disabled = false;
|
saveBtn.disabled = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -450,12 +499,15 @@
|
|||||||
const svcId = currentService.id || currentService.appTemplate;
|
const svcId = currentService.id || currentService.appTemplate;
|
||||||
const isArr = arrServices.includes(svcId);
|
const isArr = arrServices.includes(svcId);
|
||||||
if (currentService.isExternal) {
|
if (currentService.isExternal) {
|
||||||
await secureFetch(`/api/v1/seedhost-creds?serviceId=${currentService.id}`, { method: 'DELETE' });
|
const response = await secureFetch(`/api/v1/seedhost-creds?serviceId=${currentService.id}`, { method: 'DELETE' });
|
||||||
|
await requireSuccessfulWrite(response, 'Seedhost credential removal');
|
||||||
}
|
}
|
||||||
// Delete from both namespaces
|
// Delete from both namespaces
|
||||||
await secureFetch(`/api/v1/services/${currentService.id}/credentials`, { method: 'DELETE' });
|
const response = await secureFetch(`/api/v1/services/${currentService.id}/credentials`, { method: 'DELETE' });
|
||||||
|
await requireSuccessfulWrite(response, 'Service credential removal');
|
||||||
if (isArr) {
|
if (isArr) {
|
||||||
await secureFetch(`/api/v1/arr/credentials/${svcId}`, { method: 'DELETE' });
|
const arrResponse = await secureFetch(`/api/v1/arr/credentials/${svcId}`, { method: 'DELETE' });
|
||||||
|
await requireSuccessfulWrite(arrResponse, 'ARR credential removal');
|
||||||
}
|
}
|
||||||
const btn = document.getElementById(`creds-btn-${currentService.id}`);
|
const btn = document.getElementById(`creds-btn-${currentService.id}`);
|
||||||
if (btn) btn.classList.remove('has-creds');
|
if (btn) btn.classList.remove('has-creds');
|
||||||
@@ -470,11 +522,13 @@
|
|||||||
document.getElementById('svc-creds-close')?.addEventListener('click', () => {
|
document.getElementById('svc-creds-close')?.addEventListener('click', () => {
|
||||||
modal.classList.remove('show');
|
modal.classList.remove('show');
|
||||||
currentService = null;
|
currentService = null;
|
||||||
|
credentialReturnUrl = null;
|
||||||
});
|
});
|
||||||
modal?.addEventListener('click', (e) => {
|
modal?.addEventListener('click', (e) => {
|
||||||
if (e.target === modal) {
|
if (e.target === modal) {
|
||||||
modal.classList.remove('show');
|
modal.classList.remove('show');
|
||||||
currentService = null;
|
currentService = null;
|
||||||
|
credentialReturnUrl = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -501,4 +555,18 @@
|
|||||||
}
|
}
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Protected service login pages send missing credentials here. Reuse the
|
||||||
|
// normal vault form, then resume through the existing one-time SSO handoff.
|
||||||
|
window.openRequestedCredentialForm = function() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const serviceId = params.get('credentials');
|
||||||
|
if (!serviceId) return false;
|
||||||
|
const service = (window.APPS || []).find(app => app.id === serviceId || app.appTemplate === serviceId);
|
||||||
|
if (!service) return false;
|
||||||
|
const returnUrl = params.get('return');
|
||||||
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
|
window.openServiceCredsModal(service, { returnUrl });
|
||||||
|
return true;
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
+12
-2
@@ -90,11 +90,22 @@
|
|||||||
errorEl.textContent = 'Verifying...';
|
errorEl.textContent = 'Verifying...';
|
||||||
errorEl.className = 'totp-error verifying';
|
errorEl.className = 'totp-error verifying';
|
||||||
|
|
||||||
|
const redirect = safeSessionGet('totp_redirect');
|
||||||
|
let serviceId = null;
|
||||||
|
if (redirect) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(redirect, window.location.origin);
|
||||||
|
const suffix = SITE.tld.startsWith('.') ? SITE.tld : `.${SITE.tld}`;
|
||||||
|
const candidate = parsed.hostname.slice(0, -suffix.length);
|
||||||
|
if (parsed.hostname.endsWith(suffix) && /^[a-z0-9][a-z0-9-]*$/.test(candidate)) serviceId = candidate;
|
||||||
|
} catch (_) { /* invalid redirect is handled by the normal auth flow */ }
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await secureFetch('/api/v1/totp/verify', {
|
const res = await secureFetch('/api/v1/totp/verify', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ code })
|
body: JSON.stringify({ code, serviceId })
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
@@ -106,7 +117,6 @@
|
|||||||
}
|
}
|
||||||
hideTotpOverlay();
|
hideTotpOverlay();
|
||||||
// Check if redirected here from another service
|
// Check if redirected here from another service
|
||||||
const redirect = safeSessionGet('totp_redirect');
|
|
||||||
if (redirect) {
|
if (redirect) {
|
||||||
try { sessionStorage.removeItem('totp_redirect'); } catch (_) {}
|
try { sessionStorage.removeItem('totp_redirect'); } catch (_) {}
|
||||||
// .sami is an unregistered TLD, so browsers silently drop the
|
// .sami is an unregistered TLD, so browsers silently drop the
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'dashcaddy-shell-5dbd809d3b';
|
const CACHE = 'dashcaddy-shell-c25cea8485';
|
||||||
const PRECACHE = [
|
const PRECACHE = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ test('an existing status.sami session returns to a service without another TOTP
|
|||||||
setTimeout(fn) { scheduled = fn; },
|
setTimeout(fn) { scheduled = fn; },
|
||||||
console,
|
console,
|
||||||
fetch: async (url) => {
|
fetch: async (url) => {
|
||||||
assert.equal(url, '/api/v1/auth/sso-handoff');
|
assert.equal(url, '/api/v1/auth/sso-handoff?serviceId=plex');
|
||||||
return { ok: true, json: async () => ({ success: true, ssoToken: 'existing-session-token' }) };
|
return { ok: true, json: async () => ({ success: true, ssoToken: 'existing-session-token' }) };
|
||||||
},
|
},
|
||||||
window: {
|
window: {
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
const { JSDOM } = require('jsdom');
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const handoffSource = fs.readFileSync(path.join(__dirname, '..', 'js', 'credential-vault-handoff.js'), 'utf8');
|
||||||
|
const formSource = fs.readFileSync(path.join(__dirname, '..', 'js', 'service-credentials.js'), 'utf8');
|
||||||
|
const initSource = fs.readFileSync(path.join(__dirname, '..', 'js', 'core', 'init.js'), 'utf8');
|
||||||
|
|
||||||
|
function loadVault() {
|
||||||
|
const window = { location: { origin: 'https://status.sami' } };
|
||||||
|
const context = vm.createContext({ window, SITE: { tld: '.sami' }, URL });
|
||||||
|
vm.runInContext(handoffSource, context);
|
||||||
|
return window.DCCredentialVault;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exerciseFailedModalWrite({
|
||||||
|
service,
|
||||||
|
fetchJson,
|
||||||
|
setupInputs,
|
||||||
|
expectedEndpoint,
|
||||||
|
writeResponse,
|
||||||
|
expectedError = /vault write rejected/,
|
||||||
|
}) {
|
||||||
|
const dom = new JSDOM('<!doctype html><body></body>', {
|
||||||
|
url: 'https://status.sami/',
|
||||||
|
runScripts: 'outside-only',
|
||||||
|
});
|
||||||
|
const { window } = dom;
|
||||||
|
const writeUrls = [];
|
||||||
|
let resumeCalls = 0;
|
||||||
|
window.ErrorHandler = class { logError() {} };
|
||||||
|
window.SITE = { tld: '.sami' };
|
||||||
|
window.injectModal = (_id, html) => window.document.body.insertAdjacentHTML('beforeend', html);
|
||||||
|
window.fetch = async (url) => ({ ok: true, json: async () => fetchJson(url) });
|
||||||
|
window.secureFetch = async (url) => {
|
||||||
|
writeUrls.push(url);
|
||||||
|
return writeResponse || {
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
json: async () => ({ success: false, error: 'vault write rejected' }),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
window.DCCredentialVault = {
|
||||||
|
isAllowedReturnUrl: () => true,
|
||||||
|
resume: async () => { resumeCalls++; return true; },
|
||||||
|
};
|
||||||
|
window.confirm = () => true;
|
||||||
|
window.eval(formSource);
|
||||||
|
|
||||||
|
await window.openServiceCredsModal(service, { returnUrl: `https://${service.id}.sami/` });
|
||||||
|
setupInputs(window.document);
|
||||||
|
window.document.getElementById('svc-creds-save').click();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 20));
|
||||||
|
|
||||||
|
assert.equal(writeUrls[0], expectedEndpoint);
|
||||||
|
assert.equal(resumeCalls, 0);
|
||||||
|
assert.match(window.document.getElementById('svc-creds-error').textContent, expectedError);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('existing dashboard session mints a one-time token and resumes on the target host', async () => {
|
||||||
|
const vault = loadVault();
|
||||||
|
const calls = [];
|
||||||
|
const replacements = [];
|
||||||
|
const resumed = await vault.resume('https://plex.sami/web/?direct=1#home', 'plex', {
|
||||||
|
fetch: async (url, options) => {
|
||||||
|
calls.push({ url, options });
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true, ssoToken: 'one-time-token' }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
location: { replace: (target) => replacements.push(target) },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(resumed, true);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(calls[0].url, '/api/v1/auth/sso-handoff?serviceId=plex');
|
||||||
|
assert.equal(calls[0].options.credentials, 'include');
|
||||||
|
assert.equal(calls[0].options.cache, 'no-store');
|
||||||
|
assert.equal(
|
||||||
|
replacements[0],
|
||||||
|
'https://plex.sami/dashcaddy-sso?token=one-time-token&return=%2Fweb%2F%3Fdirect%3D1%23home',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vault handoff rejects an external return URL before minting a token', async () => {
|
||||||
|
const vault = loadVault();
|
||||||
|
let fetchCalled = false;
|
||||||
|
const resumed = await vault.resume('https://plex.sami.evil.example/phish', 'plex', {
|
||||||
|
fetch: async () => { fetchCalled = true; },
|
||||||
|
location: { replace: () => assert.fail('must not navigate') },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(resumed, false);
|
||||||
|
assert.equal(fetchCalled, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('credential request opens the form and save path calls the tested handoff helper', () => {
|
||||||
|
assert.match(formSource, /params\.get\('credentials'\)/);
|
||||||
|
assert.match(formSource, /openServiceCredsModal\(service, \{ returnUrl \}\)/);
|
||||||
|
assert.match(formSource, /DCCredentialVault\?\.resume\(returnUrl, currentService\.id\)/);
|
||||||
|
assert.match(initSource, /openRequestedCredentialForm\(\)/);
|
||||||
|
assert.match(formSource, /Save to vault & open service/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('actual vault modal save handler stores credentials then resumes the handoff', async () => {
|
||||||
|
const dom = new JSDOM('<!doctype html><body></body>', {
|
||||||
|
url: 'https://status.sami/?credentials=plex&return=https%3A%2F%2Fplex.sami%2Fweb%2F',
|
||||||
|
runScripts: 'outside-only',
|
||||||
|
});
|
||||||
|
const { window } = dom;
|
||||||
|
let stored = false;
|
||||||
|
const writes = [];
|
||||||
|
const resumed = [];
|
||||||
|
window.ErrorHandler = class { logError() {} };
|
||||||
|
window.SITE = { tld: '.sami' };
|
||||||
|
window.APPS = [{ id: 'plex', name: 'Plex', appTemplate: 'plex', url: 'https://plex.sami' }];
|
||||||
|
window.injectModal = (_id, html) => window.document.body.insertAdjacentHTML('beforeend', html);
|
||||||
|
window.fetch = async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
hasApiKey: false,
|
||||||
|
hasBasicAuth: stored,
|
||||||
|
username: stored ? 'vault-user' : null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
window.secureFetch = async (url, options) => {
|
||||||
|
writes.push({ url, body: JSON.parse(options.body) });
|
||||||
|
stored = true;
|
||||||
|
return { ok: true, json: async () => ({ success: true }) };
|
||||||
|
};
|
||||||
|
window.DCCredentialVault = {
|
||||||
|
isAllowedReturnUrl: () => true,
|
||||||
|
resume: async (returnUrl, serviceId) => { resumed.push({ returnUrl, serviceId }); return true; },
|
||||||
|
};
|
||||||
|
window.confirm = () => true;
|
||||||
|
window.eval(formSource);
|
||||||
|
|
||||||
|
await window.openServiceCredsModal(window.APPS[0], { returnUrl: 'https://plex.sami/web/' });
|
||||||
|
window.document.getElementById('svc-basic-user').value = 'vault-user';
|
||||||
|
window.document.getElementById('svc-basic-pass').value = 'vault-password';
|
||||||
|
window.document.getElementById('svc-creds-save').click();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 20));
|
||||||
|
|
||||||
|
assert.deepEqual(writes, [{
|
||||||
|
url: '/api/v1/services/plex/credentials',
|
||||||
|
body: { username: 'vault-user', password: 'vault-password' },
|
||||||
|
}]);
|
||||||
|
assert.deepEqual(resumed, [{ returnUrl: 'https://plex.sami/web/', serviceId: 'plex' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed credential write does not mint a handoff or navigate', async () => {
|
||||||
|
const dom = new JSDOM('<!doctype html><body></body>', {
|
||||||
|
url: 'https://status.sami/',
|
||||||
|
runScripts: 'outside-only',
|
||||||
|
});
|
||||||
|
const { window } = dom;
|
||||||
|
let resumeCalls = 0;
|
||||||
|
window.ErrorHandler = class { logError() {} };
|
||||||
|
window.SITE = { tld: '.sami' };
|
||||||
|
window.injectModal = (_id, html) => window.document.body.insertAdjacentHTML('beforeend', html);
|
||||||
|
window.fetch = async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true, hasApiKey: false, hasBasicAuth: false, username: null }),
|
||||||
|
});
|
||||||
|
window.secureFetch = async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
json: async () => ({ success: false, error: 'vault write rejected' }),
|
||||||
|
});
|
||||||
|
window.DCCredentialVault = {
|
||||||
|
isAllowedReturnUrl: () => true,
|
||||||
|
resume: async () => { resumeCalls++; return true; },
|
||||||
|
};
|
||||||
|
window.confirm = () => true;
|
||||||
|
window.eval(formSource);
|
||||||
|
|
||||||
|
const service = { id: 'plex', name: 'Plex', appTemplate: 'plex', url: 'https://plex.sami' };
|
||||||
|
await window.openServiceCredsModal(service, { returnUrl: 'https://plex.sami/web/' });
|
||||||
|
window.document.getElementById('svc-basic-user').value = 'vault-user';
|
||||||
|
window.document.getElementById('svc-basic-pass').value = 'vault-password';
|
||||||
|
window.document.getElementById('svc-creds-save').click();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 20));
|
||||||
|
|
||||||
|
assert.equal(resumeCalls, 0);
|
||||||
|
assert.match(window.document.getElementById('svc-creds-error').textContent, /vault write rejected/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed ARR credential write does not mint a handoff or navigate', async () => {
|
||||||
|
await exerciseFailedModalWrite({
|
||||||
|
service: { id: 'radarr', name: 'Radarr', appTemplate: 'radarr', url: 'https://radarr.sami' },
|
||||||
|
fetchJson: (url) => url.includes('/services/')
|
||||||
|
? { success: true, hasApiKey: false, hasBasicAuth: false, username: null }
|
||||||
|
: { success: true, profiles: [] },
|
||||||
|
setupInputs: (document) => { document.getElementById('svc-apikey-input').value = 'arr-key'; },
|
||||||
|
expectedEndpoint: '/api/v1/arr/credentials',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed ARR quality-profile write does not mint a handoff or navigate', async () => {
|
||||||
|
await exerciseFailedModalWrite({
|
||||||
|
service: { id: 'radarr', name: 'Radarr', appTemplate: 'radarr', url: 'https://radarr.sami' },
|
||||||
|
fetchJson: (url) => url.includes('/services/')
|
||||||
|
? { success: true, hasApiKey: true, hasBasicAuth: false, username: null }
|
||||||
|
: { success: true, profiles: [{ id: 1, name: 'Default' }], storedProfileId: 1 },
|
||||||
|
setupInputs: () => {},
|
||||||
|
expectedEndpoint: '/api/v1/arr/quality-profiles',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed seedhost write does not mint a handoff or navigate', async () => {
|
||||||
|
await exerciseFailedModalWrite({
|
||||||
|
service: { id: 'torrent', name: 'qBittorrent', isExternal: true, externalUrl: 'https://torrent.sami' },
|
||||||
|
fetchJson: (url) => url.includes('/seedhost-creds')
|
||||||
|
? { success: true, hasCredentials: false, username: null }
|
||||||
|
: { success: true, hasApiKey: false, hasBasicAuth: false, username: null },
|
||||||
|
setupInputs: (document) => {
|
||||||
|
document.getElementById('svc-seedhost-user').value = 'seed-user';
|
||||||
|
document.getElementById('svc-seedhost-pass').value = 'seed-password';
|
||||||
|
},
|
||||||
|
expectedEndpoint: '/api/v1/seedhost-creds',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed generic API-key write does not mint a handoff or navigate', async () => {
|
||||||
|
await exerciseFailedModalWrite({
|
||||||
|
service: { id: 'custom', name: 'Custom', url: 'https://custom.sami' },
|
||||||
|
fetchJson: () => ({ success: true, hasApiKey: false, hasBasicAuth: false, username: null }),
|
||||||
|
setupInputs: (document) => {
|
||||||
|
document.getElementById('svc-apikey-input').value = 'custom-key';
|
||||||
|
document.getElementById('svc-basic-user').value = 'user';
|
||||||
|
document.getElementById('svc-basic-pass').value = 'password';
|
||||||
|
},
|
||||||
|
expectedEndpoint: '/api/v1/services/custom/credentials',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HTTP 2xx with malformed JSON does not mint a handoff or navigate', async () => {
|
||||||
|
await exerciseFailedModalWrite({
|
||||||
|
service: { id: 'plex', name: 'Plex', appTemplate: 'plex', url: 'https://plex.sami' },
|
||||||
|
fetchJson: () => ({ success: true, hasApiKey: false, hasBasicAuth: false, username: null }),
|
||||||
|
setupInputs: (document) => {
|
||||||
|
document.getElementById('svc-basic-user').value = 'user';
|
||||||
|
document.getElementById('svc-basic-pass').value = 'password';
|
||||||
|
},
|
||||||
|
expectedEndpoint: '/api/v1/services/plex/credentials',
|
||||||
|
writeResponse: { ok: true, status: 200, json: async () => { throw new Error('bad json'); } },
|
||||||
|
expectedError: /invalid server response/,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HTTP 2xx without success:true does not mint a handoff or navigate', async () => {
|
||||||
|
await exerciseFailedModalWrite({
|
||||||
|
service: { id: 'plex', name: 'Plex', appTemplate: 'plex', url: 'https://plex.sami' },
|
||||||
|
fetchJson: () => ({ success: true, hasApiKey: false, hasBasicAuth: false, username: null }),
|
||||||
|
setupInputs: (document) => {
|
||||||
|
document.getElementById('svc-basic-user').value = 'user';
|
||||||
|
document.getElementById('svc-basic-pass').value = 'password';
|
||||||
|
},
|
||||||
|
expectedEndpoint: '/api/v1/services/plex/credentials',
|
||||||
|
writeResponse: { ok: true, status: 200, json: async () => ({ message: 'ambiguous' }) },
|
||||||
|
expectedError: /failed \(200\)/,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed credential clear remains visibly failed and keeps stored-state UI', async () => {
|
||||||
|
const dom = new JSDOM('<!doctype html><body><button id="creds-btn-plex" class="has-creds"></button></body>', {
|
||||||
|
url: 'https://status.sami/',
|
||||||
|
runScripts: 'outside-only',
|
||||||
|
});
|
||||||
|
const { window } = dom;
|
||||||
|
window.ErrorHandler = class { logError() {} };
|
||||||
|
window.SITE = { tld: '.sami' };
|
||||||
|
window.injectModal = (_id, html) => window.document.body.insertAdjacentHTML('beforeend', html);
|
||||||
|
window.fetch = async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true, hasApiKey: false, hasBasicAuth: true, username: 'vault-user' }),
|
||||||
|
});
|
||||||
|
window.secureFetch = async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
json: async () => ({ success: false, error: 'clear rejected' }),
|
||||||
|
});
|
||||||
|
window.DCCredentialVault = { isAllowedReturnUrl: () => false };
|
||||||
|
window.confirm = () => true;
|
||||||
|
window.eval(formSource);
|
||||||
|
|
||||||
|
const service = { id: 'plex', name: 'Plex', appTemplate: 'plex', url: 'https://plex.sami' };
|
||||||
|
await window.openServiceCredsModal(service);
|
||||||
|
window.document.getElementById('svc-creds-clear').click();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 20));
|
||||||
|
|
||||||
|
assert.match(window.document.getElementById('svc-creds-error').textContent, /clear rejected/);
|
||||||
|
assert.equal(window.document.getElementById('creds-btn-plex').classList.contains('has-creds'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handoff rejects a private-TLD host that is not the requested protected service', async () => {
|
||||||
|
const vault = loadVault();
|
||||||
|
let fetchCalled = false;
|
||||||
|
const resumed = await vault.resume('https://dns1.sami/', 'plex', {
|
||||||
|
fetch: async () => { fetchCalled = true; },
|
||||||
|
location: { replace: () => assert.fail('must not navigate') },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(resumed, false);
|
||||||
|
assert.equal(fetchCalled, false);
|
||||||
|
});
|
||||||
@@ -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