Files
UNITRONIX 59b3f93d46 fix(oidc): proxy panel callback so RustDesk SSO leaves Waiting (#304)
Panel Redirect URLs on :5000/:5443 used to 404, so auth-query never completed. Proxy the callback to Go, validate the path in Settings, and document the fix.
2026-07-25 08:43:16 +02:00

387 lines
15 KiB
JavaScript

/**
* BetterDesk Console - Auth Routes Tests
*/
const request = require('supertest');
const { createTestApp } = require('./helpers');
// Mock dependencies before requiring routes
jest.mock('../services/database', () => ({
logAction: jest.fn().mockResolvedValue(undefined),
getUser: jest.fn().mockResolvedValue(null),
getUserById: jest.fn().mockResolvedValue({ id: 1, username: 'admin', role: 'admin', password_hash: 'hash', totp_secret: 'SECRET' }),
getUserByUsername: jest.fn().mockResolvedValue(null),
createUser: jest.fn().mockResolvedValue(undefined),
syncUserFromGo: jest.fn().mockResolvedValue(undefined),
enableTotp: jest.fn().mockResolvedValue(undefined),
disableTotp: jest.fn().mockResolvedValue(undefined)
}));
jest.mock('../services/authService', () => ({
authenticate: jest.fn().mockResolvedValue(null),
isAuthFailure: jest.fn((result) => !!(result && result.__authFailure)),
changePassword: jest.fn().mockResolvedValue({ success: true }),
hashPassword: jest.fn().mockResolvedValue('hashed'),
verifyPassword: jest.fn().mockResolvedValue(true),
verifyAndEnableTotp: jest.fn().mockResolvedValue({ success: true, recoveryCodes: ['CODE1', 'CODE2'] }),
disableTotp: jest.fn().mockResolvedValue({ success: true }),
isTotpEnabled: jest.fn().mockResolvedValue(false),
generateTotpSetup: jest.fn().mockResolvedValue({ success: true, qrCode: 'qr', secret: 'SECRET', otpauthUrl: 'otpauth://totp/test' }),
generateRecoveryCodes: jest.fn().mockReturnValue(['CODE1', 'CODE2']),
recordAttempt: jest.fn().mockResolvedValue(undefined)
}));
jest.mock('../services/userSync', () => ({
mirrorUpdate: jest.fn().mockResolvedValue(undefined),
mirrorTotpEnable: jest.fn().mockResolvedValue(undefined),
mirrorTotpDisable: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../middleware/rateLimiter', () => ({
loginLimiter: (_req, _res, next) => next(),
passwordChangeLimiter: (_req, _res, next) => next(),
apiLimiter: (_req, _res, next) => next()
}));
jest.mock('../services/betterdeskApi', () => ({
exchangeOIDCCode: jest.fn(),
getOIDCStatus: jest.fn().mockResolvedValue({ success: true, data: { enabled: false } }),
startOIDCAuthorize: jest.fn(),
proxyOIDCCallback: jest.fn(),
}));
const authService = require('../services/authService');
const db = require('../services/database');
const userSync = require('../services/userSync');
const betterdeskApi = require('../services/betterdeskApi');
const authRoutes = require('../routes/auth.routes');
describe('Auth Routes', () => {
let app;
beforeEach(() => {
app = createTestApp();
app.use('/', authRoutes);
jest.clearAllMocks();
});
function authenticatedApp(user = { id: 1, username: 'admin', role: 'admin' }) {
const authed = createTestApp();
authed.use((req, _res, next) => {
req.session.userId = user.id;
req.session.user = user;
next();
});
authed.use('/', authRoutes);
return authed;
}
describe('POST /api/auth/login', () => {
it('should return 400 when username is missing', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ password: 'test123' });
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
it('should return 400 when password is missing', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'admin' });
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
it('should return 400 when username exceeds 128 chars', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'a'.repeat(129), password: 'test123' });
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
it('should return 401 when credentials are invalid', async () => {
authService.authenticate.mockResolvedValue(null);
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'admin', password: 'wrong' });
expect(res.status).toBe(401);
expect(res.body.success).toBe(false);
expect(db.logAction).toHaveBeenCalledWith(
null, 'login_failed', expect.stringContaining('admin'), expect.anything()
);
});
it('should return 409 when local/SSO username collision is detected', async () => {
authService.authenticate.mockResolvedValue({ __authFailure: 'username_collision' });
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'domainuser', password: 'ad-pass' });
expect(res.status).toBe(409);
expect(res.body.success).toBe(false);
expect(res.body.code).toBe('username_collision');
expect(db.logAction).toHaveBeenCalledWith(
null, 'login_failed', expect.stringContaining('collision'), expect.anything()
);
});
it('should return 200 with user on valid login', async () => {
authService.authenticate.mockResolvedValue({
id: 1,
username: 'admin',
role: 'admin'
});
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'admin', password: 'correct' });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.user).toBeDefined();
expect(res.body.user.username).toBe('admin');
});
it('should return totpRequired when 2FA is enabled', async () => {
authService.authenticate.mockResolvedValue({
id: 1,
username: 'admin',
role: 'admin',
totpRequired: true
});
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'admin', password: 'correct' });
expect(res.status).toBe(200);
expect(res.body.totpRequired).toBe(true);
});
});
describe('POST /api/auth/logout', () => {
it('should destroy session on logout', async () => {
// Set up auth
app.use((req, _res, next) => {
req.session.userId = 1;
req.session.user = { id: 1, username: 'admin', role: 'admin' };
next();
});
// Re-mount routes after auth middleware
const logoutApp = createTestApp();
logoutApp.use((req, _res, next) => {
req.session.userId = 1;
req.session.user = { id: 1, username: 'admin', role: 'admin' };
next();
});
logoutApp.use('/', authRoutes);
const res = await request(logoutApp)
.post('/api/auth/logout');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
});
describe('POST /api/auth/password', () => {
it('mirrors successful self-service password changes to Go', async () => {
const res = await request(authenticatedApp())
.post('/api/auth/password')
.send({
currentPassword: 'old-password',
newPassword: 'new-password-123',
confirmPassword: 'new-password-123'
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(userSync.mirrorUpdate).toHaveBeenCalledWith('admin', { password: 'new-password-123' });
});
it('does not fail password changes when Go mirror is unavailable', async () => {
userSync.mirrorUpdate.mockRejectedValueOnce(new Error('go offline'));
const res = await request(authenticatedApp())
.post('/api/auth/password')
.send({
currentPassword: 'old-password',
newPassword: 'new-password-123',
confirmPassword: 'new-password-123'
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
});
describe('POST /api/auth/totp/enable', () => {
it('mirrors enabled TOTP state to Go without issuing tokens', async () => {
const res = await request(authenticatedApp())
.post('/api/auth/totp/enable')
.send({ code: '123456' });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(userSync.mirrorTotpEnable).toHaveBeenCalledWith('admin', { secret: 'SECRET' });
});
});
describe('POST /api/auth/totp/disable', () => {
it('mirrors disabled TOTP state to Go after password verification', async () => {
const res = await request(authenticatedApp())
.post('/api/auth/totp/disable')
.send({ password: 'current-password' });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(authService.verifyPassword).toHaveBeenCalledWith('current-password', expect.anything());
expect(userSync.mirrorTotpDisable).toHaveBeenCalledWith('admin');
});
});
describe('GET /api/auth/oidc/authorize', () => {
it('redirects the browser to the IdP URL from Go (not localhost API)', async () => {
const idpUrl = 'https://idp.example.com/oauth/authorize?client_id=abc&redirect_uri=https%3A%2F%2Fdomain.com%2Fapi%2Fauth%2Foidc%2Fcallback';
betterdeskApi.startOIDCAuthorize.mockResolvedValue({
success: true,
data: { auth_url: idpUrl },
});
const res = await request(app).get('/api/auth/oidc/authorize?return_url=%2Fdashboard');
expect(res.status).toBe(302);
expect(res.headers.location).toBe(idpUrl);
expect(res.headers.location).not.toMatch(/localhost|127\.0\.0\.1/);
expect(betterdeskApi.startOIDCAuthorize).toHaveBeenCalledWith('/dashboard');
});
it('sanitizes unsafe return_url before calling Go', async () => {
betterdeskApi.startOIDCAuthorize.mockResolvedValue({
success: true,
data: { auth_url: 'https://idp.example.com/auth' },
});
await request(app).get('/api/auth/oidc/authorize?return_url=https://evil.example');
expect(betterdeskApi.startOIDCAuthorize).toHaveBeenCalledWith('/');
});
it('redirects to oidc_error when Go authorize fails', async () => {
betterdeskApi.startOIDCAuthorize.mockResolvedValue({
success: false,
error: 'OIDC is not enabled',
});
const res = await request(app).get('/api/auth/oidc/authorize');
expect(res.status).toBe(302);
expect(res.headers.location).toBe('/login?error=oidc_error');
});
it('redirects to oidc_error when auth_url is missing', async () => {
betterdeskApi.startOIDCAuthorize.mockResolvedValue({ success: true, data: {} });
const res = await request(app).get('/api/auth/oidc/authorize');
expect(res.status).toBe(302);
expect(res.headers.location).toBe('/login?error=oidc_error');
});
});
describe('GET /api/auth/oidc/callback (panel → Go proxy, #304)', () => {
it('proxies IdP callback query to Go', async () => {
betterdeskApi.proxyOIDCCallback.mockImplementation(async (req, res) => {
res.status(200).type('html').send('<html>Sign-in successful</html>');
});
const res = await request(app).get('/api/auth/oidc/callback?code=abc&state=xyz');
expect(res.status).toBe(200);
expect(res.text).toContain('Sign-in successful');
expect(betterdeskApi.proxyOIDCCallback).toHaveBeenCalledTimes(1);
const proxiedReq = betterdeskApi.proxyOIDCCallback.mock.calls[0][0];
expect(proxiedReq.query).toEqual({ code: 'abc', state: 'xyz' });
});
it('also exposes /api/oidc/callback alias', async () => {
betterdeskApi.proxyOIDCCallback.mockImplementation(async (_req, res) => {
res.status(302).set('Location', '/api/auth/oidc/session?code=one-time').end();
});
const res = await request(app).get('/api/oidc/callback?code=c&state=s');
expect(res.status).toBe(302);
expect(res.headers.location).toBe('/api/auth/oidc/session?code=one-time');
expect(betterdeskApi.proxyOIDCCallback).toHaveBeenCalled();
});
});
describe('GET /api/auth/oidc/session', () => {
it('redirects to oidc_invalid when code is missing', async () => {
const res = await request(app).get('/api/auth/oidc/session');
expect(res.status).toBe(302);
expect(res.headers.location).toBe('/login?error=oidc_invalid');
expect(betterdeskApi.exchangeOIDCCode).not.toHaveBeenCalled();
});
it('redirects to oidc_invalid when Go exchange fails', async () => {
betterdeskApi.exchangeOIDCCode.mockResolvedValue({ success: false, error: 'invalid or expired code' });
const res = await request(app).get('/api/auth/oidc/session?code=bad-code');
expect(res.status).toBe(302);
expect(res.headers.location).toBe('/login?error=oidc_invalid');
expect(betterdeskApi.exchangeOIDCCode).toHaveBeenCalledWith('bad-code');
});
it('creates an OIDC session and redirects to the safe return URL', async () => {
betterdeskApi.exchangeOIDCCode.mockResolvedValue({
success: true,
data: {
token: 'go-jwt-token',
username: 'sso-user',
role: 'operator',
return_url: '/dashboard',
},
});
db.getUserByUsername.mockResolvedValue({
id: 42,
username: 'sso-user',
role: 'viewer',
preferred_language: null,
});
const agent = request.agent(app);
const res = await agent.get('/api/auth/oidc/session?code=valid-code');
expect(res.status).toBe(302);
expect(res.headers.location).toBe('/dashboard');
expect(betterdeskApi.exchangeOIDCCode).toHaveBeenCalledWith('valid-code');
expect(db.syncUserFromGo).toHaveBeenCalledWith(42, {
role: 'operator',
authProvider: 'oidc',
});
const verify = await agent.get('/api/auth/verify');
expect(verify.status).toBe(200);
expect(verify.body.success).toBe(true);
expect(verify.body.user).toEqual({
id: 42,
username: 'sso-user',
role: 'operator',
preferred_language: null,
});
});
});
});