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 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)), setCookieHostOnly: jest.fn((res) => { res.setHeader('Set-Cookie', 'dashcaddy_session=test; Path=/; HttpOnly; Secure; SameSite=Lax'); }), isValid: jest.fn().mockReturnValue(valid), createHandoffToken: jest.fn().mockReturnValue('fresh-sso-handoff-token'), }; const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); const errorResponse = (res, status, message, extra = {}) => res.status(status).json({ success: false, error: message, ...extra }); const router = createSsoRouter({ totpConfig: { enabled: true, sessionDuration: '24h' }, session, asyncHandler, errorResponse, log: { warn: jest.fn(), info: jest.fn(), debug: jest.fn(), error: jest.fn() }, getAppSession: jest.fn(), appSessionCache: new Map(), credentialManager: { retrieve: jest.fn((key) => Promise.resolve(storedCredentials[key] || null)) }, fetchT: 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 }; } describe('cross-host SSO exchange redirect', () => { test('sets a host-only cookie and redirects to a relative service path', async () => { const { app, session } = createApp(); const res = await request(app) .get('/api/v1/auth/sso-exchange') .query({ token: 'one-time', return: '/settings?tab=network#dns' }); 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', '127.0.0.1'); }); test.each([ 'https://evil.example/phish', '//evil.example/phish', '/\\evil.example/phish', ])('rejects cross-origin return value %s', async (returnValue) => { const { app } = createApp(); const res = await request(app) .get('/api/v1/auth/sso-exchange') .query({ token: 'one-time', return: returnValue }); expect(res.status).toBe(303); expect(res.headers.location).toBe('/'); }); test('keeps the existing JSON exchange behavior when no return is supplied', async () => { const { app } = createApp(); const res = await request(app) .get('/api/v1/auth/sso-exchange') .query({ token: 'one-time' }); expect(res.status).toBe(200); expect(res.body).toMatchObject({ success: true, authenticated: true }); }); test('does not set a cookie or redirect for an invalid token', async () => { const { app, session } = createApp({ redeem: false }); const res = await request(app) .get('/api/v1/auth/sso-exchange') .query({ token: 'bad', return: '/settings' }); expect(res.status).toBe(401); expect(res.headers['set-cookie']).toBeUndefined(); expect(session.setCookieHostOnly).not.toHaveBeenCalled(); }); }); describe('existing-session SSO handoff', () => { test('mints a handoff token without asking for TOTP again', async () => { const { app, session } = createApp(); const res = await request(app) .get('/api/v1/auth/sso-handoff?serviceId=plex') .set('Cookie', 'dashcaddy_session=valid-session'); expect(res.status).toBe(200); expect(res.body).toMatchObject({ success: true, ssoToken: 'fresh-sso-handoff-token' }); expect(session.createHandoffToken).toHaveBeenCalledTimes(1); expect(session.createHandoffToken).toHaveBeenCalledWith('plex.sami'); }); test('refuses to mint a handoff token without a valid session', async () => { const { app, session } = createApp({ valid: false }); const res = await request(app).get('/api/v1/auth/sso-handoff?serviceId=plex'); expect(res.status).toBe(401); expect(session.createHandoffToken).not.toHaveBeenCalled(); }); test('completes the full mint, exchange, cookie, redirect lifecycle', async () => { const issued = new Set(['fresh-sso-handoff-token']); const redeemOnce = (token) => issued.delete(token); const { app } = createApp({ redeem: redeemOnce }); const mint = await request(app) .get('/api/v1/auth/sso-handoff?serviceId=plex') .set('Cookie', 'dashcaddy_session=valid-session'); const exchange = await request(app) .get('/api/v1/auth/sso-exchange') .query({ token: mint.body.ssoToken, return: '/web/' }); expect(exchange.status).toBe(303); expect(exchange.headers.location).toBe('/web/'); expect(exchange.headers['set-cookie'][0]).toContain('dashcaddy_session='); expect(exchange.headers['set-cookie'][0]).not.toMatch(/Domain=/i); const replay = await request(app) .get('/api/v1/auth/sso-exchange') .query({ token: mint.body.ssoToken, return: '/web/' }); expect(replay.status).toBe(401); }); }); describe('encrypted-vault credential onboarding', () => { test('app-token identifies missing credentials as a form requirement', async () => { const { app } = createApp(); const res = await request(app) .get('/api/v1/auth/app-token/plex') .set('Cookie', 'dashcaddy_session=valid-session'); expect(res.status).toBe(428); expect(res.body).toMatchObject({ success: false, credentialsRequired: true, serviceId: 'plex', }); }); test('service login page sends missing credentials to the encrypted vault form', async () => { const { app } = createApp(); const res = await request(app).get('/api/v1/auth/login-page?service=plex'); expect(res.status).toBe(200); expect(res.text).toContain("if(j.credentialsRequired){vault('plex');return}"); expect(res.text).toContain("dashboardOrigin+'?credentials='"); }); test('service login page derives the vault origin from trusted dashboard config', async () => { const { app } = createApp({ dashboardHost: 'dashboard.home' }); const res = await request(app).get('/api/v1/auth/login-page?service=plex'); expect(res.status).toBe(200); expect(res.text).toContain('dashboardOrigin="https://dashboard.home"'); }); test('full vault-save handoff lifecycle reaches exchange, cookie, and final service path', async () => { const issued = new Set(['fresh-sso-handoff-token']); const { app } = createApp({ redeem: (token) => issued.delete(token) }); const mint = await request(app) .get('/api/v1/auth/sso-handoff?serviceId=plex') .set('Cookie', 'dashcaddy_session=valid-session'); const vault = loadCredentialVaultHandoff(); const target = new URL(vault.buildHandoffTarget( 'https://plex.sami/web/?direct=1#home', mint.body.ssoToken, 'plex', )); // The shared Caddy snippet rewrites /dashcaddy-sso to the canonical API // route while preserving the token and relative return query. const exchange = await request(app).get('/api/v1/auth/sso-exchange' + target.search); expect(target.pathname).toBe('/dashcaddy-sso'); expect(exchange.status).toBe(303); expect(exchange.headers.location).toBe('/web/?direct=1#home'); expect(exchange.headers['set-cookie'][0]).toContain('dashcaddy_session='); expect(exchange.headers['set-cookie'][0]).not.toMatch(/Domain=/i); }); });