mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-17 14:08:19 +00:00
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:
@@ -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
@@ -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> => {
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user