Files
dashcaddy/dashcaddy-api/__tests__/sso-handoff-exchange.test.js
T
Hermes d313b1e872
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
[grade=B] fix(auth): reuse valid session for cross-host SSO
2026-08-22 04:06:27 -07:00

130 lines
5.0 KiB
JavaScript

const express = require('express');
const request = require('supertest');
const createSsoRouter = require('../routes/auth/sso-gate');
function createApp({ redeem = true, valid = true } = {}) {
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() },
fetchT: jest.fn(),
getServiceById: jest.fn(),
licenseManager: {
hasFeature: jest.fn().mockReturnValue(true),
requirePremium: jest.fn(() => (_req, _res, next) => next()),
},
servicesStateManager: { read: jest.fn().mockResolvedValue([]) },
});
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');
});
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')
.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);
});
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');
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')
.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);
});
});