mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 02:12:59 +00:00
e175db8e62
* feat(auth): add SSO-only authentication mode Let administrators disable interactive local password login when SSO is configured, with backend enforcement, activation safeguards, and host CLI recovery. Closes #1709 * fix: resolve CI failures in auth mode PR - Add useLicense mock to SSOSection test to prevent crash from AuthenticationModePanel rendering without LicenseProvider - Remove username from authMode console.log calls that CodeQL flags as clear-text logging of sensitive information * fix(auth): keep SSO-only on named disableSso and fail-closed login Named provider disable no longer reverts authentication_mode. Login initializes localLoginEnabled false so a status fetch failure cannot reveal the password form. Center a single OIDC provider button on the login card. * fix(auth): move SSO-only authentication mode from Admiral to Community tier Security-hardening features belong on the Community tier per the existing Community rebalance. The reporter of #1709 noted that disabling local password login after configuring SSO is a basic security measure, not an enterprise governance feature. LDAP provider configuration remains Admiral-gated via requireTierForSsoProvider. * fix(ui): keep SSO Active badge and ON toggle in sync Provider cards mounted before config fetch finished with enabled:false, so a saved Active provider showed OFF until the local draft was resynced. Drive both the badge and TogglePill from the synced local config. * feat(auth): auto-redirect to sole OIDC provider under SSO-only When authentication mode is SSO only and exactly one OIDC provider is enabled (no LDAP), skip the login chooser and send the browser to that provider's authorize URL. Returning sso_error stays on the login page so the failure message remains visible. * fix(ui): move oidcAutoRedirectUrl out of Login for fast refresh Exporting the helper alongside the Login component tripped react-refresh/only-export-components and failed Frontend lint CI. Keep Login as a component-only module and colocate the helper with its unit tests under lib/.
172 lines
6.4 KiB
TypeScript
172 lines
6.4 KiB
TypeScript
/**
|
|
* Tests for authentication: login, rate limiting, and auth middleware.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
import request from 'supertest';
|
|
import jwt from 'jsonwebtoken';
|
|
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_PASSWORD, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
|
|
|
let tmpDir: string;
|
|
let app: import('express').Express;
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
({ app } = await import('../index'));
|
|
});
|
|
|
|
afterAll(() => {
|
|
cleanupTestDb(tmpDir);
|
|
});
|
|
|
|
// ─── Login ───────────────────────────────────────────────────────────────────
|
|
|
|
describe('POST /api/auth/login', () => {
|
|
it('returns 200 and sets a cookie on valid credentials', async () => {
|
|
const res = await request(app)
|
|
.post('/api/auth/login')
|
|
.send({ username: TEST_USERNAME, password: TEST_PASSWORD });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.success).toBe(true);
|
|
expect(res.headers['set-cookie']).toBeDefined();
|
|
});
|
|
|
|
it('returns 401 on wrong password', async () => {
|
|
const res = await request(app)
|
|
.post('/api/auth/login')
|
|
.send({ username: TEST_USERNAME, password: 'wrong-password' });
|
|
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('returns 401 on unknown username', async () => {
|
|
const res = await request(app)
|
|
.post('/api/auth/login')
|
|
.send({ username: 'nobody', password: 'anything' });
|
|
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('returns 400 when credentials are missing', async () => {
|
|
const res = await request(app).post('/api/auth/login').send({});
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('returns 403 when authentication_mode is sso_only', async () => {
|
|
const { setAuthenticationMode } = await import('../helpers/authenticationMode');
|
|
setAuthenticationMode('sso_only');
|
|
try {
|
|
const res = await request(app)
|
|
.post('/api/auth/login')
|
|
.send({ username: TEST_USERNAME, password: TEST_PASSWORD });
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.error).toMatch(/Local password authentication is disabled/i);
|
|
} finally {
|
|
setAuthenticationMode('local_and_sso');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('GET /api/auth/status', () => {
|
|
it('reports localLoginEnabled true by default', async () => {
|
|
const res = await request(app).get('/api/auth/status');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.localLoginEnabled).toBe(true);
|
|
expect(res.body.authenticationMode).toBe('local_and_sso');
|
|
});
|
|
|
|
it('reports localLoginEnabled false when sso_only', async () => {
|
|
const { setAuthenticationMode } = await import('../helpers/authenticationMode');
|
|
setAuthenticationMode('sso_only');
|
|
try {
|
|
const res = await request(app).get('/api/auth/status');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.localLoginEnabled).toBe(false);
|
|
expect(res.body.authenticationMode).toBe('sso_only');
|
|
} finally {
|
|
setAuthenticationMode('local_and_sso');
|
|
}
|
|
});
|
|
|
|
it('defaults localLoginEnabled to true when the setting key is missing', async () => {
|
|
const { DatabaseService } = await import('../services/DatabaseService');
|
|
const db = DatabaseService.getInstance();
|
|
db.getDb().prepare('DELETE FROM global_settings WHERE key = ?').run('authentication_mode');
|
|
// Bust the settings cache so the next read rebuilds without the deleted key.
|
|
const cpu = db.getDb().prepare('SELECT value FROM global_settings WHERE key = ?').get('host_cpu_limit') as { value: string };
|
|
db.updateGlobalSetting('host_cpu_limit', cpu.value);
|
|
const res = await request(app).get('/api/auth/status');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.localLoginEnabled).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─── Auth middleware ──────────────────────────────────────────────────────────
|
|
|
|
describe('authMiddleware', () => {
|
|
it('rejects requests with no token (401)', async () => {
|
|
const res = await request(app).get('/api/stacks');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('rejects requests with an invalid token (401)', async () => {
|
|
const res = await request(app)
|
|
.get('/api/stacks')
|
|
.set('Authorization', 'Bearer this.is.not.valid');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('accepts a valid Bearer token', async () => {
|
|
// Issue a real token using the known test secret
|
|
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
|
const res = await request(app)
|
|
.get('/api/stacks')
|
|
.set('Authorization', `Bearer ${token}`);
|
|
// Will succeed (200) or fail with a docker/fs error (500) - but NOT 401
|
|
expect(res.status).not.toBe(401);
|
|
});
|
|
|
|
it('accepts a valid cookie token', async () => {
|
|
// First login to get the cookie
|
|
const loginRes = await request(app)
|
|
.post('/api/auth/login')
|
|
.send({ username: TEST_USERNAME, password: TEST_PASSWORD });
|
|
const cookies = loginRes.headers['set-cookie'] as string | string[];
|
|
const cookieHeader = Array.isArray(cookies) ? cookies[0] : cookies;
|
|
|
|
const res = await request(app)
|
|
.get('/api/stacks')
|
|
.set('Cookie', cookieHeader);
|
|
expect(res.status).not.toBe(401);
|
|
});
|
|
});
|
|
|
|
// ─── Protected endpoint: console-token ───────────────────────────────────────
|
|
|
|
describe('POST /api/system/console-token', () => {
|
|
// Console-token requires the paid tier — mock LicenseService for the happy-path test
|
|
beforeAll(async () => {
|
|
const { LicenseService } = await import('../services/LicenseService');
|
|
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
|
});
|
|
|
|
afterAll(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('returns 401 without authentication (was a security bug - C1 fix)', async () => {
|
|
const res = await request(app).post('/api/system/console-token');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('returns a token when authenticated', async () => {
|
|
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
|
const res = await request(app)
|
|
.post('/api/system/console-token')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send({ path: 'host-console' });
|
|
expect(res.status).toBe(200);
|
|
expect(typeof res.body.token).toBe('string');
|
|
});
|
|
});
|