feat: SSO & LDAP authentication for Team Pro (#209)

* feat: SSO & LDAP authentication for Team Pro

Add SSO integration allowing Team Pro users to authenticate via LDAP/Active Directory, Google, GitHub, and Okta identity providers. SSO works alongside password authentication with auto-provisioning and role mapping.

- LDAP bind+search authentication with group-based role mapping
- OIDC/OAuth2 flows with PKCE and CSRF protection for Google, GitHub, Okta
- Auto-provisioning: first SSO login creates a Sencho account automatically
- Role mapping via LDAP group membership or OIDC JWT claims
- SSO settings UI in Settings → SSO with per-provider config and test connection
- SSO login buttons on login page with LDAP toggle
- Environment variable seeding for infrastructure-as-code workflows
- Secrets encrypted at rest via CryptoService (AES-256-GCM)
- Seat limit enforcement during auto-provisioning
- Full documentation: feature docs, quickstart guides, env var reference

* fix: resolve ESLint errors in SSO feature

- Remove unnecessary escape characters in regex character classes
- Remove unused `issuer` variable from OIDC callback handler
- Fix setState-in-effect lint error in Login.tsx by using useState initializer
- Suppress set-state-in-effect for SSOSection fetch pattern (matches existing codebase convention)
This commit is contained in:
Anso
2026-03-28 03:30:01 -04:00
committed by GitHub
parent b429097fa2
commit bd4008f509
20 changed files with 2247 additions and 14 deletions
+270
View File
@@ -0,0 +1,270 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb';
import supertest from 'supertest';
import jwt from 'jsonwebtoken';
import type { Express } from 'express';
let tmpDir: string;
let app: Express;
let adminToken: string;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
adminToken = jwt.sign({ username: 'testadmin', role: 'admin' }, TEST_JWT_SECRET, { expiresIn: '1h' });
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
describe('SSO Providers Endpoint', () => {
it('GET /api/auth/sso/providers returns empty array when none configured', async () => {
const res = await supertest(app).get('/api/auth/sso/providers');
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
});
describe('SSO LDAP Login', () => {
it('POST /api/auth/sso/ldap returns error when LDAP not configured', async () => {
const res = await supertest(app)
.post('/api/auth/sso/ldap')
.send({ username: 'testuser', password: 'testpass' });
expect(res.status).toBe(401);
expect(res.body.error).toContain('not configured');
});
it('POST /api/auth/sso/ldap returns 400 when missing credentials', async () => {
const res = await supertest(app)
.post('/api/auth/sso/ldap')
.send({});
expect(res.status).toBe(400);
expect(res.body.error).toContain('required');
});
});
describe('SSO Config Endpoints (Protected)', () => {
it('GET /api/sso/config returns 401 without auth', async () => {
const res = await supertest(app).get('/api/sso/config');
expect(res.status).toBe(401);
});
it('GET /api/sso/config returns 403 without Team Pro', async () => {
const res = await supertest(app)
.get('/api/sso/config')
.set('Authorization', `Bearer ${adminToken}`);
// Without a Team Pro license, this should be 403
expect(res.status).toBe(403);
expect(res.body.code).toBe('PRO_REQUIRED');
});
it('PUT /api/sso/config/:provider returns 401 without auth', async () => {
const res = await supertest(app)
.put('/api/sso/config/ldap')
.send({ enabled: true });
expect(res.status).toBe(401);
});
it('DELETE /api/sso/config/:provider returns 401 without auth', async () => {
const res = await supertest(app).delete('/api/sso/config/ldap');
expect(res.status).toBe(401);
});
});
describe('SSO OIDC Authorize', () => {
it('GET /api/auth/sso/oidc/:provider/authorize returns 400 for invalid provider', async () => {
const res = await supertest(app).get('/api/auth/sso/oidc/invalid_provider/authorize');
expect(res.status).toBe(400);
expect(res.body.error).toContain('Invalid SSO provider');
});
it('GET /api/auth/sso/oidc/oidc_google/authorize redirects to error when not configured', async () => {
const res = await supertest(app).get('/api/auth/sso/oidc/oidc_google/authorize');
// Should redirect to /?sso_error=...
expect(res.status).toBe(302);
expect(res.headers.location).toContain('sso_error');
});
});
describe('SSO OIDC Callback', () => {
it('GET /api/auth/sso/oidc/:provider/callback redirects with error when no state cookie', async () => {
const res = await supertest(app)
.get('/api/auth/sso/oidc/oidc_google/callback?code=test&state=test');
expect(res.status).toBe(302);
expect(res.headers.location).toContain('sso_error');
expect(res.headers.location).toContain('expired');
});
it('GET /api/auth/sso/oidc/:provider/callback redirects with provider error if error param present', async () => {
const res = await supertest(app)
.get('/api/auth/sso/oidc/oidc_google/callback?error=access_denied&error_description=User+denied');
expect(res.status).toBe(302);
expect(res.headers.location).toContain('User');
});
});
describe('SSO User Provisioning', () => {
// Mock LicenseService to return team variant (unlimited seats) for provisioning tests
beforeAll(async () => {
const { LicenseService } = await import('../services/LicenseService');
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
});
afterAll(() => {
vi.restoreAllMocks();
});
it('provisionUser creates a new SSO user with correct fields', async () => {
const { SSOService } = await import('../services/SSOService');
const { DatabaseService } = await import('../services/DatabaseService');
const sso = SSOService.getInstance();
const user = sso.provisionUser({
authProvider: 'oidc_google',
providerId: 'google-sub-123',
preferredUsername: 'John Doe',
email: 'john@example.com',
role: 'viewer',
});
expect(user.username).toBe('John_Doe');
expect(user.auth_provider).toBe('oidc_google');
expect(user.provider_id).toBe('google-sub-123');
expect(user.email).toBe('john@example.com');
expect(user.role).toBe('viewer');
// Password hash should be unusable (SSO prefix)
expect(user.password_hash).toMatch(/^\$sso\$/);
// Verify they appear in DB
const dbUser = DatabaseService.getInstance().getUserByProviderIdentity('oidc_google', 'google-sub-123');
expect(dbUser).toBeDefined();
expect(dbUser!.username).toBe('John_Doe');
});
it('provisionUser returns existing user on second call', async () => {
const { SSOService } = await import('../services/SSOService');
const sso = SSOService.getInstance();
const user1 = sso.provisionUser({
authProvider: 'oidc_github',
providerId: 'github-id-456',
preferredUsername: 'janedoe',
email: 'jane@example.com',
role: 'admin',
});
const user2 = sso.provisionUser({
authProvider: 'oidc_github',
providerId: 'github-id-456',
preferredUsername: 'janedoe',
email: 'jane-new@example.com',
role: 'admin',
});
expect(user1.id).toBe(user2.id);
// Email should be updated
expect(user2.email).toBe('jane-new@example.com');
});
it('provisionUser handles username collision', async () => {
const { SSOService } = await import('../services/SSOService');
const { DatabaseService } = await import('../services/DatabaseService');
const sso = SSOService.getInstance();
// Create a local user first
DatabaseService.getInstance().addUser({
username: 'collision',
password_hash: '$2b$10$fake',
role: 'viewer',
});
// Now provision an SSO user with the same preferred username
const user = sso.provisionUser({
authProvider: 'ldap',
providerId: 'cn=collision,ou=users,dc=example',
preferredUsername: 'collision',
role: 'viewer',
});
// Should have a suffixed username
expect(user.username).toBe('collision_ldap');
expect(user.auth_provider).toBe('ldap');
});
it('SSO users cannot log in via local password endpoint', async () => {
// The SSO user from the first test has a $sso$ password hash
// Trying to log in with any password should fail
const res = await supertest(app)
.post('/api/auth/login')
.send({ username: 'John_Doe', password: 'anything' });
expect(res.status).toBe(401);
});
});
describe('SSO Config CRUD (DB layer)', () => {
it('upsertSSOConfig and getSSOConfig work correctly', async () => {
const { DatabaseService } = await import('../services/DatabaseService');
const db = DatabaseService.getInstance();
db.upsertSSOConfig('ldap', true, JSON.stringify({ ldapUrl: 'ldap://test:389' }));
const config = db.getSSOConfig('ldap');
expect(config).toBeDefined();
expect(config!.enabled).toBe(1);
expect(JSON.parse(config!.config_json)).toEqual({ ldapUrl: 'ldap://test:389' });
// Update
db.upsertSSOConfig('ldap', false, JSON.stringify({ ldapUrl: 'ldap://test2:389' }));
const updated = db.getSSOConfig('ldap');
expect(updated!.enabled).toBe(0);
expect(JSON.parse(updated!.config_json)).toEqual({ ldapUrl: 'ldap://test2:389' });
// Delete
db.deleteSSOConfig('ldap');
expect(db.getSSOConfig('ldap')).toBeUndefined();
});
it('getEnabledSSOConfigs filters correctly', async () => {
const { DatabaseService } = await import('../services/DatabaseService');
const db = DatabaseService.getInstance();
db.upsertSSOConfig('oidc_google', true, '{}');
db.upsertSSOConfig('oidc_github', false, '{}');
const enabled = db.getEnabledSSOConfigs();
expect(enabled.length).toBe(1);
expect(enabled[0].provider).toBe('oidc_google');
// Cleanup
db.deleteSSOConfig('oidc_google');
db.deleteSSOConfig('oidc_github');
});
});
describe('Database migration — SSO columns', () => {
it('users table has auth_provider, provider_id, email columns', async () => {
const { DatabaseService } = await import('../services/DatabaseService');
const db = DatabaseService.getInstance();
const user = db.addUser({
username: 'sso_migration_test',
password_hash: '$sso$test',
role: 'viewer',
auth_provider: 'ldap',
provider_id: 'cn=test,dc=example',
email: 'test@example.com',
});
const fetched = db.getUser(user);
expect(fetched).toBeDefined();
expect(fetched!.auth_provider).toBe('ldap');
expect(fetched!.provider_id).toBe('cn=test,dc=example');
expect(fetched!.email).toBe('test@example.com');
// getUserByProviderIdentity
const byProvider = db.getUserByProviderIdentity('ldap', 'cn=test,dc=example');
expect(byProvider).toBeDefined();
expect(byProvider!.username).toBe('sso_migration_test');
});
});
+270 -1
View File
@@ -18,7 +18,7 @@ import httpProxy from 'http-proxy';
import { createProxyMiddleware } from 'http-proxy-middleware';
import path from 'path';
import { HostTerminalService } from './services/HostTerminalService';
import { DatabaseService, Node } from './services/DatabaseService';
import { DatabaseService, Node, AuthProvider } from './services/DatabaseService';
import { NotificationService } from './services/NotificationService';
import { MonitorService } from './services/MonitorService';
import { ImageUpdateService } from './services/ImageUpdateService';
@@ -27,6 +27,7 @@ import { ErrorParser } from './utils/ErrorParser';
import { NodeRegistry } from './services/NodeRegistry';
import { LicenseService } from './services/LicenseService';
import { WebhookService } from './services/WebhookService';
import { SSOService } from './services/SSOService';
import { isValidStackName, isValidRemoteUrl } from './utils/validation';
import YAML from 'yaml';
import fs, { promises as fsPromises } from 'fs';
@@ -66,6 +67,10 @@ const getCookieOptions = (req: Request) => ({
// Middleware
// Trust the first reverse proxy (nginx, Traefik, etc.) for correct req.protocol,
// req.ip, and secure cookie detection behind a proxy.
app.set('trust proxy', 1);
// Security headers (X-Frame-Options, X-Content-Type-Options, etc.)
// crossOriginEmbedderPolicy: disabled - Monaco editor workers lack COEP headers.
// hsts: disabled - HSTS must only be set when the app is served over HTTPS.
@@ -431,6 +436,178 @@ app.post('/api/auth/generate-node-token', authMiddleware, async (req: Request, r
}
});
// --- SSO Auth Routes (public, under /api/auth/sso/*) ---
// Seed SSO config from environment variables on startup
SSOService.getInstance().seedFromEnv();
const ssoRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: process.env.NODE_ENV === 'production' ? 10 : 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many SSO attempts. Please try again later.' },
});
// List enabled SSO providers (for login page)
app.get('/api/auth/sso/providers', (_req: Request, res: Response): void => {
try {
const providers = SSOService.getInstance().getEnabledProviders();
res.json(providers);
} catch {
res.json([]);
}
});
// LDAP login
app.post('/api/auth/sso/ldap', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
try {
const { username, password } = req.body;
if (!username || !password) {
res.status(400).json({ error: 'Username and password are required' });
return;
}
const result = await SSOService.getInstance().authenticateLDAP(username, password);
if (!result.success || !result.user) {
res.status(401).json({ error: result.error || 'Authentication failed' });
return;
}
// Provision or find existing user
const user = SSOService.getInstance().provisionUser({
authProvider: 'ldap',
providerId: result.user.providerId,
preferredUsername: result.user.preferredUsername,
email: result.user.email,
role: result.user.role,
});
// Issue JWT (same as local login)
const settings = DatabaseService.getInstance().getGlobalSettings();
const token = jwt.sign({ username: user.username, role: user.role }, settings.auth_jwt_secret, { expiresIn: '24h' });
res.cookie(COOKIE_NAME, token, getCookieOptions(req));
res.json({ success: true, message: 'Login successful' });
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'LDAP login failed';
console.error('[SSO] LDAP login error:', msg);
res.status(500).json({ error: msg });
}
});
// OIDC: Initiate authorization flow
app.get('/api/auth/sso/oidc/:provider/authorize', ssoRateLimiter, async (req: Request, res: Response): Promise<void> => {
try {
const provider = String(req.params.provider);
const validProviders = ['oidc_google', 'oidc_github', 'oidc_okta'];
if (!validProviders.includes(provider)) {
res.status(400).json({ error: 'Invalid SSO provider' });
return;
}
const baseUrl = process.env.SSO_CALLBACK_URL || `${req.protocol}://${req.get('host')}`;
const callbackUrl = `${baseUrl}/api/auth/sso/oidc/${provider}/callback`;
const { url, state, codeVerifier } = await SSOService.getInstance().getOIDCAuthorizationUrl(provider, callbackUrl);
// Store state + codeVerifier in an encrypted short-lived cookie
const cryptoSvc = (await import('./services/CryptoService')).CryptoService.getInstance();
const statePayload = JSON.stringify({ state, codeVerifier, provider });
res.cookie('sencho_sso_state', cryptoSvc.encrypt(statePayload), {
httpOnly: true,
secure: isSecureRequest(req),
sameSite: 'lax', // Must be lax for cross-site IdP redirect
maxAge: 5 * 60 * 1000, // 5 minutes
});
res.redirect(url);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'SSO initialization failed';
console.error('[SSO] OIDC authorize error:', msg);
res.redirect(`/?sso_error=${encodeURIComponent(msg)}`);
}
});
// OIDC: Callback from identity provider
app.get('/api/auth/sso/oidc/:provider/callback', async (req: Request, res: Response): Promise<void> => {
try {
const provider = String(req.params.provider);
const code = String(req.query.code || '');
const state = String(req.query.state || '');
const oidcError = req.query.error ? String(req.query.error) : '';
const error_description = req.query.error_description ? String(req.query.error_description) : '';
if (oidcError) {
res.redirect(`/?sso_error=${encodeURIComponent(error_description || oidcError)}`);
return;
}
if (!code || !state) {
res.redirect('/?sso_error=Missing+authorization+code');
return;
}
// Read and validate state cookie
const stateCookie = req.cookies?.sencho_sso_state;
if (!stateCookie) {
res.redirect('/?sso_error=SSO+session+expired.+Please+try+again.');
return;
}
const cryptoSvc = (await import('./services/CryptoService')).CryptoService.getInstance();
let statePayload: { state: string; codeVerifier: string; provider: string };
try {
statePayload = JSON.parse(cryptoSvc.decrypt(stateCookie));
} catch {
res.redirect('/?sso_error=Invalid+SSO+session');
return;
}
if (statePayload.provider !== provider) {
res.redirect('/?sso_error=Provider+mismatch');
return;
}
const baseUrl = process.env.SSO_CALLBACK_URL || `${req.protocol}://${req.get('host')}`;
const callbackUrl = `${baseUrl}/api/auth/sso/oidc/${provider}/callback`;
const result = await SSOService.getInstance().handleOIDCCallback(
provider, callbackUrl,
{ code, state },
statePayload.state,
statePayload.codeVerifier
);
// Clear state cookie
res.clearCookie('sencho_sso_state', { httpOnly: true, secure: isSecureRequest(req), sameSite: 'lax' });
if (!result.success || !result.user) {
res.redirect(`/?sso_error=${encodeURIComponent(result.error || 'Authentication failed')}`);
return;
}
// Provision or find existing user
const user = SSOService.getInstance().provisionUser({
authProvider: provider as AuthProvider,
providerId: result.user.providerId,
preferredUsername: result.user.preferredUsername,
email: result.user.email,
role: result.user.role,
});
// Issue JWT + cookie (same as local login)
const settings = DatabaseService.getInstance().getGlobalSettings();
const token = jwt.sign({ username: user.username, role: user.role }, settings.auth_jwt_secret, { expiresIn: '24h' });
res.cookie(COOKIE_NAME, token, getCookieOptions(req));
res.redirect('/');
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'SSO callback failed';
console.error('[SSO] OIDC callback error:', msg);
res.redirect(`/?sso_error=${encodeURIComponent(msg)}`);
}
});
// Apply authentication middleware to all /api/* routes except /api/auth/*
app.use('/api', (req: Request, res: Response, next: NextFunction): void => {
if (req.path.startsWith('/auth/') || /^\/webhooks\/\d+\/trigger$/.test(req.path)) {
@@ -468,6 +645,8 @@ const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
'POST /fleet/snapshot': 'Created fleet backup',
'DELETE /fleet/snapshot': 'Deleted fleet backup',
'POST /fleet/snapshot/restore': 'Restored fleet backup',
'PUT /sso/config': 'Updated SSO configuration',
'DELETE /sso/config': 'Deleted SSO configuration',
};
function getAuditSummary(method: string, apiPath: string): string {
@@ -2805,6 +2984,96 @@ app.post('/api/system/console-token', authMiddleware, (req: Request, res: Respon
}
});
// --- SSO Config Routes (admin + Team Pro, local-only) ---
app.get('/api/sso/config', (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requireTeamPro(req, res)) return;
try {
const configs = DatabaseService.getInstance().getSSOConfigs();
const result = configs.map(c => {
const parsed = JSON.parse(c.config_json);
// Strip encrypted secrets from response
delete parsed.ldapBindPassword;
delete parsed.oidcClientSecret;
return { ...parsed, provider: c.provider, enabled: c.enabled === 1 };
});
res.json(result);
} catch (error) {
console.error('[SSO] Failed to fetch SSO configs:', error);
res.status(500).json({ error: 'Failed to fetch SSO configuration' });
}
});
app.get('/api/sso/config/:provider', (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requireTeamPro(req, res)) return;
try {
const config = SSOService.getInstance().getProviderConfig(String(req.params.provider));
if (!config) {
res.status(404).json({ error: 'Provider not configured' });
return;
}
// Strip encrypted secrets
const result = { ...config };
delete result.ldapBindPassword;
delete result.oidcClientSecret;
res.json(result);
} catch (error) {
console.error('[SSO] Failed to fetch SSO config:', error);
res.status(500).json({ error: 'Failed to fetch SSO configuration' });
}
});
app.put('/api/sso/config/:provider', (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requireTeamPro(req, res)) return;
try {
const provider = String(req.params.provider);
const validProviders = ['ldap', 'oidc_google', 'oidc_github', 'oidc_okta'];
if (!validProviders.includes(provider)) {
res.status(400).json({ error: 'Invalid SSO provider' });
return;
}
const config = { ...req.body, provider } as import('./services/SSOService').SSOProviderConfig;
SSOService.getInstance().saveProviderConfig(config);
res.json({ success: true, message: 'SSO configuration saved' });
} catch (error) {
console.error('[SSO] Failed to save SSO config:', error);
res.status(500).json({ error: 'Failed to save SSO configuration' });
}
});
app.delete('/api/sso/config/:provider', (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requireTeamPro(req, res)) return;
try {
SSOService.getInstance().deleteProviderConfig(String(req.params.provider));
res.json({ success: true, message: 'SSO configuration deleted' });
} catch (error) {
console.error('[SSO] Failed to delete SSO config:', error);
res.status(500).json({ error: 'Failed to delete SSO configuration' });
}
});
app.post('/api/sso/config/:provider/test', async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requireTeamPro(req, res)) return;
try {
const provider = String(req.params.provider);
if (provider === 'ldap') {
const result = await SSOService.getInstance().testLdapConnection();
res.json(result);
} else {
const result = await SSOService.getInstance().testOidcDiscovery(provider);
res.json(result);
}
} catch (error) {
console.error('[SSO] Connection test failed:', error);
res.status(500).json({ success: false, error: 'Connection test failed' });
}
});
// --- Audit Log Routes (Team Pro, local-only) ---
app.get('/api/audit-log', async (req: Request, res: Response): Promise<void> => {
+75 -5
View File
@@ -60,11 +60,25 @@ export interface WebhookExecution {
executed_at: number;
}
export type AuthProvider = 'local' | 'ldap' | 'oidc_google' | 'oidc_github' | 'oidc_okta';
export interface User {
id: number;
username: string;
password_hash: string;
role: 'admin' | 'viewer';
auth_provider: AuthProvider;
provider_id: string | null;
email: string | null;
created_at: number;
updated_at: number;
}
export interface SSOConfig {
id: number;
provider: string;
enabled: number;
config_json: string;
created_at: number;
updated_at: number;
}
@@ -127,6 +141,7 @@ export class DatabaseService {
this.migrateJsonConfig(dataDir);
this.migrateAdminToUsersTable();
this.migrateEncryptNodeTokens();
this.migrateSSOColumns();
}
public static getInstance(): DatabaseService {
@@ -366,6 +381,27 @@ export class DatabaseService {
}
}
private migrateSSOColumns(): void {
const maybeAddCol = (table: string, col: string, def: string) => {
try { this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`).run(); } catch { /* already exists */ }
};
maybeAddCol('users', 'auth_provider', "TEXT NOT NULL DEFAULT 'local'");
maybeAddCol('users', 'provider_id', 'TEXT DEFAULT NULL');
maybeAddCol('users', 'email', 'TEXT DEFAULT NULL');
this.db.exec(`
CREATE TABLE IF NOT EXISTS sso_config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT NOT NULL UNIQUE,
enabled INTEGER DEFAULT 0,
config_json TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_provider ON users(auth_provider, provider_id) WHERE provider_id IS NOT NULL;
`);
}
// --- Agents ---
public getAgents(): Agent[] {
@@ -720,7 +756,7 @@ export class DatabaseService {
// --- Users ---
public getUsers(): Omit<User, 'password_hash'>[] {
return this.db.prepare('SELECT id, username, role, created_at, updated_at FROM users ORDER BY created_at ASC').all() as Omit<User, 'password_hash'>[];
return this.db.prepare('SELECT id, username, role, auth_provider, provider_id, email, created_at, updated_at FROM users ORDER BY created_at ASC').all() as Omit<User, 'password_hash'>[];
}
public getUser(id: number): User | undefined {
@@ -731,21 +767,26 @@ export class DatabaseService {
return this.db.prepare('SELECT * FROM users WHERE username = ?').get(username) as User | undefined;
}
public addUser(user: { username: string; password_hash: string; role: 'admin' | 'viewer' }): number {
public getUserByProviderIdentity(authProvider: string, providerId: string): User | undefined {
return this.db.prepare('SELECT * FROM users WHERE auth_provider = ? AND provider_id = ?').get(authProvider, providerId) as User | undefined;
}
public addUser(user: { username: string; password_hash: string; role: 'admin' | 'viewer'; auth_provider?: AuthProvider; provider_id?: string | null; email?: string | null }): number {
const now = Date.now();
const result = this.db.prepare(
'INSERT INTO users (username, password_hash, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'
).run(user.username, user.password_hash, user.role, now, now);
'INSERT INTO users (username, password_hash, role, auth_provider, provider_id, email, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).run(user.username, user.password_hash, user.role, user.auth_provider ?? 'local', user.provider_id ?? null, user.email ?? null, now, now);
return result.lastInsertRowid as number;
}
public updateUser(id: number, updates: Partial<{ username: string; password_hash: string; role: string }>): void {
public updateUser(id: number, updates: Partial<{ username: string; password_hash: string; role: string; email: string }>): void {
const fields: string[] = [];
const values: (string | number)[] = [];
if (updates.username !== undefined) { fields.push('username = ?'); values.push(updates.username); }
if (updates.password_hash !== undefined) { fields.push('password_hash = ?'); values.push(updates.password_hash); }
if (updates.role !== undefined) { fields.push('role = ?'); values.push(updates.role); }
if (updates.email !== undefined) { fields.push('email = ?'); values.push(updates.email); }
if (fields.length === 0) return;
@@ -771,6 +812,35 @@ export class DatabaseService {
return (this.db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'viewer'").get() as { count: number })?.count || 0;
}
// --- SSO Config ---
public getSSOConfigs(): SSOConfig[] {
return this.db.prepare('SELECT * FROM sso_config ORDER BY provider ASC').all() as SSOConfig[];
}
public getSSOConfig(provider: string): SSOConfig | undefined {
return this.db.prepare('SELECT * FROM sso_config WHERE provider = ?').get(provider) as SSOConfig | undefined;
}
public getEnabledSSOConfigs(): SSOConfig[] {
return this.db.prepare('SELECT * FROM sso_config WHERE enabled = 1 ORDER BY provider ASC').all() as SSOConfig[];
}
public upsertSSOConfig(provider: string, enabled: boolean, configJson: string): void {
const now = Date.now();
const existing = this.getSSOConfig(provider);
if (existing) {
this.db.prepare('UPDATE sso_config SET enabled = ?, config_json = ?, updated_at = ? WHERE provider = ?')
.run(enabled ? 1 : 0, configJson, now, provider);
} else {
this.db.prepare('INSERT INTO sso_config (provider, enabled, config_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?)')
.run(provider, enabled ? 1 : 0, configJson, now, now);
}
}
public deleteSSOConfig(provider: string): void {
this.db.prepare('DELETE FROM sso_config WHERE provider = ?').run(provider);
}
// --- Fleet Snapshots ---
+587
View File
@@ -0,0 +1,587 @@
import crypto from 'crypto';
import { Client as LdapClient } from 'ldapts';
import { Issuer, Client as OIDCClient, generators } from 'openid-client';
import { DatabaseService, User, AuthProvider } from './DatabaseService';
import { CryptoService } from './CryptoService';
import { LicenseService } from './LicenseService';
export interface SSOProviderConfig {
provider: string;
enabled: boolean;
displayName: string;
// LDAP
ldapUrl?: string;
ldapBindDn?: string;
ldapBindPassword?: string;
ldapSearchBase?: string;
ldapSearchFilter?: string;
ldapAdminGroupDn?: string;
ldapDefaultRole?: 'admin' | 'viewer';
ldapTlsRejectUnauthorized?: boolean;
// OIDC
oidcIssuerUrl?: string;
oidcClientId?: string;
oidcClientSecret?: string;
oidcScopes?: string;
oidcAdminClaim?: string;
oidcAdminClaimValue?: string;
oidcDefaultRole?: 'admin' | 'viewer';
}
export interface SSOAuthResult {
success: boolean;
error?: string;
user?: {
providerId: string;
preferredUsername: string;
email?: string;
role: 'admin' | 'viewer';
};
}
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
ldap: 'LDAP',
oidc_google: 'Google',
oidc_github: 'GitHub',
oidc_okta: 'Okta',
};
const WELL_KNOWN_ISSUERS: Record<string, string> = {
oidc_google: 'https://accounts.google.com',
oidc_github: 'https://github.com',
};
const LDAP_USERNAME_REGEX = /^[a-zA-Z0-9_@.-]+$/;
export class SSOService {
private static instance: SSOService;
public static getInstance(): SSOService {
if (!SSOService.instance) {
SSOService.instance = new SSOService();
}
return SSOService.instance;
}
public seedFromEnv(): void {
this.seedLdapFromEnv();
this.seedOidcFromEnv('oidc_google', 'SSO_OIDC_GOOGLE');
this.seedOidcFromEnv('oidc_github', 'SSO_OIDC_GITHUB');
this.seedOidcFromEnv('oidc_okta', 'SSO_OIDC_OKTA');
}
private seedLdapFromEnv(): void {
if (!process.env.SSO_LDAP_ENABLED || process.env.SSO_LDAP_ENABLED !== 'true') return;
const db = DatabaseService.getInstance();
if (db.getSSOConfig('ldap')) return; // DB already has config, don't overwrite
const cryptoSvc = CryptoService.getInstance();
const config: SSOProviderConfig = {
provider: 'ldap',
enabled: true,
displayName: process.env.SSO_LDAP_DISPLAY_NAME || 'LDAP',
ldapUrl: process.env.SSO_LDAP_URL || '',
ldapBindDn: process.env.SSO_LDAP_BIND_DN || '',
ldapBindPassword: process.env.SSO_LDAP_BIND_PASSWORD || '',
ldapSearchBase: process.env.SSO_LDAP_SEARCH_BASE || '',
ldapSearchFilter: process.env.SSO_LDAP_SEARCH_FILTER || '(uid={{username}})',
ldapAdminGroupDn: process.env.SSO_LDAP_ADMIN_GROUP_DN || '',
ldapDefaultRole: (process.env.SSO_LDAP_DEFAULT_ROLE as 'admin' | 'viewer') || 'viewer',
ldapTlsRejectUnauthorized: process.env.SSO_LDAP_TLS_REJECT_UNAUTHORIZED !== 'false',
};
const configForStorage = { ...config };
if (configForStorage.ldapBindPassword) {
configForStorage.ldapBindPassword = cryptoSvc.encrypt(configForStorage.ldapBindPassword);
}
db.upsertSSOConfig('ldap', true, JSON.stringify(configForStorage));
}
private seedOidcFromEnv(provider: string, envPrefix: string): void {
if (!process.env[`${envPrefix}_ENABLED`] || process.env[`${envPrefix}_ENABLED`] !== 'true') return;
const db = DatabaseService.getInstance();
if (db.getSSOConfig(provider)) return;
const cryptoSvc = CryptoService.getInstance();
const config: SSOProviderConfig = {
provider,
enabled: true,
displayName: PROVIDER_DISPLAY_NAMES[provider] || provider,
oidcIssuerUrl: process.env[`${envPrefix}_ISSUER_URL`] || WELL_KNOWN_ISSUERS[provider] || '',
oidcClientId: process.env[`${envPrefix}_CLIENT_ID`] || '',
oidcClientSecret: process.env[`${envPrefix}_CLIENT_SECRET`] || '',
oidcScopes: process.env[`${envPrefix}_SCOPES`] || 'openid email profile',
oidcAdminClaim: process.env.SSO_OIDC_ADMIN_CLAIM || 'groups',
oidcAdminClaimValue: process.env.SSO_OIDC_ADMIN_CLAIM_VALUE || 'sencho-admins',
oidcDefaultRole: (process.env.SSO_DEFAULT_ROLE as 'admin' | 'viewer') || 'viewer',
};
const configForStorage = { ...config };
if (configForStorage.oidcClientSecret) {
configForStorage.oidcClientSecret = cryptoSvc.encrypt(configForStorage.oidcClientSecret);
}
db.upsertSSOConfig(provider, true, JSON.stringify(configForStorage));
}
// --- Config Management ---
public getEnabledProviders(): Array<{ provider: string; displayName: string; type: 'ldap' | 'oidc' }> {
const configs = DatabaseService.getInstance().getEnabledSSOConfigs();
return configs.map(c => {
const parsed = JSON.parse(c.config_json) as SSOProviderConfig;
return {
provider: c.provider,
displayName: parsed.displayName || PROVIDER_DISPLAY_NAMES[c.provider] || c.provider,
type: c.provider === 'ldap' ? 'ldap' as const : 'oidc' as const,
};
});
}
public getProviderConfig(provider: string): SSOProviderConfig | null {
const row = DatabaseService.getInstance().getSSOConfig(provider);
if (!row) return null;
const config = JSON.parse(row.config_json) as SSOProviderConfig;
config.enabled = row.enabled === 1;
config.provider = row.provider;
return config;
}
public getProviderConfigDecrypted(provider: string): SSOProviderConfig | null {
const config = this.getProviderConfig(provider);
if (!config) return null;
const cryptoSvc = CryptoService.getInstance();
if (config.ldapBindPassword && cryptoSvc.isEncrypted(config.ldapBindPassword)) {
config.ldapBindPassword = cryptoSvc.decrypt(config.ldapBindPassword);
}
if (config.oidcClientSecret && cryptoSvc.isEncrypted(config.oidcClientSecret)) {
config.oidcClientSecret = cryptoSvc.decrypt(config.oidcClientSecret);
}
return config;
}
public saveProviderConfig(config: SSOProviderConfig): void {
const cryptoSvc = CryptoService.getInstance();
const configForStorage = { ...config };
if (configForStorage.ldapBindPassword && !cryptoSvc.isEncrypted(configForStorage.ldapBindPassword)) {
configForStorage.ldapBindPassword = cryptoSvc.encrypt(configForStorage.ldapBindPassword);
}
if (configForStorage.oidcClientSecret && !cryptoSvc.isEncrypted(configForStorage.oidcClientSecret)) {
configForStorage.oidcClientSecret = cryptoSvc.encrypt(configForStorage.oidcClientSecret);
}
DatabaseService.getInstance().upsertSSOConfig(
config.provider,
config.enabled,
JSON.stringify(configForStorage)
);
}
public deleteProviderConfig(provider: string): void {
DatabaseService.getInstance().deleteSSOConfig(provider);
}
// --- LDAP Authentication ---
public async authenticateLDAP(username: string, password: string): Promise<SSOAuthResult> {
if (!LDAP_USERNAME_REGEX.test(username)) {
return { success: false, error: 'Invalid username format' };
}
if (!password) {
return { success: false, error: 'Password is required' };
}
const config = this.getProviderConfigDecrypted('ldap');
if (!config || !config.enabled) {
return { success: false, error: 'LDAP authentication is not configured' };
}
if (!config.ldapUrl || !config.ldapSearchBase) {
return { success: false, error: 'LDAP configuration is incomplete' };
}
const client = new LdapClient({
url: config.ldapUrl,
tlsOptions: {
rejectUnauthorized: config.ldapTlsRejectUnauthorized !== false,
},
});
try {
// Step 1: Bind with service account to search for the user
if (config.ldapBindDn && config.ldapBindPassword) {
await client.bind(config.ldapBindDn, config.ldapBindPassword);
}
// Step 2: Search for the user
const filter = (config.ldapSearchFilter || '(uid={{username}})').replace('{{username}}', this.escapeLdapFilter(username));
const { searchEntries } = await client.search(config.ldapSearchBase, {
scope: 'sub',
filter,
attributes: ['dn', 'uid', 'sAMAccountName', 'mail', 'email', 'cn', 'memberOf'],
});
if (searchEntries.length === 0) {
return { success: false, error: 'Invalid credentials' };
}
const userEntry = searchEntries[0];
const userDn = userEntry.dn;
// Step 3: Bind as the user to verify their password
await client.unbind();
const userClient = new LdapClient({
url: config.ldapUrl,
tlsOptions: {
rejectUnauthorized: config.ldapTlsRejectUnauthorized !== false,
},
});
try {
await userClient.bind(userDn, password);
} catch {
return { success: false, error: 'Invalid credentials' };
} finally {
try { await userClient.unbind(); } catch { /* ignore */ }
}
// Step 4: Determine role from group membership
const role = this.resolveRoleFromLdap(userEntry, config);
// Extract user info
const preferredUsername = String(
userEntry['sAMAccountName'] || userEntry['uid'] || userEntry['cn'] || username
);
const email = String(userEntry['mail'] || userEntry['email'] || '');
return {
success: true,
user: {
providerId: userDn,
preferredUsername,
email: email || undefined,
role,
},
};
} catch (err) {
const message = err instanceof Error ? err.message : 'LDAP connection failed';
console.error('[SSO] LDAP authentication error:', message);
return { success: false, error: 'LDAP authentication failed. Check server connectivity.' };
} finally {
try { await client.unbind(); } catch { /* ignore */ }
}
}
private resolveRoleFromLdap(
userEntry: Record<string, string | string[] | Buffer | Buffer[]>,
config: SSOProviderConfig
): 'admin' | 'viewer' {
if (!config.ldapAdminGroupDn) {
return config.ldapDefaultRole || 'viewer';
}
const memberOf = userEntry['memberOf'];
if (!memberOf) return config.ldapDefaultRole || 'viewer';
const groups = Array.isArray(memberOf)
? memberOf.map(g => String(g).toLowerCase())
: [String(memberOf).toLowerCase()];
if (groups.includes(config.ldapAdminGroupDn.toLowerCase())) {
return 'admin';
}
return config.ldapDefaultRole || 'viewer';
}
private escapeLdapFilter(value: string): string {
return value
.replace(/\\/g, '\\5c')
.replace(/\*/g, '\\2a')
.replace(/\(/g, '\\28')
.replace(/\)/g, '\\29')
.replace(/\0/g, '\\00');
}
// --- OIDC Authentication ---
public async getOIDCAuthorizationUrl(
provider: string,
callbackUrl: string
): Promise<{ url: string; state: string; codeVerifier: string }> {
const config = this.getProviderConfigDecrypted(provider);
if (!config || !config.enabled) {
throw new Error(`SSO provider ${provider} is not configured`);
}
if (!config.oidcClientId) {
throw new Error(`SSO provider ${provider} is missing client ID`);
}
const { client } = await this.getOIDCClient(provider, config, callbackUrl);
const state = generators.state();
const codeVerifier = generators.codeVerifier();
const codeChallenge = generators.codeChallenge(codeVerifier);
const scopes = config.oidcScopes || 'openid email profile';
const url = client.authorizationUrl({
scope: scopes,
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
return { url, state, codeVerifier };
}
public async handleOIDCCallback(
provider: string,
callbackUrl: string,
params: { code: string; state: string },
expectedState: string,
codeVerifier: string
): Promise<SSOAuthResult> {
if (params.state !== expectedState) {
return { success: false, error: 'Invalid state parameter (possible CSRF attack)' };
}
const config = this.getProviderConfigDecrypted(provider);
if (!config || !config.enabled) {
return { success: false, error: `SSO provider ${provider} is not configured` };
}
try {
const { client } = await this.getOIDCClient(provider, config, callbackUrl);
const tokenSet = await client.callback(callbackUrl, { code: params.code, state: params.state }, {
state: expectedState,
code_verifier: codeVerifier,
});
let userInfo: Record<string, unknown>;
if (provider === 'oidc_github') {
// GitHub doesn't support standard OIDC userinfo; use their API
userInfo = await this.fetchGitHubUserInfo(tokenSet.access_token as string);
} else if (tokenSet.id_token) {
const claims = tokenSet.claims();
// Also fetch userinfo for complete profile
try {
const info = await client.userinfo(tokenSet.access_token as string);
userInfo = { ...claims, ...info };
} catch {
userInfo = claims as Record<string, unknown>;
}
} else {
userInfo = await client.userinfo(tokenSet.access_token as string) as Record<string, unknown>;
}
const sub = String(userInfo.sub || userInfo.id || '');
if (!sub) {
return { success: false, error: 'Could not determine user identity from provider' };
}
const email = String(userInfo.email || '');
const name = String(userInfo.name || userInfo.preferred_username || userInfo.login || email.split('@')[0] || 'sso_user');
const role = this.resolveRoleFromOidc(userInfo, config);
return {
success: true,
user: {
providerId: sub,
preferredUsername: name,
email: email || undefined,
role,
},
};
} catch (err) {
const message = err instanceof Error ? err.message : 'OIDC authentication failed';
console.error('[SSO] OIDC callback error:', message);
return { success: false, error: 'Authentication failed. Please try again.' };
}
}
private async fetchGitHubUserInfo(accessToken: string): Promise<Record<string, unknown>> {
const [userRes, emailRes] = await Promise.all([
fetch('https://api.github.com/user', {
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
}),
fetch('https://api.github.com/user/emails', {
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
}),
]);
const user = await userRes.json() as Record<string, unknown>;
let primaryEmail = '';
try {
const emails = await emailRes.json() as Array<{ email: string; primary: boolean }>;
primaryEmail = emails.find(e => e.primary)?.email || emails[0]?.email || '';
} catch { /* email fetch is best-effort */ }
return {
sub: String(user.id),
id: user.id,
login: user.login,
name: user.name || user.login,
email: primaryEmail || user.email,
preferred_username: user.login,
};
}
private async getOIDCClient(
provider: string,
config: SSOProviderConfig,
callbackUrl: string
): Promise<{ client: OIDCClient; issuer: InstanceType<typeof Issuer> }> {
let issuer: InstanceType<typeof Issuer>;
if (provider === 'oidc_github') {
// GitHub is not a standard OIDC provider — manually configure
issuer = new Issuer({
issuer: 'https://github.com',
authorization_endpoint: 'https://github.com/login/oauth/authorize',
token_endpoint: 'https://github.com/login/oauth/access_token',
userinfo_endpoint: 'https://api.github.com/user',
});
} else {
const issuerUrl = config.oidcIssuerUrl || WELL_KNOWN_ISSUERS[provider];
if (!issuerUrl) {
throw new Error(`Issuer URL not configured for ${provider}`);
}
issuer = await Issuer.discover(issuerUrl);
}
const client = new issuer.Client({
client_id: config.oidcClientId || '',
client_secret: config.oidcClientSecret || '',
redirect_uris: [callbackUrl],
response_types: ['code'],
token_endpoint_auth_method: 'client_secret_post',
});
return { client, issuer };
}
private resolveRoleFromOidc(userInfo: Record<string, unknown>, config: SSOProviderConfig): 'admin' | 'viewer' {
const claimName = config.oidcAdminClaim || 'groups';
const claimValue = config.oidcAdminClaimValue || 'sencho-admins';
if (!claimValue) return config.oidcDefaultRole || 'viewer';
const claim = userInfo[claimName];
if (!claim) return config.oidcDefaultRole || 'viewer';
if (Array.isArray(claim)) {
if (claim.map(String).includes(claimValue)) return 'admin';
} else if (String(claim) === claimValue) {
return 'admin';
}
return config.oidcDefaultRole || 'viewer';
}
// --- User Provisioning ---
public provisionUser(params: {
authProvider: AuthProvider;
providerId: string;
preferredUsername: string;
email?: string;
role: 'admin' | 'viewer';
}): User {
const db = DatabaseService.getInstance();
// Check if user already exists by provider identity
const existing = db.getUserByProviderIdentity(params.authProvider, params.providerId);
if (existing) {
// Update email if changed
if (params.email && params.email !== existing.email) {
db.updateUser(existing.id, { email: params.email });
}
return db.getUser(existing.id) || existing;
}
// Check seat limits
let { role } = params;
const seatLimits = LicenseService.getInstance().getSeatLimits();
if (role === 'admin' && seatLimits.maxAdmins !== null && db.getAdminCount() >= seatLimits.maxAdmins) {
role = 'viewer'; // Downgrade to viewer if admin seats full
}
if (role === 'viewer' && seatLimits.maxViewers !== null && db.getViewerCount() >= seatLimits.maxViewers) {
throw new Error('User seat limit reached. Contact your administrator to increase your license.');
}
// Generate unique username
let username = params.preferredUsername.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 50);
if (!username) username = 'sso_user';
if (db.getUserByUsername(username)) {
const suffix = params.authProvider.replace('oidc_', '');
username = `${username}_${suffix}`;
let counter = 2;
const base = username;
while (db.getUserByUsername(username)) {
username = `${base}_${counter++}`;
}
}
// Create user with unusable password hash
const randomHash = `$sso$${crypto.randomBytes(32).toString('hex')}`;
const id = db.addUser({
username,
password_hash: randomHash,
role,
auth_provider: params.authProvider,
provider_id: params.providerId,
email: params.email ?? null,
});
const user = db.getUser(id);
if (!user) throw new Error('Failed to create SSO user');
return user;
}
// --- Test Connection ---
public async testLdapConnection(): Promise<{ success: boolean; error?: string }> {
const config = this.getProviderConfigDecrypted('ldap');
if (!config || !config.ldapUrl) {
return { success: false, error: 'LDAP not configured' };
}
const client = new LdapClient({
url: config.ldapUrl,
tlsOptions: { rejectUnauthorized: config.ldapTlsRejectUnauthorized !== false },
connectTimeout: 5000,
});
try {
if (config.ldapBindDn && config.ldapBindPassword) {
await client.bind(config.ldapBindDn, config.ldapBindPassword);
}
return { success: true };
} catch (err) {
const message = err instanceof Error ? err.message : 'Connection failed';
return { success: false, error: message };
} finally {
try { await client.unbind(); } catch { /* ignore */ }
}
}
public async testOidcDiscovery(provider: string): Promise<{ success: boolean; error?: string; issuer?: string }> {
const config = this.getProviderConfigDecrypted(provider);
if (!config) {
return { success: false, error: `Provider ${provider} not configured` };
}
try {
if (provider === 'oidc_github') {
return { success: true, issuer: 'https://github.com (OAuth2, non-standard OIDC)' };
}
const issuerUrl = config.oidcIssuerUrl || WELL_KNOWN_ISSUERS[provider];
if (!issuerUrl) {
return { success: false, error: 'Issuer URL not configured' };
}
const issuer = await Issuer.discover(issuerUrl);
return { success: true, issuer: issuer.metadata.issuer };
} catch (err) {
const message = err instanceof Error ? err.message : 'Discovery failed';
return { success: false, error: message };
}
}
}