[grade=B] fix(auth): generalize cross-host SSO handoff
Codex deployment review: urn:ump:sufisot7ewy33mhjude3ly6wxcjizagt42ywaicwve6qufqdtvbq Caddy path-order correction: urn:ump:o6apvvpvhynkouii4cl5ghxpprrwtilrg2dejdy2lqsupoktc6tq
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const createSsoRouter = require('../routes/auth/sso-gate');
|
||||
|
||||
function createApp({ redeem = true } = {}) {
|
||||
const app = express();
|
||||
const session = {
|
||||
redeemHandoffToken: jest.fn().mockReturnValue(redeem),
|
||||
setCookieHostOnly: jest.fn((res) => {
|
||||
res.setHeader('Set-Cookie', 'dashcaddy_session=test; Path=/; HttpOnly; Secure; SameSite=Lax');
|
||||
}),
|
||||
isValid: jest.fn().mockReturnValue(true),
|
||||
};
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user