diff --git a/dashcaddy-api/__tests__/routes/auth.totp.routes.test.js b/dashcaddy-api/__tests__/routes/auth.totp.routes.test.js index d0f6643..84940af 100644 --- a/dashcaddy-api/__tests__/routes/auth.totp.routes.test.js +++ b/dashcaddy-api/__tests__/routes/auth.totp.routes.test.js @@ -112,6 +112,7 @@ function createApp(depsOverride = {}) { errorResponse: jest.fn(), log, renewCSRFToken, + siteConfig: { tld: '.sami', dashboardHost: 'status.sami' }, ...depsOverride, }; @@ -299,7 +300,7 @@ describe('TOTP Auth Routes — DC-006 Integration Test', () => { it('returns 200 + creates new session + rotates CSRF on valid code (BACKLOG: "valid TOTP → session token → authenticated request succeeds")', async () => { const secret = await setupTOTP(); const token = authenticator.generate(secret); - const res = await request(app).post('/api/totp/verify').send({ code: token }); + const res = await request(app).post('/api/totp/verify').send({ code: token, serviceId: 'plex' }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(res.body.message).toMatch(/Authenticated successfully/); @@ -308,8 +309,29 @@ describe('TOTP Auth Routes — DC-006 Integration Test', () => { expect(deps.session.create).toHaveBeenCalled(); expect(deps.session.setCookie).toHaveBeenCalled(); expect(deps.session.createHandoffToken).toHaveBeenCalledTimes(1); + expect(deps.session.createHandoffToken).toHaveBeenCalledWith('plex.sami'); expect(deps.renewCSRFToken).toHaveBeenCalled(); }); + + it('does not issue an unbound handoff token for a dashboard-only login', async () => { + const secret = await setupTOTP(); + const token = authenticator.generate(secret); + const res = await request(app).post('/api/totp/verify').send({ code: token }); + + expect(res.status).toBe(200); + expect(res.body.ssoToken).toBeNull(); + expect(deps.session.createHandoffToken).not.toHaveBeenCalled(); + }); + + it('rejects an invalid handoff service ID before issuing a token', async () => { + const secret = await setupTOTP(); + const token = authenticator.generate(secret); + const res = await request(app).post('/api/totp/verify').send({ code: token, serviceId: 'plex.sami' }); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Invalid service ID/); + expect(deps.session.createHandoffToken).not.toHaveBeenCalled(); + }); }); // ──────────────────────────────────────────────────────────────────── @@ -450,7 +472,7 @@ describe('TOTP Auth Routes — DC-006 Integration Test', () => { // 4. Re-login via /totp/verify (the "login" path) const loginCode = authenticator.generate(secret); - const loginRes = await request(app).post('/api/totp/verify').send({ code: loginCode }); + const loginRes = await request(app).post('/api/totp/verify').send({ code: loginCode, serviceId: 'plex' }); expect(loginRes.status).toBe(200); expect(loginRes.body.csrfToken).toBeDefined(); expect(loginRes.body.ssoToken).toBe('mock-sso-handoff-token'); diff --git a/dashcaddy-api/__tests__/routes/services.routes.test.js b/dashcaddy-api/__tests__/routes/services.routes.test.js index 9b88a77..f226253 100644 --- a/dashcaddy-api/__tests__/routes/services.routes.test.js +++ b/dashcaddy-api/__tests__/routes/services.routes.test.js @@ -288,6 +288,21 @@ describe('Services Routes', () => { expect(res.status).toBe(200); expect(res.body.hasApiKey).toBe(true); }); + + it('requires both username and password before reporting Basic Auth ready', async () => { + const credentialManager = { + store: jest.fn(), + retrieve: jest.fn().mockImplementation((key) => { + if (key === 'service.radarr.username') return Promise.resolve('admin'); + return Promise.resolve(null); + }), + delete: jest.fn(), + }; + const { app } = createApp({ credentialManager }); + const res = await request(app).get('/api/services/radarr/credentials'); + expect(res.status).toBe(200); + expect(res.body.hasBasicAuth).toBe(false); + }); }); // ===== SEEDHOST CREDENTIAL ENDPOINTS ===== diff --git a/dashcaddy-api/__tests__/session-cookie-scope.test.js b/dashcaddy-api/__tests__/session-cookie-scope.test.js index 0fbbfbe..4eac220 100644 --- a/dashcaddy-api/__tests__/session-cookie-scope.test.js +++ b/dashcaddy-api/__tests__/session-cookie-scope.test.js @@ -50,6 +50,17 @@ describe('TOTP session cookie scope', () => { expect(cookie).not.toMatch(/(?:^|;)\s*Domain=/i); }); + test('host-bound SSO token can only be redeemed on its intended service host', () => { + const session = buildSession(); + const wrongHostToken = session.createHandoffToken('plex.sami'); + expect(session.redeemHandoffToken(wrongHostToken, 'chat.sami')).toBe(false); + expect(session.redeemHandoffToken(wrongHostToken, 'plex.sami')).toBe(false); + + const correctHostToken = session.createHandoffToken('plex.sami'); + expect(session.redeemHandoffToken(correctHostToken, 'plex.sami')).toBe(true); + expect(session.redeemHandoffToken(correctHostToken, 'plex.sami')).toBe(false); + }); + test('logout clears the host-only secure cookie', () => { const session = buildSession(); const headers = {}; diff --git a/dashcaddy-api/__tests__/sso-handoff-exchange.test.js b/dashcaddy-api/__tests__/sso-handoff-exchange.test.js index 3a95a1d..365dc6d 100644 --- a/dashcaddy-api/__tests__/sso-handoff-exchange.test.js +++ b/dashcaddy-api/__tests__/sso-handoff-exchange.test.js @@ -1,8 +1,21 @@ const express = require('express'); +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); const request = require('supertest'); const createSsoRouter = require('../routes/auth/sso-gate'); -function createApp({ redeem = true, 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 session = { 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() }, getAppSession: jest.fn(), appSessionCache: new Map(), - credentialManager: { retrieve: jest.fn() }, + credentialManager: { retrieve: jest.fn((key) => Promise.resolve(storedCredentials[key] || null)) }, fetchT: jest.fn(), - getServiceById: jest.fn(), + getServiceById: jest.fn((id) => Promise.resolve({ id, url: `https://${id}.sami` })), licenseManager: { hasFeature: jest.fn().mockReturnValue(true), requirePremium: jest.fn(() => (_req, _res, next) => next()), }, servicesStateManager: { read: jest.fn().mockResolvedValue([]) }, + siteConfig: { dashboardHost }, }); app.use('/api/v1', router); return { app, session }; @@ -45,7 +59,7 @@ describe('cross-host SSO exchange redirect', () => { expect(res.status).toBe(303); expect(res.headers.location).toBe('/settings?tab=network#dns'); expect(res.headers['set-cookie'][0]).not.toMatch(/Domain=/i); - expect(session.redeemHandoffToken).toHaveBeenCalledWith('one-time'); + expect(session.redeemHandoffToken).toHaveBeenCalledWith('one-time', '127.0.0.1'); }); test.each([ @@ -88,17 +102,18 @@ describe('existing-session SSO handoff', () => { test('mints a handoff token without asking for TOTP again', async () => { const { app, session } = createApp(); const res = await request(app) - .get('/api/v1/auth/sso-handoff') + .get('/api/v1/auth/sso-handoff?serviceId=plex') .set('Cookie', 'dashcaddy_session=valid-session'); expect(res.status).toBe(200); expect(res.body).toMatchObject({ success: true, ssoToken: 'fresh-sso-handoff-token' }); expect(session.createHandoffToken).toHaveBeenCalledTimes(1); + expect(session.createHandoffToken).toHaveBeenCalledWith('plex.sami'); }); test('refuses to mint a handoff token without a valid session', async () => { const { app, session } = createApp({ valid: false }); - const res = await request(app).get('/api/v1/auth/sso-handoff'); + const res = await request(app).get('/api/v1/auth/sso-handoff?serviceId=plex'); expect(res.status).toBe(401); expect(session.createHandoffToken).not.toHaveBeenCalled(); @@ -110,7 +125,7 @@ describe('existing-session SSO handoff', () => { const { app } = createApp({ redeem: redeemOnce }); 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'); const exchange = await request(app) .get('/api/v1/auth/sso-exchange') @@ -127,3 +142,60 @@ describe('existing-session SSO handoff', () => { 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); + }); +}); diff --git a/dashcaddy-api/routes/auth/sso-gate.js b/dashcaddy-api/routes/auth/sso-gate.js index 2f14a17..00c48b2 100644 --- a/dashcaddy-api/routes/auth/sso-gate.js +++ b/dashcaddy-api/routes/auth/sso-gate.js @@ -12,7 +12,7 @@ module.exports = function(deps) { const router = express.Router(); // Extract dependencies - const { authManager, totpConfig, session, asyncHandler, errorResponse, log, getAppSession, appSessionCache, credentialManager, fetchT, getServiceById, licenseManager, servicesStateManager } = deps; + const { authManager, totpConfig, session, asyncHandler, errorResponse, log, getAppSession, appSessionCache, credentialManager, fetchT, getServiceById, licenseManager, servicesStateManager, siteConfig } = deps; // Create ctx-like object for compatibility const ctx = { @@ -126,7 +126,12 @@ module.exports = function(deps) { try { const username = await ctx.credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null); const password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null); - if (!username || !password) throw new NotFoundError('[DC-500] No credentials stored'); + if (!username || !password) { + return errorResponse(res, 428, '[DC-500] No credentials stored', { + credentialsRequired: true, + serviceId, + }); + } const service = await ctx.getServiceById(serviceId); const baseUrl = service?.url; if (!baseUrl) throw new NotFoundError('No service URL'); @@ -181,7 +186,12 @@ module.exports = function(deps) { password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null); } - if (!username || !password) throw new NotFoundError('[DC-500] No credentials stored'); + if (!username || !password) { + return errorResponse(res, 428, '[DC-500] No credentials stored', { + credentialsRequired: true, + serviceId, + }); + } const appCookies = await getAppSession(serviceId, baseUrl, username, password); if (appCookies) { @@ -213,7 +223,13 @@ module.exports = function(deps) { if (!session.isValid(req)) { 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 @@ -229,7 +245,9 @@ module.exports = function(deps) { router.get('/auth/sso-exchange', (req, res) => { res.setHeader('Cache-Control', 'no-store'); const token = req.query.token; - if (!session.redeemHandoffToken(token)) { + const forwardedHost = String(req.headers['x-forwarded-host'] || req.headers.host || '') + .split(',')[0].trim().replace(/:\d+$/, '').toLowerCase(); + if (!session.redeemHandoffToken(token, forwardedHost)) { return errorResponse(res, 401, 'Invalid or expired handoff token'); } session.setCookieHostOnly(res, totpConfig.sessionDuration); @@ -251,7 +269,12 @@ module.exports = function(deps) { // Serve service-specific auto-login page (auth enforced by Caddy forward_auth upstream) router.get('/auth/login-page', (req, res) => { const service = (req.query.service || '').replace(/[^a-z]/g, ''); - const html = buildLoginPage(service); + const configuredHost = siteConfig?.dashboardHost; + const dashboardOrigin = typeof configuredHost === 'string' + && /^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(configuredHost) + ? `https://${configuredHost}` + : 'https://status.sami'; + const html = buildLoginPage(service, dashboardOrigin); if (!html) return res.status(404).send('Unknown service'); res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.setHeader('Cache-Control', 'no-store'); @@ -269,7 +292,7 @@ module.exports = function(deps) { return router; }; -function buildLoginPage(service) { +function buildLoginPage(service, dashboardOrigin = 'https://status.sami') { // Pre-auth check via so it fires even when JS is // disabled or blocked. The cookie is sent automatically because we hit the // same origin (plex.sami); if the API returns 200 the user has a valid @@ -280,7 +303,7 @@ function buildLoginPage(service) {
__TITLE__